前端(新增 ui/yohaku/,各视图按 ui_id=vivid 分支渲染;classic 与后台不受影响) - 令牌层:设计稿数值原样落地,28 个 --v-* 为站主接口(自定义 CSS 可覆盖, 放 @layer 让出优先级);另加防线,中和 styles.css 裸选择器 (h1 衬线 / body 行高 / 后台 .stat/.toolbar/.chip 卡片)漏进余白元素 - 顶栏(滚动收缩态)/ 阅读进度(顶部 2px + READ n%)/ 页脚 - 首页:hero(统计条)/ 筛选(类型分段 + 标签 chips + 搜索)/ 长文卡片与短文纸条 / 分页 / 作品展示区(3 件 + 查看所有,参照 diygod.cc); 面板栅格改 1.6fr 1fr 1fr 且高度自适应内容(标签云明显大一圈) - 文章页:三栏「纸」—— 左时间线(前后各两篇)/ 中纸张 / 右目录与阅读进度; 短文详情不显示标题(h1 视觉隐藏保留无障碍) - 全部规则收敛在 html[data-ui='vivid'] 前缀下;无 !important 后端 - GET /api/posts/:slug 增 neighbors(前后各两篇,详情页侧栏时间线用) - SPA 缓存头:index.html no-cache,assets/ 带 hash 长期 immutable (此前无缓存头,浏览器会一直拿旧构建) 其他 - 站主自定义 CSS 管线(customCss.js + ui/sections.js,按分区注入,仅 vivid) - 删除旧 vivid 覆盖层实现(VividNav/Hero/Footer/Timeline/Toc、ui/vivid.css) - 设计稿原型附于仓库根 index.html;作品表清掉外链 diygod.cc 的示例封面, 改用本地占位图(public/covers/)
1356 lines
38 KiB
Go
1356 lines
38 KiB
Go
// Package store owns the schema and every SQL query. All SQL is written with
|
|
// `?` placeholders and re-bound to `$n` when running on PostgreSQL.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"oneblog/internal/db"
|
|
"oneblog/internal/model"
|
|
"oneblog/internal/render"
|
|
)
|
|
|
|
func renderHTML(md string) string { return render.Markdown(md) }
|
|
|
|
func readingMinutes(md string) int { return render.ReadingMinutes(md) }
|
|
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
type Store struct {
|
|
db *db.DB
|
|
}
|
|
|
|
func New(d *db.DB) (*Store, error) {
|
|
s := &Store{db: d}
|
|
if err := s.migrate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Ping 供 /api/health 探活数据库连接。
|
|
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
|
|
|
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
|
|
|
func (s *Store) migrate() error {
|
|
ai := s.db.AutoInc()
|
|
stmts := []string{
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS posts (
|
|
id %s,
|
|
kind TEXT NOT NULL DEFAULT 'long',
|
|
title TEXT NOT NULL DEFAULT '',
|
|
slug TEXT NOT NULL,
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
cover_url TEXT NOT NULL DEFAULT '',
|
|
content_md TEXT NOT NULL DEFAULT '',
|
|
content_html TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'draft',
|
|
published_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
reading_minutes INTEGER NOT NULL DEFAULT 1
|
|
)`, ai),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS tags (
|
|
id %s,
|
|
name TEXT NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
color TEXT NOT NULL DEFAULT ''
|
|
)`, ai),
|
|
`CREATE TABLE IF NOT EXISTS post_tags (
|
|
post_id INTEGER NOT NULL,
|
|
tag_id INTEGER NOT NULL,
|
|
PRIMARY KEY (post_id, tag_id)
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL DEFAULT ''
|
|
)`,
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS projects (
|
|
id %s,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
slug TEXT NOT NULL,
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
cover_url TEXT NOT NULL DEFAULT '',
|
|
url TEXT NOT NULL DEFAULT '',
|
|
repo_url TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'published',
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`, ai),
|
|
}
|
|
for _, q := range stmts {
|
|
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
|
|
return fmt.Errorf("migrate: %w", err)
|
|
}
|
|
}
|
|
|
|
// Lightweight column-add migrations for older databases. SQLite supports
|
|
// ADD COLUMN; Postgres doesn't support IF NOT EXISTS on column add, so we
|
|
// swallow "already exists" errors either way.
|
|
columnAdds := []string{
|
|
`ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
|
|
}
|
|
for _, q := range columnAdds {
|
|
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
|
|
!strings.Contains(err.Error(), "duplicate column") {
|
|
return fmt.Errorf("migrate column: %w", err)
|
|
}
|
|
}
|
|
|
|
// Indexes/unique constraints need dialect-specific "IF NOT EXISTS" support.
|
|
indexes := []struct{ name, ddl string }{
|
|
{"idx_posts_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)`},
|
|
{"idx_posts_feed", `CREATE INDEX IF NOT EXISTS idx_posts_feed ON posts(status, published_at DESC)`},
|
|
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
|
|
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
|
|
{"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`},
|
|
}
|
|
for _, ix := range indexes {
|
|
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
|
|
return fmt.Errorf("migrate index %s: %w", ix.name, err)
|
|
}
|
|
}
|
|
|
|
return s.seedSettings()
|
|
}
|
|
|
|
func (s *Store) seedSettings() error {
|
|
defs := map[string]string{
|
|
"site_title": "ONE · 一个博客",
|
|
"site_desc": "长文与短文,同一种节奏。",
|
|
"author_name": "ONE",
|
|
"author_bio": "写点长的,也写点短的。",
|
|
"footer_note": "© ONE · 一个博客",
|
|
"icp": "",
|
|
"posts_per_page": "10",
|
|
}
|
|
for k, v := range defs {
|
|
if s.db.Dialect == db.Postgres {
|
|
_, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
|
ON CONFLICT (key) DO NOTHING`), k, v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if _, err := s.db.Exec(s.db.Q(`INSERT OR IGNORE INTO settings(key,value) VALUES (?,?)`), k, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------- settings ----------
|
|
|
|
func (s *Store) GetSettings() (model.Settings, error) {
|
|
rows, err := s.db.Query(s.db.Q(`SELECT key, value FROM settings`))
|
|
if err != nil {
|
|
return model.Settings{}, err
|
|
}
|
|
defer rows.Close()
|
|
m := map[string]string{}
|
|
for rows.Next() {
|
|
var k, v string
|
|
if err := rows.Scan(&k, &v); err != nil {
|
|
return model.Settings{}, err
|
|
}
|
|
m[k] = v
|
|
}
|
|
return settingsFromMap(m), rows.Err()
|
|
}
|
|
|
|
func settingsFromMap(m map[string]string) model.Settings {
|
|
st := model.Settings{
|
|
SiteTitle: m["site_title"],
|
|
SiteDesc: m["site_desc"],
|
|
AuthorName: m["author_name"],
|
|
AuthorBio: m["author_bio"],
|
|
FooterNote: m["footer_note"],
|
|
ICPLicense: m["icp"],
|
|
PostsPerPage: 10,
|
|
}
|
|
// LightSkinID is the new canonical name; fall back to legacy theme_id
|
|
// for clients that haven't been updated, and finally to "paper".
|
|
st.LightSkinID = m["light_skin_id"]
|
|
if st.LightSkinID == "" {
|
|
st.LightSkinID = m["theme_id"]
|
|
}
|
|
if st.LightSkinID == "" {
|
|
st.LightSkinID = "paper"
|
|
}
|
|
// Mirror the value into ThemeID so legacy API consumers still see it
|
|
// in the JSON response.
|
|
st.ThemeID = st.LightSkinID
|
|
st.UIID = sanitizeUI(m["ui_id"])
|
|
st.CustomCSS = decodeCSSMap(m["custom_css"])
|
|
if n := atoi(m["posts_per_page"]); n > 0 {
|
|
st.PostsPerPage = n
|
|
}
|
|
return st
|
|
}
|
|
|
|
func atoi(v string) int {
|
|
n := 0
|
|
for _, r := range v {
|
|
if r < '0' || r > '9' {
|
|
return 0
|
|
}
|
|
n = n*10 + int(r-'0')
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ValidLightSkins is the whitelist of light-side skin ids the API accepts.
|
|
// Anything outside this set falls back to "paper" on write.
|
|
var ValidLightSkins = map[string]bool{
|
|
"paper": true,
|
|
"sage": true,
|
|
"rose": true,
|
|
}
|
|
|
|
const (
|
|
// UIDefault is the UI used when nothing valid is stored.
|
|
UIDefault = "classic"
|
|
// UIClassic is the original minimalist front-end.
|
|
UIClassic = "classic"
|
|
// UIVivid is the livelier front-end, which also owns custom-CSS support.
|
|
UIVivid = "vivid"
|
|
)
|
|
|
|
// ValidUIs is the whitelist of front-end UI ids. Anything else falls back to
|
|
// "classic" on both read and write.
|
|
var ValidUIs = map[string]bool{
|
|
UIClassic: true,
|
|
UIVivid: true,
|
|
}
|
|
|
|
// ValidCSSSections whitelists the page sections an owner may target with
|
|
// custom CSS. Keep in sync with frontend/src/ui/sections.js.
|
|
var ValidCSSSections = map[string]bool{
|
|
"global": true,
|
|
"home": true,
|
|
"post": true,
|
|
"archive": true,
|
|
"tags": true,
|
|
"projects": true,
|
|
"about": true,
|
|
}
|
|
|
|
const (
|
|
// maxCustomCSSBytes caps the whole stylesheet set; maxSectionCSSBytes caps
|
|
// any single section. Both are generous for hand-written CSS but keep a
|
|
// runaway paste from bloating every /api/site response.
|
|
maxCustomCSSBytes = 64 << 10
|
|
maxSectionCSSBytes = 16 << 10
|
|
)
|
|
|
|
func sanitizeUI(id string) string {
|
|
id = strings.TrimSpace(id)
|
|
if ValidUIs[id] {
|
|
return id
|
|
}
|
|
return UIDefault
|
|
}
|
|
|
|
// decodeCSSMap turns the stored JSON blob into a section→CSS map. It never
|
|
// returns nil, and it drops unknown sections and over-long values even if the
|
|
// row was hand-edited, so callers can trust the result.
|
|
func decodeCSSMap(raw string) map[string]string {
|
|
out := map[string]string{}
|
|
if strings.TrimSpace(raw) == "" {
|
|
return out
|
|
}
|
|
var parsed map[string]string
|
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
|
return out
|
|
}
|
|
total := 0
|
|
for section, css := range parsed {
|
|
if !ValidCSSSections[section] {
|
|
continue
|
|
}
|
|
css = strings.ReplaceAll(css, "\x00", "")
|
|
if strings.TrimSpace(css) == "" || len(css) > maxSectionCSSBytes {
|
|
continue
|
|
}
|
|
if total+len(css) > maxCustomCSSBytes {
|
|
continue
|
|
}
|
|
total += len(css)
|
|
out[section] = css
|
|
}
|
|
return out
|
|
}
|
|
|
|
// encodeCSSMap is the write-side counterpart: unknown sections are dropped and
|
|
// oversized values are refused, then the map is serialized. A nil or empty map
|
|
// encodes to "{}" so a full-replace PUT clears everything deterministically.
|
|
func encodeCSSMap(in map[string]string) string {
|
|
clean := map[string]string{}
|
|
total := 0
|
|
for section, css := range in {
|
|
if !ValidCSSSections[section] {
|
|
continue
|
|
}
|
|
css = strings.ReplaceAll(css, "\x00", "")
|
|
if strings.TrimSpace(css) == "" || len(css) > maxSectionCSSBytes {
|
|
continue
|
|
}
|
|
if total+len(css) > maxCustomCSSBytes {
|
|
continue
|
|
}
|
|
total += len(css)
|
|
clean[section] = css
|
|
}
|
|
b, err := json.Marshal(clean)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func (s *Store) UpdateSettings(st model.Settings) error {
|
|
if st.PostsPerPage <= 0 {
|
|
st.PostsPerPage = 10
|
|
}
|
|
if !ValidLightSkins[st.LightSkinID] {
|
|
st.LightSkinID = "paper"
|
|
}
|
|
st.UIID = sanitizeUI(st.UIID)
|
|
sets := map[string]string{
|
|
"site_title": st.SiteTitle,
|
|
"site_desc": st.SiteDesc,
|
|
"author_name": st.AuthorName,
|
|
"author_bio": st.AuthorBio,
|
|
"footer_note": st.FooterNote,
|
|
"icp": st.ICPLicense,
|
|
"posts_per_page": fmt.Sprint(st.PostsPerPage),
|
|
"light_skin_id": st.LightSkinID,
|
|
// Mirror to theme_id so any older client still sees something.
|
|
"theme_id": st.LightSkinID,
|
|
"ui_id": st.UIID,
|
|
// Full replace: an omitted/empty map clears every section's CSS.
|
|
"custom_css": encodeCSSMap(st.CustomCSS),
|
|
}
|
|
for k, v := range sets {
|
|
if s.db.Dialect == db.Postgres {
|
|
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`), k, v); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`), k, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------- posts ----------
|
|
|
|
type ListOptions struct {
|
|
Kind string
|
|
Tag string
|
|
Query string
|
|
Status string // "" = published only (public), "any" = all (admin)
|
|
Page int
|
|
Size int
|
|
OrderBy string
|
|
}
|
|
|
|
// OrderBy 来自查询参数,必须白名单校验后才能拼进 SQL。
|
|
var allowedOrder = map[string]bool{
|
|
"published_at desc": true,
|
|
"published_at asc": true,
|
|
"updated_at desc": true,
|
|
"updated_at asc": true,
|
|
"created_at desc": true,
|
|
"reading_minutes desc": true,
|
|
"reading_minutes asc": true,
|
|
}
|
|
|
|
func sanitizeOrder(o string) string {
|
|
key := strings.ToLower(strings.Join(strings.Fields(o), " "))
|
|
if allowedOrder[key] {
|
|
return key
|
|
}
|
|
return "published_at desc"
|
|
}
|
|
|
|
const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status,
|
|
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
|
|
|
|
// listCols 用于列表/时间线:不传 content_md(前端不用),
|
|
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
|
|
const listCols = `id, kind, title, slug, summary, cover_url,
|
|
'' AS content_md,
|
|
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html,
|
|
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
|
|
|
|
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
|
|
var p model.Post
|
|
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
|
|
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
|
|
&p.ReadingMinutes, &p.ContentLen)
|
|
p.Tags = []string{}
|
|
return p, err
|
|
}
|
|
|
|
func (s *Store) List(o ListOptions) (model.Page, error) {
|
|
if o.Page < 1 {
|
|
o.Page = 1
|
|
}
|
|
if o.Size < 1 || o.Size > 100 {
|
|
o.Size = 10
|
|
}
|
|
where := []string{}
|
|
args := []any{}
|
|
if o.Status == "any" {
|
|
// admin: no status filter
|
|
} else if o.Status != "" {
|
|
where = append(where, "status = ?")
|
|
args = append(args, o.Status)
|
|
} else {
|
|
where = append(where, "status = 'published'")
|
|
}
|
|
if o.Kind != "" {
|
|
where = append(where, "kind = ?")
|
|
args = append(args, o.Kind)
|
|
}
|
|
if o.Tag != "" {
|
|
where = append(where, `id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
|
|
WHERE t.slug = ? OR t.name = ?)`)
|
|
args = append(args, o.Tag, o.Tag)
|
|
}
|
|
if o.Query != "" {
|
|
like := "%" + strings.ToLower(o.Query) + "%"
|
|
where = append(where, `(lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(content_md) LIKE ?)`)
|
|
args = append(args, like, like, like)
|
|
}
|
|
w := ""
|
|
if len(where) > 0 {
|
|
w = "WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
order := sanitizeOrder(o.OrderBy)
|
|
|
|
var total int
|
|
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM posts `+w), args...).Scan(&total); err != nil {
|
|
return model.Page{}, err
|
|
}
|
|
|
|
q := s.db.Q(fmt.Sprintf(`SELECT %s FROM posts %s ORDER BY %s LIMIT ? OFFSET ?`, listCols, w, order))
|
|
rows, err := s.db.Query(q, append(args, o.Size, (o.Page-1)*o.Size)...)
|
|
if err != nil {
|
|
return model.Page{}, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []model.Post{}
|
|
for rows.Next() {
|
|
p, err := scanPost(rows)
|
|
if err != nil {
|
|
return model.Page{}, err
|
|
}
|
|
items = append(items, p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return model.Page{}, err
|
|
}
|
|
if err := s.attachTags(items); err != nil {
|
|
return model.Page{}, err
|
|
}
|
|
return model.Page{Items: items, Total: total, Page: o.Page, Size: o.Size}, nil
|
|
}
|
|
|
|
func (s *Store) attachTags(posts []model.Post) error {
|
|
if len(posts) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]any, 0, len(posts))
|
|
idx := map[int64]int{}
|
|
for i, p := range posts {
|
|
ids = append(ids, p.ID)
|
|
idx[p.ID] = i
|
|
}
|
|
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
|
q := s.db.Q(fmt.Sprintf(`SELECT pt.post_id, t.name FROM post_tags pt
|
|
JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id IN (%s) ORDER BY t.name`, ph))
|
|
rows, err := s.db.Query(q, ids...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var pid int64
|
|
var name string
|
|
if err := rows.Scan(&pid, &name); err != nil {
|
|
return err
|
|
}
|
|
if i, ok := idx[pid]; ok {
|
|
posts[i].Tags = append(posts[i].Tags, name)
|
|
}
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func (s *Store) Get(id int64) (model.Post, error) {
|
|
var p model.Post
|
|
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE id = ?`), id)
|
|
if err := scanPostInto(row, &p); err != nil {
|
|
return p, err
|
|
}
|
|
items := []model.Post{p}
|
|
if err := s.attachTags(items); err != nil {
|
|
return p, err
|
|
}
|
|
return items[0], nil
|
|
}
|
|
|
|
// Neighbors 返回某篇已发布文章在时间线上的前后邻居(详情页侧栏用)。
|
|
// 返回顺序与时间线一致(published_at DESC):最旧在最前、最新在最后,
|
|
// 目标文章夹在中间,前后各取最多 2 篇。
|
|
func (s *Store) Neighbors(slug string) ([]model.Post, error) {
|
|
rows, err := s.db.Query(s.db.Q(`SELECT ` + listCols + ` FROM posts
|
|
WHERE status = ? ORDER BY published_at DESC`), model.StatusPublished)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
all := []model.Post{}
|
|
for rows.Next() {
|
|
p, err := scanPost(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
idx := -1
|
|
for i, p := range all {
|
|
if p.Slug == slug {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return []model.Post{}, nil
|
|
}
|
|
lo, hi := idx-2, idx+2
|
|
if lo < 0 {
|
|
lo = 0
|
|
}
|
|
if hi > len(all)-1 {
|
|
hi = len(all) - 1
|
|
}
|
|
out := all[lo : hi+1]
|
|
if err := s.attachTags(out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) GetBySlug(slug string) (model.Post, error) {
|
|
var p model.Post
|
|
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE slug = ?`), slug)
|
|
if err := scanPostInto(row, &p); err != nil {
|
|
return p, err
|
|
}
|
|
items := []model.Post{p}
|
|
if err := s.attachTags(items); err != nil {
|
|
return p, err
|
|
}
|
|
return items[0], nil
|
|
}
|
|
|
|
func scanPostInto(row *sql.Row, p *model.Post) error {
|
|
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
|
|
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
|
|
&p.ReadingMinutes, &p.ContentLen)
|
|
if err == sql.ErrNoRows {
|
|
return ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Tags = []string{}
|
|
return nil
|
|
}
|
|
|
|
// ---------- slug ----------
|
|
|
|
var slugSep = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
|
|
|
func Slugify(s string) string {
|
|
s = strings.TrimSpace(strings.ToLower(s))
|
|
s = slugSep.ReplaceAllString(s, "-")
|
|
s = strings.Trim(s, "-")
|
|
return s
|
|
}
|
|
|
|
func (s *Store) uniqueSlug(base string, excludeID int64) string {
|
|
base = Slugify(base)
|
|
if base == "" {
|
|
base = "post"
|
|
}
|
|
candidate := base
|
|
for i := 2; ; i++ {
|
|
var id int64
|
|
err := s.db.QueryRow(s.db.Q(`SELECT id FROM posts WHERE slug = ? AND id <> ?`), candidate, excludeID).Scan(&id)
|
|
if err == sql.ErrNoRows {
|
|
return candidate
|
|
}
|
|
if err != nil {
|
|
return fmt.Sprintf("%s-%d", base, time.Now().Unix())
|
|
}
|
|
candidate = fmt.Sprintf("%s-%d", base, i)
|
|
}
|
|
}
|
|
|
|
func (s *Store) uniqueProjectSlug(base string, excludeID int64) string {
|
|
base = Slugify(base)
|
|
if base == "" {
|
|
base = "project"
|
|
}
|
|
candidate := base
|
|
for i := 2; ; i++ {
|
|
var id int64
|
|
err := s.db.QueryRow(s.db.Q(`SELECT id FROM projects WHERE slug = ? AND id <> ?`), candidate, excludeID).Scan(&id)
|
|
if err == sql.ErrNoRows {
|
|
return candidate
|
|
}
|
|
if err != nil {
|
|
return fmt.Sprintf("%s-%d", base, time.Now().Unix())
|
|
}
|
|
candidate = fmt.Sprintf("%s-%d", base, i)
|
|
}
|
|
}
|
|
|
|
// ---------- projects ----------
|
|
|
|
func scanProject(row interface{ Scan(...any) error }) (model.Project, error) {
|
|
var p model.Project
|
|
err := row.Scan(&p.ID, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, &p.URL,
|
|
&p.RepoURL, &p.Status, &p.Position, &p.CreatedAt, &p.UpdatedAt)
|
|
return p, err
|
|
}
|
|
|
|
// ListProjects returns projects with an optional status filter. Pass "" to
|
|
// get every project (admin), or model.StatusPublished / model.StatusDraft to
|
|
// narrow. Results are ordered by explicit Position then recency.
|
|
func (s *Store) ListProjects(status string) ([]model.Project, error) {
|
|
where := ""
|
|
args := []any{}
|
|
if status == model.StatusPublished || status == model.StatusDraft {
|
|
where = " WHERE status = ?"
|
|
args = append(args, status)
|
|
}
|
|
q := s.db.Q(`SELECT id,title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at
|
|
FROM projects` + where + ` ORDER BY position ASC, created_at DESC`)
|
|
rows, err := s.db.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []model.Project{}
|
|
for rows.Next() {
|
|
p, err := scanProject(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetProject(id int64) (model.Project, error) {
|
|
var p model.Project
|
|
err := s.db.QueryRow(s.db.Q(`SELECT id,title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at
|
|
FROM projects WHERE id = ?`), id).Scan(&p.ID, &p.Title, &p.Slug, &p.Summary,
|
|
&p.CoverURL, &p.URL, &p.RepoURL, &p.Status, &p.Position, &p.CreatedAt, &p.UpdatedAt)
|
|
if err == sql.ErrNoRows {
|
|
return p, ErrNotFound
|
|
}
|
|
return p, err
|
|
}
|
|
|
|
func (s *Store) CreateProject(in model.ProjectInput) (model.Project, error) {
|
|
p := model.Project{
|
|
Title: strings.TrimSpace(in.Title),
|
|
Slug: strings.TrimSpace(in.Slug),
|
|
Summary: strings.TrimSpace(in.Summary),
|
|
CoverURL: strings.TrimSpace(in.CoverURL),
|
|
URL: strings.TrimSpace(in.URL),
|
|
RepoURL: strings.TrimSpace(in.RepoURL),
|
|
Status: NormalizeStatus(in.Status),
|
|
Position: in.Position,
|
|
}
|
|
if p.Status == "" {
|
|
p.Status = model.StatusPublished
|
|
}
|
|
if p.Slug == "" {
|
|
p.Slug = Slugify(p.Title)
|
|
}
|
|
if p.Slug == "" {
|
|
p.Slug = "project-" + time.Now().UTC().Format("20060102-150405")
|
|
}
|
|
p.Slug = s.uniqueProjectSlug(p.Slug, 0)
|
|
p.CreatedAt = now()
|
|
p.UpdatedAt = p.CreatedAt
|
|
|
|
q := s.db.Q(`INSERT INTO projects (title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
var id int64
|
|
if s.db.Dialect == db.Postgres {
|
|
err := s.db.QueryRow(q, p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL,
|
|
p.Status, p.Position, p.CreatedAt, p.UpdatedAt).Scan(&id)
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
} else {
|
|
res, err := s.db.Exec(q, p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL,
|
|
p.Status, p.Position, p.CreatedAt, p.UpdatedAt)
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
id, err = res.LastInsertId()
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
}
|
|
return s.GetProject(id)
|
|
}
|
|
|
|
func (s *Store) UpdateProject(id int64, in model.ProjectInput) (model.Project, error) {
|
|
cur, err := s.GetProject(id)
|
|
if err != nil {
|
|
return cur, err
|
|
}
|
|
p := cur
|
|
if in.Title != "" {
|
|
p.Title = strings.TrimSpace(in.Title)
|
|
}
|
|
if in.Summary != "" {
|
|
p.Summary = strings.TrimSpace(in.Summary)
|
|
}
|
|
if in.CoverURL != "" {
|
|
p.CoverURL = strings.TrimSpace(in.CoverURL)
|
|
}
|
|
if in.URL != "" {
|
|
p.URL = strings.TrimSpace(in.URL)
|
|
}
|
|
if in.RepoURL != "" {
|
|
p.RepoURL = strings.TrimSpace(in.RepoURL)
|
|
}
|
|
if in.Status != "" {
|
|
p.Status = NormalizeStatus(in.Status)
|
|
}
|
|
if in.Slug != "" && in.Slug != cur.Slug {
|
|
p.Slug = s.uniqueProjectSlug(in.Slug, id)
|
|
}
|
|
p.Position = in.Position
|
|
p.UpdatedAt = now()
|
|
|
|
if _, err := s.db.Exec(s.db.Q(`UPDATE projects SET title=?,slug=?,summary=?,cover_url=?,url=?,repo_url=?,
|
|
status=?,position=?,updated_at=? WHERE id=?`),
|
|
p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL, p.Status, p.Position, p.UpdatedAt, id); err != nil {
|
|
return p, err
|
|
}
|
|
return s.GetProject(id)
|
|
}
|
|
|
|
func (s *Store) DeleteProject(id int64) error {
|
|
res, err := s.db.Exec(s.db.Q(`DELETE FROM projects WHERE id = ?`), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------- write ----------
|
|
|
|
func (s *Store) Create(in model.PostInput) (model.Post, error) {
|
|
p := model.Post{
|
|
Kind: in.Kind,
|
|
Title: strings.TrimSpace(in.Title),
|
|
Slug: in.Slug,
|
|
Status: in.Status,
|
|
CoverURL: strings.TrimSpace(in.CoverURL),
|
|
Tags: []string{},
|
|
}
|
|
if p.Kind == "" {
|
|
p.Kind = model.KindLong
|
|
}
|
|
if p.Status == "" {
|
|
p.Status = model.StatusDraft
|
|
}
|
|
if p.Slug == "" {
|
|
p.Slug = Slugify(p.Title)
|
|
}
|
|
if p.Slug == "" {
|
|
// Titles without any latin characters (typical for short notes) get a
|
|
// date-based slug instead of colliding on "post", "post-2", ...
|
|
p.Slug = "s-" + time.Now().UTC().Format("20060102-150405")
|
|
}
|
|
p.Slug = s.uniqueSlug(p.Slug, 0)
|
|
// Short posts have no visible title, but archive and tag listings still
|
|
// need something to index them by.
|
|
if p.Kind == model.KindShort && p.Title == "" {
|
|
p.Title = render.TitleFromMarkdown(in.ContentMd)
|
|
}
|
|
p.Summary = strings.TrimSpace(in.Summary)
|
|
p.ContentMd = in.ContentMd
|
|
p.ContentHTML = renderHTML(in.ContentMd)
|
|
p.PublishedAt = in.PublishedAt
|
|
p.CreatedAt = now()
|
|
p.UpdatedAt = p.CreatedAt
|
|
if p.PublishedAt == "" {
|
|
p.PublishedAt = p.CreatedAt
|
|
}
|
|
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
|
|
p.ReadingMinutes = *in.ReadingMinutes
|
|
} else {
|
|
p.ReadingMinutes = readingMinutes(in.ContentMd)
|
|
}
|
|
|
|
var id int64
|
|
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status,
|
|
published_at,created_at,updated_at,reading_minutes)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
if s.db.Dialect == db.Postgres {
|
|
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
|
|
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id)
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
} else {
|
|
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
|
|
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes)
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
id, err = res.LastInsertId()
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
}
|
|
p.ID = id
|
|
if err := s.setTags(id, in.Tags); err != nil {
|
|
return p, err
|
|
}
|
|
return s.Get(id)
|
|
}
|
|
|
|
func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
|
|
cur, err := s.Get(id)
|
|
if err != nil {
|
|
return cur, err
|
|
}
|
|
p := cur
|
|
if in.Kind != "" {
|
|
p.Kind = in.Kind
|
|
}
|
|
if in.Title != "" || in.Kind == model.KindShort {
|
|
p.Title = strings.TrimSpace(in.Title)
|
|
}
|
|
if in.Summary != "" {
|
|
p.Summary = strings.TrimSpace(in.Summary)
|
|
}
|
|
if in.ContentMd != "" {
|
|
p.ContentMd = in.ContentMd
|
|
p.ContentHTML = renderHTML(in.ContentMd)
|
|
}
|
|
if in.Status != "" {
|
|
p.Status = in.Status
|
|
}
|
|
if in.Slug != "" && in.Slug != cur.Slug {
|
|
p.Slug = s.uniqueSlug(in.Slug, id)
|
|
}
|
|
if in.PublishedAt != "" {
|
|
p.PublishedAt = in.PublishedAt
|
|
}
|
|
// CoverURL is taken verbatim from input. The frontend always sends the
|
|
// current value (empty string included), so any update round-trips with
|
|
// whatever the user last saved.
|
|
p.CoverURL = strings.TrimSpace(in.CoverURL)
|
|
p.UpdatedAt = now()
|
|
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
|
|
p.ReadingMinutes = *in.ReadingMinutes
|
|
} else if in.ContentMd != "" {
|
|
p.ReadingMinutes = readingMinutes(in.ContentMd)
|
|
}
|
|
|
|
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?,
|
|
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`),
|
|
p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status,
|
|
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
|
|
return p, err
|
|
}
|
|
if in.Tags != nil {
|
|
if err := s.setTags(id, in.Tags); err != nil {
|
|
return p, err
|
|
}
|
|
}
|
|
return s.Get(id)
|
|
}
|
|
|
|
// BulkUpdateStatus flips the status of many posts in a single transaction.
|
|
func (s *Store) BulkUpdateStatus(ids []int64, status string) (int, error) {
|
|
if len(ids) == 0 {
|
|
return 0, nil
|
|
}
|
|
status = NormalizeStatus(status)
|
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
|
args := []any{status, now()}
|
|
for _, id := range ids {
|
|
args = append(args, id)
|
|
}
|
|
q := s.db.Q(`UPDATE posts SET status=?, updated_at=? WHERE id IN (` + placeholders + `)`)
|
|
res, err := s.db.Exec(q, args...)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return int(n), nil
|
|
}
|
|
|
|
// NormalizeStatus coerces an arbitrary input string into a known post status
|
|
// value, defaulting to draft when nothing matches.
|
|
func NormalizeStatus(s string) string {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case model.StatusDraft, model.StatusPublished:
|
|
return strings.ToLower(strings.TrimSpace(s))
|
|
default:
|
|
return model.StatusDraft
|
|
}
|
|
}
|
|
|
|
func (s *Store) Delete(id int64) error {
|
|
return s.tx(func(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil {
|
|
return err
|
|
}
|
|
res, err := tx.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// tx 把多语句写包进一个事务:中途失败整体回滚,不留半截状态。
|
|
func (s *Store) tx(fn func(tx *sql.Tx) error) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := fn(tx); err != nil {
|
|
_ = tx.Rollback()
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ---------- tags ----------
|
|
|
|
func (s *Store) setTags(postID int64, names []string) error {
|
|
return s.tx(func(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), postID); err != nil {
|
|
return err
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, raw := range names {
|
|
name := strings.TrimSpace(raw)
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
tagID, err := s.upsertTagOn(tx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// execer 让 upsertTag 在普通连接和事务里都能跑。
|
|
type execer interface {
|
|
Exec(query string, args ...any) (sql.Result, error)
|
|
QueryRow(query string, args ...any) *sql.Row
|
|
}
|
|
|
|
func (s *Store) upsertTag(name string) (int64, error) {
|
|
return s.upsertTagOn(s.db, name)
|
|
}
|
|
|
|
func (s *Store) upsertTagOn(x execer, name string) (int64, error) {
|
|
slug := Slugify(name)
|
|
if slug == "" {
|
|
slug = "tag"
|
|
}
|
|
var id int64
|
|
err := x.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
|
|
if err == nil {
|
|
return id, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return 0, err
|
|
}
|
|
if s.db.Dialect == db.Postgres {
|
|
err = x.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id)
|
|
return id, err
|
|
}
|
|
res, err := x.Exec(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?)`), name, slug)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
func (s *Store) ListTags() ([]model.Tag, error) {
|
|
q := s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id) AS c
|
|
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
|
|
GROUP BY t.id, t.name, t.slug, t.color ORDER BY c DESC, t.name`)
|
|
rows, err := s.db.Query(q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []model.Tag{}
|
|
for rows.Next() {
|
|
var t model.Tag
|
|
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CreateTag(name string) (model.Tag, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return model.Tag{}, errors.New("tag name required")
|
|
}
|
|
id, err := s.upsertTag(name)
|
|
if err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
return model.Tag{ID: id, Name: name, Slug: Slugify(name)}, nil
|
|
}
|
|
|
|
// CreateTagFull is used by the admin API when color is supplied.
|
|
func (s *Store) CreateTagFull(name, color string) (model.Tag, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return model.Tag{}, errors.New("tag name required")
|
|
}
|
|
slug := Slugify(name)
|
|
if slug == "" {
|
|
return model.Tag{}, errors.New("tag slug required")
|
|
}
|
|
var id int64
|
|
err := s.db.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return model.Tag{}, err
|
|
}
|
|
if err == nil {
|
|
// already exists · update color and return
|
|
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET color=? WHERE id=?`), color, id); err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
return s.getTag(id)
|
|
}
|
|
color = strings.TrimSpace(color)
|
|
if s.db.Dialect == db.Postgres {
|
|
err := s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug,color) VALUES (?,?,?) RETURNING id`),
|
|
name, slug, color).Scan(&id)
|
|
if err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
} else {
|
|
res, err := s.db.Exec(s.db.Q(`INSERT INTO tags(name,slug,color) VALUES (?,?,?)`), name, slug, color)
|
|
if err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
id, err = res.LastInsertId()
|
|
if err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
}
|
|
return s.getTag(id)
|
|
}
|
|
|
|
func (s *Store) getTag(id int64) (model.Tag, error) {
|
|
var t model.Tag
|
|
err := s.db.QueryRow(s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id)
|
|
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
|
|
WHERE t.id = ? GROUP BY t.id`), id).Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count)
|
|
if err == sql.ErrNoRows {
|
|
return t, ErrNotFound
|
|
}
|
|
return t, err
|
|
}
|
|
|
|
func (s *Store) RenameTag(id int64, name string) (model.Tag, error) {
|
|
name = strings.TrimSpace(name)
|
|
slug := Slugify(name)
|
|
if name == "" {
|
|
return model.Tag{}, errors.New("tag name required")
|
|
}
|
|
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=? WHERE id=?`), name, slug, id); err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
return s.getTag(id)
|
|
}
|
|
|
|
// UpdateTag is the combined rename + recolor used by the admin UI.
|
|
func (s *Store) UpdateTag(id int64, name, color string) (model.Tag, error) {
|
|
name = strings.TrimSpace(name)
|
|
slug := Slugify(name)
|
|
if name == "" {
|
|
return model.Tag{}, errors.New("tag name required")
|
|
}
|
|
color = strings.TrimSpace(color)
|
|
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=?, color=? WHERE id=?`), name, slug, color, id); err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
return s.getTag(id)
|
|
}
|
|
|
|
// MergeTags moves every post from fromID to toID, then deletes fromID. It is a
|
|
// no-op if the ids are equal or either doesn't exist.
|
|
func (s *Store) MergeTags(fromID, toID int64) (model.Tag, error) {
|
|
if fromID == toID {
|
|
return model.Tag{}, errors.New("source and target are the same tag")
|
|
}
|
|
if _, err := s.getTag(fromID); err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
if _, err := s.getTag(toID); err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
// De-duplicate before the move so we don't end up with two rows pointing
|
|
// at the same post.
|
|
if err := s.tx(func(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags
|
|
WHERE post_id IN (SELECT post_id FROM post_tags WHERE tag_id = ?)
|
|
AND tag_id = ?`), toID, toID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(s.db.Q(`UPDATE post_tags SET tag_id=? WHERE tag_id=?`), toID, fromID); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.Exec(s.db.Q(`DELETE FROM tags WHERE id=?`), fromID)
|
|
return err
|
|
}); err != nil {
|
|
return model.Tag{}, err
|
|
}
|
|
return s.getTag(toID)
|
|
}
|
|
|
|
func (s *Store) DeleteTag(id int64) error {
|
|
return s.tx(func(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// ---------- archive ----------
|
|
|
|
func (s *Store) Archive() ([]model.ArchiveYear, error) {
|
|
var items []model.Post
|
|
if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts
|
|
WHERE status = ? ORDER BY published_at DESC`), []any{model.StatusPublished}, &items); err != nil {
|
|
return nil, err
|
|
}
|
|
years := []model.ArchiveYear{}
|
|
yearIdx := map[string]int{}
|
|
monthIdx := map[string]int{}
|
|
for _, p := range items {
|
|
y, m := splitDate(p.PublishedAt)
|
|
if y == "" {
|
|
continue
|
|
}
|
|
yi, ok := yearIdx[y]
|
|
if !ok {
|
|
years = append(years, model.ArchiveYear{Year: y, Months: []model.ArchiveMonth{}})
|
|
yi = len(years) - 1
|
|
yearIdx[y] = yi
|
|
}
|
|
key := y + "-" + m
|
|
mi, ok := monthIdx[key]
|
|
if !ok {
|
|
years[yi].Months = append(years[yi].Months, model.ArchiveMonth{Month: m, Posts: []model.Post{}})
|
|
mi = len(years[yi].Months) - 1
|
|
monthIdx[key] = mi
|
|
}
|
|
years[yi].Count++
|
|
years[yi].Months[mi].Posts = append(years[yi].Months[mi].Posts, p)
|
|
}
|
|
return years, nil
|
|
}
|
|
|
|
func splitDate(rfc3339 string) (string, string) {
|
|
if len(rfc3339) < 7 {
|
|
return "", ""
|
|
}
|
|
return rfc3339[0:4], rfc3339[5:7]
|
|
}
|
|
|
|
// ---------- dashboard ----------
|
|
|
|
// Dashboard produces the snapshot rendered on the admin home page.
|
|
func (s *Store) Dashboard() (model.Dashboard, error) {
|
|
d := model.Dashboard{
|
|
RecentPosts: []model.Post{},
|
|
RecentDrafts: []model.Post{},
|
|
TopTags: []model.Tag{},
|
|
PublishedByMonth: []model.MonthBucket{},
|
|
}
|
|
|
|
// status / kind counts --------------------------------------------------
|
|
rows, err := s.db.Query(s.db.Q(`SELECT status, kind, COUNT(*) FROM posts GROUP BY status, kind`))
|
|
if err != nil {
|
|
return d, err
|
|
}
|
|
for rows.Next() {
|
|
var status, kind string
|
|
var n int
|
|
if err := rows.Scan(&status, &kind, &n); err != nil {
|
|
rows.Close()
|
|
return d, err
|
|
}
|
|
d.TotalPosts += n
|
|
switch status {
|
|
case model.StatusPublished:
|
|
d.PublishedPosts += n
|
|
case model.StatusDraft:
|
|
d.DraftPosts += n
|
|
}
|
|
switch kind {
|
|
case model.KindShort:
|
|
d.ShortPosts += n
|
|
case model.KindLong:
|
|
d.LongPosts += n
|
|
}
|
|
}
|
|
rows.Close()
|
|
|
|
// total tag count -------------------------------------------------------
|
|
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM tags`)).Scan(&d.TotalTags); err != nil {
|
|
return d, err
|
|
}
|
|
|
|
// total word count: sum of content_md length ----------------------------
|
|
if err := s.db.QueryRow(s.db.Q(`SELECT COALESCE(SUM(LENGTH(content_md)),0) FROM posts`)).Scan(&d.TotalWords); err != nil {
|
|
return d, err
|
|
}
|
|
|
|
// recent published posts (5) --------------------------------------------
|
|
if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts
|
|
WHERE status=? ORDER BY published_at DESC LIMIT 5`), []any{model.StatusPublished}, &d.RecentPosts); err != nil {
|
|
return d, err
|
|
}
|
|
|
|
// recent drafts (5) -----------------------------------------------------
|
|
if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts
|
|
WHERE status=? ORDER BY updated_at DESC LIMIT 5`), []any{model.StatusDraft}, &d.RecentDrafts); err != nil {
|
|
return d, err
|
|
}
|
|
|
|
// top tags (10) ---------------------------------------------------------
|
|
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM tags`)).Scan(&d.TotalTags); err != nil {
|
|
return d, err
|
|
}
|
|
tagRows, err := s.db.Query(s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id) AS c
|
|
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
|
|
GROUP BY t.id, t.name, t.slug, t.color ORDER BY c DESC, t.name LIMIT 10`))
|
|
if err != nil {
|
|
return d, err
|
|
}
|
|
for tagRows.Next() {
|
|
var t model.Tag
|
|
if err := tagRows.Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count); err != nil {
|
|
tagRows.Close()
|
|
return d, err
|
|
}
|
|
d.TopTags = append(d.TopTags, t)
|
|
}
|
|
tagRows.Close()
|
|
|
|
// published_by_month · last 12 months -----------------------------------
|
|
monthRows, err := s.db.Query(s.db.Q(`SELECT substr(published_at,1,7) AS m, COUNT(*) AS c
|
|
FROM posts WHERE status=? AND length(published_at) >= 7
|
|
GROUP BY m ORDER BY m DESC LIMIT 12`), model.StatusPublished)
|
|
if err != nil {
|
|
return d, err
|
|
}
|
|
defer monthRows.Close()
|
|
for monthRows.Next() {
|
|
var b model.MonthBucket
|
|
if err := monthRows.Scan(&b.Month, &b.Count); err != nil {
|
|
return d, err
|
|
}
|
|
d.PublishedByMonth = append(d.PublishedByMonth, b)
|
|
}
|
|
// reverse so we can render left-to-right on the chart
|
|
for i, j := 0, len(d.PublishedByMonth)-1; i < j; i, j = i+1, j-1 {
|
|
d.PublishedByMonth[i], d.PublishedByMonth[j] = d.PublishedByMonth[j], d.PublishedByMonth[i]
|
|
}
|
|
|
|
return d, nil
|
|
}
|
|
|
|
// scanPostsInto is a small helper that runs a query and scans rows into the
|
|
// given slice, attaching tags at the end.
|
|
func (s *Store) scanPostsInto(query string, args []any, dst *[]model.Post) error {
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
out := []model.Post{}
|
|
for rows.Next() {
|
|
p, err := scanPost(rows)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.attachTags(out); err != nil {
|
|
return err
|
|
}
|
|
*dst = out
|
|
return nil
|
|
}
|