package draft
|
|
import (
|
"encoding/json"
|
"fmt"
|
"os"
|
"path/filepath"
|
"regexp"
|
"sort"
|
"strings"
|
"time"
|
|
"github.com/aisim/kb-cli/internal/index"
|
"github.com/aisim/kb-cli/internal/llm"
|
"github.com/aisim/kb-cli/internal/search"
|
)
|
|
// Intake 草稿录入器
|
type Intake struct {
|
vaultPath string
|
llmClient *llm.Client
|
store *index.Store
|
}
|
|
// DraftInfo 草稿信息(用于 list 输出)
|
type DraftInfo struct {
|
Filepath string `json:"filepath"`
|
Filename string `json:"filename"`
|
Title string `json:"title,omitempty"`
|
Status string `json:"status,omitempty"`
|
Type string `json:"type,omitempty"`
|
Created string `json:"created,omitempty"`
|
HasMergeHint bool `json:"has_merge_hint"`
|
}
|
|
// DraftMeta 草稿元数据
|
type DraftMeta struct {
|
Title string `yaml:"title"`
|
Type string `yaml:"type"`
|
Status string `yaml:"status"`
|
Source string `yaml:"source,omitempty"`
|
Tags []string `yaml:"tags"`
|
Created string `yaml:"created"`
|
}
|
|
// Counter 计数器
|
type Counter struct {
|
Week string `json:"week"`
|
Next int `json:"next"`
|
ResetDay string `json:"reset_day"`
|
LastUpdated string `json:"last_updated"`
|
}
|
|
// UnmarshalJSON 自定义反序列化,兼容 week 字段为数字或字符串
|
func (c *Counter) UnmarshalJSON(data []byte) error {
|
type Alias Counter
|
aux := &struct {
|
Week interface{} `json:"week"`
|
*Alias
|
}{
|
Alias: (*Alias)(c),
|
}
|
if err := json.Unmarshal(data, aux); err != nil {
|
return err
|
}
|
switch v := aux.Week.(type) {
|
case float64:
|
c.Week = fmt.Sprintf("%d", int(v))
|
case string:
|
c.Week = v
|
}
|
return nil
|
}
|
|
// NewIntake 创建草稿录入器
|
func NewIntake(vaultPath string, store *index.Store) *Intake {
|
// 展开 ~ 为实际的用户目录
|
if strings.HasPrefix(vaultPath, "~/") {
|
if home, err := os.UserHomeDir(); err == nil {
|
vaultPath = filepath.Join(home, vaultPath[2:])
|
}
|
}
|
|
return &Intake{
|
vaultPath: vaultPath,
|
llmClient: llm.NewClient(),
|
store: store,
|
}
|
}
|
|
// CreateDraft 创建草稿
|
func (i *Intake) CreateDraft(draftType, title, content, source string, force bool) error {
|
// 1. 调用 LLM 提取 tags
|
fmt.Println("正在提取 tags...")
|
extractResult, err := i.llmClient.ExtractTags(content)
|
if err != nil {
|
return fmt.Errorf("提取 tags 失败: %w", err)
|
}
|
fmt.Printf("提取到 %d 个 tags: %v\n", len(extractResult.Tags), extractResult.Tags)
|
|
// 2. 使用 tags 搜索知识库
|
fmt.Println("正在搜索相关文档...")
|
candidates, err := i.searchByTags(extractResult.Tags)
|
if err != nil {
|
return fmt.Errorf("搜索知识库失败: %w", err)
|
}
|
fmt.Printf("找到 %d 个相关文档\n", len(candidates))
|
|
// 3. 调用 LLM 生成合并指示
|
var mergeHint *llm.MergeHint
|
if len(candidates) > 0 {
|
fmt.Println("正在生成合并指示...")
|
dirStructure := i.scanDirectoryStructure()
|
mergeHint, err = i.llmClient.GenerateMergeHint(content, candidates, dirStructure)
|
if err != nil {
|
fmt.Printf("警告: 生成合并指示失败: %v\n", err)
|
mergeHint = nil
|
}
|
}
|
|
// 4. 创建草稿目录和文件
|
draftDir, err := i.createDraftDir(draftType, title, content, source, extractResult.Tags, force)
|
if err != nil {
|
return fmt.Errorf("创建草稿目录失败: %w", err)
|
}
|
|
// 5. 写入 merge.md(无论是否有候选文档都生成)
|
if mergeHint != nil {
|
if err := i.writeMergeHint(draftDir, mergeHint, candidates); err != nil {
|
fmt.Printf("警告: 写入合并指示失败: %v\n", err)
|
}
|
} else {
|
// 没有候选文档时,生成默认的 merge.md
|
if err := i.writeDefaultMergeHint(draftDir); err != nil {
|
fmt.Printf("警告: 写入默认合并指示失败: %v\n", err)
|
}
|
}
|
|
fmt.Printf("草稿创建成功: %s\n", draftDir)
|
return nil
|
}
|
|
// searchByTags 使用 tags 搜索知识库
|
func (i *Intake) searchByTags(tags []string) ([]llm.SearchCandidate, error) {
|
if len(tags) == 0 {
|
return nil, nil
|
}
|
|
// 使用所有 tags 作为关键词搜索,使用 OR 逻辑(任意一个匹配即可)
|
// 这样即使某些 tag 匹配不到,其他 tag 也能找到结果
|
opts := search.SearchOptions{
|
TopN: 3,
|
WithContent: true, // 需要内容给 LLM 评估
|
}
|
|
results, err := search.Search(i.store, tags, opts)
|
if err != nil {
|
// 如果搜索失败,尝试逐个 tag 搜索,返回第一个有结果的
|
for _, tag := range tags {
|
singleResults, singleErr := search.Search(i.store, []string{tag}, opts)
|
if singleErr == nil && len(singleResults) > 0 {
|
results = singleResults
|
break
|
}
|
}
|
if len(results) == 0 {
|
return nil, nil // 所有 tag 都搜索失败,返回空而不是错误
|
}
|
}
|
|
candidates := make([]llm.SearchCandidate, 0, len(results))
|
for _, r := range results {
|
// 截取前5000字给 LLM 评估
|
content := r.Content
|
if len(content) > 5000 {
|
content = content[:5000] + "\n...(内容已截断)"
|
}
|
candidates = append(candidates, llm.SearchCandidate{
|
Title: r.Title,
|
Path: r.Path,
|
Score: float64(r.Score),
|
Content: content,
|
})
|
}
|
|
return candidates, nil
|
}
|
|
// createDraftDir 创建草稿目录和文件
|
func (i *Intake) createDraftDir(draftType, title, content, source string, tags []string, force bool) (string, error) {
|
// 确定子目录
|
subDir := i.getSubDir(draftType)
|
reviewDir := filepath.Join(i.vaultPath, "待审阅", subDir)
|
|
// 检查是否已存在相同标题的草稿
|
if !force {
|
if err := i.checkDuplicate(reviewDir, title); err != nil {
|
return "", err
|
}
|
}
|
|
// 获取下一个编号
|
seq, err := i.getNextSeq(reviewDir)
|
if err != nil {
|
return "", fmt.Errorf("获取编号失败: %w", err)
|
}
|
|
// 生成目录名
|
now := time.Now()
|
dateStr := now.Format("20060102")
|
safeTitle := i.sanitizeTitle(title)
|
dirName := fmt.Sprintf("%03d-%s-%s", seq, dateStr, safeTitle)
|
draftDir := filepath.Join(reviewDir, dirName)
|
|
// 创建目录
|
if err := os.MkdirAll(draftDir, 0755); err != nil {
|
return "", fmt.Errorf("创建目录失败: %w", err)
|
}
|
|
// 写入 draft.md
|
draftPath := filepath.Join(draftDir, "draft.md")
|
if err := i.writeDraftFile(draftPath, title, draftType, source, tags, content); err != nil {
|
return "", fmt.Errorf("写入 draft.md 失败: %w", err)
|
}
|
|
return draftDir, nil
|
}
|
|
// getSubDir 获取子目录名
|
func (i *Intake) getSubDir(draftType string) string {
|
typeMap := map[string]string{
|
"售后": "售后提取",
|
"产品": "产品提取",
|
"运营": "运营提取",
|
"行业": "行业提取",
|
"TAPD": "TAPD提取",
|
}
|
if subDir, ok := typeMap[draftType]; ok {
|
return subDir
|
}
|
return "沟通提取"
|
}
|
|
// checkDuplicate 检查重复
|
func (i *Intake) checkDuplicate(reviewDir, title string) error {
|
entries, err := os.ReadDir(reviewDir)
|
if err != nil {
|
if os.IsNotExist(err) {
|
return nil
|
}
|
return err
|
}
|
|
safeTitle := i.sanitizeTitle(title)
|
for _, entry := range entries {
|
if !entry.IsDir() {
|
continue
|
}
|
// 检查目录名是否包含相同标题
|
if strings.Contains(entry.Name(), safeTitle) {
|
return fmt.Errorf("待审阅区已存在相同标题的草稿: %s,使用 --force 强制创建", entry.Name())
|
}
|
}
|
|
return nil
|
}
|
|
// getNextSeq 获取下一个编号
|
func (i *Intake) getNextSeq(reviewDir string) (int, error) {
|
// 读取 counter.json
|
counterPath := filepath.Join(i.vaultPath, "待审阅", "counter.json")
|
counter, err := i.readCounter(counterPath)
|
if err != nil {
|
// 如果不存在,从目录中推断
|
return i.inferNextSeq(reviewDir)
|
}
|
|
// 检查是否需要重置(每周重置)
|
now := time.Now()
|
currentWeek := i.getWeekNumber(now)
|
if counter.Week != currentWeek {
|
counter.Week = currentWeek
|
counter.Next = 1
|
counter.ResetDay = now.Format("2006-01-02")
|
}
|
|
nextSeq := counter.Next
|
counter.Next++
|
counter.LastUpdated = now.Format(time.RFC3339)
|
|
// 写回 counter.json
|
if err := i.writeCounter(counterPath, counter); err != nil {
|
return 0, fmt.Errorf("更新计数器失败: %w", err)
|
}
|
|
return nextSeq, nil
|
}
|
|
// readCounter 读取计数器
|
func (i *Intake) readCounter(path string) (*Counter, error) {
|
data, err := os.ReadFile(path)
|
if err != nil {
|
return nil, err
|
}
|
|
var counter Counter
|
if err := json.Unmarshal(data, &counter); err != nil {
|
return nil, err
|
}
|
|
return &counter, nil
|
}
|
|
// writeCounter 写入计数器
|
func (i *Intake) writeCounter(path string, counter *Counter) error {
|
data, err := json.MarshalIndent(counter, "", " ")
|
if err != nil {
|
return err
|
}
|
|
return os.WriteFile(path, data, 0644)
|
}
|
|
// inferNextSeq 从目录推断下一个编号
|
func (i *Intake) inferNextSeq(reviewDir string) (int, error) {
|
entries, err := os.ReadDir(reviewDir)
|
if err != nil {
|
if os.IsNotExist(err) {
|
return 1, nil
|
}
|
return 0, err
|
}
|
|
maxSeq := 0
|
re := regexp.MustCompile(`^(\d{3})-`)
|
for _, entry := range entries {
|
if !entry.IsDir() {
|
continue
|
}
|
matches := re.FindStringSubmatch(entry.Name())
|
if len(matches) > 1 {
|
var seq int
|
fmt.Sscanf(matches[1], "%d", &seq)
|
if seq > maxSeq {
|
maxSeq = seq
|
}
|
}
|
}
|
|
return maxSeq + 1, nil
|
}
|
|
// getWeekNumber 获取周数(返回纯数字周数,与 counter.json 格式一致)
|
func (i *Intake) getWeekNumber(t time.Time) string {
|
_, week := t.ISOWeek()
|
return fmt.Sprintf("%d", week)
|
}
|
|
// sanitizeTitle 清理标题
|
func (i *Intake) sanitizeTitle(title string) string {
|
// 替换不安全字符
|
re := regexp.MustCompile(`[\\/:*?"<>|\s]`)
|
safe := re.ReplaceAllString(title, "_")
|
// 限制长度
|
if len(safe) > 50 {
|
safe = safe[:50]
|
}
|
return safe
|
}
|
|
// writeDraftFile 写入 draft.md
|
func (i *Intake) writeDraftFile(path, title, draftType, source string, tags []string, content string) error {
|
now := time.Now().Format("2006-01-02 15:04:05")
|
|
// 构建 frontmatter
|
meta := DraftMeta{
|
Title: title,
|
Type: draftType,
|
Status: "draft",
|
Source: source,
|
Tags: tags,
|
Created: now,
|
}
|
|
var sb strings.Builder
|
sb.WriteString("---\n")
|
sb.WriteString(fmt.Sprintf("title: %q\n", meta.Title))
|
sb.WriteString(fmt.Sprintf("type: %q\n", meta.Type))
|
sb.WriteString(fmt.Sprintf("status: %q\n", meta.Status))
|
if meta.Source != "" {
|
sb.WriteString(fmt.Sprintf("source: %q\n", meta.Source))
|
}
|
sb.WriteString("tags: [")
|
for i, tag := range meta.Tags {
|
if i > 0 {
|
sb.WriteString(", ")
|
}
|
sb.WriteString(fmt.Sprintf("%q", tag))
|
}
|
sb.WriteString("]\n")
|
sb.WriteString(fmt.Sprintf("created: %q\n", meta.Created))
|
sb.WriteString("---\n\n")
|
sb.WriteString(content)
|
|
return os.WriteFile(path, []byte(sb.String()), 0644)
|
}
|
|
// writeMergeHint 写入 merge.md
|
func (i *Intake) writeMergeHint(draftDir string, hint *llm.MergeHint, candidates []llm.SearchCandidate) error {
|
mergePath := filepath.Join(draftDir, "merge.md")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
|
var sb strings.Builder
|
sb.WriteString("---\n")
|
sb.WriteString(fmt.Sprintf("generated_at: %q\n", now))
|
sb.WriteString(fmt.Sprintf("confidence: %q\n", hint.Recommendation.Confidence))
|
sb.WriteString("---\n\n")
|
|
sb.WriteString("## 合并指示\n\n")
|
sb.WriteString(fmt.Sprintf("**操作类型**: %s\n", hint.Recommendation.Action))
|
sb.WriteString(fmt.Sprintf("**目标**: %s\n", hint.Recommendation.Target))
|
sb.WriteString(fmt.Sprintf("**理由**: %s\n\n", hint.Recommendation.Reason))
|
|
sb.WriteString("## 依据\n\n")
|
sb.WriteString("### 搜索到的相关文档(Top 3)\n\n")
|
for i, cand := range candidates {
|
sb.WriteString(fmt.Sprintf("%d. **%s** (相似度: %.2f)\n", i+1, cand.Title, cand.Score))
|
sb.WriteString(fmt.Sprintf(" - 路径: `%s`\n\n", cand.Path))
|
}
|
|
sb.WriteString("## LLM 判断\n\n")
|
for _, a := range hint.Analysis {
|
sb.WriteString(fmt.Sprintf("- **%s**: %s - %s\n", a.Path, a.Relevance, a.Reason))
|
}
|
|
return os.WriteFile(mergePath, []byte(sb.String()), 0644)
|
}
|
|
// writeDefaultMergeHint 写入默认的 merge.md(当没有候选文档时)
|
func (i *Intake) writeDefaultMergeHint(draftDir string) error {
|
mergePath := filepath.Join(draftDir, "merge.md")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
|
var sb strings.Builder
|
sb.WriteString("---\n")
|
sb.WriteString(fmt.Sprintf("generated_at: %q\n", now))
|
sb.WriteString("confidence: \"low\"\n")
|
sb.WriteString("---\n\n")
|
|
sb.WriteString("## 合并指示\n\n")
|
sb.WriteString("**操作类型**: new\n")
|
sb.WriteString("**目标**: 新建独立文档\n")
|
sb.WriteString("**理由**: 未找到相关文档,建议新建\n\n")
|
|
sb.WriteString("## 依据\n\n")
|
sb.WriteString("### 搜索到的相关文档\n\n")
|
sb.WriteString("未找到相关文档。\n\n")
|
|
sb.WriteString("## 建议\n\n")
|
sb.WriteString("审阅时请根据草稿内容确定合适的分类和存放位置。\n")
|
|
return os.WriteFile(mergePath, []byte(sb.String()), 0644)
|
}
|
|
// scanDirectoryStructure 扫描知识库目录结构,返回格式化的目录树字符串
|
func (i *Intake) scanDirectoryStructure() string {
|
// 知识库根目录下的主要分类目录
|
topDirs := []string{
|
"电子秤平台",
|
"运营管理平台",
|
"第三方平台",
|
"共有硬件",
|
"通用",
|
}
|
|
var sb strings.Builder
|
for _, topDir := range topDirs {
|
dirPath := filepath.Join(i.vaultPath, topDir)
|
entries, err := os.ReadDir(dirPath)
|
if err != nil {
|
continue
|
}
|
for _, entry := range entries {
|
if entry.IsDir() {
|
sb.WriteString(fmt.Sprintf("%s/%s/\n", topDir, entry.Name()))
|
}
|
}
|
}
|
|
return sb.String()
|
}
|
|
// RebuildTags 批量重建 tags
|
func (i *Intake) RebuildTags(limit int, dryRun bool) error {
|
// 扫描知识库所有 .md 文件
|
files, err := i.scanMarkdownFiles()
|
if err != nil {
|
return fmt.Errorf("扫描文件失败: %w", err)
|
}
|
|
if limit > 0 && len(files) > limit {
|
files = files[:limit]
|
}
|
|
fmt.Printf("找到 %d 个文件\n", len(files))
|
|
updated := 0
|
for _, file := range files {
|
fmt.Printf("处理: %s\n", file)
|
|
// 读取文件内容
|
content, err := os.ReadFile(file)
|
if err != nil {
|
fmt.Printf(" 警告: 读取失败: %v\n", err)
|
continue
|
}
|
|
// 提取正文(去掉 frontmatter)
|
body := i.extractBody(string(content))
|
|
// 调用 LLM 提取 tags
|
extractResult, err := i.llmClient.ExtractTags(body)
|
if err != nil {
|
fmt.Printf(" 警告: 提取 tags 失败: %v\n", err)
|
continue
|
}
|
|
if dryRun {
|
fmt.Printf(" 将更新 tags: %v\n", extractResult.Tags)
|
} else {
|
// 更新文件 frontmatter
|
if err := i.updateTagsInFile(file, string(content), extractResult.Tags); err != nil {
|
fmt.Printf(" 警告: 更新 tags 失败: %v\n", err)
|
continue
|
}
|
fmt.Printf(" 已更新 tags: %v\n", extractResult.Tags)
|
}
|
|
updated++
|
}
|
|
fmt.Printf("处理完成: %d/%d 文件\n", updated, len(files))
|
return nil
|
}
|
|
// scanMarkdownFiles 扫描 markdown 文件
|
func (i *Intake) scanMarkdownFiles() ([]string, error) {
|
var files []string
|
|
// 扫描 FAQ、知识、文档目录
|
dirs := []string{"FAQ", "知识", "文档"}
|
for _, dir := range dirs {
|
dirPath := filepath.Join(i.vaultPath, dir)
|
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
|
continue
|
}
|
|
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
if err != nil {
|
return err
|
}
|
if !info.IsDir() && strings.HasSuffix(path, ".md") {
|
files = append(files, path)
|
}
|
return nil
|
})
|
if err != nil {
|
return nil, err
|
}
|
}
|
|
// 排序
|
sort.Strings(files)
|
return files, nil
|
}
|
|
// extractBody 提取正文(去掉 frontmatter)
|
func (i *Intake) extractBody(content string) string {
|
// 查找 frontmatter 结束位置
|
re := regexp.MustCompile(`(?s)^---\n.*?\n---\n`)
|
loc := re.FindStringIndex(content)
|
if loc != nil {
|
return content[loc[1]:]
|
}
|
return content
|
}
|
|
// updateTagsInFile 更新文件中的 tags
|
func (i *Intake) updateTagsInFile(path, content string, tags []string) error {
|
// 解析现有 frontmatter
|
re := regexp.MustCompile(`(?s)^---\n(.*?)\n---`)
|
matches := re.FindStringSubmatchIndex(content)
|
if matches == nil {
|
return fmt.Errorf("未找到 frontmatter")
|
}
|
|
frontmatter := content[matches[2]:matches[3]]
|
|
// 替换 tags 行
|
tagsRe := regexp.MustCompile(`(?m)^tags:.*$`)
|
tagsLine := "tags: ["
|
for i, tag := range tags {
|
if i > 0 {
|
tagsLine += ", "
|
}
|
tagsLine += fmt.Sprintf("%q", tag)
|
}
|
tagsLine += "]"
|
|
newFrontmatter := tagsRe.ReplaceAllString(frontmatter, tagsLine)
|
|
// 重建文件内容
|
newContent := content[:matches[2]] + newFrontmatter + content[matches[3]:]
|
|
return os.WriteFile(path, []byte(newContent), 0644)
|
}
|
|
// ListDrafts 列出所有活跃草稿
|
func (i *Intake) ListDrafts() ([]DraftInfo, error) {
|
reviewBase := filepath.Join(i.vaultPath, "待审阅")
|
|
if _, err := os.Stat(reviewBase); os.IsNotExist(err) {
|
return []DraftInfo{}, nil
|
}
|
|
var drafts []DraftInfo
|
|
err := filepath.Walk(reviewBase, func(path string, info os.FileInfo, err error) error {
|
if err != nil {
|
return err
|
}
|
|
// 跳过 archive 目录
|
if info.IsDir() && info.Name() == "archive" {
|
return filepath.SkipDir
|
}
|
|
// 检查是否是目录结构(包含 draft.md)
|
if !info.IsDir() && info.Name() == "draft.md" {
|
draftDir := filepath.Dir(path)
|
mergeFile := filepath.Join(draftDir, "merge.md")
|
|
content, err := os.ReadFile(path)
|
if err != nil {
|
return nil
|
}
|
|
relPath, _ := filepath.Rel(i.vaultPath, path)
|
hasMerge := fileExists(mergeFile)
|
|
draft := DraftInfo{
|
Filepath: relPath,
|
Filename: filepath.Base(draftDir),
|
HasMergeHint: hasMerge,
|
}
|
|
// 解析 frontmatter
|
fm := parseFrontmatter(string(content))
|
if title, ok := fm["title"]; ok {
|
draft.Title = title
|
}
|
if status, ok := fm["status"]; ok {
|
draft.Status = status
|
}
|
if draftType, ok := fm["type"]; ok {
|
draft.Type = draftType
|
}
|
if created, ok := fm["created"]; ok {
|
draft.Created = created
|
}
|
|
drafts = append(drafts, draft)
|
}
|
|
// 兼容旧结构(单文件 .md)
|
if !info.IsDir() && filepath.Ext(info.Name()) == ".md" && info.Name() != "draft.md" && info.Name() != "merge.md" && info.Name() != "索引.json" {
|
// 检查是否是数字开头
|
name := info.Name()
|
if len(name) >= 3 && isDigit(name[0]) && isDigit(name[1]) && isDigit(name[2]) {
|
content, err := os.ReadFile(path)
|
if err != nil {
|
return nil
|
}
|
|
relPath, _ := filepath.Rel(i.vaultPath, path)
|
|
draft := DraftInfo{
|
Filepath: relPath,
|
Filename: name,
|
HasMergeHint: false,
|
}
|
|
// 解析 frontmatter
|
fm := parseFrontmatter(string(content))
|
if title, ok := fm["title"]; ok {
|
draft.Title = title
|
}
|
if status, ok := fm["status"]; ok {
|
draft.Status = status
|
}
|
if draftType, ok := fm["type"]; ok {
|
draft.Type = draftType
|
}
|
if created, ok := fm["created"]; ok {
|
draft.Created = created
|
}
|
|
drafts = append(drafts, draft)
|
}
|
}
|
|
return nil
|
})
|
|
if err != nil {
|
return nil, err
|
}
|
|
return drafts, nil
|
}
|
|
// ConvertOldDrafts 转换老格式草稿到新格式
|
func (i *Intake) ConvertOldDrafts(target string) (int, error) {
|
reviewDir := filepath.Join(i.vaultPath, "待审阅")
|
|
var dirs []string
|
if target != "" {
|
dirs = append(dirs, filepath.Join(reviewDir, target))
|
} else {
|
entries, err := os.ReadDir(reviewDir)
|
if err != nil {
|
return 0, err
|
}
|
|
for _, entry := range entries {
|
if entry.IsDir() && entry.Name() != "archive" {
|
dirs = append(dirs, filepath.Join(reviewDir, entry.Name()))
|
}
|
}
|
}
|
|
total := 0
|
for _, dir := range dirs {
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
continue
|
}
|
|
entries, err := os.ReadDir(dir)
|
if err != nil {
|
continue
|
}
|
|
var oldFiles []string
|
for _, entry := range entries {
|
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".md" {
|
// 检查是否是数字开头的单文件
|
name := entry.Name()
|
if len(name) >= 3 && isDigit(name[0]) && isDigit(name[1]) && isDigit(name[2]) {
|
oldFiles = append(oldFiles, filepath.Join(dir, name))
|
}
|
}
|
}
|
|
if len(oldFiles) == 0 {
|
continue
|
}
|
|
fmt.Printf("\n🔍 %s: 找到 %d 个老格式文件\n\n", filepath.Base(dir), len(oldFiles))
|
|
for _, oldFile := range oldFiles {
|
if convertOneDraft(oldFile) {
|
total++
|
}
|
}
|
}
|
|
return total, nil
|
}
|
|
// convertOneDraft 转换单个老格式草稿
|
func convertOneDraft(oldFile string) bool {
|
fmt.Printf("📄 转换: %s\n", filepath.Base(oldFile))
|
|
content, err := os.ReadFile(oldFile)
|
if err != nil {
|
fmt.Printf(" ⚠️ 读取失败: %v\n", err)
|
return false
|
}
|
|
// 解析文件名
|
name := filepath.Base(oldFile)
|
ext := filepath.Ext(name)
|
nameWithoutExt := strings.TrimSuffix(name, ext)
|
|
// 提取编号、日期、标题
|
parts := strings.SplitN(nameWithoutExt, "-", 3)
|
if len(parts) < 3 {
|
fmt.Printf(" ⚠️ 文件名格式不匹配: %s\n", name)
|
return false
|
}
|
|
num := parts[0]
|
dateStr := parts[1]
|
title := parts[2]
|
|
// 创建新目录
|
newDirName := fmt.Sprintf("%s-%s-%s", num, dateStr, title)
|
newDir := filepath.Join(filepath.Dir(oldFile), newDirName)
|
|
if err := os.MkdirAll(newDir, 0755); err != nil {
|
fmt.Printf(" ⚠️ 创建目录失败: %v\n", err)
|
return false
|
}
|
|
// 解析 frontmatter
|
fm := parseFrontmatter(string(content))
|
|
// 构建标准化 frontmatter
|
var sb strings.Builder
|
sb.WriteString("---\n")
|
if title, ok := fm["title"]; ok {
|
sb.WriteString(fmt.Sprintf("title: %q\n", title))
|
} else {
|
sb.WriteString(fmt.Sprintf("title: %q\n", strings.ReplaceAll(title, "_", "/")))
|
}
|
sb.WriteString("status: \"draft\"\n")
|
if draftType, ok := fm["type"]; ok {
|
sb.WriteString(fmt.Sprintf("type: %q\n", draftType))
|
} else {
|
sb.WriteString("type: \"TAPD\"\n")
|
}
|
if source, ok := fm["source"]; ok {
|
sb.WriteString(fmt.Sprintf("source: %q\n", source))
|
} else {
|
sb.WriteString(fmt.Sprintf("source: \"TAPD-%s\"\n", dateStr))
|
}
|
if tags, ok := fm["tags"]; ok {
|
// 如果 tags 已经是数组格式,直接使用
|
if strings.HasPrefix(tags, "[") {
|
sb.WriteString(fmt.Sprintf("tags: %s\n", tags))
|
} else {
|
sb.WriteString(fmt.Sprintf("tags: [%s]\n", tags))
|
}
|
} else {
|
sb.WriteString("tags: []\n")
|
}
|
if created, ok := fm["created"]; ok {
|
sb.WriteString(fmt.Sprintf("created: %q\n", created))
|
} else if created, ok := fm["date"]; ok {
|
sb.WriteString(fmt.Sprintf("created: %q\n", created))
|
} else {
|
// 从日期字符串构造
|
if len(dateStr) == 8 {
|
created := fmt.Sprintf("%s-%s-%s", dateStr[:4], dateStr[4:6], dateStr[6:8])
|
sb.WriteString(fmt.Sprintf("created: %q\n", created))
|
}
|
}
|
sb.WriteString("---\n\n")
|
|
// 提取正文(去掉原 frontmatter)
|
body := extractBody(string(content))
|
sb.WriteString(body)
|
|
// 写入 draft.md
|
draftFile := filepath.Join(newDir, "draft.md")
|
if err := os.WriteFile(draftFile, []byte(sb.String()), 0644); err != nil {
|
fmt.Printf(" ⚠️ 写入 draft.md 失败: %v\n", err)
|
return false
|
}
|
fmt.Printf(" ✅ 创建: draft.md\n")
|
|
// 删除原文件
|
if err := os.Remove(oldFile); err != nil {
|
fmt.Printf(" ⚠️ 删除原文件失败: %v\n", err)
|
} else {
|
fmt.Printf(" 🗑️ 删除: %s\n", name)
|
}
|
|
return true
|
}
|
|
// extractBody 提取正文(去掉 frontmatter)
|
func extractBody(content string) string {
|
if !strings.HasPrefix(content, "---") {
|
return content
|
}
|
|
endIdx := strings.Index(content[3:], "\n---")
|
if endIdx == -1 {
|
return content
|
}
|
|
return strings.TrimSpace(content[endIdx+7:])
|
}
|
|
// parseFrontmatter 解析 frontmatter
|
func parseFrontmatter(content string) map[string]string {
|
result := make(map[string]string)
|
|
if !strings.HasPrefix(content, "---") {
|
return result
|
}
|
|
endIdx := strings.Index(content[3:], "\n---")
|
if endIdx == -1 {
|
return result
|
}
|
|
fmText := content[3 : endIdx+3]
|
lines := strings.Split(fmText, "\n")
|
|
for _, line := range lines {
|
line = strings.TrimSpace(line)
|
if line == "" || !strings.Contains(line, ":") {
|
continue
|
}
|
|
parts := strings.SplitN(line, ":", 2)
|
if len(parts) != 2 {
|
continue
|
}
|
|
key := strings.TrimSpace(parts[0])
|
value := strings.TrimSpace(parts[1])
|
|
// 去除引号
|
value = strings.Trim(value, "\"'")
|
|
result[key] = value
|
}
|
|
return result
|
}
|
|
// isDigit 检查字符是否为数字
|
func isDigit(c byte) bool {
|
return c >= '0' && c <= '9'
|
}
|
|
// fileExists 检查文件是否存在
|
func fileExists(path string) bool {
|
_, err := os.Stat(path)
|
return err == nil
|
}
|