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>
This commit is contained in:
parent
9d2628b318
commit
b78f4b39c9
40 changed files with 9140 additions and 0 deletions
368
backend/internal/cli/posts.go
Normal file
368
backend/internal/cli/posts.go
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue