// Copyright (c) 2026 Petr BalvĂ­n (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 }