525 lines
12 KiB
Go
525 lines
12 KiB
Go
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
|
|
}
|