Add the CLI, site, and sample content so the project can run locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
845 lines
22 KiB
Go
845 lines
22 KiB
Go
package cli
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func runConfigImport(root string, args []string) error {
|
|
fs := flag.NewFlagSet("config import", flag.ContinueOnError)
|
|
fs.SetOutput(os.Stderr)
|
|
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
db, dbPath, err := openProjectSQLite(root, *dbPathFlag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
config, err := readSiteConfig(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := upsertSetting(db, "site", config.Meta.ConfigVersion, config.Meta.UpdatedAt, config); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("imported config/site.yaml into %s\n", mustRel(root, dbPath))
|
|
return nil
|
|
}
|
|
|
|
func runConfigExport(root string, args []string) error {
|
|
fs := flag.NewFlagSet("config export", flag.ContinueOnError)
|
|
fs.SetOutput(os.Stderr)
|
|
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
|
overwrite := fs.Bool("overwrite", false, "overwrite config/site.yaml")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
config, ok, err := loadSiteSetting(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return errors.New("settings.site not found; run `osaetctl config import` first")
|
|
}
|
|
|
|
path := filepath.Join(root, "config/site.yaml")
|
|
if _, err := os.Stat(path); err == nil && !*overwrite {
|
|
return errors.New("config/site.yaml exists; pass --overwrite to replace it")
|
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
if err := writeSiteConfig(root, config); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("exported settings.site into config/site.yaml")
|
|
return nil
|
|
}
|
|
|
|
func runConfigDiff(root string, args []string) error {
|
|
fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
|
|
fs.SetOutput(os.Stderr)
|
|
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
return printConfigDiff(root, db)
|
|
}
|
|
|
|
func runConfigSync(root string, args []string) error {
|
|
fs := flag.NewFlagSet("config sync", flag.ContinueOnError)
|
|
fs.SetOutput(os.Stderr)
|
|
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
|
from := fs.String("from", "", "sync source: file, db, or auto")
|
|
yes := fs.Bool("yes", false, "confirm automatic sync without prompting")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
switch *from {
|
|
case "file", "files":
|
|
return syncConfigFromFile(root, db)
|
|
case "db":
|
|
return syncConfigFromDB(root, db)
|
|
case "auto":
|
|
return syncConfigAuto(root, db)
|
|
case "":
|
|
if err := printConfigDiff(root, db); err != nil {
|
|
return err
|
|
}
|
|
if !*yes && !confirm("Auto Sync config by newer updated_at?") {
|
|
fmt.Println("No changes applied.")
|
|
fmt.Println("Use `osaetctl config sync --from file` to write config/site.yaml into SQLite.")
|
|
fmt.Println("Use `osaetctl config sync --from db` to write SQLite settings into config/site.yaml.")
|
|
return nil
|
|
}
|
|
return syncConfigAuto(root, db)
|
|
default:
|
|
return errors.New("--from must be file, db, or auto")
|
|
}
|
|
}
|
|
|
|
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 upsertSetting(db *sql.DB, key string, version int, updatedAt string, value any) error {
|
|
if version == 0 {
|
|
version = 1
|
|
}
|
|
if strings.TrimSpace(updatedAt) == "" {
|
|
updatedAt = time.Now().Format(time.RFC3339)
|
|
}
|
|
valueJSON, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.Exec(`INSERT INTO settings (key, value_json, version, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value_json = excluded.value_json,
|
|
version = excluded.version,
|
|
updated_at = excluded.updated_at`,
|
|
key, string(valueJSON), version, updatedAt)
|
|
return err
|
|
}
|
|
|
|
func loadSiteSetting(db *sql.DB) (siteConfigFile, bool, error) {
|
|
var config siteConfigFile
|
|
var valueJSON string
|
|
err := db.QueryRow(`SELECT value_json FROM settings WHERE key = 'site'`).Scan(&valueJSON)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return config, false, nil
|
|
}
|
|
return config, false, err
|
|
}
|
|
if err := json.Unmarshal([]byte(valueJSON), &config); err != nil {
|
|
return config, false, err
|
|
}
|
|
return config, true, nil
|
|
}
|
|
|
|
func printConfigDiff(root string, db *sql.DB) error {
|
|
fileConfig, fileErr := readSiteConfig(root)
|
|
dbConfig, dbOK, err := loadSiteSetting(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch {
|
|
case fileErr != nil && !errors.Is(fileErr, os.ErrNotExist):
|
|
return fileErr
|
|
case errors.Is(fileErr, os.ErrNotExist) && !dbOK:
|
|
fmt.Println("summary: file=no db=no")
|
|
case errors.Is(fileErr, os.ErrNotExist):
|
|
fmt.Println("only-db settings.site")
|
|
fmt.Println("summary: file=no db=yes")
|
|
case !dbOK:
|
|
fmt.Println("only-file config/site.yaml")
|
|
fmt.Println("summary: file=yes db=no")
|
|
default:
|
|
fields := changedConfigFields(fileConfig, dbConfig)
|
|
if len(fields) == 0 {
|
|
fmt.Println("same config/site.yaml <-> settings.site")
|
|
fmt.Println("summary: same=1 changed=0")
|
|
} else {
|
|
fmt.Println("changed config/site.yaml <-> settings.site")
|
|
for _, field := range fields {
|
|
fmt.Printf(" - %s\n", field)
|
|
}
|
|
fmt.Println("summary: same=0 changed=1")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func syncConfigFromFile(root string, db *sql.DB) error {
|
|
config, err := readSiteConfig(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := upsertSetting(db, "site", config.Meta.ConfigVersion, config.Meta.UpdatedAt, config); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("synced config/site.yaml into settings.site")
|
|
return nil
|
|
}
|
|
|
|
func syncConfigFromDB(root string, db *sql.DB) error {
|
|
config, ok, err := loadSiteSetting(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return errors.New("settings.site not found; run `osaetctl config import` first")
|
|
}
|
|
if err := writeSiteConfig(root, config); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("synced settings.site into config/site.yaml")
|
|
return nil
|
|
}
|
|
|
|
func syncConfigAuto(root string, db *sql.DB) error {
|
|
fileConfig, fileErr := readSiteConfig(root)
|
|
dbConfig, dbOK, err := loadSiteSetting(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch {
|
|
case fileErr != nil && !errors.Is(fileErr, os.ErrNotExist):
|
|
return fileErr
|
|
case errors.Is(fileErr, os.ErrNotExist) && !dbOK:
|
|
fmt.Println("nothing to sync")
|
|
case errors.Is(fileErr, os.ErrNotExist):
|
|
return syncConfigFromDB(root, db)
|
|
case !dbOK:
|
|
return syncConfigFromFile(root, db)
|
|
default:
|
|
if len(changedConfigFields(fileConfig, dbConfig)) == 0 {
|
|
fmt.Println("config already in sync")
|
|
return nil
|
|
}
|
|
fileTime, fileOK := parseTime(fileConfig.Meta.UpdatedAt)
|
|
dbTime, dbTimeOK := parseTime(dbConfig.Meta.UpdatedAt)
|
|
if !fileOK && !dbTimeOK {
|
|
return errors.New("cannot auto sync config: both updated_at values are invalid")
|
|
}
|
|
if fileOK && (!dbTimeOK || fileTime.After(dbTime)) {
|
|
return syncConfigFromFile(root, db)
|
|
}
|
|
return syncConfigFromDB(root, db)
|
|
}
|
|
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
|
|
}
|
|
|
|
func changedConfigFields(fileConfig siteConfigFile, dbConfig siteConfigFile) []string {
|
|
var fields []string
|
|
if fileConfig.Meta.ConfigVersion != dbConfig.Meta.ConfigVersion {
|
|
fields = append(fields, "meta.config_version")
|
|
}
|
|
if fileConfig.Meta.UpdatedAt != dbConfig.Meta.UpdatedAt {
|
|
fields = append(fields, "meta.updated_at")
|
|
}
|
|
if fileConfig.Meta.UpdatedBy != dbConfig.Meta.UpdatedBy {
|
|
fields = append(fields, "meta.updated_by")
|
|
}
|
|
if fileConfig.Site.Title != dbConfig.Site.Title {
|
|
fields = append(fields, "site.title")
|
|
}
|
|
if fileConfig.Site.Description != dbConfig.Site.Description {
|
|
fields = append(fields, "site.description")
|
|
}
|
|
if fileConfig.Site.BaseURL != dbConfig.Site.BaseURL {
|
|
fields = append(fields, "site.base_url")
|
|
}
|
|
if fileConfig.Site.Language != dbConfig.Site.Language {
|
|
fields = append(fields, "site.language")
|
|
}
|
|
if fileConfig.Site.Timezone != dbConfig.Site.Timezone {
|
|
fields = append(fields, "site.timezone")
|
|
}
|
|
if fileConfig.Content.PostsDir != dbConfig.Content.PostsDir {
|
|
fields = append(fields, "content.posts_dir")
|
|
}
|
|
if fileConfig.Content.AssetsDir != dbConfig.Content.AssetsDir {
|
|
fields = append(fields, "content.assets_dir")
|
|
}
|
|
if fileConfig.Build.AstroProject != dbConfig.Build.AstroProject {
|
|
fields = append(fields, "build.astro_project")
|
|
}
|
|
if fileConfig.Build.OutputDir != dbConfig.Build.OutputDir {
|
|
fields = append(fields, "build.output_dir")
|
|
}
|
|
return fields
|
|
}
|