Simplify admin publishing pipeline

This commit is contained in:
yarnom 2026-06-03 18:18:50 +08:00
parent 13e7e4026d
commit 9186801c7f
37 changed files with 750 additions and 3367 deletions

View file

@ -1,80 +0,0 @@
package admin
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
const maxAssetSizeBytes = 12 * 1024 * 1024
var safeAssetNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
type AssetUploader struct {
store *Store
assetsDir string
}
func NewAssetUploader(store *Store, assetsDir string) *AssetUploader {
return &AssetUploader{store: store, assetsDir: assetsDir}
}
func (u *AssetUploader) Upload(ctx context.Context, file multipart.File, header *multipart.FileHeader) (Asset, error) {
if header.Size > maxAssetSizeBytes {
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
}
data, err := io.ReadAll(io.LimitReader(file, maxAssetSizeBytes+1))
if err != nil {
return Asset{}, fmt.Errorf("read asset: %w", err)
}
if int64(len(data)) > maxAssetSizeBytes {
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
}
mimeType := http.DetectContentType(data)
if !strings.HasPrefix(mimeType, "image/") {
return Asset{}, fmt.Errorf("unsupported asset type: %s", mimeType)
}
sum := sha256.Sum256(data)
sha := hex.EncodeToString(sum[:])
name := assetFilename(header.Filename, sha)
if err := os.MkdirAll(u.assetsDir, 0o755); err != nil {
return Asset{}, fmt.Errorf("create assets dir: %w", err)
}
if err := os.WriteFile(filepath.Join(u.assetsDir, name), data, 0o644); err != nil {
return Asset{}, fmt.Errorf("write asset: %w", err)
}
asset := Asset{
Path: "/assets/" + name,
OriginalName: header.Filename,
MimeType: mimeType,
SizeBytes: int64(len(data)),
SHA256: sha,
}
return u.store.CreateAsset(ctx, asset)
}
func assetFilename(original string, sha string) string {
ext := strings.ToLower(filepath.Ext(original))
base := strings.TrimSuffix(filepath.Base(original), filepath.Ext(original))
base = strings.Trim(safeAssetNamePattern.ReplaceAllString(base, "-"), "-._")
if base == "" {
base = "image"
}
if ext == "" {
ext = ".bin"
}
return fmt.Sprintf("%s-%s%s", base, sha[:12], ext)
}

View file

@ -20,8 +20,11 @@ type Config struct {
RepoRoot string
PostsDir string
SiteDir string
AssetsDir string
StaticDir string
AdminDir string
LogFile string
LogMaxBytes int64
LogMaxBackups int
DeepSeek DeepSeekConfig
LocalLLM LocalLLMConfig
SlugProvider string
@ -89,9 +92,9 @@ func LoadConfig() Config {
siteDir = filepath.Join(repoRoot, "frontend", "site")
}
assetsDir := os.Getenv("OSAET_ASSETS_DIR")
if assetsDir == "" {
assetsDir = filepath.Join(siteDir, "public", "assets")
staticDir := os.Getenv("OSAET_STATIC_DIR")
if staticDir == "" {
staticDir = filepath.Join(repoRoot, "dist", "site")
}
adminDir := os.Getenv("OSAET_ADMIN_DIR")
@ -99,6 +102,11 @@ func LoadConfig() Config {
adminDir = filepath.Join(repoRoot, "frontend", "admin", "dist", "admin", "browser")
}
logFile := os.Getenv("OSAET_LOG_FILE")
if logFile == "" {
logFile = filepath.Join(repoRoot, ".osaet", "logs", "osaet-admin.log")
}
local := loadLocalConfig(repoRoot)
databaseURL := firstNonEmpty(os.Getenv("DATABASE_URL"), local.Database.PostgresDSN)
@ -118,8 +126,11 @@ func LoadConfig() Config {
RepoRoot: repoRoot,
PostsDir: postsDir,
SiteDir: siteDir,
AssetsDir: assetsDir,
StaticDir: staticDir,
AdminDir: adminDir,
LogFile: logFile,
LogMaxBytes: int64(firstNonZeroInt(envInt("OSAET_LOG_MAX_BYTES"), 10*1024*1024)),
LogMaxBackups: firstNonZeroInt(envInt("OSAET_LOG_MAX_BACKUPS"), 5),
SlugProvider: firstNonEmpty(os.Getenv("OSAET_SLUG_PROVIDER"), local.Slug.Provider, "deepseek"),
DeepSeek: DeepSeekConfig{
APIKey: deepSeekAPIKey,
@ -236,8 +247,8 @@ func (c Config) Validate() error {
if c.SiteDir == "" {
return errors.New("site dir is required")
}
if c.AssetsDir == "" {
return errors.New("assets dir is required")
if c.StaticDir == "" {
return errors.New("static dir is required")
}
return nil
}

View file

@ -0,0 +1,139 @@
package admin
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
)
type rotatingLogWriter struct {
path string
maxBytes int64
maxBackups int
file *os.File
size int64
mu sync.Mutex
}
func ConfigureLogging(cfg Config) (io.Closer, error) {
if cfg.LogFile == "" {
return nil, nil
}
writer, err := newRotatingLogWriter(cfg.LogFile, cfg.LogMaxBytes, cfg.LogMaxBackups)
if err != nil {
return nil, err
}
multi := io.MultiWriter(os.Stdout, writer)
log.SetOutput(multi)
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.LUTC)
return writer, nil
}
func newRotatingLogWriter(path string, maxBytes int64, maxBackups int) (*rotatingLogWriter, error) {
if maxBytes <= 0 {
maxBytes = 10 * 1024 * 1024
}
if maxBackups <= 0 {
maxBackups = 5
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create log dir: %w", err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, fmt.Errorf("stat log file: %w", err)
}
return &rotatingLogWriter{
path: path,
maxBytes: maxBytes,
maxBackups: maxBackups,
file: file,
size: info.Size(),
}, nil
}
func (w *rotatingLogWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.size+int64(len(p)) > w.maxBytes {
if err := w.rotateLocked(); err != nil {
return 0, err
}
}
n, err := w.file.Write(p)
w.size += int64(n)
return n, err
}
func (w *rotatingLogWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rotatingLogWriter) rotateLocked() error {
if w.file != nil {
if err := w.file.Close(); err != nil {
return err
}
}
stamp := time.Now().UTC().Format("20060102T150405")
rotated := fmt.Sprintf("%s.%s", w.path, stamp)
if err := os.Rename(w.path, rotated); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("rotate log: %w", err)
}
if err := w.pruneLocked(); err != nil {
return err
}
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open rotated log file: %w", err)
}
w.file = file
w.size = 0
return nil
}
func (w *rotatingLogWriter) pruneLocked() error {
pattern := w.path + ".*"
matches, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("list rotated logs: %w", err)
}
if len(matches) <= w.maxBackups {
return nil
}
sortStrings(matches)
for _, path := range matches[:len(matches)-w.maxBackups] {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove old log %s: %w", path, err)
}
}
return nil
}
func sortStrings(values []string) {
for i := 1; i < len(values); i++ {
value := values[i]
j := i - 1
for ; j >= 0 && values[j] > value; j-- {
values[j+1] = values[j]
}
values[j+1] = value
}
}

View file

