Initial commit
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
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
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
// 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`))
|
||||
}
|
||||
Reference in New Issue
Block a user