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 }