diff --git a/ui/oopsyraidsy/data/03-hw/trial/sephirot-ex.ts b/ui/oopsyraidsy/data/03-hw/trial/sephirot-ex.ts index 4ddbe662530..b023e6db76a 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 8369a2a883c..4ccb45572ac 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 93d8feaf08c..f6256227e99 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 edb224c6a23..39babff1b90 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 c37e98fa1d9..ca0709d8ff5 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 d020823d26f..158d3389547 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 5acf41036d5..1865fb6411b 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 5cae866d86c..8e2f8bab2fd 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 e0a1a4cf25e..5842edfd30f 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 3ec2fba7534..75f83598806 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 0cf4349848c..3c5ed1db473 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 5ec475a32e2..f1f4011a0d1 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 388ee45c1ed..91480d99710 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(?:e|er|es|en) 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; diff --git a/util/gen_oopsy_timeline_replace.ts b/util/gen_oopsy_timeline_replace.ts new file mode 100644 index 00000000000..b1d11ab0cae --- /dev/null +++ b/util/gen_oopsy_timeline_replace.ts @@ -0,0 +1,419 @@ +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(); + +// --- Types --- + +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 allLocales: Locale[] = [...localesFromXivApi, ...localesFromGitHub]; + +interface LocaleData { + enBnpcMap: Map; + allLocaleMaps: Map>; +} + +type ReplaceSyncResult = { + replaceSync: { [key: string]: string }; + allTranslated: boolean; +}; + +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> => { + const result = new Map>(); + const match = /timelineReplace:\s*\[([\s\S]*?)\],/.exec(content); + if (!match) + return result; + + const arrayContent = match[1] ?? ''; + const blockRegex = /{\s*'locale':\s*'(\w+)'[\s\S]*?'replaceSync':\s*{([\s\S]*?)}\s*,?\s*}/g; + + 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; + + for (const kv of syncContent.matchAll(kvRegex)) { + translations.set((kv[1] ?? '').replace(/\\'/g, `'`), (kv[2] ?? '').replace(/\\'/g, `'`)); + } + + if (translations.size > 0) + result.set(locale, translations); + } + return result; +}; + +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()); +}; + +// --- Data Fetching & Processing --- + +const fetchRawData = async (): Promise => { + 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}`)]), + getKoTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']), + getCnTable('BNpcName', ['#', 'Singular'], ['#', 'Singular']), + 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, + }; + + const enBnpcMap = new Map(); + 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); + } + } + + const allLocaleMaps = new Map>(); + + for (const locale of localesFromXivApi) { + 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() !== '') { + const name = locale === 'de' ? replaceGermanGrammarTags(val) : val.trim(); + map.set(row.row_id, name); + } + } + allLocaleMaps.set(locale, map); + } + + localesFromGitHub.forEach((locale) => { + const table = githubTables[locale]; + if (table === undefined) + return; + const map = new Map(); + for (const [idStr, row] of Object.entries(table)) { + const name = row['Singular']; + if (name !== undefined && name.trim() !== '') + map.set(parseInt(idStr), name.trim()); + } + allLocaleMaps.set(locale, map); + }); + + return { enBnpcMap, allLocaleMaps }; +}; + +const fetchLocaleData = async (): Promise => { + const rawData = await fetchRawData(); + return buildLocaleMaps(rawData); +}; + +// --- 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; +}; + +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 ?? ''; + } + + 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('|')})`; + } + + if (existingValue !== undefined) { + return existingValue; + } + + log.alert(`${oopsyFile}: Missing translation for '${source}' in ${locale}`); + return undefined; +}; + +const generateReplaceSync = ( + locale: Locale, + uniqueSources: Set, + localeData: LocaleData, + existingLocaleTranslations: Map | undefined, + oopsyFile: string, +): ReplaceSyncResult => { + const replaceSync: { [key: string]: string } = {}; + let translatedCount = 0; + + 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, allTranslated }; +}; + +const generateReplaceBlocks = ( + uniqueSources: Set, + localeData: LocaleData, + existingTranslations: Map>, + oopsyFile: string, +): string[] => { + return allLocales.map((locale: Locale): string => { + const { replaceSync, allTranslated } = generateReplaceSync( + locale, + uniqueSources, + localeData, + existingTranslations.get(locale), + oopsyFile, + ); + + if (Object.keys(replaceSync).length === 0) + return ''; + + const syncLines = Object.entries(replaceSync).map(([en, loc]: [string, string]) => { + 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 !== ''); +}; + +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 ],`; + const replaceRegex = /(\s*)timelineReplace:\s*\[[\s\S]*?\],/; + const insertRegex = /(};\s*\n\s*export default triggerSet;)/; + + if (replaceRegex.test(content)) { + return content.replace(replaceRegex, newBlock); + } else if (insertRegex.test(content)) { + 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, updatedContent, 'utf8'); + return true; +}; + +const genOopsyTimelineReplace = async (logLevel: LogLevelKey, target?: string): Promise => { + log.setLogLevel(logLevel); + log.info(`Starting processing for ${_SCRIPT_NAME}`); + + try { + const filesToProcess = getTargetFiles(target); + if (filesToProcess.length > 1) + log.info(`Processing ${filesToProcess.length} oopsy files...`); + + const localeData = await fetchLocaleData(); + + let updatedCount = 0; + let skippedCount = 0; + + for (const file of filesToProcess) { + try { + const updated = processFile(file, localeData); + if (updated) { + log.info(`Updated: ${file}`); + updatedCount++; + } else { + skippedCount++; + } + } catch (err) { + log.nonFatalError(`Error processing ${file}:`); + if (err instanceof Error) { + log.printNoHeader(err.message); + log.debug(err.stack ?? ''); + } else { + log.printNoHeader(String(err)); + } + } + } + + log.successDone(`Updated: ${updatedCount}, Skipped: ${skippedCount}`); + } catch (err) { + if (err instanceof Error) { + log.fatalError(`Fatal initialization error: ${err.message}\n${err.stack ?? ''}`); + } else { + log.fatalError(`Fatal initialization error: ${String(err)}`); + } + } +}; + +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]); +}