2026-09-19 17:33:44 +02:00
|
|
|
// Package service holds the application use cases. The demo CMS keeps its
|
|
|
|
|
// content in memory: it is seeded at startup and optionally mirrored to a single
|
|
|
|
|
// JSON file, so the container needs no database server.
|
2026-09-19 14:56:49 +02:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
2026-10-07 15:10:10 +02:00
|
|
|
"fmt"
|
2026-09-19 17:33:44 +02:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-09-19 14:56:49 +02:00
|
|
|
"regexp"
|
2026-09-19 17:33:44 +02:00
|
|
|
"sort"
|
2026-09-19 14:56:49 +02:00
|
|
|
"strings"
|
2026-09-19 17:33:44 +02:00
|
|
|
"sync"
|
2026-09-19 14:56:49 +02:00
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-19 17:33:44 +02:00
|
|
|
// Content is a single article in the backlog.
|
|
|
|
|
type Content struct {
|
|
|
|
|
ID uint `json:"id"`
|
|
|
|
|
Slug string `json:"slug"`
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
Summary string `json:"summary"`
|
|
|
|
|
Body string `json:"body"`
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
Published bool `json:"published"`
|
|
|
|
|
Category string `json:"category,omitempty"`
|
|
|
|
|
Badges []string `json:"badges"`
|
|
|
|
|
Author string `json:"author"`
|
|
|
|
|
PublishedAt *time.Time `json:"publishedAt,omitempty"`
|
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Category groups content for the filter dropdown.
|
|
|
|
|
type Category struct {
|
|
|
|
|
ID uint `json:"id"`
|
|
|
|
|
Slug string `json:"slug"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Badge is a reusable tag shared by any number of articles.
|
|
|
|
|
type Badge struct {
|
|
|
|
|
ID uint `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// contentRevision records one edit so the app keeps an audit trail.
|
|
|
|
|
type contentRevision struct {
|
|
|
|
|
ContentID uint `json:"contentId"`
|
|
|
|
|
Operation string `json:"operation"`
|
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
|
Snapshot string `json:"snapshot"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IContentService exposes the content use cases. Reads are anonymous; Update is
|
|
|
|
|
// only reachable once the web layer verified the SSO identity.
|
2026-09-19 14:56:49 +02:00
|
|
|
type IContentService interface {
|
2026-09-19 17:33:44 +02:00
|
|
|
List(context.Context, int, int, string, string, []string) ([]Content, error)
|
|
|
|
|
ListCategories(context.Context) ([]Category, error)
|
|
|
|
|
ListBadges(context.Context) ([]Badge, error)
|
|
|
|
|
Get(context.Context, string) (Content, error)
|
|
|
|
|
Update(context.Context, Content) (Content, error)
|
2026-10-03 12:25:10 +02:00
|
|
|
Create(context.Context, Content) (Content, error)
|
|
|
|
|
Delete(context.Context, string) error
|
|
|
|
|
Reset(context.Context) error
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
|
|
|
|
// ContentService keeps the demo content in memory behind a mutex. When file is
|
|
|
|
|
// set, the store is loaded from that JSON file on startup and written back after
|
|
|
|
|
// every edit, so a mounted volume keeps changes across restarts.
|
2026-09-19 14:56:49 +02:00
|
|
|
type ContentService struct {
|
2026-09-19 17:33:44 +02:00
|
|
|
mu sync.RWMutex
|
|
|
|
|
items []Content
|
|
|
|
|
history []contentRevision
|
|
|
|
|
categories map[string]string
|
|
|
|
|
categoryOrder []string
|
|
|
|
|
allBadges []string
|
|
|
|
|
nextID uint
|
|
|
|
|
file string
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-19 17:33:44 +02:00
|
|
|
// NewContentService seeds the store from the given articles. When file is not
|
|
|
|
|
// empty and already exists, the store is loaded from it instead, which lets a
|
|
|
|
|
// mounted volume keep edits across restarts.
|
|
|
|
|
func NewContentService(seed []Content, file string) (*ContentService, error) {
|
|
|
|
|
s := &ContentService{file: file}
|
|
|
|
|
if file != "" {
|
|
|
|
|
if _, err := os.Stat(file); err == nil {
|
|
|
|
|
if err := s.load(file); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return s, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(seed) == 0 {
|
|
|
|
|
return nil, errors.New("no seed content available")
|
|
|
|
|
}
|
|
|
|
|
s.applySeed(seed)
|
|
|
|
|
if err := s.persistLocked(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return s, nil
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
|
|
|
|
func (s *ContentService) applySeed(seed []Content) {
|
|
|
|
|
s.categories = map[string]string{}
|
|
|
|
|
s.categoryOrder = nil
|
|
|
|
|
s.items = make([]Content, 0, len(seed))
|
|
|
|
|
s.history = nil
|
|
|
|
|
known := map[string]struct{}{}
|
|
|
|
|
for i, item := range seed {
|
|
|
|
|
item.ID = uint(i + 1)
|
|
|
|
|
item.Category = normalizeCategory(item.Category)
|
|
|
|
|
if _, seen := s.categories[item.Category]; !seen && item.Category != "" {
|
|
|
|
|
s.categories[item.Category] = categoryDisplay(item.Category)
|
|
|
|
|
s.categoryOrder = append(s.categoryOrder, item.Category)
|
|
|
|
|
}
|
|
|
|
|
item.Badges = normalizeBadges(item.Badges)
|
|
|
|
|
for _, badge := range item.Badges {
|
|
|
|
|
known[badge] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
item.Status = normalizeStatus(item.Status)
|
|
|
|
|
if item.CreatedAt.IsZero() {
|
|
|
|
|
item.CreatedAt = time.Now()
|
|
|
|
|
}
|
|
|
|
|
if item.UpdatedAt.IsZero() {
|
|
|
|
|
item.UpdatedAt = item.CreatedAt
|
|
|
|
|
}
|
|
|
|
|
s.items = append(s.items, item)
|
|
|
|
|
}
|
|
|
|
|
s.nextID = uint(len(seed) + 1)
|
|
|
|
|
s.allBadges = sortedKeys(known)
|
|
|
|
|
sortContent(s.items)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// List returns a page of articles filtered by category, status, and badges. The
|
|
|
|
|
// selected badges are combined with AND semantics.
|
|
|
|
|
func (s *ContentService) List(_ context.Context, limit, offset int, category, status string, badges []string) ([]Content, error) {
|
|
|
|
|
limit, offset = clampPage(limit, offset)
|
|
|
|
|
category = normalizeCategory(category)
|
|
|
|
|
if strings.TrimSpace(status) != "" {
|
|
|
|
|
status = normalizeStatus(status)
|
|
|
|
|
}
|
|
|
|
|
wanted := normalizeBadges(badges)
|
|
|
|
|
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
out := make([]Content, 0, limit)
|
|
|
|
|
for _, item := range s.items {
|
|
|
|
|
if category != "" && item.Category != category {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if status != "" && item.Status != status {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !hasBadges(item, wanted) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if offset > 0 {
|
|
|
|
|
offset--
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if len(out) == limit {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
out = append(out, cloneContent(item))
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ListCategories returns the fixed category list used by the filter dropdown.
|
|
|
|
|
func (s *ContentService) ListCategories(context.Context) ([]Category, error) {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
out := make([]Category, 0, len(s.categoryOrder))
|
|
|
|
|
for i, slug := range s.categoryOrder {
|
|
|
|
|
out = append(out, Category{ID: uint(i + 1), Slug: slug, Name: s.categories[slug]})
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ListBadges returns every badge carried by an article, so the filter and the
|
|
|
|
|
// editor never suggest a badge that would return nothing.
|
|
|
|
|
func (s *ContentService) ListBadges(context.Context) ([]Badge, error) {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
known := map[string]struct{}{}
|
|
|
|
|
for _, badge := range s.allBadges {
|
|
|
|
|
known[badge] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
for _, item := range s.items {
|
|
|
|
|
for _, badge := range item.Badges {
|
|
|
|
|
known[badge] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
names := sortedKeys(known)
|
|
|
|
|
out := make([]Badge, 0, len(names))
|
|
|
|
|
for i, name := range names {
|
|
|
|
|
out = append(out, Badge{ID: uint(i + 1), Name: name})
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get returns one article by slug.
|
|
|
|
|
func (s *ContentService) Get(_ context.Context, slug string) (Content, error) {
|
|
|
|
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
for _, item := range s.items {
|
2026-10-07 15:10:10 +02:00
|
|
|
if item.Slug == slug || fmt.Sprint(item.ID) == slug {
|
2026-09-19 17:33:44 +02:00
|
|
|
return cloneContent(item), nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Content{}, errors.New("content not found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update saves an edited article, records a revision, and mirrors the store to
|
|
|
|
|
// disk when a content file is configured.
|
|
|
|
|
func (s *ContentService) Update(_ context.Context, c Content) (Content, error) {
|
|
|
|
|
return s.update(c, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ContentService) UpdateAs(_ context.Context, c Content, actor string) (Content, error) {
|
|
|
|
|
return s.update(c, actor)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ContentService) update(c Content, actor string) (Content, error) {
|
|
|
|
|
c.Slug = strings.ToLower(strings.TrimSpace(c.Slug))
|
2026-10-07 15:10:10 +02:00
|
|
|
if strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
|
2026-09-19 17:33:44 +02:00
|
|
|
return Content{}, errors.New("invalid content")
|
|
|
|
|
}
|
|
|
|
|
c.Badges = normalizeBadges(c.Badges)
|
|
|
|
|
c.Status = normalizeStatus(c.Status)
|
|
|
|
|
c.Published = c.Status == "published"
|
|
|
|
|
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
index := -1
|
|
|
|
|
for i := range s.items {
|
2026-10-07 15:10:10 +02:00
|
|
|
if s.items[i].Slug == c.Slug || fmt.Sprint(s.items[i].ID) == c.Slug {
|
2026-09-19 17:33:44 +02:00
|
|
|
index = i
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if index < 0 {
|
|
|
|
|
return Content{}, errors.New("content not found")
|
|
|
|
|
}
|
|
|
|
|
current := s.items[index]
|
|
|
|
|
if actor != "" && current.Author != actor {
|
|
|
|
|
return Content{}, errors.New("you can only edit your own content")
|
|
|
|
|
}
|
|
|
|
|
c.ID = current.ID
|
|
|
|
|
c.CreatedAt = current.CreatedAt
|
|
|
|
|
c.UpdatedAt = time.Now().Truncate(time.Second)
|
2026-10-07 15:10:10 +02:00
|
|
|
if c.Category == "" {
|
|
|
|
|
c.Category = normalizeCategory(current.Category)
|
|
|
|
|
} else {
|
|
|
|
|
c.Category = normalizeCategory(c.Category)
|
|
|
|
|
}
|
|
|
|
|
if c.Author == "" {
|
|
|
|
|
c.Author = current.Author
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
if c.Status == "published" && c.PublishedAt == nil {
|
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
|
|
|
c.PublishedAt = &now
|
|
|
|
|
}
|
|
|
|
|
if c.Status != "published" {
|
|
|
|
|
c.PublishedAt = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.items[index] = c
|
|
|
|
|
sortContent(s.items)
|
|
|
|
|
snapshot, _ := json.Marshal(c)
|
|
|
|
|
s.history = append(s.history, contentRevision{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot)})
|
|
|
|
|
if err := s.persistLocked(); err != nil {
|
|
|
|
|
return Content{}, err
|
|
|
|
|
}
|
|
|
|
|
return cloneContent(c), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 12:25:10 +02:00
|
|
|
func (s *ContentService) Create(_ context.Context, c Content) (Content, error) {
|
|
|
|
|
c.Title = strings.TrimSpace(c.Title)
|
|
|
|
|
c.Body = strings.TrimSpace(c.Body)
|
|
|
|
|
if c.Title == "" || c.Body == "" {
|
|
|
|
|
return Content{}, errors.New("invalid content")
|
|
|
|
|
}
|
|
|
|
|
c.Status = normalizeStatus(c.Status)
|
|
|
|
|
if c.Slug == "" {
|
|
|
|
|
c.Slug = generateSlug(c.Title)
|
|
|
|
|
}
|
|
|
|
|
c.Slug = strings.ToLower(strings.TrimSpace(c.Slug))
|
|
|
|
|
if !validSlug(c.Slug) {
|
2026-10-07 15:10:10 +02:00
|
|
|
c.Slug = generateSlug(c.Title)
|
|
|
|
|
}
|
|
|
|
|
if !validSlug(c.Slug) {
|
|
|
|
|
c.Slug = "post"
|
2026-10-03 12:25:10 +02:00
|
|
|
}
|
|
|
|
|
c.Category = normalizeCategory(c.Category)
|
|
|
|
|
c.Badges = normalizeBadges(c.Badges)
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
c.CreatedAt = now
|
|
|
|
|
c.UpdatedAt = now
|
|
|
|
|
if c.Status == "published" {
|
|
|
|
|
if c.PublishedAt == nil {
|
|
|
|
|
t := now
|
|
|
|
|
c.PublishedAt = &t
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
c.PublishedAt = nil
|
|
|
|
|
}
|
|
|
|
|
c.Published = (c.Status == "published")
|
|
|
|
|
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
if s.nextID == 0 {
|
|
|
|
|
max := uint(0)
|
|
|
|
|
for _, it := range s.items {
|
|
|
|
|
if it.ID > max {
|
|
|
|
|
max = it.ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
s.nextID = max + 1
|
|
|
|
|
}
|
|
|
|
|
c.ID = s.nextID
|
|
|
|
|
s.nextID++
|
|
|
|
|
for _, it := range s.items {
|
|
|
|
|
if it.Slug == c.Slug {
|
2026-10-07 15:10:10 +02:00
|
|
|
c.Slug = fmt.Sprintf("%s-%d", c.Slug, c.ID)
|
|
|
|
|
break
|
2026-10-03 12:25:10 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
created := cloneContent(c)
|
|
|
|
|
s.items = append(s.items, created)
|
|
|
|
|
sortContent(s.items)
|
|
|
|
|
snapshot, _ := json.Marshal(created)
|
|
|
|
|
s.history = append(s.history, contentRevision{ContentID: created.ID, Operation: "created", CreatedAt: created.UpdatedAt, Snapshot: string(snapshot)})
|
|
|
|
|
if err := s.persistLocked(); err != nil {
|
|
|
|
|
return Content{}, err
|
|
|
|
|
}
|
|
|
|
|
return cloneContent(created), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ContentService) Delete(_ context.Context, slug string) error {
|
|
|
|
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
for i, it := range s.items {
|
2026-10-07 15:10:10 +02:00
|
|
|
if it.Slug == slug || fmt.Sprint(it.ID) == slug {
|
2026-10-03 12:25:10 +02:00
|
|
|
s.items = append(s.items[:i], s.items[i+1:]...)
|
|
|
|
|
snapshot, _ := json.Marshal(it)
|
|
|
|
|
s.history = append(s.history, contentRevision{ContentID: it.ID, Operation: "deleted", CreatedAt: time.Now(), Snapshot: string(snapshot)})
|
|
|
|
|
if err := s.persistLocked(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return errors.New("content not found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ContentService) Reset(_ context.Context) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.items = nil
|
|
|
|
|
s.nextID = 1
|
|
|
|
|
s.history = nil
|
|
|
|
|
if err := s.persistLocked(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 17:33:44 +02:00
|
|
|
// storedContent is the on-disk shape of an article. Identifiers are positional
|
|
|
|
|
// so the JSON file stays readable and diff-friendly.
|
|
|
|
|
type storedContent struct {
|
|
|
|
|
Slug string `json:"slug"`
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
Summary string `json:"summary"`
|
|
|
|
|
Body string `json:"body"`
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
Published bool `json:"published"`
|
|
|
|
|
Category string `json:"category,omitempty"`
|
|
|
|
|
Badges []string `json:"badges"`
|
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type storedFile struct {
|
|
|
|
|
Categories map[string]string `json:"categories"`
|
|
|
|
|
Content []storedContent `json:"content"`
|
|
|
|
|
History []contentRevision `json:"history,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ContentService) load(file string) error {
|
|
|
|
|
data, err := os.ReadFile(file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
var payload storedFile
|
|
|
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
s.categories = payload.Categories
|
|
|
|
|
if s.categories == nil {
|
|
|
|
|
s.categories = map[string]string{}
|
|
|
|
|
}
|
|
|
|
|
s.categoryOrder = make([]string, 0, len(s.categories))
|
|
|
|
|
for slug := range s.categories {
|
|
|
|
|
s.categoryOrder = append(s.categoryOrder, slug)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(s.categoryOrder)
|
|
|
|
|
s.history = payload.History
|
|
|
|
|
|
|
|
|
|
known := map[string]struct{}{}
|
|
|
|
|
s.items = make([]Content, 0, len(payload.Content))
|
|
|
|
|
for i, item := range payload.Content {
|
|
|
|
|
c := Content{
|
|
|
|
|
ID: uint(i + 1), Slug: item.Slug, Title: item.Title, Summary: item.Summary, Body: item.Body,
|
|
|
|
|
Status: normalizeStatus(item.Status), Category: normalizeCategory(item.Category),
|
|
|
|
|
Badges: normalizeBadges(item.Badges), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt,
|
|
|
|
|
}
|
|
|
|
|
c.Published = c.Status == "published"
|
|
|
|
|
for _, badge := range c.Badges {
|
|
|
|
|
known[badge] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
s.items = append(s.items, c)
|
|
|
|
|
}
|
|
|
|
|
s.nextID = uint(len(s.items) + 1)
|
|
|
|
|
s.allBadges = sortedKeys(known)
|
|
|
|
|
sortContent(s.items)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ContentService) persistLocked() error {
|
|
|
|
|
if s.file == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
payload := storedFile{Categories: s.categories, History: s.history}
|
|
|
|
|
payload.Content = make([]storedContent, 0, len(s.items))
|
|
|
|
|
for _, item := range s.items {
|
|
|
|
|
payload.Content = append(payload.Content, storedContent{
|
|
|
|
|
Slug: item.Slug, Title: item.Title, Summary: item.Summary, Body: item.Body,
|
|
|
|
|
Status: item.Status, Published: item.Published, Category: item.Category,
|
|
|
|
|
Badges: item.Badges, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
data, err := json.MarshalIndent(payload, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if dir := filepath.Dir(s.file); dir != "" && dir != "." {
|
|
|
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
tmp := s.file + ".tmp"
|
|
|
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return os.Rename(tmp, s.file)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clampPage(limit, offset int) (int, int) {
|
2026-09-19 14:56:49 +02:00
|
|
|
if limit < 1 {
|
|
|
|
|
limit = 20
|
|
|
|
|
}
|
|
|
|
|
if limit > 100 {
|
|
|
|
|
limit = 100
|
|
|
|
|
}
|
|
|
|
|
if offset < 0 {
|
|
|
|
|
offset = 0
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
return limit, offset
|
2026-09-19 15:11:12 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
|
|
|
|
func hasBadges(item Content, wanted []string) bool {
|
|
|
|
|
for _, badge := range wanted {
|
|
|
|
|
found := false
|
|
|
|
|
for _, have := range item.Badges {
|
|
|
|
|
if have == badge {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
|
|
|
|
// sortContent orders articles newest first, matching the list page ordering.
|
|
|
|
|
func sortContent(items []Content) {
|
|
|
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
|
|
|
if !items[i].UpdatedAt.Equal(items[j].UpdatedAt) {
|
|
|
|
|
return items[i].UpdatedAt.After(items[j].UpdatedAt)
|
|
|
|
|
}
|
|
|
|
|
if !items[i].CreatedAt.Equal(items[j].CreatedAt) {
|
|
|
|
|
return items[i].CreatedAt.After(items[j].CreatedAt)
|
|
|
|
|
}
|
|
|
|
|
return items[i].ID > items[j].ID
|
|
|
|
|
})
|
2026-09-19 15:14:16 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
|
|
|
|
func cloneContent(c Content) Content {
|
|
|
|
|
c.Badges = append([]string(nil), c.Badges...)
|
|
|
|
|
return c
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
|
|
|
|
func normalizeCategory(value string) string {
|
|
|
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
|
|
|
if !validSlug(value) {
|
|
|
|
|
return ""
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
return value
|
|
|
|
|
}
|
2026-09-19 15:24:27 +02:00
|
|
|
|
2026-09-19 17:33:44 +02:00
|
|
|
func normalizeStatus(value string) string {
|
|
|
|
|
switch value = strings.ToLower(strings.TrimSpace(value)); value {
|
|
|
|
|
case "published", "draft", "review", "archived":
|
|
|
|
|
return value
|
|
|
|
|
default:
|
|
|
|
|
return "draft"
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 15:24:27 +02:00
|
|
|
func normalizeBadges(values []string) []string {
|
|
|
|
|
result := make([]string, 0, len(values))
|
|
|
|
|
seen := make(map[string]struct{}, len(values))
|
|
|
|
|
for _, value := range values {
|
|
|
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
|
|
|
if value == "" || len(value) > 100 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, ok := seen[value]; ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[value] = struct{}{}
|
|
|
|
|
result = append(result, value)
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
sort.Strings(result)
|
2026-09-19 15:24:27 +02:00
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 17:33:44 +02:00
|
|
|
func sortedKeys(set map[string]struct{}) []string {
|
|
|
|
|
out := make([]string, 0, len(set))
|
|
|
|
|
for key := range set {
|
|
|
|
|
out = append(out, key)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(out)
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// categoryDisplay returns the readable name for a category slug, falling back
|
|
|
|
|
// to a title-cased slug for anything not in the seeded list.
|
|
|
|
|
func categoryDisplay(slug string) string {
|
|
|
|
|
if name, ok := categoryNames[slug]; ok {
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
return strings.ToUpper(slug[:1]) + slug[1:]
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 12:25:10 +02:00
|
|
|
func generateSlug(title string) string {
|
|
|
|
|
title = strings.TrimSpace(strings.ToLower(title))
|
|
|
|
|
if title == "" {
|
|
|
|
|
return "post"
|
|
|
|
|
}
|
|
|
|
|
// simple slugification
|
|
|
|
|
parts := strings.Fields(title)
|
|
|
|
|
var out strings.Builder
|
|
|
|
|
for i, p := range parts {
|
|
|
|
|
cleaned := strings.Map(func(r rune) rune {
|
|
|
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
|
|
|
return r
|
|
|
|
|
}
|
|
|
|
|
return '-'
|
|
|
|
|
}, p)
|
|
|
|
|
cleaned = strings.Trim(cleaned, "-")
|
|
|
|
|
if cleaned == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if i > 0 {
|
|
|
|
|
out.WriteByte('-')
|
|
|
|
|
}
|
|
|
|
|
out.WriteString(cleaned)
|
|
|
|
|
}
|
|
|
|
|
if out.Len() == 0 {
|
|
|
|
|
return "post"
|
|
|
|
|
}
|
|
|
|
|
res := out.String()
|
|
|
|
|
if len(res) > 60 {
|
|
|
|
|
res = res[:60]
|
|
|
|
|
}
|
|
|
|
|
return strings.Trim(res, "-")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 14:56:49 +02:00
|
|
|
var slugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
|
|
|
|
|
2026-09-19 17:33:44 +02:00
|
|
|
func validSlug(s string) bool { return len(s) > 0 && len(s) <= 200 && slugRE.MatchString(s) }
|