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.
This commit is contained in:
yarnom 2026-06-01 15:48:04 +08:00
parent b78f4b39c9
commit f0b50d13ea
121 changed files with 27139 additions and 550 deletions

View file

@ -9,122 +9,8 @@ import (
"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)
@ -445,138 +331,6 @@ func syncAuto(root string, db *sql.DB) error {
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 {
@ -802,44 +556,3 @@ func changedPostFields(root string, filePost postFile, dbPost postFile) []string
}
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
}