diff --git a/cmd/server/main.go b/cmd/server/main.go index 9a2611c..a955152 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/internal/handlers/post_tags_catalog.go b/internal/handlers/post_tags_catalog.go new file mode 100644 index 0000000..52d1151 --- /dev/null +++ b/internal/handlers/post_tags_catalog.go @@ -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 +} diff --git a/internal/handlers/posts_common.go b/internal/handlers/posts_common.go index 2d96fbf..e25cfbf 100644 --- a/internal/handlers/posts_common.go +++ b/internal/handlers/posts_common.go @@ -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) } diff --git a/internal/handlers/posts_public.go b/internal/handlers/posts_public.go index 7ed03d6..08f8574 100644 --- a/internal/handlers/posts_public.go +++ b/internal/handlers/posts_public.go @@ -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` diff --git a/internal/handlers/tag_i18n.go b/internal/handlers/tag_i18n.go new file mode 100644 index 0000000..f356d77 --- /dev/null +++ b/internal/handlers/tag_i18n.go @@ -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() +} diff --git a/migrations/010_tags_i18n.sql b/migrations/010_tags_i18n.sql new file mode 100644 index 0000000..5120f4c --- /dev/null +++ b/migrations/010_tags_i18n.sql @@ -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;