Titles v2
Updated by user · today · 11 min read
### For Users - **Smarter title display** — each series now picks a primary title per language automatically, based on the best available source. No more guessing which title is "the" title. - **Stacked language flags** — when the same title text applies to multiple languages, flags stack together instead of showing duplicate rows. - **Language priority** — you can reorder which languages you prefer for title display. Default order: English, Romanized Japanese, Japanese, Romanized Korean. - **See all titles** — series pages now have a "show all titles" toggle to reveal every known title across all languages. ### For Moderators - **Title review work zone** — a new `/mod/series/titles` page lets you browse all titles needing attention, filter by issue type (conflicts, low-confidence detections), media type, and language. Each title links to a focused review page with cover art for context. - **Keyboard-driven review** — resolve titles without touching the mouse. Default shortcuts: `A` approve, `X` reject, `S` skip, `C` change language, `R` set romanized. All shortcuts are customizable and saved to local storage. - **Conflict resolution** — when multiple metadata sources disagree on a title's text or language, the review page shows all conflicting values with one-click "Use this" buttons. Resolved conflicts auto-reopen if sources change later. - **Language detection suggestions** — titles with unknown language are automatically analyzed. High-confidence detections are applied instantly; low-confidence ones appear as suggestions you can approve or dismiss. - **Reworked title editor** — the series edit form now shows source attributions, primary/hidden/manual status, conflict warnings, and detection suggestions inline. Add, edit, hide, unhide, set primary, and delete titles without leaving the page. - **Submission diff summaries** — title change submissions now show a semantic diff (added/removed/changed) so you can see exactly what a user is proposing before approving. ### For API Users - **New `titles` field** on series responses — an array of title objects with this shape: ```json [ { "language": "en", "traits": ["official"], "title": "Re:Zero -Starting Life in Another World-", "note": null, "is_primary": true }, { "language": "ja", "traits": ["native", "official"], "title": "\u308a\u305c\u308d\u304b\u3089\u59cb\u3081\u308b\u7570\u4e16\u754c\u751f\u6d3b", "note": null, "is_primary": true }, { "language": "ja-Latn", "traits": ["official"], "title": "Re:Zero kara Hajimeru Isekai Seikatsu", "note": null, "is_primary": false } ] ``` - **Deprecation notice** — the old title fields (`title`, `native_title`, `romanized_title`, `secondary_titles`) are deprecated. They still work exactly as before and their behavior is unchanged — but they are now **frozen**. Moderators can no longer edit data through the v1 system; all title management happens through v2. The v1 and v2 fields are independent; changes to v2 titles do not affect v1 fields and vice versa. Removal of the deprecated fields will happen May 1st - in the meantime, please start migrating to `titles`. - **Key differences from v1:** - `titles` is a flat array of title objects — not keyed by locale like `secondary_titles` was. Every entry has a non-empty `title` string; there are no `null` or empty placeholders. If a language has no title, it simply isn't in the array. - `traits` is an array (e.g. `["native", "official"]`), not a single type enum - Romanized languages use script codes: `ja-Latn`, `ko-Latn`, `zh-Latn` (replaces the old `-ro` suffix convention) - `is_primary` flag indicates the preferred title for each language, this does not make then "official" or "native" - **Title length limit** increased from 500 to 1,000 characters. #### Language codes The `language` field uses [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags. Most codes are plain ISO 639-1 two-letter codes (`en`, `ja`, `ko`, `zh`, `fr`, `de`, ...). We extend the standard in a few ways: - **Romanized scripts** use the `-Latn` script subtag per BCP 47: `ja-Latn` (romaji), `ko-Latn` (romanized Korean), `zh-Latn` (pinyin). This replaces the old `-ro` suffix (`ja-ro`, `ko-ro`) from v1. - **Regional variants** use a lowercased region subtag: `es-la` (Latin American Spanish), `pt-br` (Brazilian Portuguese), `zh-hk` (Hong Kong Chinese). - **`unknown`** is a special value for titles whose language hasn't been identified yet. Common codes you'll encounter: | Code | Language | | --------- | --------------------------- | | `en` | English | | `ja` | Japanese | | `ja-Latn` | Japanese Romanized (romaji) | | `ko` | Korean | | `ko-Latn` | Korean Romanized | | `zh` | Chinese | | `zh-Latn` | Chinese Romanized (pinyin) | | `zh-hk` | Chinese (Hong Kong) | | `es-la` | Spanish (Latin America) | | `pt-br` | Portuguese (Brazil) | #### `is_primary` Each language can have at most one primary title. The `is_primary` flag marks the single best title for that language — it's what the site displays by default. - There is exactly one `is_primary: true` title per language (enforced by the database). - Primary titles are elected automatically from metadata sources, but moderators can override the choice manually. - When picking which title to show, `is_primary` always wins over trait-based ranking. If you only need one title per language, filter by `is_primary: true` and you're done. - A series will always have at least one primary title across all its languages. #### Traits Each title carries one or more traits describing its role: | Trait | Meaning | | ------------- | -------------------------------------------------------------------------------------------- | | `official` | The officially licensed or publisher-sanctioned title for that language. | | `native` | The title in the work's original language and script (e.g. Japanese kanji/kana for a manga). | | `alternative` | Any other known title — abbreviations, alternate romanizations, cover texts, etc. | A single title can have multiple traits. For a more detailed setup of traits, see: https://mangabaka.org/pages/1#title-settings-overview-table #### Finding the title you want The simplest approach: filter by `is_primary` and your preferred language. ```js // Get the primary English title const en = titles.find((t) => t.is_primary && t.language === 'en') // Get the primary title in the work's native script const native = titles.find((t) => t.is_primary && t.traits.includes('native')) ``` If you want to replicate the site's display logic, walk a priority list and pick the first match: ```js const priority = ['en', 'ja-Latn', 'ja', 'ko-Latn'] function best_title(titles) { // Group by language, pick the best in each group const best_per_lang = new Map() for (const lang of priority) { const candidates = titles.filter((t) => t.language === lang) if (candidates.length === 0) continue // is_primary wins, then official > native > alternative candidates.sort((a, b) => score(a) - score(b)) best_per_lang.set(lang, candidates[0]) } // First language with a match becomes the primary for (const lang of priority) { if (best_per_lang.has(lang)) return best_per_lang.get(lang).title } // Fallback: any title return titles[0]?.title ?? null } function score(t) { if (t.is_primary) return 0 if (t.traits.includes('official')) return 1 if (t.traits.includes('native')) return 2 return 3 } ``` the full implementation can be found here ```ts import type { MediaType, TitleLanguage_v2, TitleLanguagePriority } from '$lib/types/mangabaka' import { VIRTUAL_LANGUAGE_CODES } from '$lib/types/mangabaka' /** Input title shape — accepts both TitleInput and SeriesTitleFormEntry */ type TitleInput = { language: TitleLanguage_v2 title: string traits: string[] is_primary?: boolean note?: string | null } export type LanguageAttribution = { language: TitleLanguage_v2 traits: string[] } export type ResolvedTitle = { language: TitleLanguage_v2 title: string traits: string[] /** All languages whose best title shares this text (for stacked flag rendering) */ languages: LanguageAttribution[] } export type ResolvedTitles = { primary: string /** All languages whose best title matches the primary text */ primary_languages: LanguageAttribution[] /** Titles from the user's configured language priority list */ secondary: ResolvedTitle[] /** Titles from languages NOT in the user's priority list */ other: ResolvedTitle[] } /** * Internal fallback for callers that don't pass a language_priority option. * Used by mod/admin pages and model classes (e.g. resolve_titles(titles) with no options). * * Uses virtual codes which expand based on context: * - With media_type: resolves to the single matching language (e.g. _native → ja for manga) * - Without media_type: expands to ALL possible languages (ja, ko, zh, en for _native), * letting the resolution algorithm pick whichever the series has titles for. * * Matches the user-facing default in ListConfiguration.prefault(). */ const DEFAULT_LANGUAGE_PRIORITY: TitleLanguagePriority[] = ['en', '_romanized', '_native'] export const RESOLVE_TITLES_FALLBACK = 'unknown title (please report on Discord)' const FALLBACK_TITLES: ResolvedTitles = { primary: RESOLVE_TITLES_FALLBACK, primary_languages: [], secondary: [], other: [], } const VIRTUAL_NATIVE_MAP: Record<MediaType, TitleLanguage_v2 | null> = { manga: 'ja', manhwa: 'ko', manhua: 'zh', novel: 'ja', oel: 'en', other: null, } const VIRTUAL_ROMANIZED_MAP: Record<MediaType, TitleLanguage_v2 | null> = { manga: 'ja-Latn', manhwa: 'ko-Latn', manhua: 'zh-Latn', novel: 'ja-Latn', oel: null, other: null, } // All unique non-null values from each virtual map, used as fallback when media_type is unknown const VIRTUAL_NATIVE_ALL: TitleLanguage_v2[] = [ ...new Set(Object.values(VIRTUAL_NATIVE_MAP).filter((v): v is TitleLanguage_v2 => v !== null)), ] const VIRTUAL_ROMANIZED_ALL: TitleLanguage_v2[] = [ ...new Set(Object.values(VIRTUAL_ROMANIZED_MAP).filter((v): v is TitleLanguage_v2 => v !== null)), ] /** * Expand virtual language codes (_native, _romanized) into real language codes * based on the series media type. Deduplicates against codes already in the list. * * When media_type is provided, each virtual code resolves to the single matching language. * When media_type is absent, each virtual code expands to ALL possible languages it could * resolve to (ja, ko, zh, en for _native; ja-Latn, ko-Latn, zh-Latn for _romanized), * letting the resolution algorithm pick whichever the series has titles for. */ function expand_virtual_languages(priority: TitleLanguagePriority[], media_type?: MediaType): TitleLanguage_v2[] { const result: TitleLanguage_v2[] = [] const seen = new Set<TitleLanguage_v2>() for (const code of priority) { if (!VIRTUAL_LANGUAGE_CODES.has(code)) { const real = code as TitleLanguage_v2 if (!seen.has(real)) { seen.add(real) result.push(real) } continue } if (media_type) { const map = code === '_native' ? VIRTUAL_NATIVE_MAP : VIRTUAL_ROMANIZED_MAP const resolved = map[media_type] if (resolved && !seen.has(resolved)) { seen.add(resolved) result.push(resolved) } } else { const all = code === '_native' ? VIRTUAL_NATIVE_ALL : VIRTUAL_ROMANIZED_ALL for (const lang of all) { if (!seen.has(lang)) { seen.add(lang) result.push(lang) } } } } return result } /** * Sort score for a title within a language group. * Lower score = higher priority. */ function sort_score(t: TitleInput): number { if (t.is_primary) return 0 if (t.traits.includes('official')) return 1 if (t.traits.includes('native')) return 2 if (t.traits.includes('alternative')) return 3 return 4 } function best_in_group(titles: TitleInput[]): TitleInput | undefined { if (titles.length === 0) return undefined let best = titles[0] let best_score = sort_score(best) for (let i = 1; i < titles.length; i++) { const score = sort_score(titles[i]) if (score < best_score) { best = titles[i] best_score = score } } return best } /** * Resolve titles from a v2 titles array into a primary + ordered secondary lists. * * Instead of deduplicating identical title text, groups languages that share the same * best title into stacked language attributions (like stacked source favicons on ratings). * * Primary: the best title found by walking language_priority groups, then native-trait titles, then any title. * primary_languages: all languages whose best title matches the primary text. * Secondary: grouped titles from priority languages with different text than primary. * Other: grouped titles from non-priority languages with different text than primary. */ export function resolve_titles( titles: TitleInput[] | null | undefined, options?: { language_priority?: TitleLanguagePriority[]; media_type?: MediaType }, ): ResolvedTitles { if (!titles || titles.length === 0) { return FALLBACK_TITLES } const raw_priority = options?.language_priority ?? DEFAULT_LANGUAGE_PRIORITY const priority = expand_virtual_languages(raw_priority, options?.media_type) const priority_set = new Set(priority) // Step 1: Find the best title per language const titles_by_lang = new Map<TitleLanguage_v2, TitleInput[]>() for (const t of titles) { const group = titles_by_lang.get(t.language) if (group) { group.push(t) } else { titles_by_lang.set(t.language, [t]) } } const best_per_lang = new Map<TitleLanguage_v2, TitleInput>() for (const [lang, group] of titles_by_lang) { const best = best_in_group(group) if (best) { best_per_lang.set(lang, best) } } // Step 2: Find primary title text by walking priority let primary_title: TitleInput | undefined for (const lang of priority) { const best = best_per_lang.get(lang) if (best) { primary_title = best break } } // Fallback: titles with 'native' trait if (!primary_title) { const native_group = titles.filter((t) => t.traits.includes('native')) primary_title = best_in_group(native_group) } // Fallback: any title if (!primary_title) { primary_title = best_in_group(titles) } if (!primary_title) { return FALLBACK_TITLES } const primary_str = primary_title.title // Step 3: Group best-per-lang entries by title text const text_groups = new Map<string, LanguageAttribution[]>() for (const [lang, best] of best_per_lang) { const existing = text_groups.get(best.title) || [] existing.push({ language: lang, traits: best.traits }) text_groups.set(best.title, existing) } // Step 4: Sort languages within each group by priority order const priority_index = new Map<TitleLanguage_v2, number>() for (let i = 0; i < priority.length; i++) { priority_index.set(priority[i], i) } const sort_by_priority = (a: LanguageAttribution, b: LanguageAttribution) => { const a_idx = priority_index.get(a.language) ?? priority.length const b_idx = priority_index.get(b.language) ?? priority.length return a_idx - b_idx } // Step 5: Build primary_languages const primary_languages = text_groups.get(primary_str) || [ { language: primary_title.language, traits: primary_title.traits }, ] primary_languages.sort(sort_by_priority) // Step 6: Build secondary and other from remaining text groups const secondary: ResolvedTitle[] = [] const other: ResolvedTitle[] = [] for (const [text, langs] of text_groups) { if (text === primary_str) continue langs.sort(sort_by_priority) const entry: ResolvedTitle = { title: text, language: langs[0].language, traits: langs[0].traits, languages: langs, } if (langs.some((l) => priority_set.has(l.language))) { secondary.push(entry) } else { other.push(entry) } } // Sort secondary by priority order of first language in each group secondary.sort((a, b) => { const a_idx = priority_index.get(a.language) ?? priority.length const b_idx = priority_index.get(b.language) ?? priority.length return a_idx - b_idx }) return { primary: primary_str, primary_languages, secondary, other, } } ```