diff --git a/controllers/almanach_edit.go b/controllers/almanach_edit.go index 08620c5..db2a387 100644 --- a/controllers/almanach_edit.go +++ b/controllers/almanach_edit.go @@ -1,18 +1,27 @@ package controllers import ( + "fmt" + "net/http" + "slices" + "strings" + "time" + "github.com/Theodor-Springmann-Stiftung/musenalm/app" "github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels" + "github.com/Theodor-Springmann-Stiftung/musenalm/helpers/functions" "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_ALMANACH_EDIT = "edit/" - TEMPLATE_ALMANACH_EDIT = "/almanach/edit/" + URL_ALMANACH_EDIT = "edit/" + TEMPLATE_ALMANACH_EDIT = "/almanach/edit/" + preferredSeriesRelationType = "Bevorzugter Reihentitel" ) func init() { @@ -35,6 +44,7 @@ func (p *AlmanachEditPage) Setup(router *router.Router[*core.RequestEvent], app rg := router.Group(URL_ALMANACH) rg.BindFunc(middleware.IsAdminOrEditor()) rg.GET(URL_ALMANACH_EDIT, p.GET(engine, app)) + rg.POST(URL_ALMANACH_EDIT+"save", p.POSTSave(engine, app)) return nil } @@ -60,6 +70,10 @@ func (p *AlmanachEditPage) GET(engine *templating.Engine, app core.App) HandleFu data["abbrs"] = abbrs } + if msg := e.Request.URL.Query().Get("saved_message"); msg != "" { + data["success"] = msg + } + return engine.Response200(e, p.Template, data, p.Layout) } } @@ -97,3 +111,481 @@ func NewAlmanachEditResult(app core.App, id string, filters BeitraegeFilterParam Prev: prev, }, nil } + +func (p *AlmanachEditPage) POSTSave(engine *templating.Engine, app core.App) HandleFunc { + return func(e *core.RequestEvent) error { + id := e.Request.PathValue("id") + req := templating.NewRequest(e) + + payload := almanachEditPayload{} + if err := e.BindBody(&payload); err != nil { + return e.JSON(http.StatusBadRequest, map[string]any{ + "error": "Ungültige Formulardaten.", + }) + } + + if err := req.CheckCSRF(payload.CSRFToken); err != nil { + return e.JSON(http.StatusBadRequest, map[string]any{ + "error": err.Error(), + }) + } + + entry, err := dbmodels.Entries_MusenalmID(app, id) + if err != nil { + return e.JSON(http.StatusNotFound, map[string]any{ + "error": "Band wurde nicht gefunden.", + }) + } + + if err := payload.Validate(); err != nil { + return e.JSON(http.StatusBadRequest, map[string]any{ + "error": err.Error(), + }) + } + + if payload.LastEdited != "" { + lastEdited, err := types.ParseDateTime(payload.LastEdited) + if err != nil { + return e.JSON(http.StatusBadRequest, map[string]any{ + "error": "Ungültiger Bearbeitungszeitstempel.", + }) + } + if !entry.Updated().Time().Equal(lastEdited.Time()) { + return e.JSON(http.StatusConflict, map[string]any{ + "error": "Der Eintrag wurde inzwischen geändert. Bitte Seite neu laden.", + }) + } + } + + user := req.User() + if err := app.RunInTransaction(func(tx core.App) error { + if err := applyEntryChanges(tx, entry, &payload, user); err != nil { + return err + } + if err := applyItemsChanges(tx, entry, &payload); err != nil { + return err + } + if err := applySeriesRelations(tx, entry, &payload); err != nil { + return err + } + if err := applyAgentRelations(tx, entry, &payload); err != nil { + return err + } + return nil + }); err != nil { + app.Logger().Error("Failed to save almanach entry", "entry_id", entry.Id, "error", err) + return e.JSON(http.StatusInternalServerError, map[string]any{ + "error": "Speichern fehlgeschlagen.", + }) + } + + freshEntry, err := dbmodels.Entries_MusenalmID(app, id) + if err == nil { + entry = freshEntry + } + + updatedInfo := map[string]string{ + "raw": entry.Updated().Time().Format(time.RFC3339Nano), + "date": functions.GermanDate(entry.Updated()), + "time": functions.GermanTime(entry.Updated()), + } + if user != nil && strings.TrimSpace(user.Name) != "" { + updatedInfo["user"] = user.Name + } + + return e.JSON(http.StatusOK, map[string]any{ + "success": true, + "message": "Änderungen gespeichert.", + "updated": updatedInfo, + }) + } +} + +type almanachEditPayload struct { + CSRFToken string `json:"csrf_token"` + LastEdited string `json:"last_edited"` + Entry almanachEntryPayload `json:"entry"` + Languages []string `json:"languages"` + Places []string `json:"places"` + Items []almanachItemPayload `json:"items"` + DeletedItemIDs []string `json:"deleted_item_ids"` + SeriesRelations []almanachRelationPayload `json:"series_relations"` + NewSeriesRelations []almanachNewRelationPayload `json:"new_series_relations"` + DeletedSeriesRelationIDs []string `json:"deleted_series_relation_ids"` + AgentRelations []almanachRelationPayload `json:"agent_relations"` + NewAgentRelations []almanachNewRelationPayload `json:"new_agent_relations"` + DeletedAgentRelationIDs []string `json:"deleted_agent_relation_ids"` +} + +type almanachEntryPayload struct { + PreferredTitle string `json:"preferred_title"` + Title string `json:"title"` + ParallelTitle string `json:"parallel_title"` + Subtitle string `json:"subtitle"` + VariantTitle string `json:"variant_title"` + Incipit string `json:"incipit"` + ResponsibilityStatement string `json:"responsibility_statement"` + PublicationStatement string `json:"publication_statement"` + PlaceStatement string `json:"place_statement"` + Edition string `json:"edition"` + Annotation string `json:"annotation"` + EditComment string `json:"edit_comment"` + Extent string `json:"extent"` + Dimensions string `json:"dimensions"` + References string `json:"references"` + Status string `json:"status"` + Year *int `json:"year"` +} + +type almanachItemPayload struct { + ID string `json:"id"` + Owner string `json:"owner"` + Identifier string `json:"identifier"` + Location string `json:"location"` + Media []string `json:"media"` + Annotation string `json:"annotation"` + URI string `json:"uri"` +} + +type almanachRelationPayload struct { + ID string `json:"id"` + TargetID string `json:"target_id"` + Type string `json:"type"` + Uncertain bool `json:"uncertain"` +} + +type almanachNewRelationPayload struct { + TargetID string `json:"target_id"` + Type string `json:"type"` + Uncertain bool `json:"uncertain"` +} + +func (payload *almanachEditPayload) Validate() error { + payload.Entry.Status = strings.TrimSpace(payload.Entry.Status) + if strings.TrimSpace(payload.Entry.PreferredTitle) == "" { + return fmt.Errorf("Kurztitel ist erforderlich.") + } + if payload.Entry.Year == nil { + return fmt.Errorf("Jahr muss angegeben werden.") + } + if payload.Entry.Status == "" || !slices.Contains(dbmodels.EDITORSTATE_VALUES, payload.Entry.Status) { + return fmt.Errorf("Ungültiger Status.") + } + for _, relation := range payload.SeriesRelations { + if err := validateRelationType(relation.Type, dbmodels.SERIES_RELATIONS); err != nil { + return err + } + } + for _, relation := range payload.NewSeriesRelations { + if err := validateRelationType(relation.Type, dbmodels.SERIES_RELATIONS); err != nil { + return err + } + } + for _, relation := range payload.AgentRelations { + if err := validateRelationType(relation.Type, dbmodels.AGENT_RELATIONS); err != nil { + return err + } + } + for _, relation := range payload.NewAgentRelations { + if err := validateRelationType(relation.Type, dbmodels.AGENT_RELATIONS); err != nil { + return err + } + } + + hasPreferred := false + for _, relation := range payload.SeriesRelations { + if strings.TrimSpace(relation.Type) == preferredSeriesRelationType { + hasPreferred = true + break + } + } + if !hasPreferred { + for _, relation := range payload.NewSeriesRelations { + if strings.TrimSpace(relation.Type) == preferredSeriesRelationType { + hasPreferred = true + break + } + } + } + if !hasPreferred { + return fmt.Errorf("Mindestens ein bevorzugter Reihentitel muss verknüpft sein.") + } + + return nil +} + +func validateRelationType(value string, allowed []string) error { + value = strings.TrimSpace(value) + if value == "" { + return fmt.Errorf("Ungültiger Beziehungstyp.") + } + if !slices.Contains(allowed, value) { + return fmt.Errorf("Ungültiger Beziehungstyp.") + } + return nil +} + +func sanitizeStrings(values []string) []string { + seen := map[string]struct{}{} + cleaned := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + cleaned = append(cleaned, value) + } + return cleaned +} + +func applyEntryChanges(tx core.App, entry *dbmodels.Entry, payload *almanachEditPayload, user *dbmodels.FixedUser) error { + entry.SetPreferredTitle(strings.TrimSpace(payload.Entry.PreferredTitle)) + entry.SetTitleStmt(strings.TrimSpace(payload.Entry.Title)) + entry.SetParallelTitle(strings.TrimSpace(payload.Entry.ParallelTitle)) + entry.SetSubtitleStmt(strings.TrimSpace(payload.Entry.Subtitle)) + entry.SetVariantTitle(strings.TrimSpace(payload.Entry.VariantTitle)) + entry.SetIncipitStmt(strings.TrimSpace(payload.Entry.Incipit)) + entry.SetResponsibilityStmt(strings.TrimSpace(payload.Entry.ResponsibilityStatement)) + entry.SetPublicationStmt(strings.TrimSpace(payload.Entry.PublicationStatement)) + entry.SetPlaceStmt(strings.TrimSpace(payload.Entry.PlaceStatement)) + entry.SetEdition(strings.TrimSpace(payload.Entry.Edition)) + entry.SetAnnotation(strings.TrimSpace(payload.Entry.Annotation)) + entry.SetComment(strings.TrimSpace(payload.Entry.EditComment)) + entry.SetExtent(strings.TrimSpace(payload.Entry.Extent)) + entry.SetDimensions(strings.TrimSpace(payload.Entry.Dimensions)) + entry.SetReferences(strings.TrimSpace(payload.Entry.References)) + entry.SetYear(*payload.Entry.Year) + entry.SetEditState(payload.Entry.Status) + entry.SetLanguage(sanitizeStrings(payload.Languages)) + entry.SetPlaces(sanitizeStrings(payload.Places)) + if user != nil { + entry.SetEditor(user.Id) + } + return tx.Save(entry) +} + +func applyItemsChanges(tx core.App, entry *dbmodels.Entry, payload *almanachEditPayload) error { + var itemsCollection *core.Collection + getItemsCollection := func() (*core.Collection, error) { + if itemsCollection != nil { + return itemsCollection, nil + } + collection, err := tx.FindCollectionByNameOrId(dbmodels.ITEMS_TABLE) + if err != nil { + return nil, err + } + itemsCollection = collection + return itemsCollection, nil + } + + for _, itemPayload := range payload.Items { + itemID := strings.TrimSpace(itemPayload.ID) + var item *dbmodels.Item + if itemID != "" { + record, err := tx.FindRecordById(dbmodels.ITEMS_TABLE, itemID) + if err != nil { + return err + } + item = dbmodels.NewItem(record) + if item.Entry() != entry.Id { + return fmt.Errorf("Exemplar %s gehört zu einem anderen Eintrag.", itemID) + } + } else { + collection, err := getItemsCollection() + if err != nil { + return err + } + item = dbmodels.NewItem(core.NewRecord(collection)) + } + + item.SetEntry(entry.Id) + item.SetOwner(strings.TrimSpace(itemPayload.Owner)) + item.SetIdentifier(strings.TrimSpace(itemPayload.Identifier)) + item.SetLocation(strings.TrimSpace(itemPayload.Location)) + item.SetAnnotation(strings.TrimSpace(itemPayload.Annotation)) + item.SetUri(strings.TrimSpace(itemPayload.URI)) + item.SetMedia(sanitizeStrings(itemPayload.Media)) + + if err := tx.Save(item); err != nil { + return err + } + } + + for _, id := range payload.DeletedItemIDs { + itemID := strings.TrimSpace(id) + if itemID == "" { + continue + } + record, err := tx.FindRecordById(dbmodels.ITEMS_TABLE, itemID) + if err != nil { + continue + } + item := dbmodels.NewItem(record) + if item.Entry() != entry.Id { + continue + } + if err := tx.Delete(record); err != nil { + return err + } + } + + return nil +} + +func applySeriesRelations(tx core.App, entry *dbmodels.Entry, payload *almanachEditPayload) error { + tableName := dbmodels.RelationTableName(dbmodels.ENTRIES_TABLE, dbmodels.SERIES_TABLE) + var collection *core.Collection + getCollection := func() (*core.Collection, error) { + if collection != nil { + return collection, nil + } + col, err := tx.FindCollectionByNameOrId(tableName) + if err != nil { + return nil, err + } + collection = col + return collection, nil + } + + for _, relation := range payload.SeriesRelations { + relationID := strings.TrimSpace(relation.ID) + if relationID == "" { + continue + } + record, err := tx.FindRecordById(tableName, relationID) + if err != nil { + return err + } + proxy := dbmodels.NewREntriesSeries(record) + if proxy.Entry() != entry.Id { + return fmt.Errorf("Relation %s gehört zu einem anderen Eintrag.", relationID) + } + proxy.SetEntry(entry.Id) + proxy.SetSeries(strings.TrimSpace(relation.TargetID)) + proxy.SetType(strings.TrimSpace(relation.Type)) + proxy.SetUncertain(relation.Uncertain) + if err := tx.Save(proxy); err != nil { + return err + } + } + + for _, relationID := range payload.DeletedSeriesRelationIDs { + relationID = strings.TrimSpace(relationID) + if relationID == "" { + continue + } + record, err := tx.FindRecordById(tableName, relationID) + if err != nil { + continue + } + proxy := dbmodels.NewREntriesSeries(record) + if proxy.Entry() != entry.Id { + continue + } + if err := tx.Delete(record); err != nil { + return err + } + } + + for _, relation := range payload.NewSeriesRelations { + targetID := strings.TrimSpace(relation.TargetID) + if targetID == "" { + continue + } + col, err := getCollection() + if err != nil { + return err + } + proxy := dbmodels.NewREntriesSeries(core.NewRecord(col)) + proxy.SetEntry(entry.Id) + proxy.SetSeries(targetID) + proxy.SetType(strings.TrimSpace(relation.Type)) + proxy.SetUncertain(relation.Uncertain) + if err := tx.Save(proxy); err != nil { + return err + } + } + + return nil +} + +func applyAgentRelations(tx core.App, entry *dbmodels.Entry, payload *almanachEditPayload) error { + tableName := dbmodels.RelationTableName(dbmodels.ENTRIES_TABLE, dbmodels.AGENTS_TABLE) + var collection *core.Collection + getCollection := func() (*core.Collection, error) { + if collection != nil { + return collection, nil + } + col, err := tx.FindCollectionByNameOrId(tableName) + if err != nil { + return nil, err + } + collection = col + return collection, nil + } + + for _, relation := range payload.AgentRelations { + relationID := strings.TrimSpace(relation.ID) + if relationID == "" { + continue + } + record, err := tx.FindRecordById(tableName, relationID) + if err != nil { + return err + } + proxy := dbmodels.NewREntriesAgents(record) + if proxy.Entry() != entry.Id { + return fmt.Errorf("Relation %s gehört zu einem anderen Eintrag.", relationID) + } + proxy.SetEntry(entry.Id) + proxy.SetAgent(strings.TrimSpace(relation.TargetID)) + proxy.SetType(strings.TrimSpace(relation.Type)) + proxy.SetUncertain(relation.Uncertain) + if err := tx.Save(proxy); err != nil { + return err + } + } + + for _, relationID := range payload.DeletedAgentRelationIDs { + relationID = strings.TrimSpace(relationID) + if relationID == "" { + continue + } + record, err := tx.FindRecordById(tableName, relationID) + if err != nil { + continue + } + proxy := dbmodels.NewREntriesAgents(record) + if proxy.Entry() != entry.Id { + continue + } + if err := tx.Delete(record); err != nil { + return err + } + } + + for _, relation := range payload.NewAgentRelations { + targetID := strings.TrimSpace(relation.TargetID) + if targetID == "" { + continue + } + col, err := getCollection() + if err != nil { + return err + } + proxy := dbmodels.NewREntriesAgents(core.NewRecord(col)) + proxy.SetEntry(entry.Id) + proxy.SetAgent(targetID) + proxy.SetType(strings.TrimSpace(relation.Type)) + proxy.SetUncertain(relation.Uncertain) + if err := tx.Save(proxy); err != nil { + return err + } + } + + return nil +} diff --git a/views/assets/scripts.js b/views/assets/scripts.js index 18a40c4..d036cca 100644 --- a/views/assets/scripts.js +++ b/views/assets/scripts.js @@ -1,11 +1,11 @@ -var Ot = Object.defineProperty; -var K = (a) => { - throw TypeError(a); +var $t = Object.defineProperty; +var j = (l) => { + throw TypeError(l); }; -var Mt = (a, i, t) => i in a ? Ot(a, i, { enumerable: !0, configurable: !0, writable: !0, value: t }) : a[i] = t; -var f = (a, i, t) => Mt(a, typeof i != "symbol" ? i + "" : i, t), x = (a, i, t) => i.has(a) || K("Cannot " + t); -var k = (a, i, t) => (x(a, i, "read from private field"), t ? t.call(a) : i.get(a)), g = (a, i, t) => i.has(a) ? K("Cannot add the same private member more than once") : i instanceof WeakSet ? i.add(a) : i.set(a, t), L = (a, i, t, e) => (x(a, i, "write to private field"), e ? e.call(a, t) : i.set(a, t), t), v = (a, i, t) => (x(a, i, "access private method"), t); -class Rt extends HTMLElement { +var Nt = (l, i, t) => i in l ? $t(l, i, { enumerable: !0, configurable: !0, writable: !0, value: t }) : l[i] = t; +var E = (l, i, t) => Nt(l, typeof i != "symbol" ? i + "" : i, t), B = (l, i, t) => i.has(l) || j("Cannot " + t); +var $ = (l, i, t) => (B(l, i, "read from private field"), t ? t.call(l) : i.get(l)), S = (l, i, t) => i.has(l) ? j("Cannot add the same private member more than once") : i instanceof WeakSet ? i.add(l) : i.set(l, t), I = (l, i, t, e) => (B(l, i, "write to private field"), e ? e.call(l, t) : i.set(l, t), t), T = (l, i, t) => (B(l, i, "access private method"), t); +class Pt extends HTMLElement { constructor() { super(), this._value = "", this.render(); } @@ -74,13 +74,13 @@ class Rt extends HTMLElement { `; } } -const A = "filter-list-list", Bt = "filter-list-item", Nt = "filter-list-input", z = "filter-list-searchable"; -var _, S, F; -class $t extends HTMLElement { +const C = "filter-list-list", qt = "filter-list-item", Dt = "filter-list-input", J = "filter-list-searchable"; +var b, y, W; +class Ht extends HTMLElement { constructor() { super(); - g(this, S); - g(this, _, !1); + S(this, y); + S(this, b, !1); this._items = [], this._url = "", this._filterstart = !1, this._placeholder = "Liste filtern...", this._queryparam = "", this._startparams = null, this.render(); } static get observedAttributes() { @@ -93,7 +93,7 @@ class $t extends HTMLElement { return this._items; } connectedCallback() { - this._url = this.getAttribute("data-url") || "./", this._filterstart = this.getAttribute("data-filterstart") === "true", this._placeholder = this.getAttribute("data-placeholder") || "Liste filtern...", this._queryparam = this.getAttribute("data-queryparam") || "", this._queryparam, this._filterstart && L(this, _, !0), this.addEventListener("input", this.onInput.bind(this)), this.addEventListener("keydown", this.onEnter.bind(this)), this.addEventListener("focusin", this.onGainFocus.bind(this)), this.addEventListener("focusout", this.onLoseFocus.bind(this)); + this._url = this.getAttribute("data-url") || "./", this._filterstart = this.getAttribute("data-filterstart") === "true", this._placeholder = this.getAttribute("data-placeholder") || "Liste filtern...", this._queryparam = this.getAttribute("data-queryparam") || "", this._queryparam, this._filterstart && I(this, b, !0), this.addEventListener("input", this.onInput.bind(this)), this.addEventListener("keydown", this.onEnter.bind(this)), this.addEventListener("focusin", this.onGainFocus.bind(this)), this.addEventListener("focusout", this.onLoseFocus.bind(this)); } attributeChangedCallback(t, e, s) { t === "data-url" && e !== s && (this._url = s, this.render()), t === "data-filterstart" && e !== s && (this._filterstart = s === "true", this.render()), t === "data-placeholder" && e !== s && (this._placeholder = s, this.render()), t === "data-queryparam" && e !== s && (this._queryparam = s, this.render()); @@ -102,14 +102,14 @@ class $t extends HTMLElement { t.target && t.target.tagName.toLowerCase() === "input" && (this._filter = t.target.value, this.renderList()); } onGainFocus(t) { - t.target && t.target.tagName.toLowerCase() === "input" && (L(this, _, !1), this.renderList()); + t.target && t.target.tagName.toLowerCase() === "input" && (I(this, b, !1), this.renderList()); } onLoseFocus(t) { let e = this.querySelector("input"); if (t.target && t.target === e) { if (relatedElement = t.relatedTarget, relatedElement && this.contains(relatedElement)) return; - e.value = "", this._filter = "", this._filterstart && L(this, _, !0), this.renderList(); + e.value = "", this._filter = "", this._filterstart && I(this, b, !0), this.renderList(); } } onEnter(t) { @@ -122,10 +122,10 @@ class $t extends HTMLElement { mark() { if (typeof Mark != "function") return; - let t = this.querySelector("#" + A); + let t = this.querySelector("#" + C); if (!t) return; - let e = new Mark(t.querySelectorAll("." + z)); + let e = new Mark(t.querySelectorAll("." + J)); this._filter && e.mark(this._filter, { separateWordSearch: !0 }); @@ -165,7 +165,7 @@ class $t extends HTMLElement { } getLinkText(t) { let e = this.getSearchText(t); - return e === "" ? "" : `${e}`; + return e === "" ? "" : `${e}`; } getURL(t) { if (this._queryparam) { @@ -175,7 +175,7 @@ class $t extends HTMLElement { return this._url + this.getHREFEncoded(t); } renderList() { - let t = this.querySelector("#" + A); + let t = this.querySelector("#" + C); t && (t.outerHTML = this.List()), this.mark(); } render() { @@ -187,7 +187,7 @@ class $t extends HTMLElement { `, htmx && htmx.process(this); } ActiveDot(t) { - return v(this, S, F).call(this, t), ""; + return T(this, y, W).call(this, t), ""; } NoItems(t) { return t.length === 0 ? '