// 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" "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 '' )`, } 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)`}, } 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 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, } func (s *Store) UpdateSettings(st model.Settings) error { if st.PostsPerPage <= 0 { st.PostsPerPage = 10 } if !ValidLightSkins[st.LightSkinID] { st.LightSkinID = "paper" } 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, } 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 } 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) } } // ---------- 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 }