CtrlK

Titles v2

### 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,
	}
}
```