Files
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

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`))
}