osaet/backend/internal/admin/markdown_import.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

239 lines
6.1 KiB
Go

package admin
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type MarkdownImportResult struct {
Imported int
Skipped int
}
type markdownFrontmatter struct {
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 (s *Store) ImportMarkdownPosts(ctx context.Context, postsDir string) (MarkdownImportResult, error) {
entries, err := os.ReadDir(postsDir)
if err != nil {
return MarkdownImportResult{}, fmt.Errorf("read posts dir: %w", err)
}
var result MarkdownImportResult
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
post, err := readMarkdownPost(filepath.Join(postsDir, entry.Name()))
if err != nil {
return result, err
}
if post.Title == "" || post.Slug == "" {
result.Skipped++
continue
}
if err := s.upsertImportedPost(ctx, post); err != nil {
return result, err
}
result.Imported++
}
return result, nil
}
func readMarkdownPost(path string) (Post, error) {
data, err := os.ReadFile(path)
if err != nil {
return Post{}, fmt.Errorf("read markdown post %s: %w", path, err)
}
frontmatterData, body, err := splitMarkdownFrontmatter(data)
if err != nil {
return Post{}, fmt.Errorf("parse markdown post %s: %w", path, err)
}
var meta markdownFrontmatter
if err := yaml.Unmarshal(frontmatterData, &meta); err != nil {
return Post{}, fmt.Errorf("parse frontmatter %s: %w", path, err)
}
slug := strings.TrimSpace(meta.Slug)
if slug == "" {
slug = strings.TrimSuffix(filepath.Base(path), ".md")
}
status := PostStatus(strings.TrimSpace(meta.Status))
if !ValidPostStatus(status) || status == PostStatusDeleted {
status = PostStatusDraft
}
version := meta.Version
if version < 1 {
version = 1
}
slugSource := strings.TrimSpace(meta.SlugSource)
if slugSource == "" {
slugSource = "manual"
}
now := time.Now()
createdAt := parseFrontmatterTime(meta.CreatedAt, now)
updatedAt := parseFrontmatterTime(meta.UpdatedAt, createdAt)
var publishedAt *time.Time
if parsed, ok := parseOptionalFrontmatterTime(meta.PublishedAt); ok {
publishedAt = &parsed
}
return Post{
Slug: slug,
Title: strings.TrimSpace(meta.Title),
Summary: meta.Summary,
BodyMarkdown: strings.TrimLeft(string(body), "\n"),
Status: status,
Tags: normalizeTagNames(meta.Tags),
Cover: meta.Cover,
Version: version,
SlugSource: slugSource,
SlugLocked: meta.SlugLocked,
PublishedAt: publishedAt,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}
func (s *Store) upsertImportedPost(ctx context.Context, post Post) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("begin markdown import: %w", err)
}
defer tx.Rollback(ctx)
imported, err := scanPost(tx.QueryRow(ctx, `
INSERT INTO posts (
slug, title, summary, body_markdown, status, cover, version,
slug_source, slug_locked, published_at, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (slug)
DO UPDATE SET
title = excluded.title,
summary = excluded.summary,
body_markdown = excluded.body_markdown,
status = excluded.status,
cover = excluded.cover,
version = GREATEST(posts.version, excluded.version),
slug_source = excluded.slug_source,
slug_locked = excluded.slug_locked,
published_at = excluded.published_at,
updated_at = excluded.updated_at,
deleted_at = NULL
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
post.Slug,
post.Title,
post.Summary,
post.BodyMarkdown,
post.Status,
post.Cover,
post.Version,
post.SlugSource,
post.SlugLocked,
post.PublishedAt,
post.CreatedAt,
post.UpdatedAt,
))
if err != nil {
return fmt.Errorf("upsert markdown post %s: %w", post.Slug, err)
}
if err := replacePostTags(ctx, tx, imported.ID, post.Tags); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (post_id, version) DO NOTHING`,
imported.ID,
imported.Version,
imported.Title,
imported.Summary,
imported.BodyMarkdown,
imported.Status,
VersionReasonImport,
); err != nil {
return fmt.Errorf("insert markdown import version %s: %w", post.Slug, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit markdown import: %w", err)
}
return nil
}
func splitMarkdownFrontmatter(data []byte) ([]byte, []byte, error) {
if !bytes.HasPrefix(data, []byte("---\n")) {
return nil, nil, errors.New("missing frontmatter opening marker")
}
rest := data[len("---\n"):]
idx := bytes.Index(rest, []byte("\n---"))
if idx < 0 {
return nil, nil, errors.New("missing frontmatter closing marker")
}
frontmatter := rest[:idx]
body := rest[idx+len("\n---"):]
if bytes.HasPrefix(body, []byte("\r\n")) {
body = body[2:]
} else if bytes.HasPrefix(body, []byte("\n")) {
body = body[1:]
}
return frontmatter, body, nil
}
func parseFrontmatterTime(value string, fallback time.Time) time.Time {
if parsed, ok := parseOptionalFrontmatterTime(value); ok {
return parsed
}
return fallback
}
func parseOptionalFrontmatterTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339,
"2006-01-02 15:04:05.999999999Z07",
"2006-01-02 15:04:05.999999Z07",
"2006-01-02 15:04:05Z07",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}