osaet/backend/internal/cli/sync.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

558 lines
14 KiB
Go

package cli
import (
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
func runPostsImport(root string, args []string) error {
fs := flag.NewFlagSet("posts import", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *dbPathFlag)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := applySQLiteSchema(db); err != nil {
return err
}
posts, err := loadPosts(root)
if err != nil {
return err
}
imported := 0
for _, post := range posts {
if post.Frontmatter.ID == "" {
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
}
if post.Frontmatter.Slug == "" {
return fmt.Errorf("%s: missing slug", mustRel(root, post.Path))
}
if err := upsertSQLitePost(root, db, post); err != nil {
return err
}
imported++
}
fmt.Printf("imported %d post(s) into %s\n", imported, mustRel(root, dbPath))
return nil
}
func runPostsExport(root string, args []string) error {
fs := flag.NewFlagSet("posts export", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
overwrite := fs.Bool("overwrite", false, "overwrite existing Markdown files")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *dbPathFlag)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
posts, err := loadSQLitePosts(db)
if err != nil {
return err
}
exported, skipped, err := exportPostsToFilesCount(root, posts, *overwrite)
if err != nil {
return err
}
fmt.Printf("exported %d post(s), skipped %d existing file(s)\n", exported, skipped)
return nil
}
func runPostsDiff(root string, args []string) error {
fs := flag.NewFlagSet("posts diff", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *dbPathFlag)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
return printPostsDiff(root, db)
}
func printPostsDiff(root string, db *sql.DB) error {
filePosts, err := loadPosts(root)
if err != nil {
return err
}
dbPosts, err := loadSQLitePosts(db)
if err != nil {
return err
}
fileByID := map[string]postFile{}
dbByID := map[string]postFile{}
for _, post := range filePosts {
if post.Frontmatter.ID == "" {
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
}
fileByID[post.Frontmatter.ID] = post
}
for _, post := range dbPosts {
dbByID[post.Frontmatter.ID] = post
}
ids := map[string]bool{}
for id := range fileByID {
ids[id] = true
}
for id := range dbByID {
ids[id] = true
}
summary := map[string]int{}
for _, id := range sortedBoolKeys(ids) {
filePost, inFile := fileByID[id]
dbPost, inDB := dbByID[id]
switch {
case inFile && !inDB:
summary["only-file"]++
fmt.Printf("only-file %-32s %s\n", filePost.Frontmatter.Slug, filePost.Frontmatter.Title)
case !inFile && inDB:
summary["only-db"]++
fmt.Printf("only-db %-32s %s\n", dbPost.Frontmatter.Slug, dbPost.Frontmatter.Title)
default:
if postsEquivalent(root, filePost, dbPost) {
summary["same"]++
} else {
summary["changed"]++
fmt.Printf("changed %-32s %s\n", filePost.Frontmatter.Slug, filePost.Frontmatter.Title)
printPostDiffFields(root, filePost, dbPost)
}
}
}
fmt.Printf("summary: same=%d changed=%d only-file=%d only-db=%d\n",
summary["same"],
summary["changed"],
summary["only-file"],
summary["only-db"],
)
return nil
}
func runPostsSync(root string, args []string) error {
fs := flag.NewFlagSet("posts sync", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
from := fs.String("from", "", "sync source: files, db, or auto")
yes := fs.Bool("yes", false, "confirm automatic sync without prompting")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *dbPathFlag)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := applySQLiteSchema(db); err != nil {
return err
}
switch *from {
case "files":
return syncFromFiles(root, db, dbPath)
case "db":
return syncFromDB(root, db)
case "auto":
return syncAuto(root, db)
case "":
if err := printPostsDiff(root, db); err != nil {
return err
}
if !*yes && !confirm("Auto Sync by newer updated_at?") {
fmt.Println("No changes applied.")
fmt.Println("Use `osaetctl posts sync --from files` to write Markdown into SQLite.")
fmt.Println("Use `osaetctl posts sync --from db` to write SQLite into Markdown.")
return nil
}
return syncAuto(root, db)
default:
return errors.New("--from must be files, db, or auto")
}
}
func syncFromFiles(root string, db *sql.DB, dbPath string) error {
posts, err := loadPosts(root)
if err != nil {
return err
}
for _, post := range posts {
if post.Frontmatter.ID == "" {
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
}
if err := upsertSQLitePost(root, db, post); err != nil {
return err
}
}
fmt.Printf("synced %d file post(s) into %s\n", len(posts), mustRel(root, dbPath))
return nil
}
func syncFromDB(root string, db *sql.DB) error {
posts, err := loadSQLitePosts(db)
if err != nil {
return err
}
if err := exportPostsToFiles(root, posts, true); err != nil {
return err
}
fmt.Printf("synced %d db post(s) into Markdown files\n", len(posts))
return nil
}
func syncAuto(root string, db *sql.DB) error {
filePosts, err := loadPosts(root)
if err != nil {
return err
}
dbPosts, err := loadSQLitePosts(db)
if err != nil {
return err
}
fileByID := map[string]postFile{}
dbByID := map[string]postFile{}
ids := map[string]bool{}
for _, post := range filePosts {
if post.Frontmatter.ID == "" {
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
}
fileByID[post.Frontmatter.ID] = post
ids[post.Frontmatter.ID] = true
}
for _, post := range dbPosts {
dbByID[post.Frontmatter.ID] = post
ids[post.Frontmatter.ID] = true
}
filesToDB := 0
dbToFiles := 0
for _, id := range sortedBoolKeys(ids) {
filePost, inFile := fileByID[id]
dbPost, inDB := dbByID[id]
switch {
case inFile && !inDB:
if err := upsertSQLitePost(root, db, filePost); err != nil {
return err
}
filesToDB++
case !inFile && inDB:
if err := exportPostsToFiles(root, []postFile{dbPost}, true); err != nil {
return err
}
dbToFiles++
case inFile && inDB:
if postsEquivalent(root, filePost, dbPost) {
continue
}
fileTime, fileOK := parseTime(filePost.Frontmatter.UpdatedAt)
dbTime, dbOK := parseTime(dbPost.Frontmatter.UpdatedAt)
if !fileOK && !dbOK {
fmt.Printf("skipped %s: cannot compare updated_at\n", filePost.Frontmatter.Slug)
continue
}
if fileOK && (!dbOK || fileTime.After(dbTime)) {
if err := upsertSQLitePost(root, db, filePost); err != nil {
return err
}
filesToDB++
} else if err := exportPostsToFiles(root, []postFile{dbPost}, true); err != nil {
return err
} else {
dbToFiles++
}
}
}
fmt.Printf("auto sync complete: files->db=%d db->files=%d\n", filesToDB, dbToFiles)
return nil
}
func upsertSQLitePost(root string, db *sql.DB, post postFile) error {
tagsJSON, err := json.Marshal(post.Frontmatter.Tags)
if err != nil {
return err
}
publishedAt := sql.NullString{}
if post.Frontmatter.PublishedAt != nil && strings.TrimSpace(*post.Frontmatter.PublishedAt) != "" {
publishedAt.Valid = true
publishedAt.String = *post.Frontmatter.PublishedAt
}
_, err = db.Exec(`INSERT INTO posts (
id,
slug,
title,
summary,
content_markdown,
status,
tags_json,
cover,
file_path,
version,
content_hash,
slug_source,
slug_locked,
published_at,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
slug = excluded.slug,
title = excluded.title,
summary = excluded.summary,
content_markdown = excluded.content_markdown,
status = excluded.status,
tags_json = excluded.tags_json,
cover = excluded.cover,
file_path = excluded.file_path,
version = excluded.version,
content_hash = excluded.content_hash,
slug_source = excluded.slug_source,
slug_locked = excluded.slug_locked,
published_at = excluded.published_at,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
deleted_at = NULL`,
post.Frontmatter.ID,
post.Frontmatter.Slug,
post.Frontmatter.Title,
post.Frontmatter.Summary,
post.Body,
post.Frontmatter.Status,
string(tagsJSON),
post.Frontmatter.Cover,
mustRel(root, post.Path),
post.Frontmatter.Version,
contentHash(post.Body),
post.Frontmatter.SlugSource,
boolInt(post.Frontmatter.SlugLocked),
publishedAt,
post.Frontmatter.CreatedAt,
post.Frontmatter.UpdatedAt,
)
return err
}
func loadSQLitePosts(db *sql.DB) ([]postFile, error) {
rows, err := db.Query(`SELECT
id,
slug,
title,
summary,
content_markdown,
status,
tags_json,
cover,
file_path,
version,
slug_source,
slug_locked,
published_at,
created_at,
updated_at
FROM posts
WHERE deleted_at IS NULL
ORDER BY COALESCE(published_at, updated_at) DESC, title ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var posts []postFile
for rows.Next() {
var post postFile
var tagsJSON string
var publishedAt sql.NullString
var slugLocked int
if err := rows.Scan(
&post.Frontmatter.ID,
&post.Frontmatter.Slug,
&post.Frontmatter.Title,
&post.Frontmatter.Summary,
&post.Body,
&post.Frontmatter.Status,
&tagsJSON,
&post.Frontmatter.Cover,
&post.Path,
&post.Frontmatter.Version,
&post.Frontmatter.SlugSource,
&slugLocked,
&publishedAt,
&post.Frontmatter.CreatedAt,
&post.Frontmatter.UpdatedAt,
); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(tagsJSON), &post.Frontmatter.Tags); err != nil {
return nil, err
}
if publishedAt.Valid {
post.Frontmatter.PublishedAt = &publishedAt.String
}
post.Frontmatter.SlugLocked = slugLocked != 0
posts = append(posts, post)
}
if err := rows.Err(); err != nil {
return nil, err
}
return posts, nil
}
func exportPostsToFiles(root string, posts []postFile, overwrite bool) error {
_, _, err := exportPostsToFilesCount(root, posts, overwrite)
return err
}
func exportPostsToFilesCount(root string, posts []postFile, overwrite bool) (int, int, error) {
postsDir := filepath.Join(root, defaultPostsDir)
if err := os.MkdirAll(postsDir, 0o755); err != nil {
return 0, 0, err
}
exported := 0
skipped := 0
for _, post := range posts {
if post.Frontmatter.Slug == "" {
return exported, skipped, fmt.Errorf("post %s has empty slug", post.Frontmatter.ID)
}
path := filepath.Join(postsDir, sanitizeSlug(post.Frontmatter.Slug)+".md")
post.Path = path
if _, err := os.Stat(path); err == nil && !overwrite {
skipped++
continue
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return exported, skipped, err
}
if err := writePostFile(post); err != nil {
return exported, skipped, err
}
exported++
}
return exported, skipped, nil
}
func postsEquivalent(root string, filePost postFile, dbPost postFile) bool {
return len(changedPostFields(root, filePost, dbPost)) == 0
}
func printPostDiffFields(root string, filePost postFile, dbPost postFile) {
for _, field := range changedPostFields(root, filePost, dbPost) {
fmt.Printf(" - %s\n", field)
}
}
func changedPostFields(root string, filePost postFile, dbPost postFile) []string {
var fields []string
if filePost.Frontmatter.Slug != dbPost.Frontmatter.Slug {
fields = append(fields, "slug")
}
if filePost.Frontmatter.Title != dbPost.Frontmatter.Title {
fields = append(fields, "title")
}
if filePost.Frontmatter.Summary != dbPost.Frontmatter.Summary {
fields = append(fields, "summary")
}
if filePost.Frontmatter.Status != dbPost.Frontmatter.Status {
fields = append(fields, "status")
}
if strings.Join(filePost.Frontmatter.Tags, "\x00") != strings.Join(dbPost.Frontmatter.Tags, "\x00") {
fields = append(fields, "tags")
}
if filePost.Frontmatter.Cover != dbPost.Frontmatter.Cover {
fields = append(fields, "cover")
}
if filePost.Frontmatter.Version != dbPost.Frontmatter.Version {
fields = append(fields, "version")
}
if filePost.Frontmatter.SlugSource != dbPost.Frontmatter.SlugSource {
fields = append(fields, "slug_source")
}
if filePost.Frontmatter.SlugLocked != dbPost.Frontmatter.SlugLocked {
fields = append(fields, "slug_locked")
}
if stringPtrValue(filePost.Frontmatter.PublishedAt) != stringPtrValue(dbPost.Frontmatter.PublishedAt) {
fields = append(fields, "published_at")
}
if filePost.Frontmatter.CreatedAt != dbPost.Frontmatter.CreatedAt {
fields = append(fields, "created_at")
}
if filePost.Frontmatter.UpdatedAt != dbPost.Frontmatter.UpdatedAt {
fields = append(fields, "updated_at")
}
if contentHash(filePost.Body) != contentHash(dbPost.Body) {
fields = append(fields, "content")
}
filePath := mustRel(root, filePost.Path)
if dbPost.Path != "" && filePath != dbPost.Path {
fields = append(fields, "file_path")
}
return fields
}