osaet/backend/internal/admin/exporter.go
yarnom f0b50d13ea feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
2026-06-01 15:48:04 +08:00

135 lines
3.3 KiB
Go

package admin
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type Exporter struct {
postsDir string
}
type postFrontmatter struct {
ID string `yaml:"id"`
Slug string `yaml:"slug"`
Title string `yaml:"title"`
Summary string `yaml:"summary"`
Status string `yaml:"status"`
Tags []string `yaml:"tags"`
Cover string `yaml:"cover"`
Version int `yaml:"version"`
SlugSource string `yaml:"slug_source"`
SlugLocked bool `yaml:"slug_locked"`
PublishedAt string `yaml:"published_at"`
CreatedAt string `yaml:"created_at"`
UpdatedAt string `yaml:"updated_at"`
}
func NewExporter(postsDir string) *Exporter {
return &Exporter{postsDir: postsDir}
}
func (e *Exporter) ExportPublishedPosts(ctx context.Context, posts []Post) error {
if err := os.MkdirAll(e.postsDir, 0o755); err != nil {
return fmt.Errorf("create posts dir: %w", err)
}
publishedFiles := make(map[string]bool, len(posts))
for _, post := range posts {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
publishedFiles[post.Slug+".md"] = true
if err := e.writePost(post); err != nil {
return err
}
}
if err := e.removeStalePosts(publishedFiles); err != nil {
return err
}
return nil
}
func (e *Exporter) removeStalePosts(publishedFiles map[string]bool) error {
entries, err := os.ReadDir(e.postsDir)
if err != nil {
return fmt.Errorf("read posts dir: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
if publishedFiles[entry.Name()] {
continue
}
if err := os.Remove(filepath.Join(e.postsDir, entry.Name())); err != nil {
return fmt.Errorf("remove stale post %s: %w", entry.Name(), err)
}
}
return nil
}
func (e *Exporter) writePost(post Post) error {
path := filepath.Join(e.postsDir, post.Slug+".md")
frontmatter := postFrontmatter{
ID: post.ID,
Slug: post.Slug,
Title: post.Title,
Summary: post.Summary,
Status: string(post.Status),
Tags: post.Tags,
Cover: post.Cover,
Version: post.Version,
SlugSource: post.SlugSource,
SlugLocked: post.SlugLocked,
PublishedAt: formatFrontmatterTime(post.PublishedAt),
CreatedAt: formatFrontmatterTime(&post.CreatedAt),
UpdatedAt: formatFrontmatterTime(&post.UpdatedAt),
}
var meta bytes.Buffer
encoder := yaml.NewEncoder(&meta)
encoder.SetIndent(2)
if err := encoder.Encode(frontmatter); err != nil {
return fmt.Errorf("encode frontmatter: %w", err)
}
if err := encoder.Close(); err != nil {
return fmt.Errorf("close frontmatter encoder: %w", err)
}
var output bytes.Buffer
output.WriteString("---\n")
output.Write(meta.Bytes())
output.WriteString("---\n\n")
output.WriteString(strings.TrimLeft(post.BodyMarkdown, "\n"))
if !strings.HasSuffix(output.String(), "\n") {
output.WriteByte('\n')
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
return fmt.Errorf("write post %s: %w", post.Slug, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("replace post %s: %w", post.Slug, err)
}
return nil
}
func formatFrontmatterTime(value *time.Time) string {
if value == nil {
return ""
}
return value.Format(time.RFC3339)
}