diff --git a/internal/handlers/post_text.go b/internal/handlers/post_text.go index de25b44..acd63da 100644 --- a/internal/handlers/post_text.go +++ b/internal/handlers/post_text.go @@ -23,6 +23,19 @@ type postLocalePayload struct { Text string `json:"text"` } +// requestLangCode is the normalized locale from ?lang= or Accept-Language (zh, en, ja, …). +func requestLangCode(r *http.Request) string { + raw := strings.TrimSpace(r.URL.Query().Get("lang")) + if raw == "" { + raw = r.Header.Get("Accept-Language") + } + raw = strings.TrimSpace(strings.Split(raw, ",")[0]) + if raw == "" { + return "zh" + } + return translateNormalizePostLang(raw) +} + func (t postTextI18n) pick(r *http.Request) string { return pickLangField(r, t.TextZh, t.TextEn, t.TextJa, t.TextKo, t.TextVi, t.TextId, t.TextMs) } diff --git a/internal/handlers/post_types.go b/internal/handlers/post_types.go index 59ab519..ac56073 100644 --- a/internal/handlers/post_types.go +++ b/internal/handlers/post_types.go @@ -1,13 +1,18 @@ package handlers -import "strings" +import ( + "regexp" + "strings" +) -// Valid post_type values (Browse filter chips + admin selector). +// Valid post_type values (Browse filter chips; inferred on admin save). var validPostTypes = map[string]bool{ "image": true, "video": true, "music": true, "ppt": true, "pdf": true, "link": true, "text": true, "archive": true, } +var postTextURLPattern = regexp.MustCompile(`https?://`) + func normalizePostType(raw string) string { s := strings.ToLower(strings.TrimSpace(raw)) switch s { @@ -33,3 +38,84 @@ func normalizePostType(raw string) string { } return "text" } + +// inferPostTypeFromContent picks post_type from attachments and text (admin no longer sends postType). +func inferPostTypeFromContent(text string, attachments []attachmentInput) string { + hasText := strings.TrimSpace(text) != "" + if len(attachments) == 0 { + if hasText && postTextURLPattern.MatchString(text) { + return "link" + } + return "text" + } + + var image, video, music, ppt, pdf, archive bool + for _, a := range attachments { + img, vid, mus, p, pd, ar := attachmentPostTypeSignals(a) + image = image || img + video = video || vid + music = music || mus + ppt = ppt || p + pdf = pdf || pd + archive = archive || ar + } + + // Specific file types first; image beats video when both exist (e.g. multi-photo posts). + switch { + case ppt: + return "ppt" + case pdf && !image && !video: + return "pdf" + case archive && !image && !video && !music && !ppt && !pdf: + return "archive" + case music && !image && !video: + return "music" + case image: + return "image" + case video: + return "video" + case pdf: + return "pdf" + case archive: + return "archive" + case music: + return "music" + } + if hasText && postTextURLPattern.MatchString(text) { + return "link" + } + return "text" +} + +func attachmentPostTypeSignals(a attachmentInput) (image, video, music, ppt, pdf, archive bool) { + kind := strings.ToLower(strings.TrimSpace(a.Kind)) + mime := strings.ToLower(strings.TrimSpace(a.Mime)) + fn := strings.ToLower(strings.TrimSpace(a.Filename)) + if kind == "" && mime != "" { + kind = classifyAttachmentKind(mime, fn) + } + + if kind == "video" || strings.HasPrefix(mime, "video/") { + video = true + } + if kind == "image" || strings.HasPrefix(mime, "image/") { + image = true + } + if strings.HasPrefix(mime, "audio/") || + strings.HasSuffix(fn, ".mp3") || strings.HasSuffix(fn, ".wav") || + strings.HasSuffix(fn, ".m4a") || strings.HasSuffix(fn, ".flac") || strings.HasSuffix(fn, ".aac") { + music = true + } + if strings.Contains(mime, "pdf") || strings.HasSuffix(fn, ".pdf") { + pdf = true + } + if strings.Contains(mime, "presentation") || + strings.HasSuffix(fn, ".ppt") || strings.HasSuffix(fn, ".pptx") { + ppt = true + } + if strings.HasSuffix(fn, ".zip") || strings.HasSuffix(fn, ".rar") || + strings.HasSuffix(fn, ".7z") || strings.Contains(mime, "zip") { + archive = true + } + return +} diff --git a/internal/handlers/posts_common.go b/internal/handlers/posts_common.go index b7b0f69..2d96fbf 100644 --- a/internal/handlers/posts_common.go +++ b/internal/handlers/posts_common.go @@ -31,7 +31,8 @@ type PostDTO struct { PostType string `json:"postType"` CategoryID int `json:"categoryId,omitempty"` CategorySlug string `json:"categorySlug,omitempty"` - Language string `json:"language"` + Language string `json:"language"` // UI locale from ?lang= (matches text selection) + SourceLanguage string `json:"sourceLanguage,omitempty"` // DB source metadata (admin input language) Text string `json:"text,omitempty"` Localizations map[string]postLocalePayload `json:"localizations"` Attachments []AttachmentDTO `json:"attachments"` @@ -212,9 +213,9 @@ func translateNormalizePostLang(lang string) string { } func validateAdminPost(ap *adminPost) error { - ap.PostType = normalizePostType(ap.PostType) - if ap.PostType == "" || !validPostTypes[ap.PostType] { - return fmt.Errorf("postType required (image, video, music, ppt, pdf, link, text, archive)") + ap.PostType = inferPostTypeFromContent(ap.Text, ap.Attachments) + if !validPostTypes[ap.PostType] { + ap.PostType = "text" } if len(ap.Attachments) > maxPostAttachments { return fmt.Errorf("too many attachments (max %d)", maxPostAttachments) @@ -389,11 +390,11 @@ func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) { var id uuid.UUID var catID *int var slug *string - var lang, postType string + var sourceLang, postType string var pub, updated, created *time.Time err := row.Scan( &id, &texts.TextZh, &texts.TextEn, &texts.TextJa, &texts.TextKo, &texts.TextVi, &texts.TextId, &texts.TextMs, - &lang, &postType, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created, + &sourceLang, &postType, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created, ) if err != nil { return dto, texts, err @@ -406,7 +407,8 @@ func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) { if slug != nil { dto.CategorySlug = *slug } - dto.Language = lang + dto.SourceLanguage = sourceLang + dto.Language = requestLangCode(r) dto.Text = texts.pick(r) dto.Localizations = texts.toLocalizations() if pub != nil {