package controllers import ( "fmt" "maps" "net/http" "net/url" "slices" "sort" "strconv" "strings" "time" "github.com/Theodor-Springmann-Stiftung/musenalm/app" "github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels" "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/filesystem" "github.com/pocketbase/pocketbase/tools/router" ) const ( URL_ALMANACH_CONTENTS_EDIT = "contents/edit" URL_ALMANACH_CONTENTS_NEW = "contents/new" URL_ALMANACH_CONTENTS_ITEM_EDIT = "contents/{contentMusenalmId}/edit" URL_ALMANACH_CONTENTS_DELETE = "contents/delete" URL_ALMANACH_CONTENTS_EDIT_EXTENT = "contents/edit/extent" URL_ALMANACH_CONTENTS_UPLOAD = "contents/upload" URL_ALMANACH_CONTENTS_DELETE_SCAN = "contents/scan/delete" TEMPLATE_ALMANACH_CONTENTS_EDIT = "/contents/edit/" TEMPLATE_ALMANACH_CONTENTS_ITEM_EDIT = "/contents/edit_item/" TEMPLATE_ALMANACH_CONTENTS_IMAGES_PANEL = "/contents/images_panel/" ) func init() { ep := &AlmanachContentsEditPage{ StaticPage: pagemodels.StaticPage{ Name: pagemodels.P_ALMANACH_CONTENTS_EDIT_NAME, URL: URL_ALMANACH_CONTENTS_EDIT, Template: TEMPLATE_ALMANACH_CONTENTS_EDIT, Layout: pagemodels.LAYOUT_LOGIN_PAGES, }, } app.Register(ep) } type AlmanachContentsEditPage struct { pagemodels.StaticPage } func (p *AlmanachContentsEditPage) Setup(router *router.Router[*core.RequestEvent], ia pagemodels.IApp, engine *templating.Engine) error { app := ia.Core() rg := router.Group(URL_ALMANACH) rg.BindFunc(middleware.IsAdminOrEditor()) rg.GET(URL_ALMANACH_CONTENTS_EDIT, p.GET(engine, app)) rg.GET(URL_ALMANACH_CONTENTS_NEW, p.GETNew(engine, app)) rg.GET(URL_ALMANACH_CONTENTS_ITEM_EDIT, p.GETItemEdit(engine, app)) rg.POST(URL_ALMANACH_CONTENTS_EDIT, p.POSTSave(engine, app)) rg.POST(URL_ALMANACH_CONTENTS_DELETE, p.POSTDelete(engine, app)) rg.POST(URL_ALMANACH_CONTENTS_EDIT_EXTENT, p.POSTUpdateExtent(engine, app)) rg.POST(URL_ALMANACH_CONTENTS_UPLOAD, p.POSTUploadScans(engine, app)) rg.POST(URL_ALMANACH_CONTENTS_DELETE_SCAN, p.POSTDeleteScan(engine, app)) return nil } func (p *AlmanachContentsEditPage) GET(engine *templating.Engine, app core.App) HandleFunc { return func(e *core.RequestEvent) error { id := e.Request.PathValue("id") req := templating.NewRequest(e) data := make(map[string]any) result, err := NewAlmanachEditResult(app, id, BeitraegeFilterParameters{}) if err != nil { engine.Response404(e, err, nil) } data["result"] = result data["csrf_token"] = req.Session().Token data["content_types"] = dbmodels.CONTENT_TYPE_VALUES data["musenalm_types"] = dbmodels.MUSENALM_TYPE_VALUES data["pagination_values"] = paginationValuesSorted() data["agent_relations"] = dbmodels.AGENT_RELATIONS data["cancel_url"] = cancelURLFromHeader(e) if msg := popFlashSuccess(e); msg != "" { data["success"] = msg } data["edit_content_id"] = strings.TrimSpace(e.Request.URL.Query().Get("edit_content")) data["new_content"] = strings.TrimSpace(e.Request.URL.Query().Get("new_content")) return engine.Response200(e, p.Template, data, p.Layout) } } func (p *AlmanachContentsEditPage) GETItemEdit(engine *templating.Engine, app core.App) HandleFunc { return func(e *core.RequestEvent) error { id := e.Request.PathValue("id") contentMusenalmID := strings.TrimSpace(e.Request.PathValue("contentMusenalmId")) if contentMusenalmID == "" { return e.String(http.StatusBadRequest, "") } req := templating.NewRequest(e) data := make(map[string]any) result, err := NewAlmanachEditResult(app, id, BeitraegeFilterParameters{}) if err != nil { engine.Response404(e, err, nil) } content, err := dbmodels.Contents_MusenalmID(app, contentMusenalmID) if err != nil || content == nil { return e.String(http.StatusNotFound, "") } if content.Entry() != result.Entry.Id { return e.String(http.StatusNotFound, "") } contents, err := dbmodels.Contents_Entry(app, result.Entry.Id) if err == nil && len(contents) > 1 { sort.Slice(contents, func(i, j int) bool { if contents[i].Numbering() == contents[j].Numbering() { return contents[i].Id < contents[j].Id } return contents[i].Numbering() < contents[j].Numbering() }) } var prevContent *dbmodels.Content var nextContent *dbmodels.Content contentIndex := 0 contentTotal := 0 if len(contents) > 0 { contentTotal = len(contents) for i, c := range contents { if c.Id != content.Id { continue } contentIndex = i + 1 if i > 0 { prevContent = contents[i-1] } if i < len(contents)-1 { nextContent = contents[i+1] } break } } agentsMap, contentAgentsMap, err := dbmodels.AgentsForContents(app, []*dbmodels.Content{content}) if err != nil { agentsMap = map[string]*dbmodels.Agent{} contentAgentsMap = map[string][]*dbmodels.RContentsAgents{} } data["result"] = result data["csrf_token"] = req.Session().Token data["content"] = content data["content_id"] = content.Id data["content_types"] = dbmodels.CONTENT_TYPE_VALUES data["musenalm_types"] = dbmodels.MUSENALM_TYPE_VALUES data["pagination_values"] = paginationValuesSorted() data["agent_relations"] = dbmodels.AGENT_RELATIONS data["agents"] = agentsMap data["content_agents"] = contentAgentsMap[content.Id] data["cancel_url"] = cancelURLFromHeader(e) data["prev_content"] = prevContent data["next_content"] = nextContent data["content_index"] = contentIndex data["content_total"] = contentTotal if msg := popFlashSuccess(e); msg != "" { data["success"] = msg } return engine.Response200(e, TEMPLATE_ALMANACH_CONTENTS_ITEM_EDIT, data, p.Layout) } } func (p *AlmanachContentsEditPage) GETNew(engine *templating.Engine, app core.App) HandleFunc { return func(e *core.RequestEvent) error { id := e.Request.PathValue("id") req := templating.NewRequest(e) data := make(map[string]any) result, err := NewAlmanachEditResult(app, id, BeitraegeFilterParameters{}) if err != nil { engine.Response404(e, err, nil) } contentCollection, err := app.FindCollectionByNameOrId(dbmodels.CONTENTS_TABLE) if err != nil { return engine.Response404(e, err, nil) } record := core.NewRecord(contentCollection) tempID := fmt.Sprintf("tmp-%d", time.Now().UnixNano()) record.Id = tempID content := dbmodels.NewContent(record) content.SetEntry(result.Entry.Id) content.SetEditState("Edited") data["result"] = result data["csrf_token"] = req.Session().Token data["content"] = content data["content_id"] = content.Id data["content_types"] = dbmodels.CONTENT_TYPE_VALUES data["musenalm_types"] = dbmodels.MUSENALM_TYPE_VALUES data["pagination_values"] = paginationValuesSorted() data["agent_relations"] = dbmodels.AGENT_RELATIONS data["agents"] = map[string]*dbmodels.Agent{} data["content_agents"] = []*dbmodels.RContentsAgents{} data["is_new"] = true data["cancel_url"] = cancelURLFromHeader(e) return engine.Response200(e, TEMPLATE_ALMANACH_CONTENTS_ITEM_EDIT, data, p.Layout) } } func (p *AlmanachContentsEditPage) renderError(engine *templating.Engine, app core.App, e *core.RequestEvent, message string) error { id := e.Request.PathValue("id") req := templating.NewRequest(e) data := make(map[string]any) result, err := NewAlmanachEditResult(app, id, BeitraegeFilterParameters{}) if err != nil { return engine.Response404(e, err, nil) } data["result"] = result data["csrf_token"] = req.Session().Token data["content_types"] = dbmodels.CONTENT_TYPE_VALUES data["musenalm_types"] = dbmodels.MUSENALM_TYPE_VALUES data["pagination_values"] = paginationValuesSorted() data["agent_relations"] = dbmodels.AGENT_RELATIONS data["error"] = message data["cancel_url"] = cancelURLFromHeader(e) data["edit_content_id"] = strings.TrimSpace(e.Request.URL.Query().Get("edit_content")) data["new_content"] = strings.TrimSpace(e.Request.URL.Query().Get("new_content")) return engine.Response200(e, p.Template, data, p.Layout) } func (p *AlmanachContentsEditPage) renderItemError(engine *templating.Engine, app core.App, e *core.RequestEvent, contentID string, message string) error { id := e.Request.PathValue("id") req := templating.NewRequest(e) data := make(map[string]any) result, err := NewAlmanachEditResult(app, id, BeitraegeFilterParameters{}) if err != nil { return engine.Response404(e, err, nil) } contents, err := dbmodels.Contents_IDs(app, []any{contentID}) if err != nil || len(contents) == 0 { return p.renderError(engine, app, e, message) } content := contents[0] if content.Entry() != result.Entry.Id { return p.renderError(engine, app, e, message) } entryContents, err := dbmodels.Contents_Entry(app, result.Entry.Id) if err == nil && len(entryContents) > 1 { sort.Slice(entryContents, func(i, j int) bool { if entryContents[i].Numbering() == entryContents[j].Numbering() { return entryContents[i].Id < entryContents[j].Id } return entryContents[i].Numbering() < entryContents[j].Numbering() }) } var prevContent *dbmodels.Content var nextContent *dbmodels.Content contentIndex := 0 contentTotal := 0 if len(entryContents) > 0 { contentTotal = len(entryContents) for i, c := range entryContents { if c.Id != content.Id { continue } contentIndex = i + 1 if i > 0 { prevContent = entryContents[i-1] } if i < len(entryContents)-1 { nextContent = entryContents[i+1] } break } } agentsMap, contentAgentsMap, err := dbmodels.AgentsForContents(app, []*dbmodels.Content{content}) if err != nil { agentsMap = map[string]*dbmodels.Agent{} contentAgentsMap = map[string][]*dbmodels.RContentsAgents{} } data["result"] = result data["csrf_token"] = req.Session().Token data["content"] = content data["content_id"] = content.Id data["content_types"] = dbmodels.CONTENT_TYPE_VALUES data["musenalm_types"] = dbmodels.MUSENALM_TYPE_VALUES data["pagination_values"] = paginationValuesSorted() data["agent_relations"] = dbmodels.AGENT_RELATIONS data["agents"] = agentsMap data["content_agents"] = contentAgentsMap[content.Id] data["prev_content"] = prevContent data["next_content"] = nextContent data["content_index"] = contentIndex data["content_total"] = contentTotal data["error"] = message data["cancel_url"] = cancelURLFromHeader(e) return engine.Response200(e, TEMPLATE_ALMANACH_CONTENTS_ITEM_EDIT, data, p.Layout) } func (p *AlmanachContentsEditPage) POSTSave(engine *templating.Engine, app core.App) HandleFunc { return func(e *core.RequestEvent) error { id := e.Request.PathValue("id") req := templating.NewRequest(e) if e.Request.MultipartForm == nil { if err := e.Request.ParseMultipartForm(router.DefaultMaxMemory); err != nil { if e.Request.MultipartForm == nil { if err := e.Request.ParseForm(); err != nil { return p.renderError(engine, app, e, err.Error()) } } } } contentID := strings.TrimSpace(e.Request.FormValue("content_id")) renderError := func(message string) error { if contentID != "" { return p.renderItemError(engine, app, e, contentID, message) } return p.renderError(engine, app, e, message) } if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil { return renderError(err.Error()) } entry, err := dbmodels.Entries_MusenalmID(app, id) if err != nil { return engine.Response404(e, err, nil) } contents, err := dbmodels.Contents_Entry(app, entry.Id) if err != nil { return p.renderError(engine, app, e, "Beiträge konnten nicht geladen werden.") } contentInputs := parseContentsForm(e.Request.PostForm) contentOrder := parseContentsOrder(e.Request.PostForm) orderMap := buildContentOrderMap(contentOrder) relationsByContent := map[string]contentAgentRelationsPayload{} for contentID := range contentInputs { payload := parseContentAgentRelations(e.Request.PostForm, contentID) if err := validateContentAgentRelations(payload); err != nil { return p.renderError(engine, app, e, err.Error()) } relationsByContent[contentID] = payload } user := req.User() existingByID := make(map[string]*dbmodels.Content, len(contents)) for _, content := range contents { existingByID[content.Id] = content } newContentIDs := make([]string, 0) for contentID := range contentInputs { if _, exists := existingByID[contentID]; !exists { newContentIDs = append(newContentIDs, contentID) } } if len(newContentIDs) > 1 { sort.Slice(newContentIDs, func(i, j int) bool { return orderMap[newContentIDs[i]] < orderMap[newContentIDs[j]] }) } uploadedScans, _ := e.FindUploadedFiles(dbmodels.SCAN_FIELD) deleteScans := valuesForKey(e.Request.PostForm, "scans_delete") scansOrder := valuesForKey(e.Request.PostForm, "scans_order") pendingScanIDs := valuesForKey(e.Request.PostForm, "scans_pending_ids") targetContentID := contentID if targetContentID == "" && len(contentInputs) == 1 { for id := range contentInputs { targetContentID = id break } } if contentID == "" { contentID = targetContentID } tempToCreated := map[string]string{} var createdContents []*dbmodels.Content var updatedContents []*dbmodels.Content if err := app.RunInTransaction(func(tx core.App) error { if len(orderMap) > 0 { for _, content := range contents { numbering, ok := orderMap[content.Id] if !ok { continue } if content.Numbering() == numbering { continue } content.SetNumbering(numbering) if err := tx.Save(content); err != nil { return err } } } nextMusenalmID := 0 if len(newContentIDs) > 0 { nextID, err := nextContentMusenalmID(tx) if err != nil { return err } nextMusenalmID = nextID } created := make([]*dbmodels.Content, 0, len(newContentIDs)) for _, tempID := range newContentIDs { fields, ok := contentInputs[tempID] if !ok { continue } contentCollection, err := tx.FindCollectionByNameOrId(dbmodels.CONTENTS_TABLE) if err != nil { return err } record := core.NewRecord(contentCollection) content := dbmodels.NewContent(record) content.SetMusenalmID(nextMusenalmID) nextMusenalmID++ content.SetEditState("Edited") numbering := orderMap[tempID] if numbering <= 0 { numbering = float64(len(contents) + len(created) + 1) } if err := applyContentForm(content, entry, fields, user, numbering); err != nil { return err } if err := tx.Save(content); err != nil { return err } tempToCreated[tempID] = content.Id if relations, ok := relationsByContent[tempID]; ok { if err := applyContentAgentRelations(tx, content, relations); err != nil { return err } } created = append(created, content) } for _, content := range contents { fields, ok := contentInputs[content.Id] if !ok { continue } numbering := orderMap[content.Id] if err := applyContentForm(content, entry, fields, user, numbering); err != nil { return err } if err := tx.Save(content); err != nil { return err } if relations, ok := relationsByContent[content.Id]; ok { if err := applyContentAgentRelations(tx, content, relations); err != nil { return err } } } effectiveContentID := targetContentID if mappedID, ok := tempToCreated[effectiveContentID]; ok { effectiveContentID = mappedID } if effectiveContentID != "" && (len(uploadedScans) > 0 || len(deleteScans) > 0 || len(scansOrder) > 0) { record, err := tx.FindRecordById(dbmodels.CONTENTS_TABLE, effectiveContentID) if err != nil { return err } content := dbmodels.NewContent(record) if content.Entry() != entry.Id { return fmt.Errorf("Beitrag gehört zu einem anderen Band.") } deleteSet := map[string]struct{}{} for _, scan := range deleteScans { scan = strings.TrimSpace(scan) if scan == "" { continue } deleteSet[scan] = struct{}{} } if len(scansOrder) > 0 || len(pendingScanIDs) > 0 { pendingMap := map[string]*filesystem.File{} for idx, id := range pendingScanIDs { if idx >= len(uploadedScans) { break } id = strings.TrimSpace(id) if id == "" { continue } pendingMap[id] = uploadedScans[idx] } ordered := make([]any, 0, len(scansOrder)+len(uploadedScans)) seenExisting := map[string]struct{}{} for _, token := range scansOrder { token = strings.TrimSpace(token) if token == "" { continue } if strings.HasPrefix(token, "pending:") { id := strings.TrimPrefix(token, "pending:") if file, ok := pendingMap[id]; ok { ordered = append(ordered, file) } continue } if strings.HasPrefix(token, "existing:") { name := strings.TrimPrefix(token, "existing:") if name == "" { continue } if _, deleted := deleteSet[name]; deleted { continue } ordered = append(ordered, name) seenExisting[name] = struct{}{} } } for _, name := range content.Scans() { if _, deleted := deleteSet[name]; deleted { continue } if _, seen := seenExisting[name]; seen { continue } ordered = append(ordered, name) } content.Set(dbmodels.SCAN_FIELD, ordered) } else { if len(uploadedScans) > 0 { content.Set(dbmodels.SCAN_FIELD+"+", uploadedScans) } if len(deleteScans) > 0 { for _, scan := range deleteScans { scan = strings.TrimSpace(scan) if scan == "" { continue } content.Set(dbmodels.SCAN_FIELD+"-", scan) } } } if user != nil { content.SetEditor(user.Id) } if err := tx.Save(content); err != nil { return err } } createdContents = append(createdContents, created...) updatedContents = append(updatedContents, contents...) updatedContents = append(updatedContents, created...) return nil }); err != nil { app.Logger().Error("Failed to save contents", "entry_id", entry.Id, "error", err) return renderError(err.Error()) } if len(updatedContents) == 0 { updatedContents = contents } shouldUpdateFTS := len(contentInputs) > 0 || len(newContentIDs) > 0 if shouldUpdateFTS { touched := updatedContents if len(contentInputs) > 0 { touchedIDs := map[string]struct{}{} for id := range contentInputs { if createdID, ok := tempToCreated[id]; ok { touchedIDs[createdID] = struct{}{} continue } touchedIDs[id] = struct{}{} } filtered := make([]*dbmodels.Content, 0, len(touchedIDs)) for _, content := range updatedContents { if _, ok := touchedIDs[content.Id]; ok { filtered = append(filtered, content) } } if len(filtered) > 0 { touched = filtered } } go updateContentsFTS5(app, entry, touched) } saveAction := strings.TrimSpace(e.Request.FormValue("save_action")) savedMessage := "Änderungen gespeichert." if contentID != "" { effectiveContentID := contentID if mappedID, ok := tempToCreated[effectiveContentID]; ok { effectiveContentID = mappedID } if effectiveContentID != "" { if resolved, err := dbmodels.Contents_IDs(app, []any{effectiveContentID}); err == nil && len(resolved) > 0 { if saveAction == "view" { redirect := fmt.Sprintf("/beitrag/%d", resolved[0].MusenalmID()) return e.Redirect(http.StatusSeeOther, redirect) } setFlashSuccess(e, savedMessage) redirect := fmt.Sprintf("/almanach/%s/contents/%d/edit", id, resolved[0].MusenalmID()) return e.Redirect(http.StatusSeeOther, redirect) } } } setFlashSuccess(e, savedMessage) redirect := fmt.Sprintf("/almanach/%s/contents/edit", id) return e.Redirect(http.StatusSeeOther, redirect) } } func (p *AlmanachContentsEditPage) POSTUpdateExtent(engine *templating.Engine, app core.App) HandleFunc { return func(e *core.RequestEvent) error { id := e.Request.PathValue("id") req := templating.NewRequest(e) if err := e.Request.ParseForm(); err != nil { return p.renderError(engine, app, e, "Formulardaten ungültig.") } if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil { return p.renderError(engine, app, e, err.Error()) } entry, err := dbmodels.Entries_MusenalmID(app, id) if err != nil { return engine.Response404(e, err, nil) } entry.SetExtent(strings.TrimSpace(e.Request.FormValue("extent"))) if user := req.User(); user != nil { entry.SetEditor(user.Id) } if err := app.Save(entry); err != nil { app.Logger().Error("Failed to update entry extent", "entry_id", entry.Id, "error", err) return p.renderError(engine, app, e, "Struktur/Umfang konnte nicht gespeichert werden.") } InvalidateSortedEntriesCache() go func(appInstance core.App, entryRecord *dbmodels.Entry) { if err := updateEntryFTS5WithContents(appInstance, entryRecord, false); err != nil { appInstance.Logger().Error("Failed to update entry FTS5", "entry_id", entryRecord.Id, "error", err) } }(app, entry) setFlashSuccess(e, "Struktur/Umfang gespeichert.") redirect := fmt.Sprintf("/almanach/%s/contents/edit", id) return e.Redirect(http.StatusSeeOther, redirect) } } func (p *AlmanachContentsEditPage) POSTDelete(engine *templating.Engine, app core.App) HandleFunc { return func(e *core.RequestEvent) error { id := e.Request.PathValue("id") req := templating.NewRequest(e) isHTMX := strings.EqualFold(e.Request.Header.Get("HX-Request"), "true") if err := e.Request.ParseForm(); err != nil { return p.renderError(engine, app, e, "Formulardaten ungültig.") } if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil { return p.renderError(engine, app, e, err.Error()) } contentID := strings.TrimSpace(e.Request.FormValue("content_id")) if contentID == "" { return p.renderError(engine, app, e, "Beitrag konnte nicht gelöscht werden.") } entry, err := dbmodels.Entries_MusenalmID(app, id) if err != nil { return engine.Response404(e, err, nil) } var remaining []*dbmodels.Content if err := app.RunInTransaction(func(tx core.App) error { record, err := tx.FindRecordById(dbmodels.CONTENTS_TABLE, contentID) if err != nil { return err } content := dbmodels.NewContent(record) if content.Entry() != entry.Id { return fmt.Errorf("Beitrag gehört zu einem anderen Band.") } relationsTable := dbmodels.RelationTableName(dbmodels.CONTENTS_TABLE, dbmodels.AGENTS_TABLE) relations, err := dbmodels.RContentsAgents_Content(tx, contentID) if err != nil { return err } for _, rel := range relations { relRecord, err := tx.FindRecordById(relationsTable, rel.Id) if err != nil { continue } if err := tx.Delete(relRecord); err != nil { return err } } if err := tx.Delete(record); err != nil { return err } remaining, err = dbmodels.Contents_Entry(tx, entry.Id) if err != nil { return err } dbmodels.Sort_Contents_Numbering(remaining) for idx, content := range remaining { content.SetNumbering(float64(idx + 1)) if err := tx.Save(content); err != nil { return err } } return nil }); err != nil { app.Logger().Error("Failed to delete content", "entry_id", entry.Id, "content_id", contentID, "error", err) return p.renderError(engine, app, e, "Beitrag konnte nicht gelöscht werden.") } go func(contentID string) { _ = dbmodels.DeleteFTS5Content(app, contentID) }(contentID) // Only delete the FTS5 record for the removed content. if isHTMX { success := `