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 }