@ -1,239 +0,0 @@
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
}

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
@ -19,11 +20,11 @@ type Server struct {
db *pgxpool.Pool
store *Store
builder *Builder
uploader *AssetUploader
deepSeek DeepSeekConfig
localLLM LocalLLMConfig
slugProvider string
adminDir string
staticDir string
ctx context.Context
}
@ -38,26 +39,22 @@ func NewServerWithConfig(db *pgxpool.Pool, cfg Config) *Server {
func NewServerWithContext(ctx context.Context, db *pgxpool.Pool, cfg Config) *Server {
var store *Store
var builder *Builder
var uploader *AssetUploader
if db != nil {
store = NewStore(db)
if cfg.PostsDir != "" && cfg.SiteDir != "" {
builder = NewBuilder(store, NewExporter(cfg.PostsDir), cfg.SiteDir)
builder.Start(ctx)
}
if cfg.AssetsDir != "" {
uploader = NewAssetUploader(store, cfg.AssetsDir)
}
}
return &Server{
db: db,
store: store,
builder: builder,
uploader: uploader,
deepSeek: cfg.DeepSeek,
localLLM: cfg.LocalLLM,
slugProvider: cfg.SlugProvider,
adminDir: cfg.AdminDir,
staticDir: cfg.StaticDir,
ctx: ctx,
}
}
@ -67,6 +64,7 @@ func (s *Server) Router() http.Handler {
r := gin.New()
r.Use(gin.Recovery())
r.Use(requestLogger())
r.GET("/healthz", s.health)
r.GET("/readyz", s.ready)
@ -82,8 +80,8 @@ func (s *Server) Router() http.Handler {
protected.Use(s.requireAuth)
protected.GET("/me", s.me)
protected.POST("/logout", s.logout)
protected.POST("/assets", s.uploadAsset)
protected.POST("/slug", s.generateSlug)
protected.GET("/audit-logs", s.listAuditLogs)
protected.GET("/posts", s.listPosts)
protected.POST("/posts", s.createPost)
protected.GET("/posts/:id", s.getPost)
@ -93,6 +91,8 @@ func (s *Server) Router() http.Handler {
protected.POST("/posts/:id/publish", s.publishPost)
protected.GET("/build-jobs/:id", s.getBuildJob)
r.NoRoute(s.siteFile)
return r
}
@ -119,6 +119,17 @@ func (s *Server) adminFile(c *gin.Context) {
s.adminPage(c)
}
func (s *Server) siteFile(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if serveSiteFile(c, s.staticDir) {
return
}
c.String(http.StatusNotFound, "not found")
}
func (s *Server) ready(c *gin.Context) {
if s.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
@ -190,10 +201,12 @@ func (s *Server) login(c *gin.Context) {
result, err := s.store.Login(c.Request.Context(), input)
if err != nil {
s.audit(c, nil, "login_failed", "user", "", gin.H{"username": input.Username, "error": err.Error()})
writeStoreError(c, err)
return
}
SetSessionCookie(c, result.Token, result.ExpiresAt)
s.audit(c, &result.User, "login", "user", result.User.ID, gin.H{"username": result.User.Username})
c.JSON(http.StatusOK, gin.H{
"user": result.User,
"expiresAt": result.ExpiresAt,
@ -214,12 +227,14 @@ func (s *Server) logout(c *gin.Context) {
return
}
user, _ := currentUser(c)
token, _ := c.Cookie(SessionCookieName)
if err := s.store.Logout(c.Request.Context(), token); err != nil {
writeStoreError(c, err)
return
}
ClearSessionCookie(c)
s.audit(c, &user, "logout", "user", user.ID, gin.H{"username": user.Username})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@ -252,6 +267,7 @@ func (s *Server) createPost(c *gin.Context) {
writeStoreError(c, err)
return
}
s.auditCurrentUser(c, "post_create", "post", post.ID, postAuditDetails(post))
c.JSON(http.StatusCreated, gin.H{"post": post})
}
@ -271,6 +287,7 @@ func (s *Server) updatePost(c *gin.Context) {
writeStoreError(c, err)
return
}
s.auditCurrentUser(c, "post_update", "post", post.ID, postAuditDetails(post))
c.JSON(http.StatusOK, gin.H{"post": post})
}
@ -285,33 +302,14 @@ func (s *Server) deletePost(c *gin.Context) {
return
}
s.enqueueBuildJob(job)
details := gin.H{}
if job != nil {
details["buildJobId"] = job.ID
}
s.auditCurrentUser(c, "post_delete", "post", c.Param("id"), details)
c.JSON(http.StatusOK, gin.H{"ok": true, "buildJob": job})
}
func (s *Server) uploadAsset(c *gin.Context) {
if !s.requireStore(c) {
return
}
if s.uploader == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "asset uploader is not configured"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
defer file.Close()
asset, err := s.uploader.Upload(c.Request.Context(), file, header)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"asset": asset})
}
type GenerateSlugInput struct {
Title string `json:"title"`
Summary string `json:"summary"`
@ -345,9 +343,35 @@ func (s *Server) generateSlug(c *gin.Context) {
writeStoreError(c, err)
return
}
s.auditCurrentUser(c, "slug_generate", "post", input.PostID, gin.H{"title": input.Title, "slug": slug})
c.JSON(http.StatusOK, gin.H{"slug": slug})
}
func (s *Server) listAuditLogs(c *gin.Context) {
if !s.requireStore(c) {
return
}
opts := AuditLogListOptions{
Action: c.Query("action"),
ResourceType: c.Query("resourceType"),
Query: c.Query("query"),
Limit: queryInt(c, "limit"),
Offset: queryInt(c, "offset"),
}
logs, err := s.store.ListAuditLogs(c.Request.Context(), opts)
if err != nil {
writeStoreError(c, err)
return
}
total, err := s.store.CountAuditLogs(c.Request.Context(), opts)
if err != nil {
writeStoreError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
}
func (s *Server) generateSlugBase(ctx context.Context, title string, summary string) (string, error) {
switch strings.ToLower(strings.TrimSpace(s.slugProvider)) {
case "", "deepseek":
@ -385,6 +409,7 @@ func (s *Server) buildPost(c *gin.Context) {
}
s.enqueueBuildJob(&job)
s.auditCurrentUser(c, "build_create", "build_job", job.ID, gin.H{"postId": c.Param("id")})
c.JSON(http.StatusAccepted, gin.H{"buildJob": job})
}
@ -400,6 +425,12 @@ func (s *Server) publishPost(c *gin.Context) {
}
s.enqueueBuildJob(&job)
s.auditCurrentUser(c, "post_publish", "post", post.ID, gin.H{
"title": post.Title,
"slug": post.Slug,
"status": post.Status,
"buildJobId": job.ID,
})
c.JSON(http.StatusAccepted, gin.H{
"post": post,
@ -472,3 +503,58 @@ func writeStoreError(c *gin.Context, err error) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}
func currentUser(c *gin.Context) (User, bool) {
user, ok := c.Request.Context().Value(userContextKey).(User)
return user, ok
}
func (s *Server) auditCurrentUser(c *gin.Context, action string, resourceType string, resourceID string, details map[string]any) {
user, ok := currentUser(c)
if !ok {
s.audit(c, nil, action, resourceType, resourceID, details)
return
}
s.audit(c, &user, action, resourceType, resourceID, details)
}
func (s *Server) audit(c *gin.Context, user *User, action string, resourceType string, resourceID string, details map[string]any) {
if s.store == nil {
return
}
var actorID *string
actorUsername := ""
if user != nil {
actorID = &user.ID
actorUsername = user.Username
}
if err := s.store.CreateAuditLog(c.Request.Context(), AuditLogInput{
ActorID: actorID,
ActorUsername: actorUsername,
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Details: details,
}); err != nil {
log.Printf("audit log failed: %v", err)
}
}
func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
started := time.Now()
c.Next()
log.Printf("%s %s %d %s %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started), c.ClientIP())
}
}
func postAuditDetails(post Post) map[string]any {
return map[string]any{
"title": post.Title,
"slug": post.Slug,
"status": post.Status,
"version": post.Version,
}
}

View file

@ -46,6 +46,33 @@ func serveAdminFile(c *gin.Context, adminDir string) bool {
return true
}
func serveSiteFile(c *gin.Context, staticDir string) bool {
if strings.TrimSpace(staticDir) == "" {
return false
}
requested := strings.TrimPrefix(c.Request.URL.Path, "/")
if requested == "" {
requested = "index.html"
}
cleaned := filepath.Clean(requested)
if cleaned == "." || strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) {
return false
}
path := filepath.Join(staticDir, cleaned)
info, err := os.Stat(path)
if err == nil && info.IsDir() {
path = filepath.Join(path, "index.html")
info, err = os.Stat(path)
}
if err != nil || info.IsDir() {
return false
}
http.ServeFile(c.Writer, c.Request, path)
return true
}
func rewriteAdminBase(page []byte) []byte {
return []byte(strings.Replace(string(page), `<base href="/">`, `<base href="/admin/">`, 1))
}

View file

@ -3,6 +3,7 @@ package admin
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
@ -25,6 +26,25 @@ type PostListOptions struct {
Offset int
}
type AuditLogInput struct {
ActorID *string
ActorUsername string
Action string
ResourceType string
ResourceID string
IPAddress string
UserAgent string
Details map[string]any
}
type AuditLogListOptions struct {
Action string
ResourceType string
Query string
Limit int
Offset int
}
type PostInput struct {
Slug string `json:"slug"`
Title string `json:"title"`
@ -389,28 +409,6 @@ WHERE id = $1`, id))
return job, nil
}
func (s *Store) CreateAsset(ctx context.Context, asset Asset) (Asset, error) {
created, err := scanAsset(s.db.QueryRow(ctx, `
INSERT INTO assets (path, original_name, mime_type, size_bytes, sha256)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (path)
DO UPDATE SET original_name = excluded.original_name,
mime_type = excluded.mime_type,
size_bytes = excluded.size_bytes,
sha256 = excluded.sha256
RETURNING id, path, original_name, mime_type, size_bytes, sha256, created_at, created_by`,
asset.Path,
asset.OriginalName,
asset.MimeType,
asset.SizeBytes,
asset.SHA256,
))
if err != nil {
return Asset{}, fmt.Errorf("create asset: %w", err)
}
return created, nil
}
func (s *Store) PublishedPostsForExport(ctx context.Context) ([]Post, error) {
rows, err := s.db.Query(ctx, `
SELECT id, slug, title, summary, body_markdown, status, cover, version,
@ -473,6 +471,114 @@ WHERE id = $1`, id, log, message)
return nil
}
func (s *Store) CreateAuditLog(ctx context.Context, input AuditLogInput) error {
action := strings.TrimSpace(input.Action)
if action == "" {
return errors.New("audit action is required")
}
details := input.Details
if details == nil {
details = map[string]any{}
}
detailsJSON, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("encode audit details: %w", err)
}
_, err = s.db.Exec(ctx, `
INSERT INTO audit_logs (actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
input.ActorID,
input.ActorUsername,
action,
strings.TrimSpace(input.ResourceType),
strings.TrimSpace(input.ResourceID),
strings.TrimSpace(input.IPAddress),
strings.TrimSpace(input.UserAgent),
string(detailsJSON),
)
if err != nil {
return fmt.Errorf("create audit log: %w", err)
}
return nil
}
func (s *Store) ListAuditLogs(ctx context.Context, opts AuditLogListOptions) ([]AuditLog, error) {
limit := opts.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
offset := opts.Offset
if offset < 0 {
offset = 0
}
args := []any{limit, offset}
where := "true"
if strings.TrimSpace(opts.Action) != "" {
args = append(args, strings.TrimSpace(opts.Action))
where += fmt.Sprintf(" AND action = $%d", len(args))
}
if strings.TrimSpace(opts.ResourceType) != "" {
args = append(args, strings.TrimSpace(opts.ResourceType))
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
}
if strings.TrimSpace(opts.Query) != "" {
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
where += fmt.Sprintf(` AND (
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args))
}
rows, err := s.db.Query(ctx, `
SELECT id, actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details, created_at
FROM audit_logs
WHERE `+where+`
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`, args...)
if err != nil {
return nil, fmt.Errorf("list audit logs: %w", err)
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
log, err := scanAuditLog(rows)
if err != nil {
return nil, err
}
logs = append(logs, log)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("audit log rows: %w", err)
}
return logs, nil
}
func (s *Store) CountAuditLogs(ctx context.Context, opts AuditLogListOptions) (int, error) {
args := []any{}
where := "true"
if strings.TrimSpace(opts.Action) != "" {
args = append(args, strings.TrimSpace(opts.Action))
where += fmt.Sprintf(" AND action = $%d", len(args))
}
if strings.TrimSpace(opts.ResourceType) != "" {
args = append(args, strings.TrimSpace(opts.ResourceType))
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
}
if strings.TrimSpace(opts.Query) != "" {
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
where += fmt.Sprintf(` AND (
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args))
}
var total int
if err := s.db.QueryRow(ctx, `SELECT count(*) FROM audit_logs WHERE `+where, args...).Scan(&total); err != nil {
return 0, fmt.Errorf("count audit logs: %w", err)
}
return total, nil
}
func validatePostInput(input PostInput, creating bool) error {
if strings.TrimSpace(input.Title) == "" {
return errors.New("title is required")
@ -780,24 +886,35 @@ func scanBuildJob(row postScanner) (BuildJob, error) {
return job, nil
}
func scanAsset(row postScanner) (Asset, error) {
var asset Asset
var createdBy sql.NullString
func scanAuditLog(row postScanner) (AuditLog, error) {
var log AuditLog
var actorID sql.NullString
var details []byte
err := row.Scan(
&asset.ID,
&asset.Path,
&asset.OriginalName,
&asset.MimeType,
&asset.SizeBytes,
&asset.SHA256,
&asset.CreatedAt,
&createdBy,
&log.ID,
&actorID,
&log.ActorUsername,
&log.Action,
&log.ResourceType,
&log.ResourceID,
&log.IPAddress,
&log.UserAgent,
&details,
&log.CreatedAt,
)
if err != nil {
return Asset{}, err
return AuditLog{}, err
}
asset.CreatedBy = nullStringPtr(createdBy)
return asset, nil
log.ActorID = nullStringPtr(actorID)
if len(details) > 0 {
if err := json.Unmarshal(details, &log.Details); err != nil {
return AuditLog{}, fmt.Errorf("decode audit details: %w", err)
}
}
if log.Details == nil {
log.Details = map[string]any{}
}
return log, nil
}
func nullTimePtr(value sql.NullTime) *time.Time {

View file

@ -76,17 +76,6 @@ type Tag struct {
UpdatedAt time.Time `json:"updatedAt"`
}
type Asset struct {
ID string `json:"id"`
Path string `json:"path"`
OriginalName string `json:"originalName"`
MimeType string `json:"mimeType"`
SizeBytes int64 `json:"sizeBytes"`
SHA256 string `json:"sha256"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy *string `json:"createdBy"`
}
type BuildJob struct {
ID string `json:"id"`
Trigger BuildJobTrigger `json:"trigger"`
@ -99,3 +88,16 @@ type BuildJob struct {
CreatedAt time.Time `json:"createdAt"`
CreatedBy *string `json:"createdBy"`
}
type AuditLog struct {
ID string `json:"id"`
ActorID *string `json:"actorId"`
ActorUsername string `json:"actorUsername"`
Action string `json:"action"`
ResourceType string `json:"resourceType"`
ResourceID string `json:"resourceId"`
IPAddress string `json:"ipAddress"`
UserAgent string `json:"userAgent"`
Details map[string]any `json:"details"`
CreatedAt time.Time `json:"createdAt"`
}

View file

@ -1,88 +0,0 @@
package cli
import (
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"osaet/backend/internal/staticserver"
)
func runBuild(root string, args []string) error {
fs := flag.NewFlagSet("build", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
siteDir := fs.String("site-dir", defaultAstroDir, "Astro project directory")
if err := fs.Parse(args); err != nil {
return err
}
sitePath := *siteDir
if !filepath.IsAbs(sitePath) {
sitePath = filepath.Join(root, sitePath)
}
if _, err := os.Stat(filepath.Join(sitePath, "package.json")); err != nil {
return fmt.Errorf("Astro project not found at %s; run from repo root", *siteDir)
}
cmd := exec.Command("npm", "run", "build")
cmd.Dir = sitePath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func runDev(root string, args []string) error {
fs := flag.NewFlagSet("dev", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
host := fs.String("host", "127.0.0.1", "host to bind")
port := fs.String("port", "4321", "port to listen on")
siteDir := fs.String("site-dir", defaultAstroDir, "Astro project directory")
if err := fs.Parse(args); err != nil {
return err
}
sitePath := *siteDir
if !filepath.IsAbs(sitePath) {
sitePath = filepath.Join(root, sitePath)
}
if _, err := os.Stat(filepath.Join(sitePath, "package.json")); err != nil {
return fmt.Errorf("Astro project not found at %s; run `osaetctl init` first", *siteDir)
}
cmd := exec.Command("npm", "run", "dev", "--", "--host", *host, "--port", *port)
cmd.Dir = sitePath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func runServe(root string, args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
host := fs.String("host", "127.0.0.1", "host to bind")
port := fs.String("port", "4321", "port to listen on")
dir := fs.String("dir", defaultBuildOutDir, "static output directory")
if err := fs.Parse(args); err != nil {
return err
}
staticDir := *dir
if !filepath.IsAbs(staticDir) {
staticDir = filepath.Join(root, staticDir)
}
if _, err := os.Stat(staticDir); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("static directory %s does not exist; run `osaetctl build` first", *dir)
}
return err
}
return staticserver.Serve(staticDir, *host, *port)
}

View file

@ -1,103 +0,0 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
)
func Run(args []string) error {
if len(args) == 0 {
printUsage()
return nil
}
root, err := findProjectRoot()
if err != nil {
return err
}
switch args[0] {
case "init":
return runInit(root, args[1:])
case "posts":
return runPosts(root, args[1:])
case "tags":
return runTags(root, args[1:])
case "db":
return runDB(root, args[1:])
case "config":
return runConfig(root, args[1:])
case "build":
return runBuild(root, args[1:])
case "serve":
return runServe(root, args[1:])
case "dev":
return runDev(root, args[1:])
case "help", "-h", "--help":
printUsage()
return nil
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
func runInit(root string, args []string) error {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return err
}
dirs := []string{
"config",
defaultPostsDir,
defaultAssetsDir,
".osaet",
defaultAstroDir,
defaultBuildOutDir,
}
for _, dir := range dirs {
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
return err
}
}
if err := writeFileIfMissing(filepath.Join(root, "config/site.yaml"), defaultSiteConfig()); err != nil {
return err
}
if err := writeFileIfMissing(filepath.Join(root, "config/local.example.yaml"), defaultLocalExampleConfig()); err != nil {
return err
}
fmt.Println("initialized local project structure")
return nil
}
func printUsage() {
fmt.Print(`osaetctl manages local blog content and static builds.
Usage:
osaetctl init
osaetctl posts slug --title "My Post" [--summary "..."]
osaetctl posts new --title "My Post" [--slug my-post|--no-ai-slug] [--tag go] [--summary "..."] [--status draft]
osaetctl posts list [--status draft|published]
osaetctl posts show <slug>
osaetctl posts publish <slug>
osaetctl posts unpublish <slug>
osaetctl posts delete <slug>
osaetctl posts edit <slug>
osaetctl posts import [--db .osaet/osaet.db]
osaetctl posts export [--db .osaet/osaet.db] [--overwrite]
osaetctl posts diff [--db .osaet/osaet.db]
osaetctl posts sync [--from files|db|auto] [--yes] [--db .osaet/osaet.db]
osaetctl tags list [--all]
osaetctl db init [--path .osaet/osaet.db]
osaetctl db status [--path .osaet/osaet.db]
osaetctl config
osaetctl dev [--host 127.0.0.1] [--port 4321]
osaetctl build
osaetctl serve [--host 127.0.0.1] [--port 4321] [--dir dist/site]
`)
}

View file

@ -1,106 +0,0 @@
package cli
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
func runConfig(root string, args []string) error {
if len(args) == 0 {
return errors.New("site config is local-file-only; edit config/site.yaml directly")
}
return fmt.Errorf("site config is local-file-only; edit config/site.yaml directly (unsupported subcommand %q)", args[0])
}
func loadLocalConfig(root string) (localConfig, error) {
var config localConfig
path := filepath.Join(root, "config/local.yaml")
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return config, nil
}
return config, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return config, fmt.Errorf("%s: %w", path, err)
}
return config, nil
}
func readSiteConfig(root string) (siteConfigFile, error) {
var config siteConfigFile
path := filepath.Join(root, "config/site.yaml")
data, err := os.ReadFile(path)
if err != nil {
return config, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return config, fmt.Errorf("%s: %w", path, err)
}
return config, nil
}
func writeSiteConfig(root string, config siteConfigFile) error {
var out bytes.Buffer
encoder := yaml.NewEncoder(&out)
encoder.SetIndent(2)
if err := encoder.Encode(config); err != nil {
return err
}
if err := encoder.Close(); err != nil {
return err
}
path := filepath.Join(root, "config/site.yaml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, out.Bytes(), 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func defaultSiteConfig() string {
return `meta:
config_version: 1
updated_at: "2026-05-28T12:00:00+08:00"
updated_by: "cli"
site:
title: "Osaet"
description: "Personal blog"
base_url: "http://localhost:4321"
language: "zh-CN"
timezone: "Asia/Shanghai"
content:
posts_dir: "content/posts"
assets_dir: "content/assets"
build:
astro_project: "frontend/site"
output_dir: "dist/site"
`
}
func defaultLocalExampleConfig() string {
return `database:
driver: "sqlite"
sqlite_path: ".osaet/osaet.db"
postgres_dsn: ""
deepseek:
api_key: ""
api_key_env: "DEEPSEEK_API_KEY"
base_url: "https://api.deepseek.com"
model: "deepseek-v4-pro"
`
}

View file

@ -1,27 +0,0 @@
package cli
import (
"strings"
"testing"
)
func TestRunConfigReportsLocalOnly(t *testing.T) {
err := runConfig(t.TempDir(), nil)
if err == nil || !strings.Contains(err.Error(), "local-file-only") {
t.Fatalf("expected local-file-only error, got %v", err)
}
err = runConfig(t.TempDir(), []string{"sync"})
if err == nil || !strings.Contains(err.Error(), "unsupported subcommand \"sync\"") {
t.Fatalf("expected unsupported subcommand error, got %v", err)
}
}
func TestParseTime(t *testing.T) {
if _, ok := parseTime("2026-05-28T12:00:00+08:00"); !ok {
t.Fatal("expected RFC3339 time to parse")
}
if _, ok := parseTime("not-time"); ok {
t.Fatal("expected invalid time")
}
}

View file

@ -1,130 +0,0 @@
package cli
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
func loadPosts(root string) ([]postFile, error) {
postsDir := filepath.Join(root, defaultPostsDir)
entries, err := os.ReadDir(postsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var posts []postFile
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
post, err := readPostFile(filepath.Join(postsDir, entry.Name()))
if err != nil {
return nil, err
}
posts = append(posts, post)
}
return posts, nil
}
func loadPostBySlug(root string, slug string) (postFile, error) {
cleanSlug := sanitizeSlug(slug)
if cleanSlug == "" {
return postFile{}, errors.New("missing slug")
}
posts, err := loadPosts(root)
if err != nil {
return postFile{}, err
}
for _, post := range posts {
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
if post.Frontmatter.Slug == cleanSlug || fileSlug == cleanSlug {
return post, nil
}
}
return postFile{}, fmt.Errorf("post not found: %s", cleanSlug)
}
func readPostFile(path string) (postFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return postFile{}, err
}
frontmatter, body, err := splitFrontmatter(data)
if err != nil {
return postFile{}, fmt.Errorf("%s: %w", path, err)
}
var meta postFrontmatter
if err := yaml.Unmarshal(frontmatter, &meta); err != nil {
return postFile{}, fmt.Errorf("%s: %w", path, err)
}
if meta.Slug == "" {
meta.Slug = strings.TrimSuffix(filepath.Base(path), ".md")
}
if meta.Status == "" {
meta.Status = "draft"
}
return postFile{
Path: path,
Frontmatter: meta,
Body: strings.TrimPrefix(string(body), "\n"),
}, nil
}
func writePostFile(post postFile) error {
var frontmatter bytes.Buffer
encoder := yaml.NewEncoder(&frontmatter)
encoder.SetIndent(2)
if err := encoder.Encode(post.Frontmatter); err != nil {
return err
}
if err := encoder.Close(); err != nil {
return err
}
var output bytes.Buffer
output.WriteString("---\n")
output.Write(frontmatter.Bytes())
output.WriteString("---\n\n")
output.WriteString(strings.TrimLeft(post.Body, "\n"))
tmp := post.Path + ".tmp"
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
return err
}
return os.Rename(tmp, post.Path)
}
func splitFrontmatter(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
}

View file

@ -1,146 +0,0 @@
package cli
import (
"database/sql"
_ "embed"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
//go:embed sqlite_schema.sql
var sqliteSchema string
func runDB(root string, args []string) error {
if len(args) == 0 {
return errors.New("missing db subcommand")
}
switch args[0] {
case "init":
return runDBInit(root, args[1:])
case "status":
return runDBStatus(root, args[1:])
default:
return fmt.Errorf("unknown db subcommand %q", args[0])
}
}
func runDBInit(root string, args []string) error {
fs := flag.NewFlagSet("db init", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
path := fs.String("path", defaultSQLitePath, "SQLite database path")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *path)
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := applySQLiteSchema(db); err != nil {
return err
}
fmt.Printf("initialized SQLite database: %s\n", mustRel(root, dbPath))
return nil
}
func runDBStatus(root string, args []string) error {
fs := flag.NewFlagSet("db status", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
path := fs.String("path", defaultSQLitePath, "SQLite database path")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *path)
info, err := os.Stat(dbPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Printf("database: %s\n", mustRel(root, dbPath))
fmt.Println("exists: no")
return nil
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
fmt.Printf("database: %s\n", mustRel(root, dbPath))
fmt.Println("exists: yes")
fmt.Printf("size: %d bytes\n", info.Size())
for _, table := range []string{"posts", "settings", "sync_state"} {
ok, err := sqliteTableExists(db, table)
if err != nil {
return err
}
fmt.Printf("table %-10s %s\n", table+":", yesNo(ok))
}
return nil
}
func openSQLite(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func openProjectSQLite(root string, path string) (*sql.DB, string, error) {
dbPath := resolveRootPath(root, path)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, "", fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return nil, "", err
}
db, err := openSQLite(dbPath)
if err != nil {
return nil, "", err
}
if err := applySQLiteSchema(db); err != nil {
db.Close()
return nil, "", err
}
return db, dbPath, nil
}
func applySQLiteSchema(db *sql.DB) error {
for _, statement := range strings.Split(sqliteSchema, ";") {
statement = strings.TrimSpace(statement)
if statement == "" {
continue
}
if _, err := db.Exec(statement); err != nil {
return err
}
}
return nil
}
func sqliteTableExists(db *sql.DB, table string) (bool, error) {
var count int
err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count)
return count > 0, err
}

View file

@ -1,57 +0,0 @@
package cli
import (
"os"
"path/filepath"
"testing"
)
func TestReadWritePostFile(t *testing.T) {
dir := t.TempDir()
publishedAt := "2026-05-28T12:00:00+08:00"
post := postFile{
Path: filepath.Join(dir, "hello.md"),
Frontmatter: postFrontmatter{
ID: "post-1",
Slug: "hello",
Title: "Hello",
Summary: "Summary",
Status: "published",
Tags: []string{"go", "astro"},
Version: 2,
SlugSource: "manual",
PublishedAt: &publishedAt,
CreatedAt: "2026-05-28T11:00:00+08:00",
UpdatedAt: "2026-05-28T12:00:00+08:00",
},
Body: "Body\n",
}
if err := writePostFile(post); err != nil {
t.Fatal(err)
}
got, err := readPostFile(post.Path)
if err != nil {
t.Fatal(err)
}
if got.Frontmatter.ID != post.Frontmatter.ID || got.Frontmatter.Slug != post.Frontmatter.Slug {
t.Fatalf("frontmatter mismatch: %#v", got.Frontmatter)
}
if got.Body != post.Body {
t.Fatalf("body = %q, want %q", got.Body, post.Body)
}
if len(got.Frontmatter.Tags) != 2 || got.Frontmatter.Tags[1] != "astro" {
t.Fatalf("tags = %#v", got.Frontmatter.Tags)
}
}
func TestReadPostFileRequiresFrontmatter(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.md")
if err := os.WriteFile(path, []byte("no frontmatter"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := readPostFile(path); err == nil {
t.Fatal("expected frontmatter error")
}
}

View file

@ -1,368 +0,0 @@
package cli
import (
"context"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"osaet/backend/internal/ai"
)
func runPosts(root string, args []string) error {
if len(args) == 0 {
return errors.New("missing posts subcommand")
}
switch args[0] {
case "new":
return runPostsNew(root, args[1:])
case "slug":
return runPostsSlug(root, args[1:])
case "list":
return runPostsList(root, args[1:])
case "show":
return runPostsShow(root, args[1:])
case "publish":
return runPostsStatus(root, args[1:], "published")
case "unpublish":
return runPostsStatus(root, args[1:], "draft")
case "delete":
return runPostsDelete(root, args[1:])
case "edit":
return runPostsEdit(root, args[1:])
case "import":
return runPostsImport(root, args[1:])
case "export":
return runPostsExport(root, args[1:])
case "diff":
return runPostsDiff(root, args[1:])
case "sync":
return runPostsSync(root, args[1:])
default:
return fmt.Errorf("unknown posts subcommand %q", args[0])
}
}
func runTags(root string, args []string) error {
if len(args) == 0 {
return errors.New("missing tags subcommand")
}
switch args[0] {
case "list":
return runTagsList(root, args[1:])
default:
return fmt.Errorf("unknown tags subcommand %q", args[0])
}
}
func runTagsList(root string, args []string) error {
fs := flag.NewFlagSet("tags list", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
all := fs.Bool("all", false, "include draft posts")
if err := fs.Parse(args); err != nil {
return err
}
posts, err := loadPosts(root)
if err != nil {
return err
}
counts := map[string]int{}
for _, post := range posts {
if !*all && post.Frontmatter.Status != "published" {
continue
}
for _, tag := range post.Frontmatter.Tags {
tag = strings.TrimSpace(tag)
if tag != "" {
counts[tag]++
}
}
}
for _, tag := range sortedKeys(counts) {
fmt.Printf("%-24s %d\n", tag, counts[tag])
}
return nil
}
func runPostsNew(root string, args []string) error {
fs := flag.NewFlagSet("posts new", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
title := fs.String("title", "", "post title")
slug := fs.String("slug", "", "post slug")
status := fs.String("status", "draft", "post status: draft or published")
summary := fs.String("summary", "", "post summary")
body := fs.String("body", "", "initial markdown body")
aiSlug := fs.Bool("ai-slug", true, "generate slug with DeepSeek when --slug is empty")
noAISlug := fs.Bool("no-ai-slug", false, "disable AI slug generation")
tags := stringListFlag{}
fs.Var(&tags, "tag", "post tag; may be repeated")
if err := fs.Parse(args); err != nil {
return err
}
if strings.TrimSpace(*title) == "" {
return errors.New("missing required --title")
}
cleanSlug := ""
slugSource := "manual"
if strings.TrimSpace(*slug) != "" {
cleanSlug = sanitizeSlug(*slug)
} else if *aiSlug && !*noAISlug {
generatedSlug, err := generateDeepSeekSlug(context.Background(), root, *title, *summary)
if err == nil {
cleanSlug = generatedSlug
slugSource = "ai"
} else {
fmt.Fprintf(os.Stderr, "warning: AI slug generation failed, using local fallback: %v\n", err)
}
}
if cleanSlug == "" {
cleanSlug = fallbackSlug(*title)
}
if cleanSlug == "" {
return errors.New("could not derive slug; pass --slug")
}
cleanSlug, err := uniqueSlug(root, cleanSlug)
if err != nil {
return err
}
if *status != "draft" && *status != "published" {
return errors.New("--status must be draft or published")
}
postsDir := filepath.Join(root, defaultPostsDir)
if err := os.MkdirAll(postsDir, 0o755); err != nil {
return err
}
path, err := uniquePostPath(postsDir, cleanSlug)
if err != nil {
return err
}
now := time.Now().Format(time.RFC3339)
publishedAt := "null"
if *status == "published" {
publishedAt = fmt.Sprintf("%q", now)
}
content := strings.TrimSpace(*body)
if content == "" {
content = "Write your post here."
}
post := fmt.Sprintf(`---
id: "%s"
slug: "%s"
title: "%s"
summary: "%s"
status: "%s"
tags: %s
cover: ""
version: 1
slug_source: "%s"
slug_locked: false
published_at: %s
created_at: "%s"
updated_at: "%s"
---
%s
`, randomID(), cleanSlug, escapeYAML(*title), escapeYAML(*summary), *status, formatYAMLStringList(tags.Values()), slugSource, publishedAt, now, now, content)
if err := os.WriteFile(path, []byte(post), 0o644); err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
rel = path
}
fmt.Println(rel)
return nil
}
func runPostsSlug(root string, args []string) error {
fs := flag.NewFlagSet("posts slug", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
title := fs.String("title", "", "post title")
summary := fs.String("summary", "", "optional post summary")
if err := fs.Parse(args); err != nil {
return err
}
if strings.TrimSpace(*title) == "" {
return errors.New("missing required --title")
}
slug, err := generateDeepSeekSlug(context.Background(), root, *title, *summary)
if err != nil {
return err
}
slug, err = uniqueSlug(root, slug)
if err != nil {
return err
}
fmt.Println(slug)
return nil
}
func runPostsList(root string, args []string) error {
fs := flag.NewFlagSet("posts list", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
status := fs.String("status", "", "filter by status")
if err := fs.Parse(args); err != nil {
return err
}
posts, err := loadPosts(root)
if err != nil {
return err
}
for _, post := range posts {
if *status != "" && post.Frontmatter.Status != *status {
continue
}
fmt.Printf("%-12s %-32s %-24s %s\n", post.Frontmatter.Status, post.Frontmatter.Slug, strings.Join(post.Frontmatter.Tags, ","), post.Frontmatter.Title)
}
return nil
}
func runPostsShow(root string, args []string) error {
if len(args) != 1 {
return errors.New("usage: osaetctl posts show <slug>")
}
post, err := loadPostBySlug(root, args[0])
if err != nil {
return err
}
fmt.Printf("title: %s\n", post.Frontmatter.Title)
fmt.Printf("slug: %s\n", post.Frontmatter.Slug)
fmt.Printf("status: %s\n", post.Frontmatter.Status)
fmt.Printf("summary: %s\n", post.Frontmatter.Summary)
fmt.Printf("tags: %s\n", strings.Join(post.Frontmatter.Tags, ", "))
fmt.Printf("path: %s\n", mustRel(root, post.Path))
fmt.Println()
fmt.Println(post.Body)
return nil
}
func runPostsStatus(root string, args []string, status string) error {
if len(args) != 1 {
return fmt.Errorf("usage: osaetctl posts %s <slug>", statusCommand(status))
}
post, err := loadPostBySlug(root, args[0])
if err != nil {
return err
}
now := time.Now().Format(time.RFC3339)
post.Frontmatter.Status = status
post.Frontmatter.UpdatedAt = now
post.Frontmatter.Version++
if status == "published" && post.Frontmatter.PublishedAt == nil {
post.Frontmatter.PublishedAt = &now
}
if status == "draft" {
post.Frontmatter.PublishedAt = nil
}
if err := writePostFile(post); err != nil {
return err
}
fmt.Printf("%s -> %s\n", post.Frontmatter.Slug, status)
return nil
}
func runPostsDelete(root string, args []string) error {
if len(args) != 1 {
return errors.New("usage: osaetctl posts delete <slug>")
}
post, err := loadPostBySlug(root, args[0])
if err != nil {
return err
}
trashDir := filepath.Join(root, defaultTrashDir)
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return err
}
target := uniquePath(filepath.Join(trashDir, filepath.Base(post.Path)))
if err := os.Rename(post.Path, target); err != nil {
return err
}
fmt.Printf("%s -> %s\n", mustRel(root, post.Path), mustRel(root, target))
return nil
}
func runPostsEdit(root string, args []string) error {
if len(args) != 1 {
return errors.New("usage: osaetctl posts edit <slug>")
}
editor := strings.TrimSpace(os.Getenv("EDITOR"))
if editor == "" {
return errors.New("EDITOR is not set")
}
post, err := loadPostBySlug(root, args[0])
if err != nil {
return err
}
cmd := exec.Command(editor, post.Path)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func generateDeepSeekSlug(ctx context.Context, root string, title string, summary string) (string, error) {
config, err := loadLocalConfig(root)
if err != nil {
return "", err
}
apiKeyEnv := strings.TrimSpace(config.DeepSeek.APIKeyEnv)
if apiKeyEnv == "" {
apiKeyEnv = "DEEPSEEK_API_KEY"
}
apiKey := strings.TrimSpace(os.Getenv(apiKeyEnv))
if apiKey == "" {
apiKey = strings.TrimSpace(config.DeepSeek.APIKey)
}
if apiKey == "" {
return "", fmt.Errorf("%s is not set and config/local.yaml deepseek.api_key is empty", apiKeyEnv)
}
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("DEEPSEEK_BASE_URL")), "/")
if baseURL == "" {
baseURL = strings.TrimRight(strings.TrimSpace(config.DeepSeek.BaseURL), "/")
}
model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
if model == "" {
model = strings.TrimSpace(config.DeepSeek.Model)
}
return ai.GenerateSlug(ctx, ai.Config{
APIKey: apiKey,
BaseURL: baseURL,
Model: model,
}, title, summary)
}

View file

@ -1,38 +0,0 @@
package cli
import "testing"
func TestSanitizeSlug(t *testing.T) {
tests := map[string]string{
"Hello Astro": "hello-astro",
" Go / Astro_Blog! ": "go-astro-blog",
"Already--Clean": "already-clean",
"喜欢你": "",
"abc123": "abc123",
}
for input, want := range tests {
if got := sanitizeSlug(input); got != want {
t.Fatalf("sanitizeSlug(%q) = %q, want %q", input, got, want)
}
}
}
func TestFallbackSlug(t *testing.T) {
if got := fallbackSlug("Hello Astro"); got != "hello-astro" {
t.Fatalf("fallbackSlug english = %q", got)
}
got := fallbackSlug("喜欢你")
if len(got) <= len("post-") || got[:5] != "post-" {
t.Fatalf("fallbackSlug non-ascii = %q, want post-*", got)
}
}
func TestFormatYAMLStringList(t *testing.T) {
got := formatYAMLStringList([]string{"go", `astro "site"`})
want := `["go", "astro \"site\""]`
if got != want {
t.Fatalf("formatYAMLStringList = %q, want %q", got, want)
}
}

View file

@ -1,35 +0,0 @@
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
content_markdown TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
tags_json TEXT NOT NULL DEFAULT '[]',
cover TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
content_hash TEXT NOT NULL DEFAULT '',
slug_source TEXT NOT NULL DEFAULT 'manual',
slug_locked INTEGER NOT NULL DEFAULT 0,
published_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
CREATE INDEX IF NOT EXISTS idx_posts_published_at ON posts(published_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);

View file

@ -1,68 +0,0 @@
package cli
import (
"path/filepath"
"testing"
)
func TestSQLitePostImportLoadExport(t *testing.T) {
root := t.TempDir()
db, err := openSQLite(filepath.Join(root, ".osaet.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := applySQLiteSchema(db); err != nil {
t.Fatal(err)
}
publishedAt := "2026-05-28T12:00:00+08:00"
post := postFile{
Path: filepath.Join(root, defaultPostsDir, "hello.md"),
Frontmatter: postFrontmatter{
ID: "post-1",
Slug: "hello",
Title: "Hello",
Status: "published",
Tags: []string{"go"},
Version: 1,
SlugSource: "manual",
PublishedAt: &publishedAt,
CreatedAt: "2026-05-28T11:00:00+08:00",
UpdatedAt: "2026-05-28T12:00:00+08:00",
},
Body: "Body\n",
}
if err := upsertSQLitePost(root, db, post); err != nil {
t.Fatal(err)
}
posts, err := loadSQLitePosts(db)
if err != nil {
t.Fatal(err)
}
if len(posts) != 1 {
t.Fatalf("loaded %d posts, want 1", len(posts))
}
if posts[0].Frontmatter.Slug != "hello" || posts[0].Body != "Body\n" {
t.Fatalf("loaded post mismatch: %#v", posts[0])
}
exported, skipped, err := exportPostsToFilesCount(root, posts, false)
if err != nil {
t.Fatal(err)
}
if exported != 1 || skipped != 0 {
t.Fatalf("exported=%d skipped=%d", exported, skipped)
}
read, err := readPostFile(filepath.Join(root, defaultPostsDir, "hello.md"))
if err != nil {
t.Fatal(err)
}
if read.Frontmatter.Title != "Hello" {
t.Fatalf("exported title = %q", read.Frontmatter.Title)
}
}

View file

@ -1,558 +0,0 @@
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
}

View file

@ -1,87 +0,0 @@
package cli
import "strings"
const (
defaultPostsDir = "content/posts"
defaultAssetsDir = "content/assets"
defaultTrashDir = "content/.trash/posts"
defaultAstroDir = "frontend/site"
defaultBuildOutDir = "dist/site"
defaultSQLitePath = ".osaet/osaet.db"
)
type postFile struct {
Path string
Frontmatter postFrontmatter
Body string
}
type postFrontmatter struct {
ID string `yaml:"id"`
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"`
}
type localConfig struct {
DeepSeek struct {
APIKey string `yaml:"api_key"`
APIKeyEnv string `yaml:"api_key_env"`
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
} `yaml:"deepseek"`
}
type siteConfigFile struct {
Meta struct {
ConfigVersion int `yaml:"config_version" json:"config_version"`
UpdatedAt string `yaml:"updated_at" json:"updated_at"`
UpdatedBy string `yaml:"updated_by" json:"updated_by"`
} `yaml:"meta" json:"meta"`
Site struct {
Title string `yaml:"title" json:"title"`
Description string `yaml:"description" json:"description"`
BaseURL string `yaml:"base_url" json:"base_url"`
Language string `yaml:"language" json:"language"`
Timezone string `yaml:"timezone" json:"timezone"`
} `yaml:"site" json:"site"`
Content struct {
PostsDir string `yaml:"posts_dir" json:"posts_dir"`
AssetsDir string `yaml:"assets_dir" json:"assets_dir"`
} `yaml:"content" json:"content"`
Build struct {
AstroProject string `yaml:"astro_project" json:"astro_project"`
OutputDir string `yaml:"output_dir" json:"output_dir"`
} `yaml:"build" json:"build"`
}
type stringListFlag struct {
values []string
}
func (f *stringListFlag) String() string {
return strings.Join(f.values, ",")
}
func (f *stringListFlag) Set(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
f.values = append(f.values, value)
return nil
}
func (f *stringListFlag) Values() []string {
return append([]string(nil), f.values...)
}

View file

@ -1,295 +0,0 @@
package cli
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
)
func statusCommand(status string) string {
if status == "published" {
return "publish"
}
return "unpublish"
}
func uniquePath(path string) string {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return path
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(path, ext)
for i := 2; i < 1000; i++ {
candidate := fmt.Sprintf("%s-%d%s", base, i, ext)
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
return candidate
}
}
return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext)
}
func mustRel(root string, path string) string {
rel, err := filepath.Rel(root, path)
if err != nil {
return path
}
return rel
}
func stringPtrValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func parseTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
parsed, err := time.Parse(time.RFC3339, value)
if err == nil {
return parsed, true
}
parsed, err = time.Parse("2006-01-02 15:04:05", value)
if err == nil {
return parsed, true
}
return time.Time{}, false
}
func confirm(prompt string) bool {
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
var answer string
if _, err := fmt.Fscan(os.Stdin, &answer); err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
func resolveRootPath(root string, path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(root, path)
}
func yesNo(value bool) string {
if value {
return "yes"
}
return "no"
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func contentHash(content string) string {
sum := sha256.Sum256([]byte(content))
return "sha256:" + hex.EncodeToString(sum[:])
}
func sortedKeys(values map[string]int) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sortedBoolKeys(values map[string]bool) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func uniqueSlug(root string, slug string) (string, error) {
base := sanitizeSlug(slug)
if base == "" {
return "", errors.New("empty slug")
}
if !slugExists(root, base) {
return base, nil
}
for i := 2; i < 1000; i++ {
candidate := fmt.Sprintf("%s-%d", base, i)
if !slugExists(root, candidate) {
return candidate, nil
}
}
return "", fmt.Errorf("could not find available slug for %q", base)
}
func slugExists(root string, slug string) bool {
posts, err := loadPosts(root)
if err == nil {
for _, post := range posts {
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
if post.Frontmatter.Slug == slug || fileSlug == slug {
return true
}
}
}
_, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md"))
return err == nil
}
func uniquePostPath(postsDir string, slug string) (string, error) {
candidate := filepath.Join(postsDir, slug+".md")
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
return candidate, nil
} else if err != nil {
return "", err
}
for i := 2; i < 1000; i++ {
candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i))
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
return candidate, nil
} else if err != nil {
return "", err
}
}
return "", fmt.Errorf("could not find available filename for slug %q", slug)
}
func findProjectRoot() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
for {
if isProjectRoot(wd) {
return wd, nil
}
parent := filepath.Dir(wd)
if parent == wd {
return "", errors.New("could not find project root")
}
wd = parent
}
}
func isProjectRoot(dir string) bool {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil {
return true
}
}
if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil {
if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil {
return true
}
}
return false
}
func fallbackSlug(title string) string {
var words []string
var b strings.Builder
flush := func() {
if b.Len() > 0 {
words = append(words, b.String())
b.Reset()
}
}
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/':
flush()
default:
flush()
}
}
flush()
if len(words) == 0 {
return "post-" + time.Now().Format("20060102150405")
}
return sanitizeSlug(strings.Join(words, "-"))
}
func sanitizeSlug(slug string) string {
slug = strings.ToLower(strings.TrimSpace(slug))
re := regexp.MustCompile(`[^a-z0-9]+`)
slug = re.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
for strings.Contains(slug, "--") {
slug = strings.ReplaceAll(slug, "--", "-")
}
if len(slug) > 80 {
slug = strings.Trim(slug[:80], "-")
}
return slug
}
func randomID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("post-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
func escapeYAML(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
func formatYAMLStringList(values []string) string {
if len(values) == 0 {
return "[]"
}
var b strings.Builder
b.WriteString("[")
for i, value := range values {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(`"`)
b.WriteString(escapeYAML(value))
b.WriteString(`"`)
}
b.WriteString("]")
return b.String()
}
func writeFileIfMissing(path string, content string) error {
if _, err := os.Stat(path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return os.WriteFile(path, []byte(content), 0o644)
}

View file

@ -1,460 +0,0 @@
package postimport
import (
"bytes"
"crypto/rand"
"encoding/csv"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
"gopkg.in/yaml.v3"
)
const defaultPostsDir = "content/posts"
type Options struct {
CSVPath string
PostsDir string
Overwrite bool
WorkingDir string
}
type Result struct {
Imported int
SkippedExisting int
SkippedNonPost int
}
type PostFile struct {
Path string
Frontmatter PostFrontmatter
Body string
}
type PostFrontmatter struct {
ID string `yaml:"id"`
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"`
}
type csvArticle struct {
ID string
Slug string
Title string
BodyMD string
BodyHTML string
Status string
ArchiveID string
AuthorID string
PublishedAt string
CreatedAt string
UpdatedAt string
Type string
}
func Import(options Options) (Result, error) {
root, err := findProjectRoot(options.WorkingDir)
if err != nil {
return Result{}, err
}
csvPath := resolveRootPath(root, firstNonEmpty(options.CSVPath, "articles.csv"))
postsDir := resolveRootPath(root, firstNonEmpty(options.PostsDir, defaultPostsDir))
if err := os.MkdirAll(postsDir, 0o755); err != nil {
return Result{}, err
}
file, err := os.Open(csvPath)
if err != nil {
return Result{}, err
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
reader.LazyQuotes = false
headers, err := reader.Read()
if err != nil {
return Result{}, err
}
index := map[string]int{}
for i, header := range headers {
index[strings.TrimSpace(header)] = i
}
var result Result
for rowNum := 2; ; rowNum++ {
record, err := reader.Read()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return result, fmt.Errorf("%s row %d: %w", csvPath, rowNum, err)
}
article := csvArticle{
ID: csvValue(record, index, "id"),
Slug: csvValue(record, index, "slug"),
Title: csvValue(record, index, "title"),
BodyMD: csvValue(record, index, "body_md"),
BodyHTML: csvValue(record, index, "body_html"),
Status: csvValue(record, index, "status"),
ArchiveID: csvValue(record, index, "archive_id"),
AuthorID: csvValue(record, index, "author_id"),
PublishedAt: csvValue(record, index, "published_at"),
CreatedAt: csvValue(record, index, "created_at"),
UpdatedAt: csvValue(record, index, "updated_at"),
Type: csvValue(record, index, "type"),
}
if strings.TrimSpace(article.Type) != "" && strings.TrimSpace(article.Type) != "post" {
result.SkippedNonPost++
continue
}
post, skippedExisting, err := articleToPost(root, postsDir, article, options.Overwrite)
if err != nil {
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
}
if skippedExisting {
result.SkippedExisting++
continue
}
if err := writePostFile(post); err != nil {
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
}
result.Imported++
}
return result, nil
}
func articleToPost(root string, postsDir string, article csvArticle, overwrite bool) (PostFile, bool, error) {
id := strings.TrimSpace(article.ID)
if id == "" {
id = randomID()
}
title := strings.TrimSpace(article.Title)
if title == "" {
return PostFile{}, false, errors.New("missing title")
}
slug := sanitizeSlug(article.Slug)
if slug == "" {
slug = fallbackSlug(title)
}
if slug == "" {
return PostFile{}, false, errors.New("missing slug")
}
status := strings.TrimSpace(article.Status)
if status != "published" && status != "draft" {
status = "draft"
}
createdAt, err := normalizeLegacyTime(article.CreatedAt)
if err != nil {
return PostFile{}, false, fmt.Errorf("invalid created_at: %w", err)
}
updatedAt, err := normalizeLegacyTime(article.UpdatedAt)
if err != nil {
return PostFile{}, false, fmt.Errorf("invalid updated_at: %w", err)
}
var publishedAt *string
if strings.TrimSpace(article.PublishedAt) != "" {
normalized, err := normalizeLegacyTime(article.PublishedAt)
if err != nil {
return PostFile{}, false, fmt.Errorf("invalid published_at: %w", err)
}
publishedAt = &normalized
}
path := filepath.Join(postsDir, slug+".md")
if _, err := os.Stat(path); err == nil {
if !overwrite {
return PostFile{}, true, nil
}
} else if !errors.Is(err, os.ErrNotExist) {
return PostFile{}, false, err
}
finalSlug := slug
if !overwrite {
finalSlug, err = uniqueSlug(root, slug)
if err != nil {
return PostFile{}, false, err
}
path, err = uniquePostPath(postsDir, finalSlug)
if err != nil {
return PostFile{}, false, err
}
}
body := strings.TrimLeft(article.BodyMD, "\n")
if strings.TrimSpace(body) == "" {
body = strings.TrimLeft(article.BodyHTML, "\n")
}
return PostFile{
Path: path,
Frontmatter: PostFrontmatter{
ID: id,
Slug: finalSlug,
Title: title,
Summary: "",
Status: status,
Tags: []string{},
Cover: "",
Version: 1,
SlugSource: "manual",
SlugLocked: true,
PublishedAt: publishedAt,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
Body: body,
}, false, nil
}
func csvValue(record []string, index map[string]int, key string) string {
i, ok := index[key]
if !ok || i >= len(record) {
return ""
}
return record[i]
}
func normalizeLegacyTime(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", errors.New("empty time")
}
layouts := []string{
"2006-01-02 15:04:05.999999999Z07",
"2006-01-02 15:04:05.999999Z07",
"2006-01-02 15:04:05Z07",
time.RFC3339,
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, value)
if err == nil {
return parsed.Format(time.RFC3339), nil
}
}
return "", fmt.Errorf("unsupported time format %q", value)
}
func writePostFile(post PostFile) error {
var frontmatter bytes.Buffer
encoder := yaml.NewEncoder(&frontmatter)
encoder.SetIndent(2)
if err := encoder.Encode(post.Frontmatter); err != nil {
return err
}
if err := encoder.Close(); err != nil {
return err
}
var output bytes.Buffer
output.WriteString("---\n")
output.Write(frontmatter.Bytes())
output.WriteString("---\n\n")
output.WriteString(strings.TrimLeft(post.Body, "\n"))
tmp := post.Path + ".tmp"
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
return err
}
return os.Rename(tmp, post.Path)
}
func loadPosts(root string, postsDir string) ([]PostFile, error) {
dir := resolveRootPath(root, postsDir)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var posts []PostFile
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
posts = append(posts, PostFile{Path: filepath.Join(dir, entry.Name()), Frontmatter: PostFrontmatter{Slug: strings.TrimSuffix(entry.Name(), ".md")}})
}
return posts, nil
}
func slugExists(root string, slug string) bool {
posts, err := loadPosts(root, defaultPostsDir)
if err == nil {
for _, post := range posts {
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
if post.Frontmatter.Slug == slug || fileSlug == slug {
return true
}
}
}
_, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md"))
return err == nil
}
func uniqueSlug(root string, slug string) (string, error) {
base := sanitizeSlug(slug)
if base == "" {
return "", errors.New("empty slug")
}
if !slugExists(root, base) {
return base, nil
}
for i := 2; i < 1000; i++ {
candidate := fmt.Sprintf("%s-%d", base, i)
if !slugExists(root, candidate) {
return candidate, nil
}
}
return "", fmt.Errorf("could not find available slug for %q", base)
}
func uniquePostPath(postsDir string, slug string) (string, error) {
candidate := filepath.Join(postsDir, slug+".md")
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
return candidate, nil
} else if err != nil {
return "", err
}
for i := 2; i < 1000; i++ {
candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i))
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
return candidate, nil
} else if err != nil {
return "", err
}
}
return "", fmt.Errorf("could not find available filename for slug %q", slug)
}
func findProjectRoot(start string) (string, error) {
wd := start
if wd == "" {
var err error
wd, err = os.Getwd()
if err != nil {
return "", err
}
}
for {
if isProjectRoot(wd) {
return wd, nil
}
parent := filepath.Dir(wd)
if parent == wd {
return "", errors.New("could not find project root")
}
wd = parent
}
}
func isProjectRoot(dir string) bool {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil {
return true
}
}
if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil {
if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil {
return true
}
}
return false
}
func resolveRootPath(root string, path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(root, path)
}
func fallbackSlug(title string) string {
var words []string
var b strings.Builder
flush := func() {
if b.Len() > 0 {
words = append(words, b.String())
b.Reset()
}
}
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/':
flush()
default:
flush()
}
}
flush()
if len(words) == 0 {
return "post-" + time.Now().Format("20060102150405")
}
return sanitizeSlug(strings.Join(words, "-"))
}
func sanitizeSlug(slug string) string {
slug = strings.ToLower(strings.TrimSpace(slug))
re := regexp.MustCompile(`[^a-z0-9]+`)
slug = re.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
for strings.Contains(slug, "--") {
slug = strings.ReplaceAll(slug, "--", "-")
}
if len(slug) > 80 {
slug = strings.Trim(slug[:80], "-")
}
return slug
}
func randomID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("post-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View file

@ -1,125 +0,0 @@
package postimport
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeLegacyTime(t *testing.T) {
got, err := normalizeLegacyTime("2026-01-13 01:25:27.486491+00")
if err != nil {
t.Fatal(err)
}
if got != "2026-01-13T01:25:27Z" {
t.Fatalf("normalized time = %q", got)
}
}
func TestArticleToPostFallbackSlugAndBody(t *testing.T) {
root := t.TempDir()
postsDir := filepath.Join(root, defaultPostsDir)
if err := os.MkdirAll(postsDir, 0o755); err != nil {
t.Fatal(err)
}
post, skipped, err := articleToPost(root, postsDir, csvArticle{
Title: "Hello World",
BodyMD: "\nBody\n",
Status: "published",
CreatedAt: "2026-01-13 01:25:27.486491+00",
UpdatedAt: "2026-01-13 01:25:27.486491+00",
PublishedAt: "2026-01-13 01:25:27.486491+00",
Type: "post",
}, false)
if err != nil {
t.Fatal(err)
}
if skipped {
t.Fatal("expected importable post")
}
if post.Frontmatter.Slug != "hello-world" {
t.Fatalf("slug = %q", post.Frontmatter.Slug)
}
if post.Body != "Body\n" {
t.Fatalf("body = %q", post.Body)
}
if post.Frontmatter.PublishedAt == nil || *post.Frontmatter.PublishedAt != "2026-01-13T01:25:27Z" {
t.Fatalf("published_at = %#v", post.Frontmatter.PublishedAt)
}
}
func TestArticleToPostSkipsExistingWithoutOverwrite(t *testing.T) {
root := t.TempDir()
postsDir := filepath.Join(root, defaultPostsDir)
if err := os.MkdirAll(postsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(postsDir, "smoking.md"), []byte("existing"), 0o644); err != nil {
t.Fatal(err)
}
_, skipped, err := articleToPost(root, postsDir, csvArticle{
Slug: "smoking",
Title: "抽烟",
BodyMD: "Body",
Status: "published",
CreatedAt: "2026-01-13 01:25:27.486491+00",
UpdatedAt: "2026-01-13 01:25:27.486491+00",
Type: "post",
}, false)
if err != nil {
t.Fatal(err)
}
if !skipped {
t.Fatal("expected existing file to be skipped")
}
}
func TestImportWritesMarkdown(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "backend", "cmd", "osaetctl"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "frontend", "site"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "frontend", "site", "package.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
csvPath := filepath.Join(root, "articles.csv")
csvContent := strings.Join([]string{
"id,slug,title,body_md,body_html,status,archive_id,author_id,published_at,created_at,updated_at,type",
"post-1,test-post,Test Post,\"Line 1\n\nLine 2\",,published,,,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,post",
}, "\n")
if err := os.WriteFile(csvPath, []byte(csvContent), 0o644); err != nil {
t.Fatal(err)
}
result, err := Import(Options{CSVPath: csvPath, PostsDir: defaultPostsDir, WorkingDir: root})
if err != nil {
t.Fatal(err)
}
if result.Imported != 1 || result.SkippedExisting != 0 || result.SkippedNonPost != 0 {
t.Fatalf("result = %#v", result)
}
data, err := os.ReadFile(filepath.Join(root, defaultPostsDir, "test-post.md"))
if err != nil {
t.Fatal(err)
}
content := string(data)
for _, want := range []string{
"slug: test-post",
"title: Test Post",
"status: published",
"published_at: \"2026-01-13T01:25:27Z\"",
"Line 1",
"Line 2",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected output to contain %q\n%s", want, content)
}
}
}

View file

@ -1,45 +0,0 @@
package staticserver
import (
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
)
func Serve(dir string, host string, port string) error {
info, err := os.Stat(dir)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("static path %s is not a directory", dir)
}
addr := net.JoinHostPort(host, port)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := filepath.Clean("/" + r.URL.Path)
if path == "/" {
path = "/index.html"
}
fullPath := filepath.Join(dir, filepath.FromSlash(path))
if info, err := os.Stat(fullPath); err == nil && info.IsDir() {
fullPath = filepath.Join(fullPath, "index.html")
} else if errors.Is(err, os.ErrNotExist) && filepath.Ext(fullPath) == "" {
fullPath = filepath.Join(dir, filepath.FromSlash(path), "index.html")
}
if _, err := os.Stat(fullPath); err != nil {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, fullPath)
})
fmt.Printf("serving %s at http://%s\n", dir, addr)
return http.ListenAndServe(addr, handler)
}