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