This commit is contained in:
@@ -48,6 +48,9 @@ func main() {
|
||||
if err := handlers.EnsurePostsSchema(ctx, pool); err != nil {
|
||||
log.Fatalf("posts schema: %v", err)
|
||||
}
|
||||
if err := handlers.EnsureTagI18nSchema(ctx, pool); err != nil {
|
||||
log.Fatalf("tags i18n schema: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
78
internal/handlers/post_tags_catalog.go
Normal file
78
internal/handlers/post_tags_catalog.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package handlers
|
||||
|
||||
import "strings"
|
||||
|
||||
// Preset post tag slugs for admin multi-select (简体中文 label in NameZh).
|
||||
var presetPostTags = []struct {
|
||||
Slug string
|
||||
NameZh string
|
||||
}{
|
||||
{Slug: "official-recommended", NameZh: "官方推荐"},
|
||||
{Slug: "newcomer", NameZh: "新人必看"},
|
||||
{Slug: "week-featured", NameZh: "本周主推"},
|
||||
{Slug: "shareable", NameZh: "可转发"},
|
||||
{Slug: "downloadable", NameZh: "可下载"},
|
||||
{Slug: "image", NameZh: "图片"},
|
||||
{Slug: "video", NameZh: "视频"},
|
||||
{Slug: "ppt", NameZh: "PPT"},
|
||||
{Slug: "pdf", NameZh: "PDF"},
|
||||
{Slug: "copy", NameZh: "文案"},
|
||||
{Slug: "tutorial", NameZh: "教程"},
|
||||
{Slug: "announcement", NameZh: "公告"},
|
||||
{Slug: "event", NameZh: "活动"},
|
||||
{Slug: "poster", NameZh: "海报"},
|
||||
{Slug: "news", NameZh: "新闻"},
|
||||
{Slug: "materials", NameZh: "物料"},
|
||||
{Slug: "class", NameZh: "课堂"},
|
||||
{Slug: "tweet", NameZh: "推文"},
|
||||
}
|
||||
|
||||
var presetPostTagBySlug map[string]string
|
||||
var presetPostTagByName map[string]string
|
||||
|
||||
func init() {
|
||||
presetPostTagBySlug = make(map[string]string, len(presetPostTags))
|
||||
presetPostTagByName = make(map[string]string, len(presetPostTags))
|
||||
for _, t := range presetPostTags {
|
||||
presetPostTagBySlug[t.Slug] = t.NameZh
|
||||
presetPostTagByName[t.NameZh] = t.Slug
|
||||
// Traditional variants from legacy data
|
||||
switch t.Slug {
|
||||
case "official-recommended":
|
||||
presetPostTagByName["官方推薦"] = t.Slug
|
||||
case "shareable":
|
||||
presetPostTagByName["可轉發"] = t.Slug
|
||||
case "downloadable":
|
||||
presetPostTagByName["可下載"] = t.Slug
|
||||
case "image":
|
||||
presetPostTagByName["圖片"] = t.Slug
|
||||
case "video":
|
||||
presetPostTagByName["影片"] = t.Slug
|
||||
case "event":
|
||||
presetPostTagByName["活動"] = t.Slug
|
||||
case "poster":
|
||||
presetPostTagByName["海報"] = t.Slug
|
||||
case "news":
|
||||
presetPostTagByName["新聞"] = t.Slug
|
||||
case "class":
|
||||
presetPostTagByName["課堂"] = t.Slug
|
||||
}
|
||||
}
|
||||
// Legacy slugs from 001_init.sql
|
||||
presetPostTagBySlug["official"] = "官方推荐"
|
||||
presetPostTagByName["official"] = "official-recommended"
|
||||
}
|
||||
|
||||
func normalizePostTagSlug(raw string) (slug string, ok bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
if _, ok := presetPostTagBySlug[raw]; ok {
|
||||
return raw, true
|
||||
}
|
||||
if slug, ok := presetPostTagByName[raw]; ok {
|
||||
return slug, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -240,6 +240,11 @@ func validateAdminPost(ap *adminPost) error {
|
||||
if ap.Status == "" {
|
||||
ap.Status = "draft"
|
||||
}
|
||||
normalized, err := normalizeAdminPostTags(ap.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ap.Tags = normalized
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -284,40 +289,41 @@ func loadAttachmentsByPostIDs(ctx context.Context, pool *pgxpool.Pool, ids []uui
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadPostTagNames(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID) ([]string, error) {
|
||||
func loadPostTagNames(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, lang string) ([]string, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT t.name FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id = $1 ORDER BY t.name`, postID)
|
||||
SELECT t.name, COALESCE(t.name_en,''), COALESCE(t.name_ja,''), COALESCE(t.name_ko,''),
|
||||
COALESCE(t.name_vi,''), COALESCE(t.name_id,''), COALESCE(t.name_ms,''), t.slug
|
||||
FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.post_id = $1 ORDER BY t.slug`, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
var name, en, ja, ko, vi, id, ms, slug string
|
||||
if err := rows.Scan(&name, &en, &ja, &ko, &vi, &id, &ms, &slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, n)
|
||||
names = append(names, tagDisplayName(name, en, ja, ko, vi, id, ms, lang))
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
func replacePostTags(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, tags []string) error {
|
||||
func replacePostTags(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, tagSlugs []string) error {
|
||||
_, err := pool.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tags {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
for _, slug := range tagSlugs {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
var tid int
|
||||
slug := tagSlug(t)
|
||||
err := pool.QueryRow(ctx, `INSERT INTO tags (name, slug) VALUES ($1, $2)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name RETURNING id`, t, slug).Scan(&tid)
|
||||
err := pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid)
|
||||
if err != nil {
|
||||
_ = pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid)
|
||||
continue
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `INSERT INTO post_tags (post_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, postID, tid)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func GetPost(w http.ResponseWriter, r *http.Request) {
|
||||
if dto.Attachments == nil {
|
||||
dto.Attachments = []AttachmentDTO{}
|
||||
}
|
||||
tags, _ := loadPostTagNames(r.Context(), pool, id)
|
||||
tags, _ := loadPostTagNames(r.Context(), pool, id, requestLangCode(r))
|
||||
dto.Tags = tags
|
||||
writeJSON(w, dto)
|
||||
}
|
||||
@@ -143,8 +143,10 @@ func listPostsQuery(w http.ResponseWriter, r *http.Request, searchMode bool) {
|
||||
p.text_zh ILIKE $%d OR p.text_en ILIKE $%d OR p.text_ja ILIKE $%d OR p.text_ko ILIKE $%d OR
|
||||
p.text_vi ILIKE $%d OR p.text_id ILIKE $%d OR p.text_ms ILIKE $%d OR
|
||||
EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.filename ILIKE $%d) OR
|
||||
EXISTS (SELECT 1 FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id = p.id AND t.name ILIKE $%d))`,
|
||||
n, n, n, n, n, n, n, n, n)
|
||||
EXISTS (SELECT 1 FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id = p.id AND (
|
||||
t.name ILIKE $%d OR t.name_en ILIKE $%d OR t.name_ja ILIKE $%d OR t.name_ko ILIKE $%d OR
|
||||
t.name_vi ILIKE $%d OR t.name_id ILIKE $%d OR t.name_ms ILIKE $%d)))`,
|
||||
n, n, n, n, n, n, n, n, n, n, n, n, n, n, n)
|
||||
}
|
||||
}
|
||||
order := ` ORDER BY p.published_at DESC NULLS LAST, p.id DESC`
|
||||
|
||||
100
internal/handlers/tag_i18n.go
Normal file
100
internal/handlers/tag_i18n.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func EnsureTagI18nSchema(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_en TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ja TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ko TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_vi TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ms TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS is_preset BOOLEAN NOT NULL DEFAULT FALSE`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range presetPostTags {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO tags (name, slug, is_preset) VALUES ($1, $2, TRUE)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, is_preset = TRUE`,
|
||||
t.NameZh, t.Slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `UPDATE tags SET slug = 'official-recommended' WHERE slug = 'official'`)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAdminPostTags(tags []string) ([]string, error) {
|
||||
out := make([]string, 0, len(tags))
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range tags {
|
||||
slug, ok := normalizePostTagSlug(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown tag %q (use preset tags only)", strings.TrimSpace(raw))
|
||||
}
|
||||
if !seen[slug] {
|
||||
seen[slug] = true
|
||||
out = append(out, slug)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func tagDisplayName(name, en, ja, ko, vi, id, ms, lang string) string {
|
||||
switch lang {
|
||||
case "en":
|
||||
if s := strings.TrimSpace(en); s != "" {
|
||||
return s
|
||||
}
|
||||
case "ja":
|
||||
if s := strings.TrimSpace(ja); s != "" {
|
||||
return s
|
||||
}
|
||||
case "ko":
|
||||
if s := strings.TrimSpace(ko); s != "" {
|
||||
return s
|
||||
}
|
||||
case "vi":
|
||||
if s := strings.TrimSpace(vi); s != "" {
|
||||
return s
|
||||
}
|
||||
case "id":
|
||||
if s := strings.TrimSpace(id); s != "" {
|
||||
return s
|
||||
}
|
||||
case "ms":
|
||||
if s := strings.TrimSpace(ms); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func loadPostTagSlugs(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID) ([]string, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT t.slug FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.post_id = $1 ORDER BY t.slug`, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var slugs []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slugs = append(slugs, s)
|
||||
}
|
||||
return slugs, rows.Err()
|
||||
}
|
||||
33
migrations/010_tags_i18n.sql
Normal file
33
migrations/010_tags_i18n.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Preset post tags: seven-locale names for public ?lang= display.
|
||||
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_en TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ja TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ko TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_vi TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ms TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS is_preset BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Seed preset tags (name = 简体中文; slug stable for admin UI)
|
||||
INSERT INTO tags (name, slug, is_preset, name_en, name_ja, name_ko, name_vi, name_id, name_ms) VALUES
|
||||
('官方推荐', 'official-recommended', TRUE, '', '', '', '', '', ''),
|
||||
('新人必看', 'newcomer', TRUE, '', '', '', '', '', ''),
|
||||
('本周主推', 'week-featured', TRUE, '', '', '', '', '', ''),
|
||||
('可转发', 'shareable', TRUE, '', '', '', '', '', ''),
|
||||
('可下载', 'downloadable', TRUE, '', '', '', '', '', ''),
|
||||
('图片', 'image', TRUE, '', '', '', '', '', ''),
|
||||
('视频', 'video', TRUE, '', '', '', '', '', ''),
|
||||
('PPT', 'ppt', TRUE, '', '', '', '', '', ''),
|
||||
('PDF', 'pdf', TRUE, '', '', '', '', '', ''),
|
||||
('文案', 'copy', TRUE, '', '', '', '', '', ''),
|
||||
('教程', 'tutorial', TRUE, '', '', '', '', '', ''),
|
||||
('公告', 'announcement', TRUE, '', '', '', '', '', ''),
|
||||
('活动', 'event', TRUE, '', '', '', '', '', ''),
|
||||
('海报', 'poster', TRUE, '', '', '', '', '', ''),
|
||||
('新闻', 'news', TRUE, '', '', '', '', '', ''),
|
||||
('物料', 'materials', TRUE, '', '', '', '', '', ''),
|
||||
('课堂', 'class', TRUE, '', '', '', '', '', ''),
|
||||
('推文', 'tweet', TRUE, '', '', '', '', '', '')
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
is_preset = TRUE;
|
||||
Reference in New Issue
Block a user