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>
368 lines
8.8 KiB
Go
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)
|
|
}
|