Files

266 lines
6.7 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// 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
}