package posts import ( "fmt" "os" "path/filepath" "regexp" "sort" "strings" "time" "write-server/internal/config" ) type FrontMatter struct { Pid int `json:"pid,omitempty"` Title string `json:"title"` Date string `json:"date"` Slug string `json:"slug"` Draft bool `json:"draft"` Categories []string `json:"categories,omitempty"` Tags []string `json:"tags,omitempty"` Author string `json:"author,omitempty"` Layout string `json:"layout,omitempty"` AiComment string `json:"ai_comment,omitempty"` Extra map[string]interface{} `json:"-"` } type Post struct { Slug string `json:"slug"` FrontMatter FrontMatter `json:"frontMatter"` Content string `json:"content"` FilePath string `json:"-"` DirPath string `json:"-"` } type PostListItem struct { Slug string `json:"slug"` Title string `json:"title"` Date string `json:"date"` Draft bool `json:"draft"` Categories []string `json:"categories"` Tags []string `json:"tags"` Author string `json:"author"` } var dateRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-(.+)$`) var yearRE = regexp.MustCompile(`^(\d{4})`) func Init() { config.Init() } func findPostDirs() []string { var result []string if _, err := os.Stat(config.CONTENT_DIR); os.IsNotExist(err) { return result } entries, err := os.ReadDir(config.CONTENT_DIR) if err != nil { return result } for _, entry := range entries { if !entry.IsDir() || !yearRE.MatchString(entry.Name()) { continue } yearDir := filepath.Join(config.CONTENT_DIR, entry.Name()) posts, err := os.ReadDir(yearDir) if err != nil { continue } for _, post := range posts { if post.IsDir() { result = append(result, filepath.Join(yearDir, post.Name())) } } } return result } func slugFromDir(dirName string) string { m := dateRE.FindStringSubmatch(dirName) if m != nil { return m[1] } return dirName } func parseDate(dateStr string) time.Time { if len(dateStr) < 10 { return time.Time{} } t, err := time.Parse("2006-01-02", dateStr[:10]) if err != nil { return time.Time{} } return t } // ParseFrontMatter parses YAML-like frontmatter (---...---) and body func ParseFrontMatter(raw string) (FrontMatter, string) { fm := FrontMatter{Layout: "post"} fm.Extra = make(map[string]interface{}) raw = strings.TrimSpace(raw) if !strings.HasPrefix(raw, "---") { return fm, raw } rest := raw[3:] endIdx := strings.Index(rest, "\n---") if endIdx < 0 { return fm, raw } yamlBlock := rest[:endIdx] body := rest[endIdx+4:] // Simple YAML-like key: value parser lines := strings.Split(yamlBlock, "\n") var currentListKey string for _, line := range lines { // Handle multi-line YAML list items (- value) if currentListKey != "" && strings.HasPrefix(strings.TrimSpace(line), "- ") { item := strings.Trim(strings.TrimPrefix(strings.TrimSpace(line), "- "), "\"'") switch currentListKey { case "categories": fm.Categories = append(fm.Categories, item) case "tags": fm.Tags = append(fm.Tags, item) } continue } currentListKey = "" line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) val := strings.TrimSpace(parts[1]) // Unquoted string value val = strings.Trim(val, "\"'") switch key { case "title": fm.Title = val case "date": fm.Date = val case "slug": fm.Slug = val case "draft": fm.Draft = val == "true" case "author": fm.Author = val case "layout": fm.Layout = val case "ai_comment": fm.AiComment = val case "categories": if val == "" { currentListKey = "categories" } else if val != "[]" { fm.Categories = parseYAMLList(val, line) } case "tags": if val == "" { currentListKey = "tags" } else if val != "[]" { fm.Tags = parseYAMLList(val, line) } case "pid": fmt.Sscanf(val, "%d", &fm.Pid) default: fm.Extra[key] = val } } return fm, body } func parseYAMLList(firstVal string, fullLine string) []string { // Try inline: [a, b, c] if strings.HasPrefix(firstVal, "[") && strings.HasSuffix(firstVal, "]") { inner := firstVal[1 : len(firstVal)-1] var items []string for _, item := range strings.Split(inner, ",") { item = strings.TrimSpace(item) item = strings.Trim(item, "\"'") if item != "" { items = append(items, item) } } return items } // Single value on same line if firstVal != "" && firstVal != "[]" { return []string{strings.Trim(firstVal, "\"'")} } return nil } // ParseYAMLListMultiLine is called for multi-line list items // In our simple parser, multi-line categories/tags aren't handled fully // but this stub exists for testing func ListPosts(search string, page, perPage int, includeDrafts bool) ([]PostListItem, int, int) { dirs := findPostDirs() var allPosts []PostListItem for _, dirPath := range dirs { indexPath := filepath.Join(dirPath, "index.md") data, err := os.ReadFile(indexPath) if err != nil { continue } fm, _ := ParseFrontMatter(string(data)) dirName := filepath.Base(dirPath) slug := fm.Slug if slug == "" { slug = slugFromDir(dirName) } title := fm.Title if title == "" { title = "Untitled" } if !includeDrafts && fm.Draft { continue } if search != "" { q := strings.ToLower(search) match := strings.Contains(strings.ToLower(title), q) || strings.Contains(strings.ToLower(slug), q) if !match { for _, c := range fm.Categories { if strings.Contains(strings.ToLower(c), q) { match = true break } } } if !match { for _, t := range fm.Tags { if strings.Contains(strings.ToLower(t), q) { match = true break } } } if !match { continue } } allPosts = append(allPosts, PostListItem{ Slug: slug, Title: title, Date: fm.Date, Draft: fm.Draft, Categories: fm.Categories, Tags: fm.Tags, Author: fm.Author, }) } sort.Slice(allPosts, func(i, j int) bool { return parseDate(allPosts[i].Date).After(parseDate(allPosts[j].Date)) }) total := len(allPosts) totalPages := (total + perPage - 1) / perPage if totalPages < 1 { totalPages = 1 } start := (page - 1) * perPage if start > total { start = total } end := start + perPage if end > total { end = total } return allPosts[start:end], total, totalPages } func GetPost(slug string) *Post { dirs := findPostDirs() for _, dirPath := range dirs { indexPath := filepath.Join(dirPath, "index.md") data, err := os.ReadFile(indexPath) if err != nil { continue } fm, body := ParseFrontMatter(string(data)) existingSlug := fm.Slug if existingSlug == "" { existingSlug = slugFromDir(filepath.Base(dirPath)) } if existingSlug == slug { return &Post{ Slug: existingSlug, FrontMatter: fm, Content: strings.TrimSpace(body), FilePath: indexPath, DirPath: dirPath, } } } return nil } func CreatePost(fm FrontMatter, content string) (string, error) { date := time.Now() if fm.Date != "" { if d, err := time.Parse("2006-01-02", fm.Date[:10]); err == nil { date = d } } else { fm.Date = date.Format("2006-01-02") } year := fmt.Sprintf("%04d", date.Year()) dateStr := date.Format("2006-01-02") titlePart := fm.Title if titlePart == "" { titlePart = "untitled" } titlePart = sanitizePath(titlePart) if titlePart == "" { titlePart = "post" } dirName := dateStr + "-" + titlePart dirPath := filepath.Join(config.CONTENT_DIR, year, dirName) if _, err := os.Stat(dirPath); err == nil { return "", fmt.Errorf("Post already exists at %s", dirPath) } if err := os.MkdirAll(dirPath, 0755); err != nil { return "", err } fileContent := buildMarkdown(fm, content) indexPath := filepath.Join(dirPath, "index.md") if err := os.WriteFile(indexPath, []byte(fileContent), 0644); err != nil { return "", err } return dirPath, nil } func UpdatePost(slug string, fmUpdates map[string]interface{}, content *string) (*Post, error) { post := GetPost(slug) if post == nil { return nil, fmt.Errorf("post not found") } // Apply updates for k, v := range fmUpdates { switch k { case "title": if s, ok := v.(string); ok { post.FrontMatter.Title = s } case "date": if s, ok := v.(string); ok { post.FrontMatter.Date = s } case "draft": if b, ok := v.(bool); ok { post.FrontMatter.Draft = b } case "categories": if arr, ok := v.([]interface{}); ok { post.FrontMatter.Categories = nil for _, item := range arr { if s, ok := item.(string); ok { post.FrontMatter.Categories = append(post.FrontMatter.Categories, s) } } } case "tags": if arr, ok := v.([]interface{}); ok { post.FrontMatter.Tags = nil for _, item := range arr { if s, ok := item.(string); ok { post.FrontMatter.Tags = append(post.FrontMatter.Tags, s) } } } case "author": if s, ok := v.(string); ok { post.FrontMatter.Author = s } case "slug": if s, ok := v.(string); ok { post.FrontMatter.Slug = s } case "ai_comment": if s, ok := v.(string); ok { post.FrontMatter.AiComment = s } } } if content != nil { post.Content = *content } // Handle slug rename if newSlug, ok := fmUpdates["slug"].(string); ok && newSlug != slug { date := time.Now() if post.FrontMatter.Date != "" { if d, err := time.Parse("2006-01-02", post.FrontMatter.Date[:10]); err == nil { date = d } } year := fmt.Sprintf("%04d", date.Year()) dateStr := date.Format("2006-01-02") newDirName := dateStr + "-" + newSlug newDirPath := filepath.Join(config.CONTENT_DIR, year, newDirName) if _, err := os.Stat(newDirPath); err == nil { return nil, fmt.Errorf("Target path already exists: %s", newDirPath) } os.MkdirAll(filepath.Dir(newDirPath), 0755) os.Rename(post.DirPath, newDirPath) post.DirPath = newDirPath post.FilePath = filepath.Join(newDirPath, "index.md") post.Slug = newSlug } fileContent := buildMarkdown(post.FrontMatter, post.Content) if err := os.WriteFile(post.FilePath, []byte(fileContent), 0644); err != nil { return nil, err } return post, nil } func GetPostCount() int { count := 0 dirs := findPostDirs() for _, dir := range dirs { if _, err := os.Stat(filepath.Join(dir, "index.md")); err == nil { count++ } } return count } func sanitizePath(s string) string { var result []byte for _, r := range []byte(s) { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { result = append(result, r) } else if r >= 0x80 { // Allow multi-byte (CJK etc.) result = append(result, r) } else { result = append(result, '-') } } out := string(result) out = strings.Trim(out, "-") return strings.ToLower(out) } func buildMarkdown(fm FrontMatter, content string) string { var sb strings.Builder sb.WriteString("---\n") sb.WriteString(fmt.Sprintf("title: %s\n", quoteYAML(fm.Title))) sb.WriteString(fmt.Sprintf("date: %s\n", fm.Date)) sb.WriteString(fmt.Sprintf("slug: %s\n", fm.Slug)) sb.WriteString(fmt.Sprintf("draft: %v\n", fm.Draft)) if len(fm.Categories) > 0 { sb.WriteString("categories:\n") for _, c := range fm.Categories { sb.WriteString(fmt.Sprintf(" - %s\n", quoteYAML(c))) } } if len(fm.Tags) > 0 { sb.WriteString("tags:\n") for _, t := range fm.Tags { sb.WriteString(fmt.Sprintf(" - %s\n", quoteYAML(t))) } } if fm.Author != "" { sb.WriteString(fmt.Sprintf("author: %s\n", quoteYAML(fm.Author))) } if fm.Layout != "post" && fm.Layout != "" { sb.WriteString(fmt.Sprintf("layout: %s\n", fm.Layout)) } if fm.AiComment != "" { sb.WriteString(fmt.Sprintf("ai_comment: %s\n", quoteYAML(fm.AiComment))) } if fm.Pid != 0 { sb.WriteString(fmt.Sprintf("pid: %d\n", fm.Pid)) } sb.WriteString("---\n") sb.WriteString(content) if !strings.HasSuffix(content, "\n") { sb.WriteString("\n") } return sb.String() } func quoteYAML(s string) string { if strings.ContainsAny(s, ":{}[]&*?|>!%@`'\"#,\n") || s == "" { return "\"" + strings.ReplaceAll(s, "\"", "\\\"") + "\"" } return s }