From b36672d9f96e0028337ef61d6652286d91732bfc Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Thu, 15 Jan 2026 22:53:30 +0900 Subject: [PATCH 01/19] types: add timelineReplace field to SimpleOopsyTriggerSet --- types/oopsy.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/types/oopsy.d.ts b/types/oopsy.d.ts index fdc586eccc..f7f76920c7 100644 --- a/types/oopsy.d.ts +++ b/types/oopsy.d.ts @@ -1,5 +1,6 @@ import { Lang } from '../resources/languages'; import { TrackedEvent } from '../ui/oopsyraidsy/player_state_tracker'; +import { TimelineReplacement } from '../ui/raidboss/timeline_parser'; import { OopsyData } from './data'; import { NetAnyMatches, NetMatches } from './net_matches'; From f34c6f2e3edc59d35067cef3ff6b859800c4746a Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Fri, 16 Jan 2026 00:31:02 +0900 Subject: [PATCH 02/19] oopsy: refactor regex translation logic in DamageTracker --- ui/oopsyraidsy/damage_tracker.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/ui/oopsyraidsy/damage_tracker.ts b/ui/oopsyraidsy/damage_tracker.ts index 4fea1e4276..0ea60b362a 100644 --- a/ui/oopsyraidsy/damage_tracker.ts +++ b/ui/oopsyraidsy/damage_tracker.ts @@ -746,14 +746,9 @@ export class DamageTracker { const parserLang = this.options.ParserLanguage; const timelineReplace = set?.timelineReplace; - let localRegex: RegExp; - if (Array.isArray(netRegex)) { - localRegex = Regexes.parse(Regexes.anyOf(netRegex)); - } else { - // RegExp (e.g. from NetRegexes.xxx()), translate the regex string - const translated = translateRegex(netRegex, parserLang, timelineReplace); - localRegex = Regexes.parse(translated); - } + // RegExp (e.g. from NetRegexes.xxx()), translate the regex string + const translated = translateRegex(netRegex, parserLang, timelineReplace); + const localRegex = Regexes.parse(translated); this.triggers.push({ ...looseTrigger, From f289ae35521a924777544ca9faef9b3bfc51f046 Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sat, 17 Jan 2026 23:08:00 +0900 Subject: [PATCH 03/19] type: move TimelineReplacement to trigger.d.ts --- types/oopsy.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/types/oopsy.d.ts b/types/oopsy.d.ts index f7f76920c7..fdc586eccc 100644 --- a/types/oopsy.d.ts +++ b/types/oopsy.d.ts @@ -1,6 +1,5 @@ import { Lang } from '../resources/languages'; import { TrackedEvent } from '../ui/oopsyraidsy/player_state_tracker'; -import { TimelineReplacement } from '../ui/raidboss/timeline_parser'; import { OopsyData } from './data'; import { NetAnyMatches, NetMatches } from './net_matches'; From 09101be3779bd4855c8c05333c53b86a26be7b3a Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Tue, 20 Jan 2026 23:27:22 +0900 Subject: [PATCH 04/19] Revert "oopsy: refactor regex translation logic in DamageTracker" This reverts commit 4ed345dce4064a6220000b29ffab96e93ebd880f. --- ui/oopsyraidsy/damage_tracker.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ui/oopsyraidsy/damage_tracker.ts b/ui/oopsyraidsy/damage_tracker.ts index 0ea60b362a..4fea1e4276 100644 --- a/ui/oopsyraidsy/damage_tracker.ts +++ b/ui/oopsyraidsy/damage_tracker.ts @@ -746,9 +746,14 @@ export class DamageTracker { const parserLang = this.options.ParserLanguage; const timelineReplace = set?.timelineReplace; - // RegExp (e.g. from NetRegexes.xxx()), translate the regex string - const translated = translateRegex(netRegex, parserLang, timelineReplace); - const localRegex = Regexes.parse(translated); + let localRegex: RegExp; + if (Array.isArray(netRegex)) { + localRegex = Regexes.parse(Regexes.anyOf(netRegex)); + } else { + // RegExp (e.g. from NetRegexes.xxx()), translate the regex string + const translated = translateRegex(netRegex, parserLang, timelineReplace); + localRegex = Regexes.parse(translated); + } this.triggers.push({ ...looseTrigger, From 91c82e2b68ced4e9d4de372000a45f4b851d1795 Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Thu, 15 Jan 2026 22:53:30 +0900 Subject: [PATCH 05/19] types: add timelineReplace field to SimpleOopsyTriggerSet --- types/oopsy.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/types/oopsy.d.ts b/types/oopsy.d.ts index fdc586eccc..f7f76920c7 100644 --- a/types/oopsy.d.ts +++ b/types/oopsy.d.ts @@ -1,5 +1,6 @@ import { Lang } from '../resources/languages'; import { TrackedEvent } from '../ui/oopsyraidsy/player_state_tracker'; +import { TimelineReplacement } from '../ui/raidboss/timeline_parser'; import { OopsyData } from './data'; import { NetAnyMatches, NetMatches } from './net_matches'; From 9dd67d84a394abac1a16d0ce259ce5ec2c6091ea Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sat, 17 Jan 2026 23:08:00 +0900 Subject: [PATCH 06/19] type: move TimelineReplacement to trigger.d.ts --- types/oopsy.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/types/oopsy.d.ts b/types/oopsy.d.ts index f7f76920c7..fdc586eccc 100644 --- a/types/oopsy.d.ts +++ b/types/oopsy.d.ts @@ -1,6 +1,5 @@ import { Lang } from '../resources/languages'; import { TrackedEvent } from '../ui/oopsyraidsy/player_state_tracker'; -import { TimelineReplacement } from '../ui/raidboss/timeline_parser'; import { OopsyData } from './data'; import { NetAnyMatches, NetMatches } from './net_matches'; From d9922a69937af77a94ba53073c31cb63451c6f3e Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 15:31:16 +0900 Subject: [PATCH 07/19] util: auto-generate oopsy replaceSync --- util/gen_oopsy_timeline_replace.ts | 199 +++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 util/gen_oopsy_timeline_replace.ts diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts new file mode 100644 index 0000000000..3d2a1c4842 --- /dev/null +++ b/util/gen_oopsy_timeline_replace.ts @@ -0,0 +1,199 @@ +import fs from 'fs'; +import path from 'path'; + +import { getCnTable, getKoTable, getTcTable } from './csv_util'; +import { walkDirSync } from './file_utils'; +import { XivApi } from './xivapi'; + +const rootDir = 'ui/oopsyraidsy/data'; + +const findOopsyFile = (shortName: string): string | undefined => { + shortName = shortName.replace(/\.[jt]s$/, '').split(path.sep).join(path.posix.sep); + let found = undefined; + walkDirSync(rootDir, (filename) => { + if (filename.endsWith(`${shortName}.ts`)) + found = filename; + }); + return found; +}; + +type BNpcNameRow = { + row_id: number; + fields: { + Singular?: string; + 'Singular@de'?: string; + 'Singular@fr'?: string; + 'Singular@ja'?: string; + }; +}; + +const localesFromXivApi = ['de', 'fr', 'ja'] as const; +const localesFromGitHub = ['cn', 'ko', 'tc'] as const; +type Locale = typeof localesFromXivApi[number] | typeof localesFromGitHub[number]; + +const run = async () => { + const args = process.argv.slice(2); + const target = args[0]; + + if (target === undefined) { + console.error( + 'Usage: node --loader=ts-node/esm util/gen_oopsy_timeline_replace.ts ', + ); + console.error( + 'Example: node --loader=ts-node/esm util/gen_oopsy_timeline_replace.ts sephirot-ex', + ); + return; + } + + const oopsyFile = findOopsyFile(target); + if (oopsyFile === undefined) { + console.error(`Could not find oopsy file for ${target}`); + return; + } + + console.error(`Processing: ${oopsyFile}`); + + const content = fs.readFileSync(oopsyFile, 'utf8'); + + // --- Extract replaceSync candidates (source: 'Name') --- + const sources = new Set(); + const sourceRegex = /source:\s*(?:['"]([^'"]+)['"]|\[((?:['"][^'"]+['"],?\s*)+)\])/g; + let sourceMatch = sourceRegex.exec(content); + while (sourceMatch !== null) { + if (sourceMatch[1] !== undefined) { + sources.add(sourceMatch[1]); + } else if (sourceMatch[2] !== undefined) { + const list = sourceMatch[2].replace(/['"]/g, '').split(',').map((s) => s.trim()); + for (const s of list) { + if (s !== '') + sources.add(s); + } + } + sourceMatch = sourceRegex.exec(content); + } + + // --- Fetch from XIVAPI (includes en, de, fr, ja) --- + console.error('Fetching BNpcName from XIVAPI...'); + const xivApi = new XivApi(null); + const bnpcData = await xivApi.queryApi('BNpcName', [ + 'Singular', + 'Singular@de', + 'Singular@fr', + 'Singular@ja', + ]) as BNpcNameRow[]; + + // Map Name -> IDs[] (English) + const enBnpcMap = new Map(); + for (const row of bnpcData) { + const id = row.row_id; + const name = row.fields.Singular; + if (typeof name === 'string' && name.trim() !== '') { + const lowerName = name.toLowerCase(); + if (!enBnpcMap.has(lowerName)) + enBnpcMap.set(lowerName, []); + enBnpcMap.get(lowerName)?.push(id); + } + } + + // Build locale maps for XIVAPI locales (de, fr, ja) + const xivApiLocaleMaps = new Map>(); + for (const locale of localesFromXivApi) { + const fieldName = `Singular@${locale}` as keyof BNpcNameRow['fields']; + const localeMap = new Map(); + for (const row of bnpcData) { + const id = row.row_id; + const name = row.fields[fieldName]; + if (typeof name === 'string' && name.trim() !== '') { + localeMap.set(id, name); + } + } + xivApiLocaleMaps.set(locale, localeMap); + } + + // Fetch GitHub locales (ko, cn, tc) + console.error('Fetching KO BNpcName from GitHub...'); + const koBnpcTable = await getKoTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']); + console.error('Fetching CN BNpcName from GitHub...'); + const cnBnpcTable = await getCnTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']); + console.error('Fetching TC BNpcName from GitHub...'); + const tcBnpcTable = await getTcTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']); + + const buildMapFromTable = (table: typeof koBnpcTable): Map => { + const map = new Map(); + for (const [idStr, row] of Object.entries(table)) { + const id = parseInt(idStr); + const name = row['Singular']; + if (!isNaN(id) && name !== undefined && name.trim() !== '') { + map.set(id, name); + } + } + return map; + }; + + const githubLocaleMaps = new Map>([ + ['ko', buildMapFromTable(koBnpcTable)], + ['cn', buildMapFromTable(cnBnpcTable)], + ['tc', buildMapFromTable(tcBnpcTable)], + ]); + + // Combine all locale maps + const allLocaleMaps = new Map>(); + for (const [locale, map] of xivApiLocaleMaps) { + allLocaleMaps.set(locale as Locale, map); + } + for (const [locale, map] of githubLocaleMaps) { + allLocaleMaps.set(locale as Locale, map); + } + + // Generate replaceSync for each source + const generateReplaceSync = (localeMap: Map): { [key: string]: string } => { + const replaceSync: { [key: string]: string } = {}; + for (const source of sources) { + const ids = enBnpcMap.get(source.toLowerCase()); + if (ids !== undefined) { + const candidates = new Set(); + for (const id of ids) { + const localeName = localeMap.get(id); + if (localeName !== undefined) { + candidates.add(localeName); + } + } + if (candidates.size > 0) { + replaceSync[source] = Array.from(candidates).sort().join(' / '); + } + } + } + return replaceSync; + }; + + // --- Generate Output for ALL locales --- + const outputLines: string[] = []; + outputLines.push('['); + + const allLocales: Locale[] = ['de', 'fr', 'ja', 'cn', 'ko', 'tc']; + for (const locale of allLocales) { + const localeMap = allLocaleMaps.get(locale); + if (localeMap === undefined) + continue; + + const replaceSync = generateReplaceSync(localeMap); + if (Object.keys(replaceSync).length === 0) + continue; + + outputLines.push(' {'); + outputLines.push(` 'locale': '${locale}',`); + outputLines.push(` 'missingTranslations': true,`); + outputLines.push(' \'replaceSync\': {'); + for (const [en, localized] of Object.entries(replaceSync)) { + outputLines.push(` '${en}': '${localized}',`); + } + outputLines.push(' },'); + outputLines.push(' },'); + } + + outputLines.push(']'); + + console.log(outputLines.join('\n')); +}; + +run().catch((e) => console.error(e)); From a614c0c7718b31c32220fba5e1c38136678347ea Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 17:11:45 +0900 Subject: [PATCH 08/19] util: auto-generate oopsy replaceSync to all data --- util/gen_oopsy_timeline_replace.ts | 394 ++++++++++++++++++++--------- 1 file changed, 269 insertions(+), 125 deletions(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index 3d2a1c4842..cda736d514 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -1,20 +1,28 @@ import fs from 'fs'; import path from 'path'; -import { getCnTable, getKoTable, getTcTable } from './csv_util'; +import { getCnTable, getKoTable, getTcTable, type Table } from './csv_util'; import { walkDirSync } from './file_utils'; import { XivApi } from './xivapi'; const rootDir = 'ui/oopsyraidsy/data'; -const findOopsyFile = (shortName: string): string | undefined => { - shortName = shortName.replace(/\.[jt]s$/, '').split(path.sep).join(path.posix.sep); - let found = undefined; +const getTargetFiles = (target?: string): string[] => { + const files: string[] = []; + const filter = target?.replace(/\.[jt]s$/, '').split(path.sep).join(path.posix.sep); + walkDirSync(rootDir, (filename) => { - if (filename.endsWith(`${shortName}.ts`)) - found = filename; + if (filename.endsWith('.ts') && !filename.includes('00-misc')) { + if (filter === undefined || filter === '' || filename.endsWith(`${filter}.ts`)) + files.push(filename); + } }); - return found; + + if (target !== undefined && files.length === 0) { + console.error(`Could not find oopsy file for ${target}`); + process.exit(1); + } + return files.sort(); }; type BNpcNameRow = { @@ -30,170 +38,306 @@ type BNpcNameRow = { const localesFromXivApi = ['de', 'fr', 'ja'] as const; const localesFromGitHub = ['cn', 'ko', 'tc'] as const; type Locale = typeof localesFromXivApi[number] | typeof localesFromGitHub[number]; +const allLocales: Locale[] = [...localesFromXivApi, ...localesFromGitHub]; -const run = async () => { - const args = process.argv.slice(2); - const target = args[0]; - - if (target === undefined) { - console.error( - 'Usage: node --loader=ts-node/esm util/gen_oopsy_timeline_replace.ts ', - ); - console.error( - 'Example: node --loader=ts-node/esm util/gen_oopsy_timeline_replace.ts sephirot-ex', - ); - return; - } +interface LocaleData { + enBnpcMap: Map; + allLocaleMaps: Map>; +} - const oopsyFile = findOopsyFile(target); - if (oopsyFile === undefined) { - console.error(`Could not find oopsy file for ${target}`); - return; - } +// Parse existing timelineReplace from file content +const parseExistingTimelineReplace = ( + content: string, +): Map> => { + const result = new Map>(); + const match = /timelineReplace:\s*\[([\s\S]*?)\],/.exec(content); + if (!match) + return result; - console.error(`Processing: ${oopsyFile}`); + const arrayContent = match[1] ?? ''; + const blockRegex = /{\s*'locale':\s*'(\w+)'[\s\S]*?'replaceSync':\s*{([\s\S]*?)}\s*,?\s*}/g; - const content = fs.readFileSync(oopsyFile, 'utf8'); + for (const block of arrayContent.matchAll(blockRegex)) { + const locale = block[1] as Locale; + const syncContent = block[2] ?? ''; + const translations = new Map(); + const kvRegex = /'([^'\\]*(?:\\.[^'\\]*)*)':\s*'([^'\\]*(?:\\.[^'\\]*)*)'/g; - // --- Extract replaceSync candidates (source: 'Name') --- - const sources = new Set(); - const sourceRegex = /source:\s*(?:['"]([^'"]+)['"]|\[((?:['"][^'"]+['"],?\s*)+)\])/g; - let sourceMatch = sourceRegex.exec(content); - while (sourceMatch !== null) { - if (sourceMatch[1] !== undefined) { - sources.add(sourceMatch[1]); - } else if (sourceMatch[2] !== undefined) { - const list = sourceMatch[2].replace(/['"]/g, '').split(',').map((s) => s.trim()); - for (const s of list) { - if (s !== '') - sources.add(s); - } + for (const kv of syncContent.matchAll(kvRegex)) { + translations.set((kv[1] ?? '').replace(/\\'/g, `'`), (kv[2] ?? '').replace(/\\'/g, `'`)); } - sourceMatch = sourceRegex.exec(content); + + if (translations.size > 0) + result.set(locale, translations); } + return result; +}; - // --- Fetch from XIVAPI (includes en, de, fr, ja) --- - console.error('Fetching BNpcName from XIVAPI...'); +const cleanName = (name: string): string => name.replace(/\[[apt]\]/g, '').trim(); + +const fetchLocaleData = async (): Promise => { + console.error('Fetching BNpcName data...'); const xivApi = new XivApi(null); - const bnpcData = await xivApi.queryApi('BNpcName', [ - 'Singular', - 'Singular@de', - 'Singular@fr', - 'Singular@ja', - ]) as BNpcNameRow[]; - - // Map Name -> IDs[] (English) + + const [bnpcData, koTable, cnTable, tcTable] = await Promise.all([ + xivApi.queryApi('BNpcName', ['Singular', ...localesFromXivApi.map((l) => `Singular@${l}`)]), + getKoTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']), + getCnTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']), + getTcTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']), + ]); + + const githubTables: { [locale: string]: Table<'#', 'Singular'> } = { + cn: cnTable, + ko: koTable, + tc: tcTable, + }; + + // English Map const enBnpcMap = new Map(); - for (const row of bnpcData) { - const id = row.row_id; - const name = row.fields.Singular; - if (typeof name === 'string' && name.trim() !== '') { - const lowerName = name.toLowerCase(); - if (!enBnpcMap.has(lowerName)) - enBnpcMap.set(lowerName, []); - enBnpcMap.get(lowerName)?.push(id); + for (const row of bnpcData as BNpcNameRow[]) { + const name = row.fields.Singular?.toLowerCase(); + if (name !== undefined && name !== '') { + const ids = enBnpcMap.get(name) ?? []; + ids.push(row.row_id); + enBnpcMap.set(name, ids); } } - // Build locale maps for XIVAPI locales (de, fr, ja) - const xivApiLocaleMaps = new Map>(); + const allLocaleMaps = new Map>(); + + // XIVAPI Locales for (const locale of localesFromXivApi) { - const fieldName = `Singular@${locale}` as keyof BNpcNameRow['fields']; - const localeMap = new Map(); - for (const row of bnpcData) { - const id = row.row_id; - const name = row.fields[fieldName]; - if (typeof name === 'string' && name.trim() !== '') { - localeMap.set(id, name); - } + const field = `Singular@${locale}` as keyof BNpcNameRow['fields']; + const map = new Map(); + for (const row of bnpcData as BNpcNameRow[]) { + const val = row.fields[field]; + if (typeof val === 'string' && val.trim() !== '') + map.set(row.row_id, cleanName(val)); } - xivApiLocaleMaps.set(locale, localeMap); + allLocaleMaps.set(locale, map); } - // Fetch GitHub locales (ko, cn, tc) - console.error('Fetching KO BNpcName from GitHub...'); - const koBnpcTable = await getKoTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']); - console.error('Fetching CN BNpcName from GitHub...'); - const cnBnpcTable = await getCnTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']); - console.error('Fetching TC BNpcName from GitHub...'); - const tcBnpcTable = await getTcTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']); - - const buildMapFromTable = (table: typeof koBnpcTable): Map => { + // GitHub Locales + localesFromGitHub.forEach((locale) => { + const table = githubTables[locale]; + if (table === undefined) + return; const map = new Map(); for (const [idStr, row] of Object.entries(table)) { - const id = parseInt(idStr); const name = row['Singular']; - if (!isNaN(id) && name !== undefined && name.trim() !== '') { - map.set(id, name); - } + if (name !== undefined && name.trim() !== '') + map.set(parseInt(idStr), cleanName(name)); } - return map; - }; + allLocaleMaps.set(locale, map); + }); - const githubLocaleMaps = new Map>([ - ['ko', buildMapFromTable(koBnpcTable)], - ['cn', buildMapFromTable(cnBnpcTable)], - ['tc', buildMapFromTable(tcBnpcTable)], - ]); + return { enBnpcMap, allLocaleMaps }; +}; - // Combine all locale maps - const allLocaleMaps = new Map>(); - for (const [locale, map] of xivApiLocaleMaps) { - allLocaleMaps.set(locale as Locale, map); +const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { + let content = fs.readFileSync(oopsyFile, 'utf8'); + + // --- Extract replaceSync candidates (source: 'Name') --- + const sources = new Set(); + const sourceRegex = /source:\s*(?:['"]([^'"]+)['"]|\[((?:['"][^'"]+['"],?\s*)+)\])/g; + for (const match of content.matchAll(sourceRegex)) { + const single = match[1]; + const list = match[2]; + if (single !== undefined) { + sources.add(single); + } else if (list !== undefined) { + list.replace(/['"]/g, '').split(',').forEach((s) => { + const trimmed = s.trim(); + if (trimmed !== '') + sources.add(trimmed); + }); + } } - for (const [locale, map] of githubLocaleMaps) { - allLocaleMaps.set(locale as Locale, map); + + if (sources.size === 0) { + return false; // No sources to translate } - // Generate replaceSync for each source - const generateReplaceSync = (localeMap: Map): { [key: string]: string } => { + const { enBnpcMap, allLocaleMaps } = localeData; + + // Parse existing translations + const existingTranslations = parseExistingTimelineReplace(content); + + // Generate replaceSync for each source with smart merge logic + // Logic table: + // | New Data | Multiple Candidates | Existing Translation | Action | + // |----------|---------------------|----------------------|--------| + // | ✓ Found | ✗ Single | - | Use new data | + // | ✓ Found | ✓ Multiple | ✓ Exists | Keep existing + console.warn() | + // | ✓ Found | ✓ Multiple | ✗ None | Output 'candidate1 / candidate2' (human review) | + // | ✗ Not found | - | - | Keep existing | + const generateReplaceSync = ( + locale: Locale, + localeMap: Map, + existingLocaleTranslations: Map | undefined, + ): { replaceSync: { [key: string]: string }; allTranslated: boolean } => { const replaceSync: { [key: string]: string } = {}; + let translatedCount = 0; + for (const source of sources) { const ids = enBnpcMap.get(source.toLowerCase()); + const existingValue = existingLocaleTranslations?.get(source); + + // Collect candidates if IDs exist + const candidates = new Set(); if (ids !== undefined) { - const candidates = new Set(); for (const id of ids) { const localeName = localeMap.get(id); if (localeName !== undefined) { candidates.add(localeName); } } - if (candidates.size > 0) { + } + + if (candidates.size === 1) { + // Case: New data Found + Single Candidate + const [firstCandidate] = Array.from(candidates); + replaceSync[source] = firstCandidate ?? ''; + translatedCount++; + } else if (candidates.size > 1) { + // Case: New data Found + Multiple Candidates + if (existingValue !== undefined) { + // Keep existing + warn + console.warn( + `[WARNING] ${oopsyFile}: Multiple candidates for '${source}' in ${locale}: ${ + Array.from(candidates).join(', ') + }`, + ); + console.warn(` Using existing translation: '${existingValue}'`); + replaceSync[source] = existingValue; + translatedCount++; + } else { + // No existing: output all candidates for human review + console.warn( + `[WARNING] ${oopsyFile}: Multiple candidates for '${source}' in ${locale}: ${ + Array.from(candidates).join(', ') + }`, + ); + console.warn(` No existing translation found. Manual review required.`); replaceSync[source] = Array.from(candidates).sort().join(' / '); + translatedCount++; } + } else if (existingValue !== undefined) { + // Case: New data Not Found (or 0 candidates) + Existing Translation exists + replaceSync[source] = existingValue; + translatedCount++; } + // Case: New data Not Found + No Existing Translation -> Skip } - return replaceSync; - }; - // --- Generate Output for ALL locales --- - const outputLines: string[] = []; - outputLines.push('['); + const allTranslated = translatedCount === sources.size; + return { replaceSync: replaceSync, allTranslated: allTranslated }; + }; - const allLocales: Locale[] = ['de', 'fr', 'ja', 'cn', 'ko', 'tc']; - for (const locale of allLocales) { - const localeMap = allLocaleMaps.get(locale); - if (localeMap === undefined) - continue; + // --- Generate timelineReplace array --- + const replaceBlocks = allLocales.map((locale: Locale): string => { + const map = allLocaleMaps.get(locale); + if (!map) + return ''; - const replaceSync = generateReplaceSync(localeMap); + const { replaceSync, allTranslated } = generateReplaceSync( + locale, + map, + existingTranslations.get(locale), + ); if (Object.keys(replaceSync).length === 0) - continue; - - outputLines.push(' {'); - outputLines.push(` 'locale': '${locale}',`); - outputLines.push(` 'missingTranslations': true,`); - outputLines.push(' \'replaceSync\': {'); - for (const [en, localized] of Object.entries(replaceSync)) { - outputLines.push(` '${en}': '${localized}',`); - } - outputLines.push(' },'); - outputLines.push(' },'); + return ''; + + const syncLines = Object.entries(replaceSync).map(([en, loc]) => { + const escapedEn = en.replace(/'/g, '\\\''); + const escapedLoc = loc.replace(/'/g, '\\\''); + return ` '${escapedEn}': '${escapedLoc}',`; + }).join('\r\n'); + + const lines = [ + ` {`, + ` 'locale': '${locale}',`, + ...(allTranslated ? [] : [` 'missingTranslations': true,`]), + ` 'replaceSync': {`, + syncLines, + ` },`, + ` },`, + ]; + return lines.join('\r\n'); + }).filter((b) => b !== ''); + + if (replaceBlocks.length === 0) + return false; + + const newBlock = `\r\n timelineReplace: [\r\n${replaceBlocks.join('\r\n')}\r\n ],`; + + // --- Insert into file --- + const replaceRegex = /(\s*)timelineReplace:\s*\[[\s\S]*?\],/; + const insertRegex = /(};\s*\n\s*export default triggerSet;)/; + + if (replaceRegex.test(content)) { + content = content.replace(replaceRegex, newBlock); + } else if (insertRegex.test(content)) { + content = content.replace(insertRegex, `${newBlock.slice(2)}\n$1`); + } else { + return false; } - outputLines.push(']'); + fs.writeFileSync(oopsyFile, content, 'utf8'); + return true; +}; + +const run = async () => { + try { + const args = process.argv.slice(2); + const target = args[0]; + + // Determine which files to process + const filesToProcess = getTargetFiles(target); + if (filesToProcess.length > 1) + console.error(`Processing ${filesToProcess.length} oopsy files...`); + + // Fetch locale data once + const localeData = await fetchLocaleData(); - console.log(outputLines.join('\n')); + let updatedCount = 0; + let skippedCount = 0; + + for (const file of filesToProcess) { + try { + const updated = processFile(file, localeData); + if (updated) { + console.error(`Updated: ${file}`); + updatedCount++; + } else { + skippedCount++; + } + } catch (err) { + console.error(`Error processing ${file}:`); + console.error(err); + } + } + + console.error(`\nDone. Updated: ${updatedCount}, Skipped: ${skippedCount}`); + } catch (err) { + console.error('Fatal initialization error:'); + if (err instanceof Error) { + console.error(err.message); + console.error(err.stack); + } else { + console.error(err); + } + process.exit(1); + } }; -run().catch((e) => console.error(e)); +run().catch((e: unknown) => { + console.error('Top-level unhandled rejection:'); + if (e instanceof Error) { + console.error(e.message); + console.error(e.stack); + } else { + console.error(e); + } + process.exit(1); +}); From 672b00a59366205bdba677ec2d7a6212aba16913 Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 17:13:30 +0900 Subject: [PATCH 09/19] i18n: auto-generated timelineReplace for oopsyraidsy --- .../data/03-hw/trial/sephirot-ex.ts | 38 ++++++++++++++++ ui/oopsyraidsy/data/04-sb/raid/o3n.ts | 8 ++-- ui/oopsyraidsy/data/05-shb/raid/e10s.ts | 18 +++++++- ui/oopsyraidsy/data/05-shb/raid/e12s.ts | 30 ++++++++++++- ui/oopsyraidsy/data/05-shb/raid/e4s.ts | 6 +++ ui/oopsyraidsy/data/05-shb/trial/hades-ex.ts | 38 ++++++++++++++++ .../05-shb/ultimate/the_epic_of_alexander.ts | 38 ++++++++++++++++ ui/oopsyraidsy/data/06-ew/dungeon/smileton.ts | 38 ++++++++++++++++ ui/oopsyraidsy/data/06-ew/raid/p11n.ts | 38 ++++++++++++++++ ui/oopsyraidsy/data/06-ew/raid/p3n.ts | 38 ++++++++++++++++ .../data/06-ew/trial/sephirot-un.ts | 38 ++++++++++++++++ .../data/06-ew/ultimate/the_omega_protocol.ts | 38 ++++++++++++++++ .../07-dt/dungeon/strayborough-deadwalk.ts | 44 +++++++++++++++++++ 13 files changed, 402 insertions(+), 8 deletions(-) diff --git a/ui/oopsyraidsy/data/03-hw/trial/sephirot-ex.ts b/ui/oopsyraidsy/data/03-hw/trial/sephirot-ex.ts index 4ddbe66253..b023e6db76 100644 --- a/ui/oopsyraidsy/data/03-hw/trial/sephirot-ex.ts +++ b/ui/oopsyraidsy/data/03-hw/trial/sephirot-ex.ts @@ -142,6 +142,44 @@ const triggerSet: OopsyTriggerSet = { }, }, ], + timelineReplace: [ + { + 'locale': 'de', + 'replaceSync': { + 'Sephirot': 'Sephirot', + }, + }, + { + 'locale': 'fr', + 'replaceSync': { + 'Sephirot': 'Sephirot', + }, + }, + { + 'locale': 'ja', + 'replaceSync': { + 'Sephirot': 'セフィロト', + }, + }, + { + 'locale': 'cn', + 'replaceSync': { + 'Sephirot': '萨菲洛特', + }, + }, + { + 'locale': 'ko', + 'replaceSync': { + 'Sephirot': '세피로트', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Sephirot': '賽菲羅特', + }, + }, + ], }; export default triggerSet; diff --git a/ui/oopsyraidsy/data/04-sb/raid/o3n.ts b/ui/oopsyraidsy/data/04-sb/raid/o3n.ts index 8369a2a883..4ccb45572a 100644 --- a/ui/oopsyraidsy/data/04-sb/raid/o3n.ts +++ b/ui/oopsyraidsy/data/04-sb/raid/o3n.ts @@ -116,15 +116,15 @@ const triggerSet: OopsyTriggerSet = { }, }, { - 'locale': 'tc', + 'locale': 'ko', 'replaceSync': { - 'Halicarnassus': '哈利卡納蘇斯', + 'Halicarnassus': '할리카르나소스', }, }, { - 'locale': 'ko', + 'locale': 'tc', 'replaceSync': { - 'Halicarnassus': '할리카르나소스', + 'Halicarnassus': '哈利卡納蘇斯', }, }, ], diff --git a/ui/oopsyraidsy/data/05-shb/raid/e10s.ts b/ui/oopsyraidsy/data/05-shb/raid/e10s.ts index 93d8feaf08..f6256227e9 100644 --- a/ui/oopsyraidsy/data/05-shb/raid/e10s.ts +++ b/ui/oopsyraidsy/data/05-shb/raid/e10s.ts @@ -102,8 +102,8 @@ const triggerSet: OopsyTriggerSet = { { 'locale': 'fr', 'replaceSync': { - 'Flameshadow': 'Flamme ombrale', - 'Shadowkeeper': 'Roi De L\'Ombre', + 'Flameshadow': 'flamme ombrale', + 'Shadowkeeper': 'roi de l\'Ombre', }, }, { @@ -120,6 +120,20 @@ const triggerSet: OopsyTriggerSet = { 'Shadowkeeper': '影之王', }, }, + { + 'locale': 'ko', + 'replaceSync': { + 'Flameshadow': '그림자 불꽃', + 'Shadowkeeper': '그림자의 왕', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Flameshadow': '影烈火', + 'Shadowkeeper': '影之王', + }, + }, ], }; diff --git a/ui/oopsyraidsy/data/05-shb/raid/e12s.ts b/ui/oopsyraidsy/data/05-shb/raid/e12s.ts index edb224c6a2..39babff1b9 100644 --- a/ui/oopsyraidsy/data/05-shb/raid/e12s.ts +++ b/ui/oopsyraidsy/data/05-shb/raid/e12s.ts @@ -487,20 +487,26 @@ const triggerSet: OopsyTriggerSet = { { 'locale': 'de', 'replaceSync': { - 'Beastly Sculpture': 'Abbild Eines Löwen', + 'Chiseled Sculpture': 'Abbild eines Mannes', + 'Ice Pillar': 'Eissäule', + 'Beastly Sculpture': 'Abbild eines Löwen', 'Regal Sculpture': 'Abbild eines großen Löwen', }, }, { 'locale': 'fr', 'replaceSync': { - 'Beastly Sculpture': 'Création Léonine', + 'Chiseled Sculpture': 'création masculine', + 'Ice Pillar': 'pilier de glace', + 'Beastly Sculpture': 'création léonine', 'Regal Sculpture': 'création léonine royale', }, }, { 'locale': 'ja', 'replaceSync': { + 'Chiseled Sculpture': '創られた男', + 'Ice Pillar': '氷柱', 'Beastly Sculpture': '創られた獅子', 'Regal Sculpture': '創られた獅子王', }, @@ -508,10 +514,30 @@ const triggerSet: OopsyTriggerSet = { { 'locale': 'cn', 'replaceSync': { + 'Chiseled Sculpture': '被创造的男性', + 'Ice Pillar': '冰柱', 'Beastly Sculpture': '被创造的狮子', 'Regal Sculpture': '被创造的狮子王', }, }, + { + 'locale': 'ko', + 'replaceSync': { + 'Chiseled Sculpture': '창조된 남자', + 'Ice Pillar': '얼음기둥', + 'Beastly Sculpture': '창조된 사자', + 'Regal Sculpture': '창조된 사자왕', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Chiseled Sculpture': '被創造的男性', + 'Ice Pillar': '冰柱', + 'Beastly Sculpture': '被創造的獅子', + 'Regal Sculpture': '被創造的獅子王', + }, + }, ], }; diff --git a/ui/oopsyraidsy/data/05-shb/raid/e4s.ts b/ui/oopsyraidsy/data/05-shb/raid/e4s.ts index c37e98fa1d..ca0709d8ff 100644 --- a/ui/oopsyraidsy/data/05-shb/raid/e4s.ts +++ b/ui/oopsyraidsy/data/05-shb/raid/e4s.ts @@ -97,6 +97,12 @@ const triggerSet: OopsyTriggerSet = { 'Titan': '타이탄', }, }, + { + 'locale': 'tc', + 'replaceSync': { + 'Titan': '泰坦', + }, + }, ], }; diff --git a/ui/oopsyraidsy/data/05-shb/trial/hades-ex.ts b/ui/oopsyraidsy/data/05-shb/trial/hades-ex.ts index d020823d26..158d338954 100644 --- a/ui/oopsyraidsy/data/05-shb/trial/hades-ex.ts +++ b/ui/oopsyraidsy/data/05-shb/trial/hades-ex.ts @@ -175,6 +175,44 @@ const triggerSet: OopsyTriggerSet = { }, }, ], + timelineReplace: [ + { + 'locale': 'de', + 'replaceSync': { + 'Shadow of the Ancients': 'Schatten der Alten', + }, + }, + { + 'locale': 'fr', + 'replaceSync': { + 'Shadow of the Ancients': 'spectre d\'Ascien', + }, + }, + { + 'locale': 'ja', + 'replaceSync': { + 'Shadow of the Ancients': '古代人の影', + }, + }, + { + 'locale': 'cn', + 'replaceSync': { + 'Shadow of the Ancients': '古代人之影', + }, + }, + { + 'locale': 'ko', + 'replaceSync': { + 'Shadow of the Ancients': '고대인의 그림자', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Shadow of the Ancients': '古代人之影', + }, + }, + ], }; export default triggerSet; diff --git a/ui/oopsyraidsy/data/05-shb/ultimate/the_epic_of_alexander.ts b/ui/oopsyraidsy/data/05-shb/ultimate/the_epic_of_alexander.ts index 5acf41036d..1865fb6411 100644 --- a/ui/oopsyraidsy/data/05-shb/ultimate/the_epic_of_alexander.ts +++ b/ui/oopsyraidsy/data/05-shb/ultimate/the_epic_of_alexander.ts @@ -192,6 +192,44 @@ const triggerSet: OopsyTriggerSet = { }, }, ], + timelineReplace: [ + { + 'locale': 'de', + 'replaceSync': { + 'Jagd Doll': 'Jagdpuppe', + }, + }, + { + 'locale': 'fr', + 'replaceSync': { + 'Jagd Doll': 'poupée jagd', + }, + }, + { + 'locale': 'ja', + 'replaceSync': { + 'Jagd Doll': 'ヤークトドール', + }, + }, + { + 'locale': 'cn', + 'replaceSync': { + 'Jagd Doll': '狩猎人偶', + }, + }, + { + 'locale': 'ko', + 'replaceSync': { + 'Jagd Doll': '인형 수렵병', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Jagd Doll': '狩獵人偶', + }, + }, + ], }; export default triggerSet; diff --git a/ui/oopsyraidsy/data/06-ew/dungeon/smileton.ts b/ui/oopsyraidsy/data/06-ew/dungeon/smileton.ts index 5cae866d86..8e2f8bab2f 100644 --- a/ui/oopsyraidsy/data/06-ew/dungeon/smileton.ts +++ b/ui/oopsyraidsy/data/06-ew/dungeon/smileton.ts @@ -64,6 +64,44 @@ const triggerSet: OopsyTriggerSet = { }, }, ], + timelineReplace: [ + { + 'locale': 'de', + 'replaceSync': { + 'Face': 'Fratze', + }, + }, + { + 'locale': 'fr', + 'replaceSync': { + 'Face': 'visage imperturbable', + }, + }, + { + 'locale': 'ja', + 'replaceSync': { + 'Face': 'フェイス', + }, + }, + { + 'locale': 'cn', + 'replaceSync': { + 'Face': '面像', + }, + }, + { + 'locale': 'ko', + 'replaceSync': { + 'Face': '얼굴', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Face': '面像', + }, + }, + ], }; export default triggerSet; diff --git a/ui/oopsyraidsy/data/06-ew/raid/p11n.ts b/ui/oopsyraidsy/data/06-ew/raid/p11n.ts index e0a1a4cf25..5842edfd30 100644 --- a/ui/oopsyraidsy/data/06-ew/raid/p11n.ts +++ b/ui/oopsyraidsy/data/06-ew/raid/p11n.ts @@ -51,6 +51,44 @@ const triggerSet: OopsyTriggerSet = { }, }, ], + timelineReplace: [ + { + 'locale': 'de', + 'replaceSync': { + 'Themis': 'Themis', + }, + }, + { + 'locale': 'fr', + 'replaceSync': { + 'Themis': 'Thémis', + }, + }, + { + 'locale': 'ja', + 'replaceSync': { + 'Themis': 'テミス', + }, + }, + { + 'locale': 'cn', + 'replaceSync': { + 'Themis': '特弥斯', + }, + }, + { + 'locale': 'ko', + 'replaceSync': { + 'Themis': '테미스', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Themis': '特彌斯', + }, + }, + ], }; export default triggerSet; diff --git a/ui/oopsyraidsy/data/06-ew/raid/p3n.ts b/ui/oopsyraidsy/data/06-ew/raid/p3n.ts index 3ec2fba753..75f8359880 100644 --- a/ui/oopsyraidsy/data/06-ew/raid/p3n.ts +++ b/ui/oopsyraidsy/data/06-ew/raid/p3n.ts @@ -59,6 +59,44 @@ const triggerSet: OopsyTriggerSet = { }, }, ], + timelineReplace: [ + { + 'locale': 'de', + 'replaceSync': { + 'Sparkfledged': 'Saat des Phoinix', + }, + }, + { + 'locale': 'fr', + 'replaceSync': { + 'Sparkfledged': 'oiselet de feu', + }, + }, + { + 'locale': 'ja', + 'replaceSync': { + 'Sparkfledged': '火霊鳥', + }, + }, + { + 'locale': 'cn', + 'replaceSync': { + 'Sparkfledged': '火灵鸟', + }, + }, + { + 'locale': 'ko', + 'replaceSync': { + 'Sparkfledged': '화령조', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Sparkfledged': '火靈鳥', + }, + }, + ], }; export default triggerSet; diff --git a/ui/oopsyraidsy/data/06-ew/trial/sephirot-un.ts b/ui/oopsyraidsy/data/06-ew/trial/sephirot-un.ts index 0cf4349848..3c5ed1db47 100644 --- a/ui/oopsyraidsy/data/06-ew/trial/sephirot-un.ts +++ b/ui/oopsyraidsy/data/06-ew/trial/sephirot-un.ts @@ -142,6 +142,44 @@ const triggerSet: OopsyTriggerSet = { }, }, ], + timelineReplace: [ + { + 'locale': 'de', + 'replaceSync': { + 'Sephirot': 'Sephirot', + }, + }, + { + 'locale': 'fr', + 'replaceSync': { + 'Sephirot': 'Sephirot', + }, + }, + { + 'locale': 'ja', + 'replaceSync': { + 'Sephirot': 'セフィロト', + }, + }, + { + 'locale': 'cn', + 'replaceSync': { + 'Sephirot': '萨菲洛特', + }, + }, + { + 'locale': 'ko', + 'replaceSync': { + 'Sephirot': '세피로트', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Sephirot': '賽菲羅特', + }, + }, + ], }; export default triggerSet; diff --git a/ui/oopsyraidsy/data/06-ew/ultimate/the_omega_protocol.ts b/ui/oopsyraidsy/data/06-ew/ultimate/the_omega_protocol.ts index 5ec475a32e..f1f4011a0d 100644 --- a/ui/oopsyraidsy/data/06-ew/ultimate/the_omega_protocol.ts +++ b/ui/oopsyraidsy/data/06-ew/ultimate/the_omega_protocol.ts @@ -1507,6 +1507,44 @@ const triggerSet: OopsyTriggerSet = { }), }, ], + timelineReplace: [ + { + 'locale': 'de', + 'replaceSync': { + 'Omega': 'Omega', + }, + }, + { + 'locale': 'fr', + 'replaceSync': { + 'Omega': 'Oméga', + }, + }, + { + 'locale': 'ja', + 'replaceSync': { + 'Omega': 'オメガ', + }, + }, + { + 'locale': 'cn', + 'replaceSync': { + 'Omega': '欧米茄', + }, + }, + { + 'locale': 'ko', + 'replaceSync': { + 'Omega': '오메가', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Omega': '歐米茄', + }, + }, + ], }; export default triggerSet; diff --git a/ui/oopsyraidsy/data/07-dt/dungeon/strayborough-deadwalk.ts b/ui/oopsyraidsy/data/07-dt/dungeon/strayborough-deadwalk.ts index 388ee45c1e..027464cd31 100644 --- a/ui/oopsyraidsy/data/07-dt/dungeon/strayborough-deadwalk.ts +++ b/ui/oopsyraidsy/data/07-dt/dungeon/strayborough-deadwalk.ts @@ -99,6 +99,50 @@ const triggerSet: OopsyTriggerSet = { }, }, ], + timelineReplace: [ + { + 'locale': 'de', + 'replaceSync': { + 'Stray Geist': 'mahrtastisch Geist', + 'Träumerei': 'Träumerei', + }, + }, + { + 'locale': 'fr', + 'replaceSync': { + 'Stray Geist': 'fantôme errant', + 'Träumerei': 'Träumerei', + }, + }, + { + 'locale': 'ja', + 'replaceSync': { + 'Stray Geist': 'ストレイ・ゴースト', + 'Träumerei': 'トロイメライ', + }, + }, + { + 'locale': 'cn', + 'replaceSync': { + 'Stray Geist': '迷途的幽灵', + 'Träumerei': '梦像', + }, + }, + { + 'locale': 'ko', + 'replaceSync': { + 'Stray Geist': '헤매는 유령', + 'Träumerei': '트로이메라이', + }, + }, + { + 'locale': 'tc', + 'replaceSync': { + 'Stray Geist': '迷途的幽靈', + 'Träumerei': '夢像', + }, + }, + ], }; export default triggerSet; From 9ade8e95192abd289f633b287e519154308f9c9a Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 17:58:12 +0900 Subject: [PATCH 10/19] util: gen_oopsy_timeline_replace.ts translates `target` data too --- util/gen_oopsy_timeline_replace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index cda736d514..fab518c837 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -139,7 +139,7 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { // --- Extract replaceSync candidates (source: 'Name') --- const sources = new Set(); - const sourceRegex = /source:\s*(?:['"]([^'"]+)['"]|\[((?:['"][^'"]+['"],?\s*)+)\])/g; + const sourceRegex = /(?:source|target):\s*(?:['"]([^'"]+)['"]|\[((?:['"][^'"]+['"],?\s*)+)\])/g; for (const match of content.matchAll(sourceRegex)) { const single = match[1]; const list = match[2]; From f1df354ee314cf87ffe6085808329b8b25b189c3 Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 18:21:07 +0900 Subject: [PATCH 11/19] util: gen_oopsy_timeline_replace.ts use ConsoleLogger --- util/gen_oopsy_timeline_replace.ts | 80 +++++++++++++++--------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index fab518c837..83df9d73cf 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -1,12 +1,17 @@ import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; +import { ConsoleLogger, LogLevelKey } from './console_logger'; import { getCnTable, getKoTable, getTcTable, type Table } from './csv_util'; import { walkDirSync } from './file_utils'; import { XivApi } from './xivapi'; const rootDir = 'ui/oopsyraidsy/data'; +const _SCRIPT_NAME = path.basename(import.meta.url); +const log = new ConsoleLogger(); + const getTargetFiles = (target?: string): string[] => { const files: string[] = []; const filter = target?.replace(/\.[jt]s$/, '').split(path.sep).join(path.posix.sep); @@ -18,10 +23,8 @@ const getTargetFiles = (target?: string): string[] => { } }); - if (target !== undefined && files.length === 0) { - console.error(`Could not find oopsy file for ${target}`); - process.exit(1); - } + if (target !== undefined && files.length === 0) + log.fatalError(`Could not find oopsy file for ${target}`); return files.sort(); }; @@ -76,8 +79,8 @@ const parseExistingTimelineReplace = ( const cleanName = (name: string): string => name.replace(/\[[apt]\]/g, '').trim(); const fetchLocaleData = async (): Promise => { - console.error('Fetching BNpcName data...'); - const xivApi = new XivApi(null); + log.info('Fetching BNpcName data...'); + const xivApi = new XivApi(null, log); const [bnpcData, koTable, cnTable, tcTable] = await Promise.all([ xivApi.queryApi('BNpcName', ['Singular', ...localesFromXivApi.map((l) => `Singular@${l}`)]), @@ -154,9 +157,8 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { } } - if (sources.size === 0) { + if (sources.size === 0) return false; // No sources to translate - } const { enBnpcMap, allLocaleMaps } = localeData; @@ -168,7 +170,7 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { // | New Data | Multiple Candidates | Existing Translation | Action | // |----------|---------------------|----------------------|--------| // | ✓ Found | ✗ Single | - | Use new data | - // | ✓ Found | ✓ Multiple | ✓ Exists | Keep existing + console.warn() | + // | ✓ Found | ✓ Multiple | ✓ Exists | Keep existing + log.alert() | // | ✓ Found | ✓ Multiple | ✗ None | Output 'candidate1 / candidate2' (human review) | // | ✗ Not found | - | - | Keep existing | const generateReplaceSync = ( @@ -203,22 +205,22 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { // Case: New data Found + Multiple Candidates if (existingValue !== undefined) { // Keep existing + warn - console.warn( - `[WARNING] ${oopsyFile}: Multiple candidates for '${source}' in ${locale}: ${ + log.alert( + `${oopsyFile}: Multiple candidates for '${source}' in ${locale}: ${ Array.from(candidates).join(', ') }`, ); - console.warn(` Using existing translation: '${existingValue}'`); + log.alert(` Using existing translation: '${existingValue}'`); replaceSync[source] = existingValue; translatedCount++; } else { // No existing: output all candidates for human review - console.warn( - `[WARNING] ${oopsyFile}: Multiple candidates for '${source}' in ${locale}: ${ + log.alert( + `${oopsyFile}: Multiple candidates for '${source}' in ${locale}: ${ Array.from(candidates).join(', ') }`, ); - console.warn(` No existing translation found. Manual review required.`); + log.alert(` No existing translation found. Manual review required.`); replaceSync[source] = Array.from(candidates).sort().join(' / '); translatedCount++; } @@ -287,15 +289,15 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { return true; }; -const run = async () => { - try { - const args = process.argv.slice(2); - const target = args[0]; +const genOopsyTimelineReplace = async (logLevel: LogLevelKey, target?: string): Promise => { + log.setLogLevel(logLevel); + log.info(`Starting processing for ${_SCRIPT_NAME}`); + try { // Determine which files to process const filesToProcess = getTargetFiles(target); if (filesToProcess.length > 1) - console.error(`Processing ${filesToProcess.length} oopsy files...`); + log.info(`Processing ${filesToProcess.length} oopsy files...`); // Fetch locale data once const localeData = await fetchLocaleData(); @@ -307,37 +309,37 @@ const run = async () => { try { const updated = processFile(file, localeData); if (updated) { - console.error(`Updated: ${file}`); + log.info(`Updated: ${file}`); updatedCount++; } else { skippedCount++; } } catch (err) { - console.error(`Error processing ${file}:`); - console.error(err); + log.nonFatalError(`Error processing ${file}:`); + if (err instanceof Error) { + log.printNoHeader(err.message); + log.debug(err.stack ?? ''); + } else { + log.printNoHeader(String(err)); + } } } - console.error(`\nDone. Updated: ${updatedCount}, Skipped: ${skippedCount}`); + log.successDone(`Updated: ${updatedCount}, Skipped: ${skippedCount}`); } catch (err) { - console.error('Fatal initialization error:'); if (err instanceof Error) { - console.error(err.message); - console.error(err.stack); + log.fatalError(`Fatal initialization error: ${err.message}\n${err.stack ?? ''}`); } else { - console.error(err); + log.fatalError(`Fatal initialization error: ${String(err)}`); } - process.exit(1); } }; -run().catch((e: unknown) => { - console.error('Top-level unhandled rejection:'); - if (e instanceof Error) { - console.error(e.message); - console.error(e.stack); - } else { - console.error(e); - } - process.exit(1); -}); +export default genOopsyTimelineReplace; + +if ( + process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const args = process.argv.slice(2); + void genOopsyTimelineReplace('alert', args[0]); +} From 275458f489398813fe9ec0f55cc768d2336239bb Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 20:03:00 +0900 Subject: [PATCH 12/19] util: gen_oopsy_timeline_replace.ts use regex for multiple candidates --- util/gen_oopsy_timeline_replace.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index 83df9d73cf..c26677c82a 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -171,7 +171,7 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { // |----------|---------------------|----------------------|--------| // | ✓ Found | ✗ Single | - | Use new data | // | ✓ Found | ✓ Multiple | ✓ Exists | Keep existing + log.alert() | - // | ✓ Found | ✓ Multiple | ✗ None | Output 'candidate1 / candidate2' (human review) | + // | ✓ Found | ✓ Multiple | ✗ None | Output '(?:c1|c2)' (human review) | // | ✗ Not found | - | - | Keep existing | const generateReplaceSync = ( locale: Locale, @@ -221,7 +221,7 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { }`, ); log.alert(` No existing translation found. Manual review required.`); - replaceSync[source] = Array.from(candidates).sort().join(' / '); + replaceSync[source] = `(?:${Array.from(candidates).sort().join('|')})`; translatedCount++; } } else if (existingValue !== undefined) { From a4829ed2e7821889c7b59db7c1fd03b769a5e14b Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 20:19:45 +0900 Subject: [PATCH 13/19] util: gen_oopsy_timeline_replace.ts fix German names --- util/gen_oopsy_timeline_replace.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index c26677c82a..860e530a0e 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -76,7 +76,13 @@ const parseExistingTimelineReplace = ( return result; }; -const cleanName = (name: string): string => name.replace(/\[[apt]\]/g, '').trim(); +const replaceGermanGrammarTags = (name: string): string => { + return name.replace(/\[t\]/g, '(?:der|die|das)') + .replace(/\[a\]/g, '(?:e|er|es|en)') + .replace(/\[A\]/g, '(?:e|er|es|en)') + .replace(/\[p\]/g, '') + .trim(); +}; const fetchLocaleData = async (): Promise => { log.info('Fetching BNpcName data...'); @@ -114,8 +120,10 @@ const fetchLocaleData = async (): Promise => { const map = new Map(); for (const row of bnpcData as BNpcNameRow[]) { const val = row.fields[field]; - if (typeof val === 'string' && val.trim() !== '') - map.set(row.row_id, cleanName(val)); + if (typeof val === 'string' && val.trim() !== '') { + const name = locale === 'de' ? replaceGermanGrammarTags(val) : val.trim(); + map.set(row.row_id, name); + } } allLocaleMaps.set(locale, map); } @@ -129,7 +137,7 @@ const fetchLocaleData = async (): Promise => { for (const [idStr, row] of Object.entries(table)) { const name = row['Singular']; if (name !== undefined && name.trim() !== '') - map.set(parseInt(idStr), cleanName(name)); + map.set(parseInt(idStr), name.trim()); } allLocaleMaps.set(locale, map); }); From 226683b0efeb13071e3396e631e57ad4b7fb2cf2 Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 20:24:59 +0900 Subject: [PATCH 14/19] i18n: fix German translation --- ui/oopsyraidsy/data/07-dt/dungeon/strayborough-deadwalk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/oopsyraidsy/data/07-dt/dungeon/strayborough-deadwalk.ts b/ui/oopsyraidsy/data/07-dt/dungeon/strayborough-deadwalk.ts index 027464cd31..91480d9971 100644 --- a/ui/oopsyraidsy/data/07-dt/dungeon/strayborough-deadwalk.ts +++ b/ui/oopsyraidsy/data/07-dt/dungeon/strayborough-deadwalk.ts @@ -103,7 +103,7 @@ const triggerSet: OopsyTriggerSet = { { 'locale': 'de', 'replaceSync': { - 'Stray Geist': 'mahrtastisch Geist', + 'Stray Geist': 'mahrtastisch(?:e|er|es|en) Geist', 'Träumerei': 'Träumerei', }, }, From b486b541ae2daa62c23e8cf306106c868a88eabe Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 21:07:09 +0900 Subject: [PATCH 15/19] fix lint --- util/gen_oopsy_timeline_replace.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index 860e530a0e..a532fcc0e7 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -48,6 +48,11 @@ interface LocaleData { allLocaleMaps: Map>; } +type ReplaceSyncResult = { + replaceSync: { [key: string]: string }; + allTranslated: boolean; +}; + // Parse existing timelineReplace from file content const parseExistingTimelineReplace = ( content: string, @@ -181,11 +186,15 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { // | ✓ Found | ✓ Multiple | ✓ Exists | Keep existing + log.alert() | // | ✓ Found | ✓ Multiple | ✗ None | Output '(?:c1|c2)' (human review) | // | ✗ Not found | - | - | Keep existing | - const generateReplaceSync = ( + const generateReplaceSync: ( locale: Locale, localeMap: Map, existingLocaleTranslations: Map | undefined, - ): { replaceSync: { [key: string]: string }; allTranslated: boolean } => { + ) => ReplaceSyncResult = ( + locale, + localeMap, + existingLocaleTranslations, + ) => { const replaceSync: { [key: string]: string } = {}; let translatedCount = 0; @@ -258,7 +267,7 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { if (Object.keys(replaceSync).length === 0) return ''; - const syncLines = Object.entries(replaceSync).map(([en, loc]) => { + const syncLines = Object.entries(replaceSync).map(([en, loc]: [string, string]) => { const escapedEn = en.replace(/'/g, '\\\''); const escapedLoc = loc.replace(/'/g, '\\\''); return ` '${escapedEn}': '${escapedLoc}',`; From b4fb88c704247401a43faab4b0d5f68a8e369336 Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 21:14:39 +0900 Subject: [PATCH 16/19] util: gen_oopsy_timeline_replace.ts logs missing translations --- util/gen_oopsy_timeline_replace.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index a532fcc0e7..a366e4a916 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -245,8 +245,10 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { // Case: New data Not Found (or 0 candidates) + Existing Translation exists replaceSync[source] = existingValue; translatedCount++; + } else { + // Case: New data Not Found + No Existing Translation -> Skip + log.alert(`${oopsyFile}: Missing translation for '${source}' in ${locale}`); } - // Case: New data Not Found + No Existing Translation -> Skip } const allTranslated = translatedCount === sources.size; From 34851b9a4041bcbe12673167575ef41f29e69a7f Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 21:35:28 +0900 Subject: [PATCH 17/19] refactor: convert hardcoded regex fields to configurable array --- util/gen_oopsy_timeline_replace.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index a366e4a916..f09fb8f13a 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -155,7 +155,11 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { // --- Extract replaceSync candidates (source: 'Name') --- const sources = new Set(); - const sourceRegex = /(?:source|target):\s*(?:['"]([^'"]+)['"]|\[((?:['"][^'"]+['"],?\s*)+)\])/g; + const syncFieldNames = ['source', 'target']; + const sourceRegex = new RegExp( + `(?:${syncFieldNames.join('|')}):\\s*(?:['"]([^'"]+)['"]|\\[((?:['"][^'"]+['"],?\\s*)+)\\])`, + 'g', + ); for (const match of content.matchAll(sourceRegex)) { const single = match[1]; const list = match[2]; From 6d5ab8a3904dc46a5af23ac64a9a0559d4204959 Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sun, 8 Feb 2026 23:31:49 +0900 Subject: [PATCH 18/19] util: gen_oopsy_timeline_replace.ts works case-insensitive --- util/gen_oopsy_timeline_replace.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index f09fb8f13a..7c53e45f5e 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -174,7 +174,17 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { } } - if (sources.size === 0) + // Deduplicate sources by case-insensitive comparison (keep first alphabetically) + const seenSources = new Map(); + for (const s of sources) { + const lower = s.toLowerCase(); + if (!seenSources.has(lower) || s < (seenSources.get(lower) ?? '')) { + seenSources.set(lower, s); + } + } + const uniqueSources = new Set(seenSources.values()); + + if (uniqueSources.size === 0) return false; // No sources to translate const { enBnpcMap, allLocaleMaps } = localeData; @@ -202,7 +212,7 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { const replaceSync: { [key: string]: string } = {}; let translatedCount = 0; - for (const source of sources) { + for (const source of uniqueSources) { const ids = enBnpcMap.get(source.toLowerCase()); const existingValue = existingLocaleTranslations?.get(source); @@ -255,7 +265,7 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { } } - const allTranslated = translatedCount === sources.size; + const allTranslated = translatedCount === uniqueSources.size; return { replaceSync: replaceSync, allTranslated: allTranslated }; }; From 4ca83b3fba3a674f23dfb50b74a3721251e53bcf Mon Sep 17 00:00:00 2001 From: Jaehyuk-Lee Date: Sat, 21 Feb 2026 16:32:22 +0900 Subject: [PATCH 19/19] refactor: improve readability --- util/gen_oopsy_timeline_replace.ts | 339 ++++++++++++++++------------- 1 file changed, 190 insertions(+), 149 deletions(-) diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts index 7c53e45f5e..b1d11ab0ca 100644 --- a/util/gen_oopsy_timeline_replace.ts +++ b/util/gen_oopsy_timeline_replace.ts @@ -8,25 +8,10 @@ import { walkDirSync } from './file_utils'; import { XivApi } from './xivapi'; const rootDir = 'ui/oopsyraidsy/data'; - const _SCRIPT_NAME = path.basename(import.meta.url); const log = new ConsoleLogger(); -const getTargetFiles = (target?: string): string[] => { - const files: string[] = []; - const filter = target?.replace(/\.[jt]s$/, '').split(path.sep).join(path.posix.sep); - - walkDirSync(rootDir, (filename) => { - if (filename.endsWith('.ts') && !filename.includes('00-misc')) { - if (filter === undefined || filter === '' || filename.endsWith(`${filter}.ts`)) - files.push(filename); - } - }); - - if (target !== undefined && files.length === 0) - log.fatalError(`Could not find oopsy file for ${target}`); - return files.sort(); -}; +// --- Types --- type BNpcNameRow = { row_id: number; @@ -53,7 +38,39 @@ type ReplaceSyncResult = { allTranslated: boolean; }; -// Parse existing timelineReplace from file content +type RawApiData = { + bnpcData: unknown; + koTable: Table<'#', 'Singular'>; + cnTable: Table<'#', 'Singular'>; + tcTable: Table<'#', 'Singular'>; +}; + +// --- File & String Utilities --- + +const getTargetFiles = (target?: string): string[] => { + const files: string[] = []; + const filter = target?.replace(/\.[jt]s$/, '').split(path.sep).join(path.posix.sep); + + walkDirSync(rootDir, (filename) => { + if (filename.endsWith('.ts') && !filename.includes('00-misc')) { + if (filter === undefined || filter === '' || filename.endsWith(`${filter}.ts`)) + files.push(filename); + } + }); + + if (target !== undefined && files.length === 0) + log.fatalError(`Could not find oopsy file for ${target}`); + return files.sort(); +}; + +const replaceGermanGrammarTags = (name: string): string => { + return name.replace(/\[t\]/g, '(?:der|die|das)') + .replace(/\[a\]/g, '(?:e|er|es|en)') + .replace(/\[A\]/g, '(?:e|er|es|en)') + .replace(/\[p\]/g, '') + .trim(); +}; + const parseExistingTimelineReplace = ( content: string, ): Map> => { @@ -81,15 +98,42 @@ const parseExistingTimelineReplace = ( return result; }; -const replaceGermanGrammarTags = (name: string): string => { - return name.replace(/\[t\]/g, '(?:der|die|das)') - .replace(/\[a\]/g, '(?:e|er|es|en)') - .replace(/\[A\]/g, '(?:e|er|es|en)') - .replace(/\[p\]/g, '') - .trim(); +const extractEnglishSources = (content: string): Set => { + const sources = new Set(); + const syncFieldNames = ['source', 'target']; + const sourceRegex = new RegExp( + `(?:${syncFieldNames.join('|')}):\\s*(?:['"]([^'"]+)['"]|\\[((?:['"][^'"]+['"],?\\s*)+)\\])`, + 'g', + ); + + for (const match of content.matchAll(sourceRegex)) { + const single = match[1]; + const list = match[2]; + if (single !== undefined) { + sources.add(single); + } else if (list !== undefined) { + list.replace(/['"]/g, '').split(',').forEach((s) => { + const trimmed = s.trim(); + if (trimmed !== '') + sources.add(trimmed); + }); + } + } + + // Deduplicate sources by case-insensitive comparison (keep first alphabetically) + const seenSources = new Map(); + for (const s of sources) { + const lower = s.toLowerCase(); + if (!seenSources.has(lower) || s < (seenSources.get(lower) ?? '')) { + seenSources.set(lower, s); + } + } + return new Set(seenSources.values()); }; -const fetchLocaleData = async (): Promise => { +// --- Data Fetching & Processing --- + +const fetchRawData = async (): Promise => { log.info('Fetching BNpcName data...'); const xivApi = new XivApi(null, log); @@ -100,13 +144,17 @@ const fetchLocaleData = async (): Promise => { getTcTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']), ]); + return { bnpcData, koTable, cnTable, tcTable }; +}; + +const buildLocaleMaps = (rawData: RawApiData): LocaleData => { + const { bnpcData, koTable, cnTable, tcTable } = rawData; const githubTables: { [locale: string]: Table<'#', 'Singular'> } = { cn: cnTable, ko: koTable, tc: tcTable, }; - // English Map const enBnpcMap = new Map(); for (const row of bnpcData as BNpcNameRow[]) { const name = row.fields.Singular?.toLowerCase(); @@ -119,7 +167,6 @@ const fetchLocaleData = async (): Promise => { const allLocaleMaps = new Map>(); - // XIVAPI Locales for (const locale of localesFromXivApi) { const field = `Singular@${locale}` as keyof BNpcNameRow['fields']; const map = new Map(); @@ -133,7 +180,6 @@ const fetchLocaleData = async (): Promise => { allLocaleMaps.set(locale, map); } - // GitHub Locales localesFromGitHub.forEach((locale) => { const table = githubTables[locale]; if (table === undefined) @@ -150,136 +196,111 @@ const fetchLocaleData = async (): Promise => { return { enBnpcMap, allLocaleMaps }; }; -const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { - let content = fs.readFileSync(oopsyFile, 'utf8'); +const fetchLocaleData = async (): Promise => { + const rawData = await fetchRawData(); + return buildLocaleMaps(rawData); +}; - // --- Extract replaceSync candidates (source: 'Name') --- - const sources = new Set(); - const syncFieldNames = ['source', 'target']; - const sourceRegex = new RegExp( - `(?:${syncFieldNames.join('|')}):\\s*(?:['"]([^'"]+)['"]|\\[((?:['"][^'"]+['"],?\\s*)+)\\])`, - 'g', - ); - for (const match of content.matchAll(sourceRegex)) { - const single = match[1]; - const list = match[2]; - if (single !== undefined) { - sources.add(single); - } else if (list !== undefined) { - list.replace(/['"]/g, '').split(',').forEach((s) => { - const trimmed = s.trim(); - if (trimmed !== '') - sources.add(trimmed); - }); +// --- Generation Logic --- + +const getCandidates = ( + source: string, + enBnpcMap: Map, + localeMap: Map, +): Set => { + const candidates = new Set(); + const ids = enBnpcMap.get(source.toLowerCase()); + + if (ids !== undefined) { + for (const id of ids) { + const localeName = localeMap.get(id); + if (localeName !== undefined) { + candidates.add(localeName); + } } } + return candidates; +}; - // Deduplicate sources by case-insensitive comparison (keep first alphabetically) - const seenSources = new Map(); - for (const s of sources) { - const lower = s.toLowerCase(); - if (!seenSources.has(lower) || s < (seenSources.get(lower) ?? '')) { - seenSources.set(lower, s); - } +const resolveTranslation = ( + source: string, + locale: Locale, + candidates: Set, + existingValue: string | undefined, + oopsyFile: string, +): string | undefined => { + if (candidates.size === 1) { + const [firstCandidate] = Array.from(candidates); + return firstCandidate ?? ''; } - const uniqueSources = new Set(seenSources.values()); - if (uniqueSources.size === 0) - return false; // No sources to translate + if (candidates.size > 1) { + log.alert( + `${oopsyFile}: Multiple candidates for '${source}' in ${locale}: ${ + Array.from(candidates).join(', ') + }`, + ); + if (existingValue !== undefined) { + log.alert(` Using existing translation: '${existingValue}'`); + return existingValue; + } + log.alert(` No existing translation found. Manual review required.`); + return `(?:${Array.from(candidates).sort().join('|')})`; + } - const { enBnpcMap, allLocaleMaps } = localeData; + if (existingValue !== undefined) { + return existingValue; + } - // Parse existing translations - const existingTranslations = parseExistingTimelineReplace(content); + log.alert(`${oopsyFile}: Missing translation for '${source}' in ${locale}`); + return undefined; +}; - // Generate replaceSync for each source with smart merge logic - // Logic table: - // | New Data | Multiple Candidates | Existing Translation | Action | - // |----------|---------------------|----------------------|--------| - // | ✓ Found | ✗ Single | - | Use new data | - // | ✓ Found | ✓ Multiple | ✓ Exists | Keep existing + log.alert() | - // | ✓ Found | ✓ Multiple | ✗ None | Output '(?:c1|c2)' (human review) | - // | ✗ Not found | - | - | Keep existing | - const generateReplaceSync: ( - locale: Locale, - localeMap: Map, - existingLocaleTranslations: Map | undefined, - ) => ReplaceSyncResult = ( - locale, - localeMap, - existingLocaleTranslations, - ) => { - const replaceSync: { [key: string]: string } = {}; - let translatedCount = 0; - - for (const source of uniqueSources) { - const ids = enBnpcMap.get(source.toLowerCase()); - const existingValue = existingLocaleTranslations?.get(source); - - // Collect candidates if IDs exist - const candidates = new Set(); - if (ids !== undefined) { - for (const id of ids) { - const localeName = localeMap.get(id); - if (localeName !== undefined) { - candidates.add(localeName); - } - } - } +const generateReplaceSync = ( + locale: Locale, + uniqueSources: Set, + localeData: LocaleData, + existingLocaleTranslations: Map | undefined, + oopsyFile: string, +): ReplaceSyncResult => { + const replaceSync: { [key: string]: string } = {}; + let translatedCount = 0; - if (candidates.size === 1) { - // Case: New data Found + Single Candidate - const [firstCandidate] = Array.from(candidates); - replaceSync[source] = firstCandidate ?? ''; - translatedCount++; - } else if (candidates.size > 1) { - // Case: New data Found + Multiple Candidates - if (existingValue !== undefined) { - // Keep existing + warn - log.alert( - `${oopsyFile}: Multiple candidates for '${source}' in ${locale}: ${ - Array.from(candidates).join(', ') - }`, - ); - log.alert(` Using existing translation: '${existingValue}'`); - replaceSync[source] = existingValue; - translatedCount++; - } else { - // No existing: output all candidates for human review - log.alert( - `${oopsyFile}: Multiple candidates for '${source}' in ${locale}: ${ - Array.from(candidates).join(', ') - }`, - ); - log.alert(` No existing translation found. Manual review required.`); - replaceSync[source] = `(?:${Array.from(candidates).sort().join('|')})`; - translatedCount++; - } - } else if (existingValue !== undefined) { - // Case: New data Not Found (or 0 candidates) + Existing Translation exists - replaceSync[source] = existingValue; - translatedCount++; - } else { - // Case: New data Not Found + No Existing Translation -> Skip - log.alert(`${oopsyFile}: Missing translation for '${source}' in ${locale}`); - } + const { enBnpcMap, allLocaleMaps } = localeData; + const localeMap = allLocaleMaps.get(locale); + if (!localeMap) + return { replaceSync: replaceSync, allTranslated: false }; + + for (const source of uniqueSources) { + const existingValue = existingLocaleTranslations?.get(source); + const candidates = getCandidates(source, enBnpcMap, localeMap); + const resolvedValue = resolveTranslation(source, locale, candidates, existingValue, oopsyFile); + + if (resolvedValue !== undefined) { + replaceSync[source] = resolvedValue; + translatedCount++; } + } - const allTranslated = translatedCount === uniqueSources.size; - return { replaceSync: replaceSync, allTranslated: allTranslated }; - }; - - // --- Generate timelineReplace array --- - const replaceBlocks = allLocales.map((locale: Locale): string => { - const map = allLocaleMaps.get(locale); - if (!map) - return ''; + const allTranslated = translatedCount === uniqueSources.size; + return { replaceSync, allTranslated }; +}; +const generateReplaceBlocks = ( + uniqueSources: Set, + localeData: LocaleData, + existingTranslations: Map>, + oopsyFile: string, +): string[] => { + return allLocales.map((locale: Locale): string => { const { replaceSync, allTranslated } = generateReplaceSync( locale, - map, + uniqueSources, + localeData, existingTranslations.get(locale), + oopsyFile, ); + if (Object.keys(replaceSync).length === 0) return ''; @@ -300,25 +321,47 @@ const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { ]; return lines.join('\r\n'); }).filter((b) => b !== ''); +}; +const insertNewBlocks = (content: string, replaceBlocks: string[]): string | false => { if (replaceBlocks.length === 0) return false; const newBlock = `\r\n timelineReplace: [\r\n${replaceBlocks.join('\r\n')}\r\n ],`; - - // --- Insert into file --- const replaceRegex = /(\s*)timelineReplace:\s*\[[\s\S]*?\],/; const insertRegex = /(};\s*\n\s*export default triggerSet;)/; if (replaceRegex.test(content)) { - content = content.replace(replaceRegex, newBlock); + return content.replace(replaceRegex, newBlock); } else if (insertRegex.test(content)) { - content = content.replace(insertRegex, `${newBlock.slice(2)}\n$1`); - } else { - return false; + return content.replace(insertRegex, `${newBlock.slice(2)}\n$1`); } + return false; +}; + +// --- Main Processing --- + +const processFile = (oopsyFile: string, localeData: LocaleData): boolean => { + const content = fs.readFileSync(oopsyFile, 'utf8'); + + const uniqueSources = extractEnglishSources(content); + if (uniqueSources.size === 0) + return false; + + const existingTranslations = parseExistingTimelineReplace(content); + + const replaceBlocks = generateReplaceBlocks( + uniqueSources, + localeData, + existingTranslations, + oopsyFile, + ); + + const updatedContent = insertNewBlocks(content, replaceBlocks); + if (updatedContent === false) + return false; - fs.writeFileSync(oopsyFile, content, 'utf8'); + fs.writeFileSync(oopsyFile, updatedContent, 'utf8'); return true; }; @@ -327,12 +370,10 @@ const genOopsyTimelineReplace = async (logLevel: LogLevelKey, target?: string): log.info(`Starting processing for ${_SCRIPT_NAME}`); try { - // Determine which files to process const filesToProcess = getTargetFiles(target); if (filesToProcess.length > 1) log.info(`Processing ${filesToProcess.length} oopsy files...`); - // Fetch locale data once const localeData = await fetchLocaleData(); let updatedCount = 0;