// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package admin import ( json "encoding/json/v2" "fmt" "html/template" "log/slog" "maps" "slices" "strings" "sourcedock.dev/petrbalvin/volumen/internal/post" "sourcedock.dev/petrbalvin/volumen/internal/store" "sourcedock.dev/petrbalvin/volumen/internal/templates" ) // postVariant is one language version of a publication. The dashboard // shows one card per publication and lists its variants here, so the card // can switch what it names, links and shows between them. type postVariant struct { Lang string `json:"lang"` Slug string `json:"slug"` Title string `json:"title"` Excerpt string `json:"excerpt"` DateString string `json:"date"` Timestamp int64 `json:"timestamp,omitempty"` Status string `json:"status"` Draft bool `json:"draft,omitempty"` Scheduled bool `json:"scheduled,omitempty"` Series string `json:"series,omitempty"` HasOrder bool `json:"hasOrder,omitempty"` SeriesOrder int `json:"seriesOrder,omitempty"` Cover string `json:"cover,omitempty"` } // postCard is one dashboard card. type postCard struct { Slug string Title string Excerpt string Lang string Series string HasOrder bool SeriesOrder int DateString string Cover string Draft bool Scheduled bool Tags []string Timestamp int64 Status string // Variants carries every language version of the publication; // VariantsJSON is the same list prepared for the card's data // attribute, so the page script can switch between them. Variants []postVariant VariantsJS template.JS GroupSlugs string } // recentPost is one entry of the dashboard's recent strip. type recentPost struct { Slug string Title string DateString string } // scheduledPost is one entry of the dashboard's upcoming strip. type scheduledPost struct { Slug string Title string When string } // dashboardStats holds the counters shown above the post grid. type dashboardStats struct { Total int Published int Drafts int Scheduled int Recent []recentPost Upcoming []scheduledPost } // tagCount is one tag-cloud chip. type tagCount struct { Name string Count int } // editorPost carries the form-ready post fields for the editor. type editorPost struct { Slug string Title string Lang string Author string FediverseCreator string DOI string ORCID string Date string PublishAt string TagsCSV string Series string SeriesOrder string Excerpt string ExcerptIsSet bool ExcerptPlaceholder string Cover string CoverAlt string CoverCaption string Body string Draft bool AllLangs bool Scheduled bool // RefsJSON is the post's reference list as a JS literal for the // bibliography card: the frontmatter tables as they are stored, so // the editor edits what the file holds and no field is lost in a // decode/encode cycle. RefsCount is the same list's length, shown in // the card's head. RefsJSON template.JS RefsCount int } // revisionRow is one history table row. type revisionRow struct { Name string When string SizeKB string } // tplOption is one post-template dropdown entry. type tplOption struct { Name string `json:"name"` Title string `json:"title"` Slug string `json:"slug"` Tags []string `json:"tags"` Body string `json:"body"` // Fields are the extra editor inputs the template pre-fills. The // keys are the editor's own input names. Fields map[string]string `json:"fields,omitzero"` // FieldsText lists the same fields for the settings screen, sorted // and joined, so the template row shows what it will pre-fill. FieldsText string `json:"-"` } // newPostVariant maps one stored post onto one card variant. func newPostVariant(p *post.Post) postVariant { v := postVariant{ Lang: p.Lang(), Slug: p.Slug(), Title: p.Title(), Excerpt: p.Excerpt(), DateString: p.DateString(), Status: statusKey(p), Draft: p.Draft(), Scheduled: p.Scheduled(), Series: p.Series(), Cover: p.Cover(), } if order, ok := p.SeriesOrder(); ok { v.HasOrder = true v.SeriesOrder = order } if ts, ok := p.PublishedTimestamp(); ok { v.Timestamp = ts } return v } // statusKey names the post status with the filter vocabulary. func statusKey(p *post.Post) string { switch { case p.Draft(): return "draft" case p.Scheduled(): return "scheduled" default: return "published" } } // groupPosts merges the posts whose frontmatter names each other in the // translations map into one group: the dashboard then shows one card per // publication instead of one per language file. The groups keep the order // of their first appearance, and the posts arrive newest first, so the // grid stays date-ordered. func groupPosts(posts []*post.Post) [][]*post.Post { parent := map[string]string{} var find func(slug string) string find = func(slug string) string { root, ok := parent[slug] if !ok { parent[slug] = slug return slug } if root == slug { return slug } parent[slug] = find(root) return parent[slug] } union := func(a, b string) { ra, rb := find(a), find(b) if ra != rb { parent[rb] = ra } } for _, p := range posts { find(p.Slug()) } for _, p := range posts { for _, target := range p.Translations() { if target != "" { union(p.Slug(), target) } } } var order []string members := map[string][]*post.Post{} for _, p := range posts { root := find(p.Slug()) if _, seen := members[root]; !seen { order = append(order, root) } members[root] = append(members[root], p) } groups := make([][]*post.Post, 0, len(order)) for _, root := range order { groups = append(groups, members[root]) } return groups } // pickDisplay chooses the variant the card shows: the one in the // interface language when the publication carries it, the newest one // otherwise. func pickDisplay(group []*post.Post, lang string) *post.Post { if lang != "" { for _, p := range group { if p.Lang() == lang { return p } } } return group[0] } // variantsJSON renders the language variants as a JS literal for the // card's data attribute. The same script-embedding rule as templatesJSON // applies: no literal "<" may reach the page. func variantsJSON(variants []postVariant) template.JS { raw, err := json.Marshal(variants) if err != nil { slog.Warn("admin: cannot encode post variants", "error", err) return template.JS("[]") } return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`)) } // newEditorPost maps a stored post onto the editor form fields. func newEditorPost(p *post.Post) *editorPost { view := &editorPost{ Slug: p.Slug(), Title: p.Title(), Lang: p.Lang(), Author: p.Author(), Date: p.DateString(), TagsCSV: strings.Join(p.Tags(), ", "), Series: p.Series(), Excerpt: p.StoredExcerpt(), Cover: p.Cover(), CoverAlt: p.CoverAlt(), CoverCaption: p.CoverCaption(), Body: p.Body, Draft: p.Draft(), AllLangs: p.AllLangs(), Scheduled: p.Scheduled(), } view.RefsJSON, view.RefsCount = refsJS(p) if fc := p.FediverseCreator(); fc != "" { view.FediverseCreator = fc } view.DOI = p.DOI() view.ORCID = p.ORCID() if d, ok := p.DueAt(); ok { view.PublishAt = d.Format("2006-01-02") } if order, ok := p.SeriesOrder(); ok { view.SeriesOrder = fmt.Sprintf("%d", order) } view.ExcerptIsSet = strings.TrimSpace(view.Excerpt) != "" // An unset excerpt shows the derived text as a placeholder, so saving // the form never writes text the author did not type. view.ExcerptPlaceholder = "Short summary for listings and previews" if !view.ExcerptIsSet { if derived := p.Excerpt(); derived != "" { view.ExcerptPlaceholder = derived } } return view } // refsJS renders the post's stored reference tables as a JS literal for // the bibliography card, with the list's length beside it. The same // script-embedding rule as templatesJSON applies: no literal "<" may // reach the page, and a post without refs still gets a literal the // script can iterate. func refsJS(p *post.Post) (template.JS, int) { raw, _ := p.Metadata.Get("refs") // A parsed file hands the tables over as []map[string]any while a // freshly written list is []any; both shapes carry the same entries. var list []any switch tables := raw.(type) { case []any: list = tables case []map[string]any: list = make([]any, len(tables)) for i, table := range tables { list[i] = table } } if len(list) == 0 { return template.JS("[]"), 0 } encoded, err := json.Marshal(list) if err != nil { slog.Warn("admin: cannot encode the post references", "slug", p.Slug(), "error", err) return template.JS("[]"), 0 } return template.JS(strings.ReplaceAll(string(encoded), "<", `\u003c`)), len(list) } // newPostCard maps a stored post onto one dashboard card. func newPostCard(p *post.Post) postCard { card := postCard{ Slug: p.Slug(), Title: p.Title(), Excerpt: p.Excerpt(), Lang: p.Lang(), Series: p.Series(), DateString: p.DateString(), Cover: p.Cover(), Draft: p.Draft(), Scheduled: p.Scheduled(), Tags: p.Tags(), } if order, ok := p.SeriesOrder(); ok { card.HasOrder = true card.SeriesOrder = order } if ts, ok := p.PublishedTimestamp(); ok { card.Timestamp = ts } switch { case card.Draft: card.Status = "draft" case card.Scheduled: card.Status = "scheduled" default: card.Status = "published" } return card } // newRevisionRow formats one revision for the history table. func newRevisionRow(rev store.Revision) revisionRow { return revisionRow{ Name: rev.Name, When: rev.When, SizeKB: fmt.Sprintf("%.1f", float64(rev.Size)/1024), } } // tplOptions converts stored post templates for the dropdown and the // embedded JSON blob. func tplOptions(list []templates.PostTemplate) []tplOption { out := make([]tplOption, 0, len(list)) for _, tpl := range list { tags := tpl.Tags if tags == nil { tags = []string{} } out = append(out, tplOption{ Name: tpl.Name, Title: tpl.Title, Slug: tpl.Slug, Tags: tags, Body: tpl.Body, Fields: tpl.Fields, FieldsText: fieldsText(tpl.Fields), }) } return out } // fieldsText renders a sorted "key = value" summary of template fields. func fieldsText(fields map[string]string) string { if len(fields) == 0 { return "" } parts := make([]string, 0, len(fields)) for _, key := range slices.Sorted(maps.Keys(fields)) { parts = append(parts, key+" = "+fields[key]) } return strings.Join(parts, ", ") } // templatesJSON renders the template list as a JS literal. func templatesJSON(list []tplOption) template.JS { raw, err := json.Marshal(list) if err != nil { slog.Warn("admin: cannot encode post templates", "error", err) return template.JS("[]") } // A template body is free-text admin content, and encoding/json/v2 // escapes only what JSON requires: a literal "" inside the // JSON would end the script element the literal is embedded in. "<" // cannot occur outside a string in JSON, so escaping it as a unicode // escape inside the literal closes the hole while staying valid JSON. return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`)) }