snapshot before merge
This commit is contained in:
1 parent
06a5f3da4d
commit
671871e0b2
145 files changed
+9786
-59
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user