// 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 ( "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 } 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 '', 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 )`, 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) } } // 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, } 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 } func (s *Store) UpdateSettings(st model.Settings) error { if st.PostsPerPage <= 0 { st.PostsPerPage = 10 } 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), } 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 } const postCols = `id, kind, title, slug, summary, content_md, content_html, status, published_at, created_at, updated_at, reading_minutes` 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.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes) 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 := "published_at DESC" if o.OrderBy != "" { order = 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 ?`, postCols, 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.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes) 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, 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,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.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.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 } 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=?,content_md=?, content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`), p.Kind, p.Title, p.Slug, p.Summary, 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) } func (s *Store) Delete(id int64) error { if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil { return err } res, err := s.db.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id) if err != nil { return err } n, err := res.RowsAffected() if err == nil && n == 0 { return ErrNotFound } return nil } // ---------- tags ---------- func (s *Store) setTags(postID int64, names []string) error { if _, err := s.db.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.upsertTag(name) if err != nil { return err } if _, err := s.db.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); err != nil { return err } } return nil } func (s *Store) upsertTag(name string) (int64, error) { slug := Slugify(name) if slug == "" { slug = "tag" } var id int64 err := s.db.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 = s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id) return id, err } res, err := s.db.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, 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 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.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 } 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 } var t model.Tag err := s.db.QueryRow(s.db.Q(`SELECT id, name, slug, 0 FROM tags WHERE id = ?`), id). Scan(&t.ID, &t.Name, &t.Slug, &t.Count) if err == sql.ErrNoRows { return t, ErrNotFound } return t, err } func (s *Store) DeleteTag(id int64) error { if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil { return err } if _, err := s.db.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id); err != nil { return err } return nil } // ---------- archive ---------- func (s *Store) Archive() ([]model.ArchiveYear, error) { page, err := s.List(ListOptions{Status: model.StatusPublished, Page: 1, Size: 500}) if err != nil { return nil, err } years := []model.ArchiveYear{} yearIdx := map[string]int{} monthIdx := map[string]int{} for _, p := range page.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] }