427 lines
12 KiB
Go
427 lines
12 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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 "</script>" 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`))
|
||
|
|
}
|