This commit is contained in:
@@ -48,6 +48,9 @@ func main() {
|
|||||||
if err := handlers.EnsurePostsSchema(ctx, pool); err != nil {
|
if err := handlers.EnsurePostsSchema(ctx, pool); err != nil {
|
||||||
log.Fatalf("posts schema: %v", err)
|
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 {
|
if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil {
|
||||||
log.Fatal(err)
|
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 == "" {
|
if ap.Status == "" {
|
||||||
ap.Status = "draft"
|
ap.Status = "draft"
|
||||||
}
|
}
|
||||||
|
normalized, err := normalizeAdminPostTags(ap.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ap.Tags = normalized
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,40 +289,41 @@ func loadAttachmentsByPostIDs(ctx context.Context, pool *pgxpool.Pool, ids []uui
|
|||||||
return out, rows.Err()
|
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, `
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var names []string
|
var names []string
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n string
|
var name, en, ja, ko, vi, id, ms, slug string
|
||||||
if err := rows.Scan(&n); err != nil {
|
if err := rows.Scan(&name, &en, &ja, &ko, &vi, &id, &ms, &slug); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
names = append(names, n)
|
names = append(names, tagDisplayName(name, en, ja, ko, vi, id, ms, lang))
|
||||||
}
|
}
|
||||||
return names, rows.Err()
|
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)
|
_, err := pool.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, t := range tags {
|
for _, slug := range tagSlugs {
|
||||||
t = strings.TrimSpace(t)
|
slug = strings.TrimSpace(slug)
|
||||||
if t == "" {
|
if slug == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var tid int
|
var tid int
|
||||||
slug := tagSlug(t)
|
err := pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid)
|
||||||
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)
|
|
||||||
if err != nil {
|
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)
|
_, _ = 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 {
|
if dto.Attachments == nil {
|
||||||
dto.Attachments = []AttachmentDTO{}
|
dto.Attachments = []AttachmentDTO{}
|
||||||
}
|
}
|
||||||
tags, _ := loadPostTagNames(r.Context(), pool, id)
|
tags, _ := loadPostTagNames(r.Context(), pool, id, requestLangCode(r))
|
||||||
dto.Tags = tags
|
dto.Tags = tags
|
||||||
writeJSON(w, dto)
|
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_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
|
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_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))`,
|
EXISTS (SELECT 1 FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id = p.id AND (
|
||||||
n, n, n, n, n, n, n, n, n)
|
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`
|
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