This commit is contained in:
@@ -39,6 +39,10 @@ func main() {
|
|||||||
log.Print("RUN_WALLET_AUTH_SCHEMA=false: skipping wallet_auth_* DDL at startup")
|
log.Print("RUN_WALLET_AUTH_SCHEMA=false: skipping wallet_auth_* DDL at startup")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := handlers.EnsureResourceI18nColumns(ctx, pool); err != nil {
|
||||||
|
log.Fatalf("resources i18n columns: %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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ func ListResources(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
typ := strings.TrimSpace(r.URL.Query().Get("type"))
|
typ := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||||
lang := strings.TrimSpace(r.URL.Query().Get("language"))
|
|
||||||
catSlug := strings.TrimSpace(r.URL.Query().Get("category"))
|
catSlug := strings.TrimSpace(r.URL.Query().Get("category"))
|
||||||
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
||||||
if sort == "" {
|
if sort == "" {
|
||||||
@@ -139,8 +138,8 @@ func ListResources(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
base := `
|
base := `
|
||||||
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
|
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''),
|
||||||
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
||||||
FROM resources r JOIN categories c ON c.id = r.category_id
|
FROM resources r JOIN categories c ON c.id = r.category_id
|
||||||
WHERE r.status = 'published' AND r.is_public = TRUE`
|
WHERE r.status = 'published' AND r.is_public = TRUE`
|
||||||
@@ -155,17 +154,15 @@ func ListResources(w http.ResponseWriter, r *http.Request) {
|
|||||||
args = append(args, typ)
|
args = append(args, typ)
|
||||||
cond += " AND r.type = $" + strconv.Itoa(len(args))
|
cond += " AND r.type = $" + strconv.Itoa(len(args))
|
||||||
}
|
}
|
||||||
if lang != "" {
|
|
||||||
args = append(args, lang)
|
|
||||||
cond += " AND r.language = $" + strconv.Itoa(len(args))
|
|
||||||
}
|
|
||||||
if q != "" {
|
if q != "" {
|
||||||
pat := "%" + q + "%"
|
pat := "%" + q + "%"
|
||||||
start := len(args) + 1
|
start := len(args) + 1
|
||||||
args = append(args, pat, pat, pat)
|
args = append(args, pat)
|
||||||
cond += fmt.Sprintf(` AND (r.title ILIKE $%d OR r.description ILIKE $%d OR EXISTS (
|
cond += fmt.Sprintf(` AND (
|
||||||
SELECT 1 FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = r.id AND t.name ILIKE $%d))`,
|
r.title_zh_tw ILIKE $%d OR r.title_zh_cn ILIKE $%d OR r.title_en ILIKE $%d OR
|
||||||
start, start+1, start+2)
|
r.description_zh_tw ILIKE $%d OR r.description_zh_cn ILIKE $%d OR r.description_en ILIKE $%d OR
|
||||||
|
EXISTS (SELECT 1 FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = r.id AND t.name ILIKE $%d))`,
|
||||||
|
start, start, start, start, start, start, start)
|
||||||
}
|
}
|
||||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||||
if tag != "" {
|
if tag != "" {
|
||||||
@@ -220,8 +217,8 @@ func listFeatured(w http.ResponseWriter, r *http.Request, extra string, order st
|
|||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
sqlStr := `
|
sqlStr := `
|
||||||
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
|
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''),
|
||||||
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
||||||
FROM resources r JOIN categories c ON c.id = r.category_id
|
FROM resources r JOIN categories c ON c.id = r.category_id
|
||||||
WHERE r.status = 'published' AND r.is_public = TRUE AND (` + extra + `)
|
WHERE r.status = 'published' AND r.is_public = TRUE AND (` + extra + `)
|
||||||
@@ -250,8 +247,8 @@ func GetResource(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
row := pool.QueryRow(r.Context(), `
|
row := pool.QueryRow(r.Context(), `
|
||||||
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
|
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''),
|
||||||
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
||||||
FROM resources r JOIN categories c ON c.id = r.category_id
|
FROM resources r JOIN categories c ON c.id = r.category_id
|
||||||
WHERE r.id = $1 AND r.status = 'published' AND r.is_public = TRUE`, id)
|
WHERE r.id = $1 AND r.status = 'published' AND r.is_public = TRUE`, id)
|
||||||
@@ -281,8 +278,8 @@ func RelatedResources(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := pool.Query(r.Context(), `
|
rows, err := pool.Query(r.Context(), `
|
||||||
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
|
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''),
|
||||||
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
||||||
FROM resources r JOIN categories c ON c.id = r.category_id
|
FROM resources r JOIN categories c ON c.id = r.category_id
|
||||||
WHERE r.status = 'published' AND r.is_public = TRUE AND r.category_id = $1 AND r.id <> $2
|
WHERE r.status = 'published' AND r.is_public = TRUE AND r.category_id = $1 AND r.id <> $2
|
||||||
@@ -409,15 +406,21 @@ func scanResourceRow(scanner interface {
|
|||||||
var updated time.Time
|
var updated time.Time
|
||||||
var zhTW, zhCN, en *string
|
var zhTW, zhCN, en *string
|
||||||
var id uuid.UUID
|
var id uuid.UUID
|
||||||
|
var texts resourceTextI18n
|
||||||
err := scanner.Scan(
|
err := scanner.Scan(
|
||||||
&id, &dto.Title, &dto.Description, &dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug, &zhTW, &zhCN, &en,
|
&id,
|
||||||
&dto.CoverImage, &dto.FileURL, &dto.PreviewURL, &dto.ExternalURL, &dto.BodyText, &dto.BadgeLabel,
|
&texts.TitleZhTw, &texts.TitleZhCn, &texts.TitleEn,
|
||||||
|
&texts.DescZhTw, &texts.DescZhCn, &texts.DescEn,
|
||||||
|
&texts.BodyZhTw, &texts.BodyZhCn, &texts.BodyEn,
|
||||||
|
&dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug, &zhTW, &zhCN, &en,
|
||||||
|
&dto.CoverImage, &dto.FileURL, &dto.PreviewURL, &dto.ExternalURL, &dto.BadgeLabel,
|
||||||
&dto.IsDownloadable, &dto.IsRecommended, &pubAt, &updated,
|
&dto.IsDownloadable, &dto.IsRecommended, &pubAt, &updated,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return dto, err
|
return dto, err
|
||||||
}
|
}
|
||||||
dto.ID = id.String()
|
dto.ID = id.String()
|
||||||
|
dto.Title, dto.Description, dto.BodyText = texts.pick(r)
|
||||||
dto.CategoryName = pickLangName(r, deref(zhTW), deref(zhCN), deref(en))
|
dto.CategoryName = pickLangName(r, deref(zhTW), deref(zhCN), deref(en))
|
||||||
if pubAt != nil {
|
if pubAt != nil {
|
||||||
s := pubAt.UTC().Format(time.RFC3339)
|
s := pubAt.UTC().Format(time.RFC3339)
|
||||||
|
|||||||
38
internal/handlers/resource_schema.go
Normal file
38
internal/handlers/resource_schema.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureResourceI18nColumns adds per-locale text columns and backfills from legacy fields.
|
||||||
|
func EnsureResourceI18nColumns(ctx context.Context, pool *pgxpool.Pool) error {
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh_tw TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh_cn TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_en TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh_tw TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh_cn TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_en TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_tw TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_cn TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_en TEXT`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = pool.Exec(ctx, `
|
||||||
|
UPDATE resources SET title_zh_tw = title WHERE COALESCE(title_zh_tw, '') = '' AND COALESCE(title, '') <> '';
|
||||||
|
UPDATE resources SET description_zh_tw = description WHERE description_zh_tw IS NULL AND description IS NOT NULL;
|
||||||
|
UPDATE resources SET body_text_zh_tw = body_text WHERE body_text_zh_tw IS NULL AND body_text IS NOT NULL;
|
||||||
|
UPDATE resources SET title_zh_cn = title WHERE language = 'zh-CN' AND COALESCE(title_zh_cn, '') = '';
|
||||||
|
UPDATE resources SET description_zh_cn = description WHERE language = 'zh-CN' AND description_zh_cn IS NULL;
|
||||||
|
UPDATE resources SET body_text_zh_cn = body_text WHERE language = 'zh-CN' AND body_text_zh_cn IS NULL;
|
||||||
|
UPDATE resources SET title_en = title WHERE language = 'en' AND COALESCE(title_en, '') = '';
|
||||||
|
UPDATE resources SET description_en = description WHERE language = 'en' AND description_en IS NULL;
|
||||||
|
UPDATE resources SET body_text_en = body_text WHERE language = 'en' AND body_text_en IS NULL;
|
||||||
|
UPDATE resources SET title = COALESCE(NULLIF(title_zh_tw, ''), title);
|
||||||
|
UPDATE resources SET description = description_zh_tw;
|
||||||
|
UPDATE resources SET body_text = body_text_zh_tw`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
49
internal/handlers/resource_text.go
Normal file
49
internal/handlers/resource_text.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQL fragment: nine i18n text columns on resources.
|
||||||
|
const resourceI18nColsSQL = `
|
||||||
|
COALESCE(r.title_zh_tw,''), COALESCE(r.title_zh_cn,''), COALESCE(r.title_en,''),
|
||||||
|
COALESCE(r.description_zh_tw,''), COALESCE(r.description_zh_cn,''), COALESCE(r.description_en,''),
|
||||||
|
COALESCE(r.body_text_zh_tw,''), COALESCE(r.body_text_zh_cn,''), COALESCE(r.body_text_en,'')`
|
||||||
|
|
||||||
|
type resourceTextI18n struct {
|
||||||
|
TitleZhTw, TitleZhCn, TitleEn string
|
||||||
|
DescZhTw, DescZhCn, DescEn string
|
||||||
|
BodyZhTw, BodyZhCn, BodyEn string
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickLangField(r *http.Request, zhTW, zhCN, en string) string {
|
||||||
|
lang := strings.TrimSpace(r.URL.Query().Get("lang"))
|
||||||
|
if lang == "" {
|
||||||
|
lang = r.Header.Get("Accept-Language")
|
||||||
|
}
|
||||||
|
lang = strings.ToLower(strings.TrimSpace(strings.Split(lang, ",")[0]))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lang, "zh-cn"), lang == "zh-hans":
|
||||||
|
if zhCN != "" {
|
||||||
|
return zhCN
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(lang, "en"):
|
||||||
|
if en != "" {
|
||||||
|
return en
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zhTW != "" {
|
||||||
|
return zhTW
|
||||||
|
}
|
||||||
|
if zhCN != "" {
|
||||||
|
return zhCN
|
||||||
|
}
|
||||||
|
return en
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t resourceTextI18n) pick(r *http.Request) (title, description, body string) {
|
||||||
|
return pickLangField(r, t.TitleZhTw, t.TitleZhCn, t.TitleEn),
|
||||||
|
pickLangField(r, t.DescZhTw, t.DescZhCn, t.DescEn),
|
||||||
|
pickLangField(r, t.BodyZhTw, t.BodyZhCn, t.BodyEn)
|
||||||
|
}
|
||||||
27
migrations/005_resource_i18n_columns.sql
Normal file
27
migrations/005_resource_i18n_columns.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- One resource row holds all languages (title / description / body per locale).
|
||||||
|
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh_tw TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh_cn TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_en TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh_tw TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh_cn TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_en TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_tw TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_cn TEXT;
|
||||||
|
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_en TEXT;
|
||||||
|
|
||||||
|
UPDATE resources SET title_zh_tw = title WHERE COALESCE(title_zh_tw, '') = '' AND COALESCE(title, '') <> '';
|
||||||
|
UPDATE resources SET description_zh_tw = description WHERE description_zh_tw IS NULL AND description IS NOT NULL;
|
||||||
|
UPDATE resources SET body_text_zh_tw = body_text WHERE body_text_zh_tw IS NULL AND body_text IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE resources SET title_zh_cn = title WHERE language = 'zh-CN' AND COALESCE(title_zh_cn, '') = '';
|
||||||
|
UPDATE resources SET description_zh_cn = description WHERE language = 'zh-CN' AND description_zh_cn IS NULL;
|
||||||
|
UPDATE resources SET body_text_zh_cn = body_text WHERE language = 'zh-CN' AND body_text_zh_cn IS NULL;
|
||||||
|
|
||||||
|
UPDATE resources SET title_en = title WHERE language = 'en' AND COALESCE(title_en, '') = '';
|
||||||
|
UPDATE resources SET description_en = description WHERE language = 'en' AND description_en IS NULL;
|
||||||
|
UPDATE resources SET body_text_en = body_text WHERE language = 'en' AND body_text_en IS NULL;
|
||||||
|
|
||||||
|
UPDATE resources SET title = COALESCE(NULLIF(title_zh_tw, ''), title);
|
||||||
|
UPDATE resources SET description = description_zh_tw;
|
||||||
|
UPDATE resources SET body_text = body_text_zh_tw;
|
||||||
Reference in New Issue
Block a user