osaet/backend/internal/cli/posts.go
yarnom b78f4b39c9 Initialize blog scaffold
Add the CLI, site, and sample content so the project can run locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:58:30 +08:00

368 lines
8.8 KiB
Go

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)
}