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.
239 lines
6.1 KiB
Go
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
|
|
}
|