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.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
460
backend/internal/postimport/import.go
Normal file
460
backend/internal/postimport/import.go
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
package postimport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultPostsDir = "content/posts"
|
||||
|
||||
type Options struct {
|
||||
CSVPath string
|
||||
PostsDir string
|
||||
Overwrite bool
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Imported int
|
||||
SkippedExisting int
|
||||
SkippedNonPost int
|
||||
}
|
||||
|
||||
type PostFile struct {
|
||||
Path string
|
||||
Frontmatter PostFrontmatter
|
||||
Body 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"`
|
||||
}
|
||||
|
||||
type csvArticle struct {
|
||||
ID string
|
||||
Slug string
|
||||
Title string
|
||||
BodyMD string
|
||||
BodyHTML string
|
||||
Status string
|
||||
ArchiveID string
|
||||
AuthorID string
|
||||
PublishedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
Type string
|
||||
}
|
||||
|
||||
func Import(options Options) (Result, error) {
|
||||
root, err := findProjectRoot(options.WorkingDir)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
csvPath := resolveRootPath(root, firstNonEmpty(options.CSVPath, "articles.csv"))
|
||||
postsDir := resolveRootPath(root, firstNonEmpty(options.PostsDir, defaultPostsDir))
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
file, err := os.Open(csvPath)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.LazyQuotes = false
|
||||
|
||||
headers, err := reader.Read()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
index := map[string]int{}
|
||||
for i, header := range headers {
|
||||
index[strings.TrimSpace(header)] = i
|
||||
}
|
||||
|
||||
var result Result
|
||||
for rowNum := 2; ; rowNum++ {
|
||||
record, err := reader.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return result, fmt.Errorf("%s row %d: %w", csvPath, rowNum, err)
|
||||
}
|
||||
|
||||
article := csvArticle{
|
||||
ID: csvValue(record, index, "id"),
|
||||
Slug: csvValue(record, index, "slug"),
|
||||
Title: csvValue(record, index, "title"),
|
||||
BodyMD: csvValue(record, index, "body_md"),
|
||||
BodyHTML: csvValue(record, index, "body_html"),
|
||||
Status: csvValue(record, index, "status"),
|
||||
ArchiveID: csvValue(record, index, "archive_id"),
|
||||
AuthorID: csvValue(record, index, "author_id"),
|
||||
PublishedAt: csvValue(record, index, "published_at"),
|
||||
CreatedAt: csvValue(record, index, "created_at"),
|
||||
UpdatedAt: csvValue(record, index, "updated_at"),
|
||||
Type: csvValue(record, index, "type"),
|
||||
}
|
||||
|
||||
if strings.TrimSpace(article.Type) != "" && strings.TrimSpace(article.Type) != "post" {
|
||||
result.SkippedNonPost++
|
||||
continue
|
||||
}
|
||||
|
||||
post, skippedExisting, err := articleToPost(root, postsDir, article, options.Overwrite)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
|
||||
}
|
||||
if skippedExisting {
|
||||
result.SkippedExisting++
|
||||
continue
|
||||
}
|
||||
if err := writePostFile(post); err != nil {
|
||||
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func articleToPost(root string, postsDir string, article csvArticle, overwrite bool) (PostFile, bool, error) {
|
||||
id := strings.TrimSpace(article.ID)
|
||||
if id == "" {
|
||||
id = randomID()
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(article.Title)
|
||||
if title == "" {
|
||||
return PostFile{}, false, errors.New("missing title")
|
||||
}
|
||||
|
||||
slug := sanitizeSlug(article.Slug)
|
||||
if slug == "" {
|
||||
slug = fallbackSlug(title)
|
||||
}
|
||||
if slug == "" {
|
||||
return PostFile{}, false, errors.New("missing slug")
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(article.Status)
|
||||
if status != "published" && status != "draft" {
|
||||
status = "draft"
|
||||
}
|
||||
|
||||
createdAt, err := normalizeLegacyTime(article.CreatedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid created_at: %w", err)
|
||||
}
|
||||
updatedAt, err := normalizeLegacyTime(article.UpdatedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid updated_at: %w", err)
|
||||
}
|
||||
|
||||
var publishedAt *string
|
||||
if strings.TrimSpace(article.PublishedAt) != "" {
|
||||
normalized, err := normalizeLegacyTime(article.PublishedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid published_at: %w", err)
|
||||
}
|
||||
publishedAt = &normalized
|
||||
}
|
||||
|
||||
path := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if !overwrite {
|
||||
return PostFile{}, true, nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
|
||||
finalSlug := slug
|
||||
if !overwrite {
|
||||
finalSlug, err = uniqueSlug(root, slug)
|
||||
if err != nil {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
path, err = uniquePostPath(postsDir, finalSlug)
|
||||
if err != nil {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.TrimLeft(article.BodyMD, "\n")
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = strings.TrimLeft(article.BodyHTML, "\n")
|
||||
}
|
||||
|
||||
return PostFile{
|
||||
Path: path,
|
||||
Frontmatter: PostFrontmatter{
|
||||
ID: id,
|
||||
Slug: finalSlug,
|
||||
Title: title,
|
||||
Summary: "",
|
||||
Status: status,
|
||||
Tags: []string{},
|
||||
Cover: "",
|
||||
Version: 1,
|
||||
SlugSource: "manual",
|
||||
SlugLocked: true,
|
||||
PublishedAt: publishedAt,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
Body: body,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
func csvValue(record []string, index map[string]int, key string) string {
|
||||
i, ok := index[key]
|
||||
if !ok || i >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return record[i]
|
||||
}
|
||||
|
||||
func normalizeLegacyTime(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New("empty time")
|
||||
}
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05.999999999Z07",
|
||||
"2006-01-02 15:04:05.999999Z07",
|
||||
"2006-01-02 15:04:05Z07",
|
||||
time.RFC3339,
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return parsed.Format(time.RFC3339), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unsupported time format %q", value)
|
||||
}
|
||||
|
||||
func writePostFile(post PostFile) error {
|
||||
var frontmatter bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&frontmatter)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(post.Frontmatter); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
output.WriteString("---\n")
|
||||
output.Write(frontmatter.Bytes())
|
||||
output.WriteString("---\n\n")
|
||||
output.WriteString(strings.TrimLeft(post.Body, "\n"))
|
||||
|
||||
tmp := post.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, post.Path)
|
||||
}
|
||||
|
||||
func loadPosts(root string, postsDir string) ([]PostFile, error) {
|
||||
dir := resolveRootPath(root, postsDir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var posts []PostFile
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, PostFile{Path: filepath.Join(dir, entry.Name()), Frontmatter: PostFrontmatter{Slug: strings.TrimSuffix(entry.Name(), ".md")}})
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func slugExists(root string, slug string) bool {
|
||||
posts, err := loadPosts(root, defaultPostsDir)
|
||||
if err == nil {
|
||||
for _, post := range posts {
|
||||
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
|
||||
if post.Frontmatter.Slug == slug || fileSlug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func uniqueSlug(root string, slug string) (string, error) {
|
||||
base := sanitizeSlug(slug)
|
||||
if base == "" {
|
||||
return "", errors.New("empty slug")
|
||||
}
|
||||
if !slugExists(root, base) {
|
||||
return base, nil
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d", base, i)
|
||||
if !slugExists(root, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available slug for %q", base)
|
||||
}
|
||||
|
||||
func uniquePostPath(postsDir string, slug string) (string, error) {
|
||||
candidate := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i))
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available filename for slug %q", slug)
|
||||
}
|
||||
|
||||
func findProjectRoot(start string) (string, error) {
|
||||
wd := start
|
||||
if wd == "" {
|
||||
var err error
|
||||
wd, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
for {
|
||||
if isProjectRoot(wd) {
|
||||
return wd, nil
|
||||
}
|
||||
parent := filepath.Dir(wd)
|
||||
if parent == wd {
|
||||
return "", errors.New("could not find project root")
|
||||
}
|
||||
wd = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isProjectRoot(dir string) bool {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveRootPath(root string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(root, path)
|
||||
}
|
||||
|
||||
func fallbackSlug(title string) string {
|
||||
var words []string
|
||||
var b strings.Builder
|
||||
flush := func() {
|
||||
if b.Len() > 0 {
|
||||
words = append(words, b.String())
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
for _, r := range strings.ToLower(title) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/':
|
||||
flush()
|
||||
default:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(words) == 0 {
|
||||
return "post-" + time.Now().Format("20060102150405")
|
||||
}
|
||||
return sanitizeSlug(strings.Join(words, "-"))
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
slug = re.ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
for strings.Contains(slug, "--") {
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
}
|
||||
if len(slug) > 80 {
|
||||
slug = strings.Trim(slug[:80], "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("post-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
125
backend/internal/postimport/import_test.go
Normal file
125
backend/internal/postimport/import_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package postimport
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeLegacyTime(t *testing.T) {
|
||||
got, err := normalizeLegacyTime("2026-01-13 01:25:27.486491+00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "2026-01-13T01:25:27Z" {
|
||||
t.Fatalf("normalized time = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleToPostFallbackSlugAndBody(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
post, skipped, err := articleToPost(root, postsDir, csvArticle{
|
||||
Title: "Hello World",
|
||||
BodyMD: "\nBody\n",
|
||||
Status: "published",
|
||||
CreatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
UpdatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
PublishedAt: "2026-01-13 01:25:27.486491+00",
|
||||
Type: "post",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped {
|
||||
t.Fatal("expected importable post")
|
||||
}
|
||||
if post.Frontmatter.Slug != "hello-world" {
|
||||
t.Fatalf("slug = %q", post.Frontmatter.Slug)
|
||||
}
|
||||
if post.Body != "Body\n" {
|
||||
t.Fatalf("body = %q", post.Body)
|
||||
}
|
||||
if post.Frontmatter.PublishedAt == nil || *post.Frontmatter.PublishedAt != "2026-01-13T01:25:27Z" {
|
||||
t.Fatalf("published_at = %#v", post.Frontmatter.PublishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleToPostSkipsExistingWithoutOverwrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(postsDir, "smoking.md"), []byte("existing"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, skipped, err := articleToPost(root, postsDir, csvArticle{
|
||||
Slug: "smoking",
|
||||
Title: "抽烟",
|
||||
BodyMD: "Body",
|
||||
Status: "published",
|
||||
CreatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
UpdatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
Type: "post",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !skipped {
|
||||
t.Fatal("expected existing file to be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportWritesMarkdown(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "backend", "cmd", "osaetctl"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "frontend", "site"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "frontend", "site", "package.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csvPath := filepath.Join(root, "articles.csv")
|
||||
csvContent := strings.Join([]string{
|
||||
"id,slug,title,body_md,body_html,status,archive_id,author_id,published_at,created_at,updated_at,type",
|
||||
"post-1,test-post,Test Post,\"Line 1\n\nLine 2\",,published,,,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,post",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(csvPath, []byte(csvContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := Import(Options{CSVPath: csvPath, PostsDir: defaultPostsDir, WorkingDir: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Imported != 1 || result.SkippedExisting != 0 || result.SkippedNonPost != 0 {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(root, defaultPostsDir, "test-post.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
for _, want := range []string{
|
||||
"slug: test-post",
|
||||
"title: Test Post",
|
||||
"status: published",
|
||||
"published_at: \"2026-01-13T01:25:27Z\"",
|
||||
"Line 1",
|
||||
"Line 2",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected output to contain %q\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue