MVP: 按 07 风格重写前端 + Go 后端落地(长文/短文、编辑器、后台管理)
This commit is contained in:
@@ -0,0 +1,669 @@
|
||||
// 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]
|
||||
}
|
||||
Reference in New Issue
Block a user