mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 02:25:30 +00:00
+FTS5 Rebuild
This commit is contained in:
40
app/fts5_scheduler.go
Normal file
40
app/fts5_scheduler.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/imports"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartWeeklyFTS5Rebuild(app core.App) {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
next := nextSundayMidnight(time.Now())
|
||||||
|
timer := time.NewTimer(time.Until(next))
|
||||||
|
<-timer.C
|
||||||
|
timer.Stop()
|
||||||
|
|
||||||
|
app.Logger().Info("Starting scheduled FTS5 rebuild", "scheduled_for", next.Format(time.RFC3339))
|
||||||
|
status, err := imports.StartFTS5Rebuild(app, false)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Scheduled FTS5 rebuild failed", "error", err)
|
||||||
|
} else if status == "running" {
|
||||||
|
app.Logger().Info("Scheduled FTS5 rebuild skipped (already running)")
|
||||||
|
} else {
|
||||||
|
app.Logger().Info("Scheduled FTS5 rebuild started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextSundayMidnight(now time.Time) time.Time {
|
||||||
|
local := now.In(time.Local)
|
||||||
|
daysUntil := (7 - int(local.Weekday())) % 7
|
||||||
|
base := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())
|
||||||
|
next := base.AddDate(0, 0, daysUntil)
|
||||||
|
if !next.After(local) {
|
||||||
|
next = next.AddDate(0, 0, 7)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -747,6 +747,8 @@ func (app *App) bindPages(engine *templating.Engine) ServeFunc {
|
|||||||
page.Setup(e.Router, app, engine)
|
page.Setup(e.Router, app, engine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StartWeeklyFTS5Rebuild(e.App)
|
||||||
|
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/exports"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/exports"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/imports"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
|
||||||
@@ -25,6 +27,9 @@ const (
|
|||||||
URL_EXPORTS_LIST = "list/"
|
URL_EXPORTS_LIST = "list/"
|
||||||
URL_EXPORTS_DOWNLOAD = "download/"
|
URL_EXPORTS_DOWNLOAD = "download/"
|
||||||
URL_EXPORTS_DELETE = "delete/"
|
URL_EXPORTS_DELETE = "delete/"
|
||||||
|
URL_EXPORTS_SETTINGS = "settings/"
|
||||||
|
URL_EXPORTS_FTS5_REBUILD = "fts5/rebuild/"
|
||||||
|
URL_EXPORTS_FTS5_STATUS = "fts5/status/"
|
||||||
TEMPLATE_EXPORTS = "/redaktion/exports/"
|
TEMPLATE_EXPORTS = "/redaktion/exports/"
|
||||||
TEMPLATE_EXPORTS_LIST = "/redaktion/exports/list/"
|
TEMPLATE_EXPORTS_LIST = "/redaktion/exports/list/"
|
||||||
LAYOUT_EXPORTS_FRAGMENT = "fragment"
|
LAYOUT_EXPORTS_FRAGMENT = "fragment"
|
||||||
@@ -56,6 +61,10 @@ func (p *ExportsAdmin) Setup(router *router.Router[*core.RequestEvent], ia pagem
|
|||||||
rg.POST(URL_EXPORTS_RUN, p.runHandler(appInstance))
|
rg.POST(URL_EXPORTS_RUN, p.runHandler(appInstance))
|
||||||
rg.GET(URL_EXPORTS_DOWNLOAD+"{id}", p.downloadHandler(appInstance))
|
rg.GET(URL_EXPORTS_DOWNLOAD+"{id}", p.downloadHandler(appInstance))
|
||||||
rg.POST(URL_EXPORTS_DELETE+"{id}", p.deleteHandler(appInstance))
|
rg.POST(URL_EXPORTS_DELETE+"{id}", p.deleteHandler(appInstance))
|
||||||
|
rg.POST(URL_EXPORTS_SETTINGS+"save/", handleSettingSave(appInstance, URL_EXPORTS_ADMIN))
|
||||||
|
rg.POST(URL_EXPORTS_SETTINGS+"delete/", handleSettingDelete(appInstance, URL_EXPORTS_ADMIN))
|
||||||
|
rg.POST(URL_EXPORTS_FTS5_REBUILD, p.fts5RunHandler(appInstance))
|
||||||
|
rg.GET(URL_EXPORTS_FTS5_STATUS, p.fts5StatusHandler(appInstance))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +91,12 @@ func (p *ExportsAdmin) pageHandler(engine *templating.Engine, app core.App) Hand
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return engine.Response500(e, err, nil)
|
return engine.Response500(e, err, nil)
|
||||||
}
|
}
|
||||||
|
if msg := popFlashSuccess(e); msg != "" {
|
||||||
|
data["success"] = msg
|
||||||
|
}
|
||||||
|
if errMsg := strings.TrimSpace(e.Request.URL.Query().Get("error")); errMsg != "" {
|
||||||
|
data["error"] = errMsg
|
||||||
|
}
|
||||||
return engine.Response200(e, TEMPLATE_EXPORTS, data, pagemodels.LAYOUT_LOGIN_PAGES)
|
return engine.Response200(e, TEMPLATE_EXPORTS, data, pagemodels.LAYOUT_LOGIN_PAGES)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +111,61 @@ func (p *ExportsAdmin) listHandler(engine *templating.Engine, app core.App) Hand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) fts5RunHandler(app core.App) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
req := templating.NewRequest(e)
|
||||||
|
if err := e.Request.ParseForm(); err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]any{"error": "Formulardaten ungueltig."})
|
||||||
|
}
|
||||||
|
if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil {
|
||||||
|
return e.JSON(http.StatusUnauthorized, map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := imports.StartFTS5Rebuild(app, true)
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == "running" {
|
||||||
|
return e.JSON(http.StatusConflict, map[string]any{"error": "FTS5-Neuaufbau läuft bereits."})
|
||||||
|
}
|
||||||
|
if status == "restarting" {
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "status": "restarting"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "status": "started"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) fts5StatusHandler(app core.App) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
status := settingString(app, "fts5_rebuild_status")
|
||||||
|
if status == "" {
|
||||||
|
status = "idle"
|
||||||
|
}
|
||||||
|
message := normalizeGermanMessage(settingString(app, "fts5_rebuild_message"))
|
||||||
|
errMsg := normalizeGermanMessage(settingString(app, "fts5_rebuild_error"))
|
||||||
|
done := 0
|
||||||
|
total := 0
|
||||||
|
if setting, err := dbmodels.Settings_Key(app, "fts5_rebuild_done"); err == nil && setting != nil {
|
||||||
|
done = parseSettingInt(setting.Value())
|
||||||
|
}
|
||||||
|
if setting, err := dbmodels.Settings_Key(app, "fts5_rebuild_total"); err == nil && setting != nil {
|
||||||
|
total = parseSettingInt(setting.Value())
|
||||||
|
}
|
||||||
|
lastRebuild := formatLastRebuild(app)
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"error": errMsg,
|
||||||
|
"done": done,
|
||||||
|
"total": total,
|
||||||
|
"last_rebuild": lastRebuild,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *ExportsAdmin) runHandler(app core.App) HandleFunc {
|
func (p *ExportsAdmin) runHandler(app core.App) HandleFunc {
|
||||||
return func(e *core.RequestEvent) error {
|
return func(e *core.RequestEvent) error {
|
||||||
req := templating.NewRequest(e)
|
req := templating.NewRequest(e)
|
||||||
@@ -234,7 +304,7 @@ func (p *ExportsAdmin) deleteHandler(app core.App) HandleFunc {
|
|||||||
|
|
||||||
status := record.GetString(dbmodels.EXPORT_STATUS_FIELD)
|
status := record.GetString(dbmodels.EXPORT_STATUS_FIELD)
|
||||||
if status == dbmodels.EXPORT_STATUS_RUNNING || status == dbmodels.EXPORT_STATUS_QUEUED {
|
if status == dbmodels.EXPORT_STATUS_RUNNING || status == dbmodels.EXPORT_STATUS_QUEUED {
|
||||||
return e.JSON(http.StatusConflict, map[string]any{"error": "Export laeuft noch."})
|
return e.JSON(http.StatusConflict, map[string]any{"error": "Export läuft noch."})
|
||||||
}
|
}
|
||||||
|
|
||||||
exportDir, err := exports.ExportDir(app)
|
exportDir, err := exports.ExportDir(app)
|
||||||
@@ -264,6 +334,10 @@ func exportsData(e *core.RequestEvent, app core.App) (map[string]any, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
settings, err := settingsData(app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
data["exports"] = exportsList
|
data["exports"] = exportsList
|
||||||
data["has_running"] = hasRunning
|
data["has_running"] = hasRunning
|
||||||
@@ -271,6 +345,9 @@ func exportsData(e *core.RequestEvent, app core.App) (map[string]any, error) {
|
|||||||
if req.Session() != nil {
|
if req.Session() != nil {
|
||||||
data["csrf_token"] = req.Session().Token
|
data["csrf_token"] = req.Session().Token
|
||||||
}
|
}
|
||||||
|
for key, value := range settings {
|
||||||
|
data[key] = value
|
||||||
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,3 +412,73 @@ func formatBytes(size int64) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.2f %s", value, units[unitIdx])
|
return fmt.Sprintf("%.2f %s", value, units[unitIdx])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func settingString(app core.App, key string) string {
|
||||||
|
setting, err := dbmodels.Settings_Key(app, key)
|
||||||
|
if err != nil || setting == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value, ok := setting.Value().(string); ok {
|
||||||
|
return normalizeSettingString(value)
|
||||||
|
}
|
||||||
|
return normalizeSettingString(fmt.Sprintf("%v", setting.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatLastRebuild(app core.App) string {
|
||||||
|
setting, err := dbmodels.Settings_Key(app, "fts5_last_rebuild")
|
||||||
|
if err != nil || setting == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if formatted, ok := formatSettingGermanDateTime(setting.Value()); ok {
|
||||||
|
return normalizeSettingString(formatted)
|
||||||
|
}
|
||||||
|
if value, ok := parseSettingString(setting.Value()); ok {
|
||||||
|
return normalizeSettingString(value)
|
||||||
|
}
|
||||||
|
return normalizeSettingString(fmt.Sprintf("%v", setting.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSettingInt(value any) int {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
case int64:
|
||||||
|
return int(v)
|
||||||
|
case string:
|
||||||
|
if parsed, err := strconv.Atoi(v); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if parsed, err := strconv.Atoi(fmt.Sprintf("%v", value)); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSettingString(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if len(trimmed) >= 2 && strings.HasPrefix(trimmed, "\"") && strings.HasSuffix(trimmed, "\"") {
|
||||||
|
if unquoted, err := strconv.Unquote(trimmed); err == nil {
|
||||||
|
return unquoted
|
||||||
|
}
|
||||||
|
return strings.Trim(trimmed, "\"")
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGermanMessage(value string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"laeuft", "läuft",
|
||||||
|
"Laeuft", "Läuft",
|
||||||
|
"Loescht", "Löscht",
|
||||||
|
"loescht", "löscht",
|
||||||
|
"fuellt", "füllt",
|
||||||
|
"Fuellt", "Füllt",
|
||||||
|
"Eintraegen", "Einträgen",
|
||||||
|
"eintraegen", "einträgen",
|
||||||
|
)
|
||||||
|
return replacer.Replace(value)
|
||||||
|
}
|
||||||
|
|||||||
255
controllers/settings_admin.go
Normal file
255
controllers/settings_admin.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/imports"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
URL_SETTINGS_ADMIN = "/redaktion/settings/"
|
||||||
|
URL_SETTINGS_SAVE = "save/"
|
||||||
|
URL_SETTINGS_DELETE = "delete/"
|
||||||
|
URL_SETTINGS_FTS5_REBUILD = "fts5/rebuild/"
|
||||||
|
TEMPLATE_SETTINGS = "/redaktion/settings/"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
app.Register(&SettingsAdmin{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsAdmin struct{}
|
||||||
|
|
||||||
|
func (p *SettingsAdmin) Up(ia pagemodels.IApp, engine *templating.Engine) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SettingsAdmin) Down(ia pagemodels.IApp, engine *templating.Engine) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SettingsAdmin) Setup(router *router.Router[*core.RequestEvent], ia pagemodels.IApp, engine *templating.Engine) error {
|
||||||
|
appInstance := ia.Core()
|
||||||
|
rg := router.Group(URL_SETTINGS_ADMIN)
|
||||||
|
rg.BindFunc(middleware.Authenticated(appInstance))
|
||||||
|
rg.BindFunc(middleware.IsAdmin())
|
||||||
|
rg.GET("", p.redirectHandler())
|
||||||
|
rg.POST(URL_SETTINGS_SAVE, handleSettingSave(appInstance, URL_SETTINGS_ADMIN))
|
||||||
|
rg.POST(URL_SETTINGS_DELETE, handleSettingDelete(appInstance, URL_SETTINGS_ADMIN))
|
||||||
|
rg.POST(URL_SETTINGS_FTS5_REBUILD, handleFTS5Rebuild(appInstance, URL_SETTINGS_ADMIN))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingView struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
Updated types.DateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SettingsAdmin) redirectHandler() HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
return e.Redirect(http.StatusSeeOther, URL_EXPORTS_ADMIN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSettingSave(app core.App, redirectBase string) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
req := templating.NewRequest(e)
|
||||||
|
if err := e.Request.ParseForm(); err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, "Formulardaten ungueltig.")
|
||||||
|
}
|
||||||
|
if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(e.Request.FormValue("key"))
|
||||||
|
if key == "" {
|
||||||
|
return redirectSettingsError(e, redirectBase, "Schluessel darf nicht leer sein.")
|
||||||
|
}
|
||||||
|
|
||||||
|
valueRaw := strings.TrimSpace(e.Request.FormValue("value"))
|
||||||
|
value := parseSettingValue(valueRaw)
|
||||||
|
|
||||||
|
collection, err := app.FindCollectionByNameOrId(dbmodels.SETTINGS_TABLE)
|
||||||
|
if err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, "Einstellungen-Tabelle nicht verfuegbar.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var record *core.Record
|
||||||
|
existing, err := dbmodels.Settings_Key(app, key)
|
||||||
|
if err != nil {
|
||||||
|
if !isRecordNotFound(err) {
|
||||||
|
return redirectSettingsError(e, redirectBase, "Konnte Einstellung nicht laden.")
|
||||||
|
}
|
||||||
|
} else if existing != nil {
|
||||||
|
record = existing.ProxyRecord()
|
||||||
|
}
|
||||||
|
|
||||||
|
if record == nil {
|
||||||
|
record = core.NewRecord(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Set(dbmodels.KEY_FIELD, key)
|
||||||
|
record.Set(dbmodels.VALUE_FIELD, value)
|
||||||
|
if err := app.Save(record); err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, "Einstellung konnte nicht gespeichert werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlashSuccess(e, "Einstellung gespeichert.")
|
||||||
|
return e.Redirect(http.StatusSeeOther, redirectBase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSettingDelete(app core.App, redirectBase string) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
req := templating.NewRequest(e)
|
||||||
|
if err := e.Request.ParseForm(); err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, "Formulardaten ungueltig.")
|
||||||
|
}
|
||||||
|
if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(e.Request.FormValue("key"))
|
||||||
|
if key == "" {
|
||||||
|
return redirectSettingsError(e, redirectBase, "Schluessel darf nicht leer sein.")
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := dbmodels.Settings_Key(app, key)
|
||||||
|
if err != nil {
|
||||||
|
if isRecordNotFound(err) {
|
||||||
|
setFlashSuccess(e, "Einstellung entfernt.")
|
||||||
|
return e.Redirect(http.StatusSeeOther, redirectBase)
|
||||||
|
}
|
||||||
|
return redirectSettingsError(e, redirectBase, "Einstellung konnte nicht geladen werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Delete(record.ProxyRecord()); err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, "Einstellung konnte nicht entfernt werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlashSuccess(e, "Einstellung entfernt.")
|
||||||
|
return e.Redirect(http.StatusSeeOther, redirectBase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFTS5Rebuild(app core.App, redirectBase string) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
req := templating.NewRequest(e)
|
||||||
|
if err := e.Request.ParseForm(); err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, "Formulardaten ungueltig.")
|
||||||
|
}
|
||||||
|
if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := imports.StartFTS5Rebuild(app, true)
|
||||||
|
if err != nil {
|
||||||
|
return redirectSettingsError(e, redirectBase, err.Error())
|
||||||
|
}
|
||||||
|
if status == "running" {
|
||||||
|
return redirectSettingsError(e, redirectBase, "FTS5-Neuaufbau läuft bereits.")
|
||||||
|
}
|
||||||
|
if status == "restarting" {
|
||||||
|
setFlashSuccess(e, "FTS5-Neuaufbau wird neu gestartet.")
|
||||||
|
return e.Redirect(http.StatusSeeOther, redirectBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlashSuccess(e, "FTS5-Neuaufbau gestartet.")
|
||||||
|
return e.Redirect(http.StatusSeeOther, redirectBase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsData(app core.App) (map[string]any, error) {
|
||||||
|
settings, err := dbmodels.Settings_All(app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]settingView, 0, len(settings))
|
||||||
|
for _, setting := range settings {
|
||||||
|
if setting == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, settingView{
|
||||||
|
Key: setting.Key(),
|
||||||
|
Value: formatSettingValue(setting.Value()),
|
||||||
|
Updated: setting.GetDateTime(dbmodels.UPDATED_FIELD),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
return list[i].Key < list[j].Key
|
||||||
|
})
|
||||||
|
|
||||||
|
var lastRebuild string
|
||||||
|
var lastRebuildDT types.DateTime
|
||||||
|
if setting, err := dbmodels.Settings_Key(app, "fts5_last_rebuild"); err == nil && setting != nil {
|
||||||
|
if dt, ok := parseSettingDateTime(setting.Value()); ok {
|
||||||
|
lastRebuildDT = dt
|
||||||
|
lastRebuild = formatSettingValue(dt)
|
||||||
|
} else {
|
||||||
|
lastRebuild = formatSettingValue(setting.Value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"settings": list,
|
||||||
|
"fts5_last_rebuild": lastRebuild,
|
||||||
|
"fts5_last_rebuild_dt": lastRebuildDT,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSettingValue(valueRaw string) any {
|
||||||
|
if valueRaw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var parsed any
|
||||||
|
if err := json.Unmarshal([]byte(valueRaw), &parsed); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return valueRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSettingValue(value any) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if formatted, ok := formatSettingDateTime(value); ok {
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
if str, ok := parseSettingString(value); ok && str != "" {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecordNotFound(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "no rows in result set") || strings.Contains(msg, "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectSettingsError(e *core.RequestEvent, baseURL, message string) error {
|
||||||
|
redirect := fmt.Sprintf("%s?error=%s", baseURL, url.QueryEscape(message))
|
||||||
|
return e.Redirect(http.StatusSeeOther, redirect)
|
||||||
|
}
|
||||||
78
controllers/settings_helpers.go
Normal file
78
controllers/settings_helpers.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/functions"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseSettingString(value any) (string, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v), true
|
||||||
|
case []byte:
|
||||||
|
if len(v) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var parsed string
|
||||||
|
if err := json.Unmarshal(v, &parsed); err == nil {
|
||||||
|
return strings.TrimSpace(parsed), true
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(v)), true
|
||||||
|
case json.RawMessage:
|
||||||
|
if len(v) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var parsed string
|
||||||
|
if err := json.Unmarshal(v, &parsed); err == nil {
|
||||||
|
return strings.TrimSpace(parsed), true
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(v)), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSettingDateTime(value any) (string, bool) {
|
||||||
|
if value == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if dt, err := types.ParseDateTime(value); err == nil && !dt.IsZero() {
|
||||||
|
return functions.GermanShortDateTime(dt), true
|
||||||
|
}
|
||||||
|
if raw, ok := parseSettingString(value); ok {
|
||||||
|
clean := strings.Trim(raw, "\"")
|
||||||
|
if dt, err := types.ParseDateTime(clean); err == nil && !dt.IsZero() {
|
||||||
|
return functions.GermanShortDateTime(dt), true
|
||||||
|
}
|
||||||
|
if clean != "" {
|
||||||
|
return clean, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSettingDateTime(value any) (types.DateTime, bool) {
|
||||||
|
if value == nil {
|
||||||
|
return types.DateTime{}, false
|
||||||
|
}
|
||||||
|
if dt, err := types.ParseDateTime(value); err == nil && !dt.IsZero() {
|
||||||
|
return dt, true
|
||||||
|
}
|
||||||
|
if raw, ok := parseSettingString(value); ok {
|
||||||
|
clean := strings.Trim(raw, "\"")
|
||||||
|
if dt, err := types.ParseDateTime(clean); err == nil && !dt.IsZero() {
|
||||||
|
return dt, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types.DateTime{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSettingGermanDateTime(value any) (string, bool) {
|
||||||
|
if dt, ok := parseSettingDateTime(value); ok {
|
||||||
|
return functions.GermanDate(dt) + " " + functions.GermanTime(dt), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -440,6 +440,7 @@ const (
|
|||||||
SESSIONS_TABLE = "sessions"
|
SESSIONS_TABLE = "sessions"
|
||||||
ACCESS_TOKENS_TABLE = "access_tokens"
|
ACCESS_TOKENS_TABLE = "access_tokens"
|
||||||
DATA_TABLE = "data"
|
DATA_TABLE = "data"
|
||||||
|
SETTINGS_TABLE = "settings"
|
||||||
IMAGES_TABLE = "images"
|
IMAGES_TABLE = "images"
|
||||||
FILES_TABLE = "files"
|
FILES_TABLE = "files"
|
||||||
HTML_TABLE = "html"
|
HTML_TABLE = "html"
|
||||||
|
|||||||
@@ -269,10 +269,42 @@ func CreateFTS5TableQuery(tablename string, fields ...string) string {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateFTS5Tables(app core.App) error {
|
||||||
|
err1 := createFTS5Table(app, AGENTS_TABLE, AGENTS_FTS5_FIELDS)
|
||||||
|
err2 := createFTS5Table(app, PLACES_TABLE, PLACES_FTS5_FIELDS)
|
||||||
|
err3 := createFTS5Table(app, SERIES_TABLE, SERIES_FTS5_FIELDS)
|
||||||
|
err4 := createFTS5Table(app, ITEMS_TABLE, ITEMS_FTS5_FIELDS)
|
||||||
|
err5 := createFTS5Table(app, ENTRIES_TABLE, ENTRIES_FTS5_FIELDS)
|
||||||
|
err6 := createFTS5Table(app, CONTENTS_TABLE, CONTENTS_FTS5_FIELDS)
|
||||||
|
return errors.Join(err1, err2, err3, err4, err5, err6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DropFTS5Tables(app core.App) error {
|
||||||
|
err1 := dropFTS5Table(app, FTS5TableName(AGENTS_TABLE))
|
||||||
|
err2 := dropFTS5Table(app, FTS5TableName(PLACES_TABLE))
|
||||||
|
err3 := dropFTS5Table(app, FTS5TableName(SERIES_TABLE))
|
||||||
|
err4 := dropFTS5Table(app, FTS5TableName(ITEMS_TABLE))
|
||||||
|
err5 := dropFTS5Table(app, FTS5TableName(ENTRIES_TABLE))
|
||||||
|
err6 := dropFTS5Table(app, FTS5TableName(CONTENTS_TABLE))
|
||||||
|
return errors.Join(err1, err2, err3, err4, err5, err6)
|
||||||
|
}
|
||||||
|
|
||||||
func FTS5TableName(table string) string {
|
func FTS5TableName(table string) string {
|
||||||
return FTS5_PREFIX + table
|
return FTS5_PREFIX + table
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createFTS5Table(app core.App, table string, fields []string) error {
|
||||||
|
query := CreateFTS5TableQuery(table, fields...)
|
||||||
|
_, err := app.DB().NewQuery(query).Execute()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dropFTS5Table(app core.App, table string) error {
|
||||||
|
query := "DROP TABLE IF EXISTS " + table
|
||||||
|
_, err := app.DB().NewQuery(query).Execute()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func InsertFTS5Agent(app core.App, agent *Agent) error {
|
func InsertFTS5Agent(app core.App, agent *Agent) error {
|
||||||
query := FTS5InsertQuery(app, AGENTS_TABLE, AGENTS_FTS5_FIELDS)
|
query := FTS5InsertQuery(app, AGENTS_TABLE, AGENTS_FTS5_FIELDS)
|
||||||
return BulkInsertFTS5Agent(query, agent)
|
return BulkInsertFTS5Agent(query, agent)
|
||||||
|
|||||||
@@ -159,6 +159,17 @@ func Data_All(app core.App) ([]*Data, error) {
|
|||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Settings_Key(app core.App, key string) (*Setting, error) {
|
||||||
|
ret, err := TableByField[Setting](app, SETTINGS_TABLE, KEY_FIELD, key)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func Settings_All(app core.App) ([]*Setting, error) {
|
||||||
|
settings := make([]*Setting, 0)
|
||||||
|
err := app.RecordQuery(SETTINGS_TABLE).All(&settings)
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
|
||||||
func Pages_All(app core.App) ([]*Page, error) {
|
func Pages_All(app core.App) ([]*Page, error) {
|
||||||
pages := make([]*Page, 0)
|
pages := make([]*Page, 0)
|
||||||
err := app.RecordQuery(PAGES_TABLE).All(&pages)
|
err := app.RecordQuery(PAGES_TABLE).All(&pages)
|
||||||
|
|||||||
23
dbmodels/settings.go
Normal file
23
dbmodels/settings.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package dbmodels
|
||||||
|
|
||||||
|
import "github.com/pocketbase/pocketbase/core"
|
||||||
|
|
||||||
|
type Setting struct {
|
||||||
|
core.BaseRecordProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) Key() string {
|
||||||
|
return s.GetString(KEY_FIELD)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) SetKey(key string) {
|
||||||
|
s.Set(KEY_FIELD, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) Value() any {
|
||||||
|
return s.GetRaw(VALUE_FIELD)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) SetValue(value any) {
|
||||||
|
s.Set(VALUE_FIELD, value)
|
||||||
|
}
|
||||||
@@ -1,50 +1,156 @@
|
|||||||
package imports
|
package imports
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
fts5Mu sync.Mutex
|
||||||
|
fts5Running bool
|
||||||
|
fts5Cancel context.CancelFunc
|
||||||
|
fts5RestartRequested bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartFTS5Rebuild(app core.App, allowRestart bool) (string, error) {
|
||||||
|
fts5Mu.Lock()
|
||||||
|
if fts5Running {
|
||||||
|
if allowRestart {
|
||||||
|
fts5RestartRequested = true
|
||||||
|
if fts5Cancel != nil {
|
||||||
|
fts5Cancel()
|
||||||
|
}
|
||||||
|
done := getSettingInt(app, "fts5_rebuild_done")
|
||||||
|
total := getSettingInt(app, "fts5_rebuild_total")
|
||||||
|
setFTS5RebuildState(app, "running", "Neuaufbau wird neu gestartet.", done, total, "")
|
||||||
|
fts5Mu.Unlock()
|
||||||
|
return "restarting", nil
|
||||||
|
}
|
||||||
|
fts5Mu.Unlock()
|
||||||
|
return "running", nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
fts5Running = true
|
||||||
|
fts5Cancel = cancel
|
||||||
|
fts5RestartRequested = false
|
||||||
|
fts5Mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := rebuildFTSFromScratchWithContext(app, ctx)
|
||||||
|
fts5Mu.Lock()
|
||||||
|
restart := fts5RestartRequested
|
||||||
|
fts5Running = false
|
||||||
|
fts5Cancel = nil
|
||||||
|
fts5RestartRequested = false
|
||||||
|
fts5Mu.Unlock()
|
||||||
|
|
||||||
|
if restart {
|
||||||
|
_, _ = StartFTS5Rebuild(app, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return "started", nil
|
||||||
|
}
|
||||||
|
|
||||||
func RebuildFTS(app core.App) error {
|
func RebuildFTS(app core.App) error {
|
||||||
if err := dbmodels.DeleteFTS5Data(app); err != nil {
|
return rebuildFTSWithContext(app, true, context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func RebuildFTSFromScratch(app core.App) error {
|
||||||
|
return rebuildFTSFromScratchWithContext(app, context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func rebuildFTSFromScratchWithContext(app core.App, ctx context.Context) error {
|
||||||
|
setFTS5RebuildState(app, "running", "Neuaufbau wird vorbereitet...", 0, 0, "")
|
||||||
|
if err := checkFTS5Canceled(app, ctx, 0, 0); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := dbmodels.DropFTS5Tables(app); err != nil {
|
||||||
|
setFTS5RebuildState(app, "error", "Neuaufbau fehlgeschlagen.", 0, 0, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := checkFTS5Canceled(app, ctx, 0, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := dbmodels.CreateFTS5Tables(app); err != nil {
|
||||||
|
setFTS5RebuildState(app, "error", "Neuaufbau fehlgeschlagen.", 0, 0, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rebuildFTSWithContext(app, false, ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updateFTS5RebuildTimestamp(app)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rebuildFTSWithContext(app core.App, clearExisting bool, ctx context.Context) error {
|
||||||
|
fail := func(err error, done, total int) error {
|
||||||
|
setFTS5RebuildState(app, "error", "Neuaufbau fehlgeschlagen.", done, total, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if clearExisting {
|
||||||
|
if err := dbmodels.DeleteFTS5Data(app); err != nil {
|
||||||
|
return fail(err, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
places := []*dbmodels.Place{}
|
places := []*dbmodels.Place{}
|
||||||
if err := app.RecordQuery(dbmodels.PLACES_TABLE).All(&places); err != nil {
|
if err := app.RecordQuery(dbmodels.PLACES_TABLE).All(&places); err != nil {
|
||||||
return err
|
return fail(err, 0, 0)
|
||||||
}
|
}
|
||||||
agents := []*dbmodels.Agent{}
|
agents := []*dbmodels.Agent{}
|
||||||
if err := app.RecordQuery(dbmodels.AGENTS_TABLE).All(&agents); err != nil {
|
if err := app.RecordQuery(dbmodels.AGENTS_TABLE).All(&agents); err != nil {
|
||||||
return err
|
return fail(err, 0, 0)
|
||||||
}
|
}
|
||||||
series := []*dbmodels.Series{}
|
series := []*dbmodels.Series{}
|
||||||
if err := app.RecordQuery(dbmodels.SERIES_TABLE).All(&series); err != nil {
|
if err := app.RecordQuery(dbmodels.SERIES_TABLE).All(&series); err != nil {
|
||||||
return err
|
return fail(err, 0, 0)
|
||||||
}
|
}
|
||||||
items := []*dbmodels.Item{}
|
items := []*dbmodels.Item{}
|
||||||
if err := app.RecordQuery(dbmodels.ITEMS_TABLE).All(&items); err != nil {
|
if err := app.RecordQuery(dbmodels.ITEMS_TABLE).All(&items); err != nil {
|
||||||
return err
|
return fail(err, 0, 0)
|
||||||
}
|
}
|
||||||
entries := []*dbmodels.Entry{}
|
entries := []*dbmodels.Entry{}
|
||||||
if err := app.RecordQuery(dbmodels.ENTRIES_TABLE).All(&entries); err != nil {
|
if err := app.RecordQuery(dbmodels.ENTRIES_TABLE).All(&entries); err != nil {
|
||||||
return err
|
return fail(err, 0, 0)
|
||||||
}
|
}
|
||||||
contents := []*dbmodels.Content{}
|
contents := []*dbmodels.Content{}
|
||||||
if err := app.RecordQuery(dbmodels.CONTENTS_TABLE).All(&contents); err != nil {
|
if err := app.RecordQuery(dbmodels.CONTENTS_TABLE).All(&contents); err != nil {
|
||||||
return err
|
return fail(err, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
entriesSeries := []*dbmodels.REntriesSeries{}
|
entriesSeries := []*dbmodels.REntriesSeries{}
|
||||||
if err := app.RecordQuery(dbmodels.RelationTableName(dbmodels.ENTRIES_TABLE, dbmodels.SERIES_TABLE)).All(&entriesSeries); err != nil {
|
if err := app.RecordQuery(dbmodels.RelationTableName(dbmodels.ENTRIES_TABLE, dbmodels.SERIES_TABLE)).All(&entriesSeries); err != nil {
|
||||||
return err
|
return fail(err, 0, 0)
|
||||||
}
|
}
|
||||||
entriesAgents := []*dbmodels.REntriesAgents{}
|
entriesAgents := []*dbmodels.REntriesAgents{}
|
||||||
if err := app.RecordQuery(dbmodels.RelationTableName(dbmodels.ENTRIES_TABLE, dbmodels.AGENTS_TABLE)).All(&entriesAgents); err != nil {
|
if err := app.RecordQuery(dbmodels.RelationTableName(dbmodels.ENTRIES_TABLE, dbmodels.AGENTS_TABLE)).All(&entriesAgents); err != nil {
|
||||||
return err
|
return fail(err, 0, 0)
|
||||||
}
|
}
|
||||||
contentsAgents := []*dbmodels.RContentsAgents{}
|
contentsAgents := []*dbmodels.RContentsAgents{}
|
||||||
if err := app.RecordQuery(dbmodels.RelationTableName(dbmodels.CONTENTS_TABLE, dbmodels.AGENTS_TABLE)).All(&contentsAgents); err != nil {
|
if err := app.RecordQuery(dbmodels.RelationTableName(dbmodels.CONTENTS_TABLE, dbmodels.AGENTS_TABLE)).All(&contentsAgents); err != nil {
|
||||||
|
return fail(err, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(places) + len(agents) + len(series) + len(items) + len(entries) + len(contents)
|
||||||
|
done := 0
|
||||||
|
setFTS5RebuildState(app, "running", "FTS5-Neuaufbau läuft.", done, total, "")
|
||||||
|
if err := checkFTS5Canceled(app, ctx, done, total); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,27 +198,50 @@ func RebuildFTS(app core.App) error {
|
|||||||
qc := dbmodels.FTS5InsertQuery(app, dbmodels.CONTENTS_TABLE, dbmodels.CONTENTS_FTS5_FIELDS)
|
qc := dbmodels.FTS5InsertQuery(app, dbmodels.CONTENTS_TABLE, dbmodels.CONTENTS_FTS5_FIELDS)
|
||||||
|
|
||||||
for _, place := range places {
|
for _, place := range places {
|
||||||
if err := dbmodels.BulkInsertFTS5Place(qp, place); err != nil {
|
if err := checkFTS5Canceled(app, ctx, done, total); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := dbmodels.BulkInsertFTS5Place(qp, place); err != nil {
|
||||||
|
return fail(err, done, total)
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
maybeUpdateFTS5Progress(app, ctx, done, total)
|
||||||
}
|
}
|
||||||
for _, agent := range agents {
|
for _, agent := range agents {
|
||||||
if err := dbmodels.BulkInsertFTS5Agent(qa, agent); err != nil {
|
if err := checkFTS5Canceled(app, ctx, done, total); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := dbmodels.BulkInsertFTS5Agent(qa, agent); err != nil {
|
||||||
|
return fail(err, done, total)
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
maybeUpdateFTS5Progress(app, ctx, done, total)
|
||||||
}
|
}
|
||||||
for _, s := range series {
|
for _, s := range series {
|
||||||
if err := dbmodels.BulkInsertFTS5Series(qs, s); err != nil {
|
if err := checkFTS5Canceled(app, ctx, done, total); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := dbmodels.BulkInsertFTS5Series(qs, s); err != nil {
|
||||||
|
return fail(err, done, total)
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
maybeUpdateFTS5Progress(app, ctx, done, total)
|
||||||
}
|
}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if err := dbmodels.BulkInsertFTS5Item(qi, item); err != nil {
|
if err := checkFTS5Canceled(app, ctx, done, total); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := dbmodels.BulkInsertFTS5Item(qi, item); err != nil {
|
||||||
|
return fail(err, done, total)
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
maybeUpdateFTS5Progress(app, ctx, done, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
if err := checkFTS5Canceled(app, ctx, done, total); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
entryPlaces := []*dbmodels.Place{}
|
entryPlaces := []*dbmodels.Place{}
|
||||||
for _, placeId := range entry.Places() {
|
for _, placeId := range entry.Places() {
|
||||||
if place := placesById[placeId]; place != nil {
|
if place := placesById[placeId]; place != nil {
|
||||||
@@ -122,17 +251,145 @@ func RebuildFTS(app core.App) error {
|
|||||||
entryAgents := entriesAgentsMap[entry.Id]
|
entryAgents := entriesAgentsMap[entry.Id]
|
||||||
entrySeries := entriesSeriesMap[entry.Id]
|
entrySeries := entriesSeriesMap[entry.Id]
|
||||||
if err := dbmodels.BulkInsertFTS5Entry(qe, entry, entryPlaces, entryAgents, entrySeries); err != nil {
|
if err := dbmodels.BulkInsertFTS5Entry(qe, entry, entryPlaces, entryAgents, entrySeries); err != nil {
|
||||||
return err
|
return fail(err, done, total)
|
||||||
}
|
}
|
||||||
|
done++
|
||||||
|
maybeUpdateFTS5Progress(app, ctx, done, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, content := range contents {
|
for _, content := range contents {
|
||||||
|
if err := checkFTS5Canceled(app, ctx, done, total); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
entry := entriesById[content.Entry()]
|
entry := entriesById[content.Entry()]
|
||||||
contentAgents := contentsAgentsMap[content.Id]
|
contentAgents := contentsAgentsMap[content.Id]
|
||||||
if err := dbmodels.BulkInsertFTS5Content(qc, content, entry, contentAgents); err != nil {
|
if err := dbmodels.BulkInsertFTS5Content(qc, content, entry, contentAgents); err != nil {
|
||||||
|
return fail(err, done, total)
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
maybeUpdateFTS5Progress(app, ctx, done, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFTS5RebuildState(app, "complete", "FTS5-Neuaufbau abgeschlossen.", done, total, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func maybeUpdateFTS5Progress(app core.App, ctx context.Context, done, total int) {
|
||||||
|
if total <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if done%100 == 0 || done == total {
|
||||||
|
if err := checkFTS5Canceled(app, ctx, done, total); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFTS5RebuildState(app, "running", "FTS5-Neuaufbau läuft.", done, total, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkFTS5Canceled(app core.App, ctx context.Context, done, total int) error {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
setFTS5RebuildState(app, "aborted", "Neuaufbau abgebrochen.", done, total, "")
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFTS5RebuildTimestamp(app core.App) {
|
||||||
|
collection, err := app.FindCollectionByNameOrId(dbmodels.SETTINGS_TABLE)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to load settings collection for FTS5 timestamp", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var record *core.Record
|
||||||
|
existing, err := dbmodels.Settings_Key(app, "fts5_last_rebuild")
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
record = existing.ProxyRecord()
|
||||||
|
} else if err != nil {
|
||||||
|
if !isRecordNotFound(err) {
|
||||||
|
app.Logger().Error("Failed to load FTS5 timestamp setting", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if record == nil {
|
||||||
|
record = core.NewRecord(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Set(dbmodels.KEY_FIELD, "fts5_last_rebuild")
|
||||||
|
record.Set(dbmodels.VALUE_FIELD, types.NowDateTime())
|
||||||
|
if err := app.Save(record); err != nil {
|
||||||
|
app.Logger().Error("Failed to save FTS5 timestamp", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecordNotFound(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "no rows in result set") || strings.Contains(msg, "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFTS5RebuildState(app core.App, status, message string, done, total int, errMsg string) {
|
||||||
|
_ = upsertSetting(app, "fts5_rebuild_status", status)
|
||||||
|
_ = upsertSetting(app, "fts5_rebuild_message", message)
|
||||||
|
_ = upsertSetting(app, "fts5_rebuild_done", done)
|
||||||
|
_ = upsertSetting(app, "fts5_rebuild_total", total)
|
||||||
|
if errMsg != "" {
|
||||||
|
_ = upsertSetting(app, "fts5_rebuild_error", errMsg)
|
||||||
|
} else {
|
||||||
|
_ = upsertSetting(app, "fts5_rebuild_error", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertSetting(app core.App, key string, value any) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId(dbmodels.SETTINGS_TABLE)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var record *core.Record
|
||||||
|
existing, err := dbmodels.Settings_Key(app, key)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
record = existing.ProxyRecord()
|
||||||
|
} else if err != nil {
|
||||||
|
if !isRecordNotFound(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if record == nil {
|
||||||
return nil
|
record = core.NewRecord(collection)
|
||||||
|
}
|
||||||
|
record.Set(dbmodels.KEY_FIELD, key)
|
||||||
|
record.Set(dbmodels.VALUE_FIELD, value)
|
||||||
|
return app.Save(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSettingInt(app core.App, key string) int {
|
||||||
|
setting, err := dbmodels.Settings_Key(app, key)
|
||||||
|
if err != nil || setting == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch v := setting.Value().(type) {
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
case int64:
|
||||||
|
return int(v)
|
||||||
|
case string:
|
||||||
|
if parsed, err := strconv.Atoi(v); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if parsed, err := strconv.Atoi(fmt.Sprintf("%v", v)); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
36
migrations/1769000002_settings.go
Normal file
36
migrations/1769000002_settings.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
collection := core.NewBaseCollection(dbmodels.SETTINGS_TABLE)
|
||||||
|
dbmodels.SetBasicPublicRules(collection)
|
||||||
|
fields := core.NewFieldsList(
|
||||||
|
&core.TextField{Name: dbmodels.KEY_FIELD, Required: true, Presentable: true},
|
||||||
|
&core.JSONField{Name: dbmodels.VALUE_FIELD, Required: false},
|
||||||
|
)
|
||||||
|
dbmodels.SetCreatedUpdatedFields(&fields)
|
||||||
|
collection.Fields = fields
|
||||||
|
dbmodels.AddIndex(collection, dbmodels.KEY_FIELD, true)
|
||||||
|
|
||||||
|
return app.Save(collection)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId(dbmodels.SETTINGS_TABLE)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no rows in result set") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
app.Logger().Error("Failed to find collection for deletion", "collection", dbmodels.SETTINGS_TABLE, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.Delete(collection)
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,11 +3,10 @@
|
|||||||
<div class="flex container-normal bg-slate-100 mx-auto !pt-36 px-8">
|
<div class="flex container-normal bg-slate-100 mx-auto !pt-36 px-8">
|
||||||
<div class="flex-col w-full">
|
<div class="flex-col w-full">
|
||||||
<a href="/" class="text-gray-700 hover:text-slate-950"> <i class="ri-arrow-left-s-line"></i> Startseite </a>
|
<a href="/" class="text-gray-700 hover:text-slate-950"> <i class="ri-arrow-left-s-line"></i> Startseite </a>
|
||||||
<h1 class="text-2xl self-baseline w-full mt-6 mb-2 font-bold text-slate-900">Datenexport</h1>
|
<h1 class="text-2xl self-baseline w-full mt-6 mb-2 font-bold text-slate-900">Einstellungen</h1>
|
||||||
<div class="text-sm text-slate-600 !hyphens-auto mb-6 max-w-[70ch]">
|
<div class="text-sm text-slate-600 !hyphens-auto mb-6 max-w-[70ch]">
|
||||||
<i class="ri-question-line"></i>
|
<i class="ri-question-line"></i>
|
||||||
Export von Daten u. Dateien als ZIP-Ordner. Die Exporte werden gespeichert und nach dem
|
Export von Daten u. Dateien. Verwaltung der Suchindizes und globalen Einstellungen.
|
||||||
Ablauf von sieben Tagen automatisch gelöscht.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +16,11 @@
|
|||||||
data-run-url="/redaktion/exports/run/"
|
data-run-url="/redaktion/exports/run/"
|
||||||
data-list-url="/redaktion/exports/list/"
|
data-list-url="/redaktion/exports/list/"
|
||||||
data-delete-url="/redaktion/exports/delete/"
|
data-delete-url="/redaktion/exports/delete/"
|
||||||
|
data-fts5-rebuild-url="/redaktion/exports/fts5/rebuild/"
|
||||||
|
data-fts5-status-url="/redaktion/exports/fts5/status/"
|
||||||
data-csrf="{{ $model.csrf_token }}">
|
data-csrf="{{ $model.csrf_token }}">
|
||||||
|
{{ template "_usermessage" $model }}
|
||||||
|
<div class="bg-slate-50 rounded-md shadow-sm border border-slate-200 p-6 mb-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
|
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
Export kann eine Weile in Anspruch nehmen und läuft ebenfalls im Hintergrund.</p>
|
Export kann eine Weile in Anspruch nehmen und läuft ebenfalls im Hintergrund.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="files">
|
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="files">
|
||||||
<i class="ri-folder-zip-line"></i> Dateien sichern
|
<i class="ri-folder-zip-line"></i> Dateien sichern
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,4 +59,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="bg-slate-50 rounded-md shadow-sm border border-slate-200 p-6">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Suchindex neu aufbauen</h2>
|
||||||
|
<p class="text-sm text-slate-600 mt-1">Löscht und erstellt den Suchindex aus den
|
||||||
|
bestehenden Einträgen neu. Kann bei Problemen bei der Suche und Auffindbarkeit von
|
||||||
|
Bänden und Beiträgen hilfreich sein. <em>Die Datenbank sollte während dem Aufbau möglichst
|
||||||
|
nicht verändert werden.</em> Ein automatischer Rebuild findet jeden Sonntag um 00:00 Uhr statt.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
|
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50 whitespace-nowrap" data-role="fts5-rebuild">
|
||||||
|
<i class="ri-refresh-line"></i>
|
||||||
|
<span data-role="fts5-rebuild-label">Neuaufbau starten</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{- if $model.fts5_last_rebuild_dt.IsZero | not -}}
|
||||||
|
<div class="text-xs text-slate-500">Zuletzt aufgebaut: <span data-role="fts5-last-rebuild">{{ GermanDate $model.fts5_last_rebuild_dt }} {{ GermanTime $model.fts5_last_rebuild_dt }}</span></div>
|
||||||
|
{{- else -}}
|
||||||
|
<div class="text-xs text-slate-500 hidden" data-role="fts5-last-rebuild-wrap">Zuletzt aufgebaut: <span data-role="fts5-last-rebuild"></span></div>
|
||||||
|
{{- end -}}
|
||||||
|
<div class="mt-3 hidden rounded-md border px-3 py-2 text-sm" data-role="fts5-status"></div>
|
||||||
|
<div class="mt-3 hidden" data-role="fts5-progress">
|
||||||
|
<div class="flex items-center justify-between text-xs text-slate-500 mb-1">
|
||||||
|
<span data-role="fts5-progress-text"></span>
|
||||||
|
<span data-role="fts5-progress-percent"></span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full bg-slate-200 rounded">
|
||||||
|
<div class="h-2 bg-slate-700 rounded transition-all duration-200" style="width: 0%;" data-role="fts5-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</export-manager>
|
</export-manager>
|
||||||
|
|||||||
34
views/routes/redaktion/settings/body.gohtml
Normal file
34
views/routes/redaktion/settings/body.gohtml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
<div class="flex container-normal bg-slate-100 mx-auto !pt-36 px-8">
|
||||||
|
<div class="flex-col w-full">
|
||||||
|
<a href="/" class="text-gray-700 hover:text-slate-950"> <i class="ri-arrow-left-s-line"></i> Startseite </a>
|
||||||
|
<h1 class="text-2xl self-baseline w-full mt-6 mb-2 font-bold text-slate-900">Einstellungen</h1>
|
||||||
|
<div class="text-sm text-slate-600 !hyphens-auto mb-6 max-w-[70ch]">
|
||||||
|
<i class="ri-settings-3-line"></i>
|
||||||
|
Globale Einstellungen fuer die Seite und Systemfunktionen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container-normal mx-auto px-8">
|
||||||
|
{{ template "_usermessage" $model }}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<section class="bg-white rounded-md shadow-sm border border-slate-200 p-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">FTS5 neu aufbauen</h2>
|
||||||
|
<p class="text-sm text-slate-600 mt-1">Löscht und erstellt die FTS5-Tabellen neu und füllt sie aus den bestehenden Einträgen.</p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/redaktion/settings/fts5/rebuild/" class="flex items-center">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
|
||||||
|
<button type="submit" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50">
|
||||||
|
<i class="ri-refresh-line"></i> Neuaufbau starten
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-3">Automatischer Neuaufbau: jeden Sonntag um 00:00 Uhr.</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,9 +4,16 @@ export class ExportManager extends HTMLElement {
|
|||||||
this.listUrl = "";
|
this.listUrl = "";
|
||||||
this.runUrl = "";
|
this.runUrl = "";
|
||||||
this.deleteUrl = "";
|
this.deleteUrl = "";
|
||||||
|
this.fts5RebuildUrl = "";
|
||||||
|
this.fts5StatusUrl = "";
|
||||||
this.csrf = "";
|
this.csrf = "";
|
||||||
this.list = null;
|
this.list = null;
|
||||||
this.status = null;
|
this.status = null;
|
||||||
|
this.fts5Status = null;
|
||||||
|
this.fts5Progress = null;
|
||||||
|
this.fts5ProgressText = null;
|
||||||
|
this.fts5ProgressPercent = null;
|
||||||
|
this.fts5ProgressBar = null;
|
||||||
this.pollTimer = null;
|
this.pollTimer = null;
|
||||||
this.pollIntervalMs = 2500;
|
this.pollIntervalMs = 2500;
|
||||||
}
|
}
|
||||||
@@ -15,13 +22,27 @@ export class ExportManager extends HTMLElement {
|
|||||||
this.listUrl = this.dataset.listUrl || "";
|
this.listUrl = this.dataset.listUrl || "";
|
||||||
this.runUrl = this.dataset.runUrl || "";
|
this.runUrl = this.dataset.runUrl || "";
|
||||||
this.deleteUrl = this.dataset.deleteUrl || "";
|
this.deleteUrl = this.dataset.deleteUrl || "";
|
||||||
|
this.fts5RebuildUrl = this.dataset.fts5RebuildUrl || "";
|
||||||
|
this.fts5StatusUrl = this.dataset.fts5StatusUrl || "";
|
||||||
this.csrf = this.dataset.csrf || "";
|
this.csrf = this.dataset.csrf || "";
|
||||||
this.list = this.querySelector("[data-role='export-list']");
|
this.list = this.querySelector("[data-role='export-list']");
|
||||||
this.status = this.querySelector("[data-role='status']");
|
this.status = this.querySelector("[data-role='status']");
|
||||||
|
this.fts5Status = this.querySelector("[data-role='fts5-status']");
|
||||||
|
this.fts5Progress = this.querySelector("[data-role='fts5-progress']");
|
||||||
|
this.fts5ProgressText = this.querySelector("[data-role='fts5-progress-text']");
|
||||||
|
this.fts5ProgressPercent = this.querySelector("[data-role='fts5-progress-percent']");
|
||||||
|
this.fts5ProgressBar = this.querySelector("[data-role='fts5-progress-bar']");
|
||||||
|
this.fts5LastRebuild = this.querySelector("[data-role='fts5-last-rebuild']");
|
||||||
|
this.fts5LastRebuildWrap = this.querySelector("[data-role='fts5-last-rebuild-wrap']");
|
||||||
|
this.fts5Button = this.querySelector("[data-role='fts5-rebuild']");
|
||||||
|
this.fts5ButtonLabel = this.querySelector("[data-role='fts5-rebuild-label']");
|
||||||
|
this.fts5StatusValue = "idle";
|
||||||
|
this.fts5HadRunning = false;
|
||||||
|
|
||||||
this.addEventListener("click", (event) => this.handleAction(event));
|
this.addEventListener("click", (event) => this.handleAction(event));
|
||||||
|
|
||||||
this.refreshList();
|
this.refreshList();
|
||||||
|
this.refreshFts5Status();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@@ -87,6 +108,12 @@ export class ExportManager extends HTMLElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fts5Target = event.target.closest("[data-role='fts5-rebuild']");
|
||||||
|
if (fts5Target) {
|
||||||
|
await this.handleFts5Rebuild(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const target = event.target.closest("[data-action]");
|
const target = event.target.closest("[data-action]");
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const action = target.getAttribute("data-action");
|
const action = target.getAttribute("data-action");
|
||||||
@@ -140,10 +167,163 @@ export class ExportManager extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshFts5Status() {
|
||||||
|
if (!this.fts5StatusUrl) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.fts5StatusUrl, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) return;
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (!json) return;
|
||||||
|
this.updateFts5Status(json);
|
||||||
|
this.syncPollingState();
|
||||||
|
} catch {
|
||||||
|
// ignore refresh errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFts5Status(data) {
|
||||||
|
if (!this.fts5Status) return;
|
||||||
|
const prevStatus = this.fts5StatusValue;
|
||||||
|
const status = this.normalizeText(data.status) || "idle";
|
||||||
|
const message = this.normalizeText(data.message || "");
|
||||||
|
const err = this.normalizeText(data.error || "");
|
||||||
|
const done = Number.isFinite(data.done) ? data.done : 0;
|
||||||
|
const total = Number.isFinite(data.total) ? data.total : 0;
|
||||||
|
const lastRebuild = this.formatGermanDateTime(this.normalizeText(data.last_rebuild || ""));
|
||||||
|
this.fts5StatusValue = status;
|
||||||
|
|
||||||
|
this.fts5Status.classList.remove(
|
||||||
|
"hidden",
|
||||||
|
"text-slate-700",
|
||||||
|
"text-green-800",
|
||||||
|
"text-red-700",
|
||||||
|
"text-amber-800",
|
||||||
|
"bg-slate-50",
|
||||||
|
"bg-green-50",
|
||||||
|
"bg-red-50",
|
||||||
|
"bg-amber-50",
|
||||||
|
"border-slate-200",
|
||||||
|
"border-green-200",
|
||||||
|
"border-red-200",
|
||||||
|
"border-amber-200",
|
||||||
|
);
|
||||||
|
if (status === "running" || status === "restarting") {
|
||||||
|
this.fts5HadRunning = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "complete" && !this.fts5HadRunning) {
|
||||||
|
this.fts5Status.textContent = "";
|
||||||
|
this.fts5Status.classList.add("hidden");
|
||||||
|
} else if (status === "error") {
|
||||||
|
this.fts5Status.textContent = err || "FTS5-Neuaufbau fehlgeschlagen.";
|
||||||
|
this.fts5Status.classList.add("text-red-700", "bg-red-50", "border-red-200");
|
||||||
|
} else if (status === "aborted") {
|
||||||
|
this.fts5Status.textContent = message || "FTS5-Neuaufbau abgebrochen.";
|
||||||
|
this.fts5Status.classList.add("text-red-700", "bg-red-50", "border-red-200");
|
||||||
|
} else if (status === "complete") {
|
||||||
|
this.fts5Status.textContent = message || "FTS5-Neuaufbau abgeschlossen.";
|
||||||
|
this.fts5Status.classList.add("text-green-800", "bg-green-50", "border-green-200");
|
||||||
|
} else if (status === "restarting") {
|
||||||
|
this.fts5Status.textContent = message || "FTS5-Neuaufbau wird neu gestartet.";
|
||||||
|
this.fts5Status.classList.add("text-amber-800", "bg-amber-50", "border-amber-200");
|
||||||
|
} else if (status === "running") {
|
||||||
|
this.fts5Status.textContent = message || "FTS5-Neuaufbau läuft.";
|
||||||
|
this.fts5Status.classList.add("text-amber-800", "bg-amber-50", "border-amber-200");
|
||||||
|
} else {
|
||||||
|
this.fts5Status.textContent = message || "";
|
||||||
|
if (!this.fts5Status.textContent) {
|
||||||
|
this.fts5Status.classList.add("hidden");
|
||||||
|
} else {
|
||||||
|
this.fts5Status.classList.add("text-slate-700", "bg-slate-50", "border-slate-200");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.fts5Status.textContent) {
|
||||||
|
this.fts5Status.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fts5Progress) {
|
||||||
|
if (status === "running" || status === "restarting") {
|
||||||
|
this.fts5Progress.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
this.fts5Progress.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fts5Button) {
|
||||||
|
const isRunning = status === "running";
|
||||||
|
if (this.fts5ButtonLabel) {
|
||||||
|
this.fts5ButtonLabel.textContent = isRunning ? "Abbrechen & neu starten" : "Neuaufbau starten";
|
||||||
|
}
|
||||||
|
this.fts5Button.classList.toggle("bg-slate-900", !isRunning);
|
||||||
|
this.fts5Button.classList.toggle("hover:bg-slate-800", !isRunning);
|
||||||
|
this.fts5Button.classList.toggle("bg-amber-600", isRunning);
|
||||||
|
this.fts5Button.classList.toggle("hover:bg-amber-700", isRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fts5LastRebuild && lastRebuild) {
|
||||||
|
this.fts5LastRebuild.textContent = lastRebuild;
|
||||||
|
if (this.fts5LastRebuildWrap) {
|
||||||
|
this.fts5LastRebuildWrap.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevStatus === "running" && status !== "running") {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.refreshFts5Status();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((status === "running" || status === "restarting") && total > 0) {
|
||||||
|
const percent = Math.min(100, Math.round((done / total) * 100));
|
||||||
|
if (this.fts5ProgressText) {
|
||||||
|
this.fts5ProgressText.textContent = `${done} / ${total}`;
|
||||||
|
}
|
||||||
|
if (this.fts5ProgressPercent) {
|
||||||
|
this.fts5ProgressPercent.textContent = `${percent}%`;
|
||||||
|
}
|
||||||
|
if (this.fts5ProgressBar) {
|
||||||
|
this.fts5ProgressBar.style.width = `${percent}%`;
|
||||||
|
}
|
||||||
|
} else if (status === "running" || status === "restarting") {
|
||||||
|
if (this.fts5ProgressText) {
|
||||||
|
this.fts5ProgressText.textContent = "Wird vorbereitet...";
|
||||||
|
}
|
||||||
|
if (this.fts5ProgressPercent) {
|
||||||
|
this.fts5ProgressPercent.textContent = "";
|
||||||
|
}
|
||||||
|
if (this.fts5ProgressBar) {
|
||||||
|
this.fts5ProgressBar.style.width = "0%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatGermanDateTime(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
const normalized = raw.replace(/^"+|"+$/g, "");
|
||||||
|
const isoMatch = normalized.match(
|
||||||
|
/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/,
|
||||||
|
);
|
||||||
|
if (!isoMatch) return normalized;
|
||||||
|
const iso = normalized.replace(" ", "T");
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return normalized;
|
||||||
|
const weekdays = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||||
|
const months = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = months[date.getMonth()];
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
return `${weekdays[date.getDay()]}, ${day}. ${month} ${year} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
syncPollingState() {
|
syncPollingState() {
|
||||||
if (!this.list) return;
|
const hasExports = this.list
|
||||||
const active = this.list.querySelector("[data-export-status='running'], [data-export-status='queued']");
|
? this.list.querySelector("[data-export-status='running'], [data-export-status='queued']")
|
||||||
if (active) {
|
: null;
|
||||||
|
const fts5Running = this.fts5Progress && !this.fts5Progress.classList.contains("hidden");
|
||||||
|
if (hasExports || fts5Running) {
|
||||||
this.startPolling();
|
this.startPolling();
|
||||||
} else {
|
} else {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
@@ -154,6 +334,7 @@ export class ExportManager extends HTMLElement {
|
|||||||
if (this.pollTimer) return;
|
if (this.pollTimer) return;
|
||||||
this.pollTimer = window.setInterval(() => {
|
this.pollTimer = window.setInterval(() => {
|
||||||
this.refreshList();
|
this.refreshList();
|
||||||
|
this.refreshFts5Status();
|
||||||
}, this.pollIntervalMs);
|
}, this.pollIntervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +344,68 @@ export class ExportManager extends HTMLElement {
|
|||||||
this.pollTimer = null;
|
this.pollTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleFts5Rebuild(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!this.fts5RebuildUrl) return;
|
||||||
|
const button = event.target.closest("[data-role='fts5-rebuild']");
|
||||||
|
if (button) button.disabled = true;
|
||||||
|
if (this.fts5Progress) {
|
||||||
|
this.fts5Progress.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
if (this.fts5ProgressText) {
|
||||||
|
this.fts5ProgressText.textContent = "Wird vorbereitet...";
|
||||||
|
}
|
||||||
|
if (this.fts5ProgressPercent) {
|
||||||
|
this.fts5ProgressPercent.textContent = "";
|
||||||
|
}
|
||||||
|
if (this.fts5ProgressBar) {
|
||||||
|
this.fts5ProgressBar.style.width = "0%";
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
payload.set("csrf_token", this.getCsrfToken());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.fts5RebuildUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await this.extractError(response);
|
||||||
|
if (this.fts5Status) {
|
||||||
|
this.fts5Status.textContent = message || "FTS5-Neuaufbau konnte nicht gestartet werden.";
|
||||||
|
this.fts5Status.classList.add("text-red-600");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json && json.error) {
|
||||||
|
if (this.fts5Status) {
|
||||||
|
this.fts5Status.textContent = json.error;
|
||||||
|
this.fts5Status.classList.add("text-red-600");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.fts5HadRunning = true;
|
||||||
|
if (json && json.status === "restarting") {
|
||||||
|
this.updateFts5Status({
|
||||||
|
status: "restarting",
|
||||||
|
message: "FTS5-Neuaufbau wird neu gestartet.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.refreshFts5Status();
|
||||||
|
this.startPolling();
|
||||||
|
} catch {
|
||||||
|
if (this.fts5Status) {
|
||||||
|
this.fts5Status.textContent = "FTS5-Neuaufbau konnte nicht gestartet werden.";
|
||||||
|
this.fts5Status.classList.add("text-red-600");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (button) button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async safeJson(response) {
|
async safeJson(response) {
|
||||||
try {
|
try {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
@@ -195,4 +438,13 @@ export class ExportManager extends HTMLElement {
|
|||||||
}
|
}
|
||||||
return this.csrf;
|
return this.csrf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeText(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
let text = String(value).trim();
|
||||||
|
if (text.length >= 2 && text.startsWith("\"") && text.endsWith("\"")) {
|
||||||
|
text = text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,8 +339,8 @@ export class FabMenu extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-[1fr_auto] group">
|
<div class="grid grid-cols-[1fr_auto] group">
|
||||||
<a href="/redaktion/exports/" class="flex items-center px-3 py-1.5 group-hover:bg-gray-100 transition-colors no-underline text-sm">
|
<a href="/redaktion/exports/" class="flex items-center px-3 py-1.5 group-hover:bg-gray-100 transition-colors no-underline text-sm">
|
||||||
<i class="ri-download-2-line text-base text-gray-700 mr-2.5"></i>
|
<i class="ri-tools-line text-base text-gray-700 mr-2.5"></i>
|
||||||
<span class="text-gray-900">Datenexport</span>
|
<span class="text-gray-900">Daten & Suche</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/redaktion/exports/" target="_blank" class="flex items-center justify-center px-2.5 py-1.5 group-hover:bg-gray-100 text-gray-700 hover:text-slate-900 transition-colors no-underline text-sm" title="In neuem Tab öffnen">
|
<a href="/redaktion/exports/" target="_blank" class="flex items-center justify-center px-2.5 py-1.5 group-hover:bg-gray-100 text-gray-700 hover:text-slate-900 transition-colors no-underline text-sm" title="In neuem Tab öffnen">
|
||||||
<i class="ri-external-link-line text-base"></i>
|
<i class="ri-external-link-line text-base"></i>
|
||||||
|
|||||||
Reference in New Issue
Block a user