some extra make target

This commit is contained in:
Daniel Noulet 2026-10-03 12:25:10 +02:00
parent dac02c61c3
commit e454940dfa
2 changed files with 182 additions and 0 deletions

View file

@ -62,6 +62,9 @@ type IContentService interface {
ListBadges(context.Context) ([]Badge, error)
Get(context.Context, string) (Content, error)
Update(context.Context, Content) (Content, error)
Create(context.Context, Content) (Content, error)
Delete(context.Context, string) error
Reset(context.Context) error
}
// ContentService keeps the demo content in memory behind a mutex. When file is
@ -279,6 +282,99 @@ func (s *ContentService) update(c Content, actor string) (Content, error) {
return cloneContent(c), nil
}
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) {
return Content{}, errors.New("invalid slug")
}
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 {
s.mu.Unlock()
return Content{}, errors.New("slug already exists")
}
}
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))
if !validSlug(slug) {
return errors.New("invalid slug")
}
s.mu.Lock()
defer s.mu.Unlock()
for i, it := range s.items {
if it.Slug == slug {
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
}
// storedContent is the on-disk shape of an article. Identifiers are positional
// so the JSON file stays readable and diff-friendly.
type storedContent struct {
@ -469,6 +565,40 @@ func categoryDisplay(slug string) string {
return strings.ToUpper(slug[:1]) + slug[1:]
}
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, "-")
}
var slugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
func validSlug(s string) bool { return len(s) > 0 && len(s) <= 200 && slugRE.MatchString(s) }