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,265 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package templates is a file-backed store of admin post templates
|
||||
// persisted as a [[templates]] array in TOML.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/tomlfile"
|
||||
)
|
||||
|
||||
// PostTemplate pre-fills the admin new-post form. Title, slug and tags
|
||||
// have their own fields because the form gives them dedicated controls;
|
||||
// Fields carries the remaining editor values to pre-fill, keyed by form
|
||||
// field name (doi, orcid, series, series_order, author, lang, excerpt,
|
||||
// cover, and so on), as strings.
|
||||
type PostTemplate struct {
|
||||
Name string
|
||||
Tags []string
|
||||
Body string
|
||||
Title string
|
||||
Slug string
|
||||
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// Store manages post templates in a TOML file, with an mtime snapshot
|
||||
// cache so that an unchanged file is not re-parsed on every read.
|
||||
type Store struct {
|
||||
path string
|
||||
|
||||
mu sync.Mutex
|
||||
cached []PostTemplate
|
||||
snapshot fileSnapshot
|
||||
haveCache bool
|
||||
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
type fileSnapshot struct {
|
||||
present bool
|
||||
mtime int64
|
||||
size int64
|
||||
}
|
||||
|
||||
// New opens the template store at path.
|
||||
func New(path string) *Store {
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
// All returns every template with a name.
|
||||
func (s *Store) All() []PostTemplate {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
templates, err := s.loadLocked()
|
||||
if err != nil {
|
||||
slog.Error("templates: cannot read the templates file", "path", s.path, "error", err)
|
||||
return nil
|
||||
}
|
||||
return slices.Clone(templates)
|
||||
}
|
||||
|
||||
// loadLocked returns the cached templates, rebuilding them when the
|
||||
// file changed. A missing file is not an error; an unreadable or
|
||||
// unparsable one is. The caller must hold s.mu.
|
||||
func (s *Store) loadLocked() ([]PostTemplate, error) {
|
||||
snapshot := s.buildSnapshot()
|
||||
if s.haveCache && snapshot == s.snapshot {
|
||||
return s.cached, nil
|
||||
}
|
||||
templates, err := s.readFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.snapshot = snapshot
|
||||
s.cached = templates
|
||||
s.haveCache = true
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
func (s *Store) buildSnapshot() fileSnapshot {
|
||||
info, err := os.Stat(s.path)
|
||||
if err != nil {
|
||||
return fileSnapshot{}
|
||||
}
|
||||
return fileSnapshot{present: true, mtime: info.ModTime().UnixNano(), size: info.Size()}
|
||||
}
|
||||
|
||||
func (s *Store) readFile() ([]PostTemplate, error) {
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read %s: %w", s.path, err)
|
||||
}
|
||||
data, err := interpres.ParseMap(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
var out []PostTemplate
|
||||
for _, entry := range tomlfile.Tables(data["templates"]) {
|
||||
name := tomlfile.String(entry["name"])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, PostTemplate{
|
||||
Name: name,
|
||||
Body: tomlfile.String(entry["body"]),
|
||||
Title: tomlfile.String(entry["title"]),
|
||||
Slug: tomlfile.String(entry["slug"]),
|
||||
Tags: tomlfile.Strings(entry["tags"]),
|
||||
Fields: stringTable(entry["fields"]),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stringTable reads one table of scalar values into a string map;
|
||||
// numbers and dates arrive already formatted by interpres.
|
||||
func stringTable(v any) map[string]string {
|
||||
table, ok := v.(map[string]any)
|
||||
if !ok || len(table) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(table))
|
||||
for key, value := range table {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
out[key] = fmt.Sprintf("%v", value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Errors a template change can be refused with.
|
||||
var (
|
||||
// ErrNameTaken is returned when a template of that name exists.
|
||||
ErrNameTaken = errors.New("a template of that name already exists")
|
||||
// ErrEmptyName is returned when the name is blank.
|
||||
ErrEmptyName = errors.New("the template name must not be empty")
|
||||
// ErrNoSuchTemplate is returned when nothing matches the name.
|
||||
ErrNoSuchTemplate = errors.New("no such template")
|
||||
)
|
||||
|
||||
// Add stores a template, or reports why it could not. The name is
|
||||
// required and unique; the rest is stored as given.
|
||||
func (s *Store) Add(tpl PostTemplate) (*PostTemplate, error) {
|
||||
if tpl.Name == "" {
|
||||
return nil, ErrEmptyName
|
||||
}
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
existing, err := s.reload()
|
||||
if err != nil {
|
||||
slog.Error("templates: refusing to add, the templates file is unreadable",
|
||||
"path", s.path, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
for _, other := range existing {
|
||||
if other.Name == tpl.Name {
|
||||
return nil, ErrNameTaken
|
||||
}
|
||||
}
|
||||
created := tpl
|
||||
if err := s.persist(append(existing, created)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
// Delete removes a template by name, or reports why it could not.
|
||||
func (s *Store) Delete(name string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
templates, err := s.reload()
|
||||
if err != nil {
|
||||
slog.Error("templates: refusing to delete, the templates file is unreadable",
|
||||
"path", s.path, "error", err)
|
||||
return err
|
||||
}
|
||||
remaining := make([]PostTemplate, 0, len(templates))
|
||||
for _, tpl := range templates {
|
||||
if tpl.Name != name {
|
||||
remaining = append(remaining, tpl)
|
||||
}
|
||||
}
|
||||
if len(remaining) == len(templates) {
|
||||
return ErrNoSuchTemplate
|
||||
}
|
||||
return s.persist(remaining)
|
||||
}
|
||||
|
||||
// reload re-reads the file, refusing to carry on when it cannot be
|
||||
// parsed: writing back a list derived from an unreadable file would
|
||||
// destroy the templates it contains. The caller must hold s.lock.
|
||||
func (s *Store) reload() ([]PostTemplate, error) {
|
||||
s.Invalidate()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.loadLocked()
|
||||
}
|
||||
|
||||
// Health reports why the templates file cannot be read, or nil when it
|
||||
// is fine or absent.
|
||||
func (s *Store) Health() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.loadLocked()
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate drops the cache so the next read re-reads the file.
|
||||
func (s *Store) Invalidate() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cached = nil
|
||||
s.snapshot = fileSnapshot{}
|
||||
s.haveCache = false
|
||||
}
|
||||
|
||||
func (s *Store) persist(templates []PostTemplate) error {
|
||||
entries := make([]map[string]any, 0, len(templates))
|
||||
for _, tpl := range templates {
|
||||
entry := map[string]any{"name": tpl.Name}
|
||||
if tpl.Title != "" {
|
||||
entry["title"] = tpl.Title
|
||||
}
|
||||
if tpl.Slug != "" {
|
||||
entry["slug"] = tpl.Slug
|
||||
}
|
||||
if len(tpl.Tags) > 0 {
|
||||
tags := make([]string, len(tpl.Tags))
|
||||
for i, tag := range tpl.Tags {
|
||||
tags[i] = tag
|
||||
}
|
||||
entry["tags"] = tags
|
||||
}
|
||||
if tpl.Body != "" {
|
||||
entry["body"] = tpl.Body
|
||||
}
|
||||
if len(tpl.Fields) > 0 {
|
||||
fields := make(map[string]any, len(tpl.Fields))
|
||||
for key, value := range tpl.Fields {
|
||||
fields[key] = value
|
||||
}
|
||||
entry["fields"] = fields
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := tomlfile.Write(s.path, "templates", entries); err != nil {
|
||||
slog.Error("templates: cannot persist", "path", s.path, "error", err)
|
||||
return err
|
||||
}
|
||||
s.Invalidate()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package templates
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// addTemplate fails the test when a template cannot be created, which is
|
||||
// not what is under test in the cases that use it.
|
||||
func addTemplate(t *testing.T, s *Store, tpl PostTemplate) *PostTemplate {
|
||||
t.Helper()
|
||||
created, err := s.Add(tpl)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(%q): %v", tpl.Name, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func TestAddAndList(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "templates.toml")
|
||||
s := New(path)
|
||||
tpl, err := s.Add(PostTemplate{
|
||||
Name: "Review",
|
||||
Tags: []string{"review"},
|
||||
Body: "## Summary\n",
|
||||
Title: "Review: ",
|
||||
Slug: "review",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if tpl.Name != "Review" {
|
||||
t.Fatalf("tpl = %+v", tpl)
|
||||
}
|
||||
all := s.All()
|
||||
if len(all) != 1 || all[0].Name != "Review" || len(all[0].Tags) != 1 {
|
||||
t.Fatalf("templates = %v", all)
|
||||
}
|
||||
if all[0].Body != "## Summary\n" || all[0].Title != "Review: " || all[0].Slug != "review" {
|
||||
t.Fatalf("template fields = %+v", all[0])
|
||||
}
|
||||
if _, err := s.Add(PostTemplate{Name: "Review"}); !errors.Is(err, ErrNameTaken) {
|
||||
t.Fatalf("duplicate accepted: %v", err)
|
||||
}
|
||||
if _, err := s.Add(PostTemplate{}); !errors.Is(err, ErrEmptyName) {
|
||||
t.Fatalf("empty name accepted: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode = %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
reopened := New(path)
|
||||
if len(reopened.All()) != 1 {
|
||||
t.Fatalf("reopened = %v", reopened.All())
|
||||
}
|
||||
}
|
||||
|
||||
// The extra fields survive the round trip through the file, which is
|
||||
// what lets a scientific template pre-fill a DOI, a series or an ORCID.
|
||||
func TestFieldsRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "templates.toml")
|
||||
s := New(path)
|
||||
addTemplate(t, s, PostTemplate{
|
||||
Name: "Paper",
|
||||
Fields: map[string]string{
|
||||
"doi": "10.5555/",
|
||||
"orcid": "0000-0002-1825-0097",
|
||||
"series": "tds",
|
||||
"series_order": "2",
|
||||
},
|
||||
})
|
||||
reopened := New(path)
|
||||
all := reopened.All()
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("templates = %v", all)
|
||||
}
|
||||
fields := all[0].Fields
|
||||
if fields["doi"] != "10.5555/" || fields["orcid"] != "0000-0002-1825-0097" ||
|
||||
fields["series"] != "tds" || fields["series_order"] != "2" {
|
||||
t.Fatalf("fields = %v", fields)
|
||||
}
|
||||
// A template without extra fields reads back with none.
|
||||
addTemplate(t, s, PostTemplate{Name: "Bare"})
|
||||
for _, tpl := range New(path).All() {
|
||||
if tpl.Name == "Bare" && tpl.Fields != nil {
|
||||
t.Fatalf("bare template has fields: %v", tpl.Fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
s := New(filepath.Join(t.TempDir(), "templates.toml"))
|
||||
addTemplate(t, s, PostTemplate{Name: "A"})
|
||||
if err := s.Delete("A"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if err := s.Delete("A"); !errors.Is(err, ErrNoSuchTemplate) {
|
||||
t.Fatalf("second Delete = %v, want ErrNoSuchTemplate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAndBrokenFiles(t *testing.T) {
|
||||
if got := New(filepath.Join(t.TempDir(), "nope.toml")).All(); got != nil {
|
||||
t.Fatalf("templates = %v, want none", got)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "templates.toml")
|
||||
if err := os.WriteFile(path, []byte("broken = = ="), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if got := New(path).All(); got != nil {
|
||||
t.Fatalf("templates = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntriesWithoutNameSkipped(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "templates.toml")
|
||||
body := "[[templates]]\nbody = \"no name\"\n\n[[templates]]\nname = \"ok\"\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
all := New(path).All()
|
||||
if len(all) != 1 || all[0].Name != "ok" {
|
||||
t.Fatalf("templates = %v", all)
|
||||
}
|
||||
}
|
||||
|
||||
// A hand-written fields table with a scalar of any TOML type arrives as
|
||||
// its text, so a series order written bare as a number still fills the
|
||||
// form.
|
||||
func TestFieldsHandWrittenTable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "templates.toml")
|
||||
body := "[[templates]]\nname = \"paper\"\n[templates.fields]\nseries_order = 3\ndoi = \"10.1/x\"\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
all := New(path).All()
|
||||
if len(all) != 1 || all[0].Fields["series_order"] != "3" || all[0].Fields["doi"] != "10.1/x" {
|
||||
t.Fatalf("templates = %+v", all)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user