snapshot before merge

This commit is contained in:
zqlit committed 2026-06-01 17:57:22 +08:00
1 parent 06a5f3da4d
commit 671871e0b2
145 files changed
+9786 -59

No files matched your search

+524
View File
@@ -0,0 +1,524 @@
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
}