Tools: wiederverwendbares Skript zum Items-Reuse für Actor-Pakete + Liste der 757 noch unübersetzten Enemy-Within-Items
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
// Reuse-only actor translator: fills in an Actors pack's "items" (Skills/Talents/Traits/
|
||||
// Trappings) from whatever is already translated elsewhere in compendium/, and leaves
|
||||
// everything else (NSC name, description/gmnotes, effects) exactly as in the English
|
||||
// source. Items with no existing translation anywhere in the corpus are left in English
|
||||
// too - this script never invents new translations, it only splices in prior work.
|
||||
//
|
||||
// Usage:
|
||||
// node tools/translate_actor_items_from_corpus.js <file.actors.json> "<German label>"
|
||||
// node tools/translate_actor_items_from_corpus.js --list-untranslated <file1.actors.json> [file2 ...]
|
||||
//
|
||||
// Examples:
|
||||
// node tools/translate_actor_items_from_corpus.js wfrp4e-pbtt.actors.json "Akteure (Power Behind the Throne)"
|
||||
// node tools/translate_actor_items_from_corpus.js --list-untranslated wfrp4e-pbtt.actors.json wfrp4e-dotr.actors.json
|
||||
//
|
||||
// Run from anywhere; paths are resolved relative to the repo root (parent of tools/).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO = path.join(__dirname, '..');
|
||||
const COMPENDIUM = path.join(REPO, 'compendium');
|
||||
const TODO = path.join(COMPENDIUM, 'To Do');
|
||||
|
||||
// Actor packs that were translated in "items-only reuse" mode: they still contain
|
||||
// English-language leftovers under their original keys/names for items with no
|
||||
// existing translation anywhere else. They must NEVER be trusted as a lookup source
|
||||
// (their own untranslated leftovers would otherwise be mistaken for "already translated").
|
||||
const PARTIAL_PACKS = new Set([
|
||||
'wfrp4e-pbtt.actors.json',
|
||||
'wfrp4e-dotr.actors.json',
|
||||
'wfrp4e-horned-rat.actors.json',
|
||||
'wfrp4e-empire-ruins.actors.json'
|
||||
]);
|
||||
|
||||
function buildLookups(extraExclude = []) {
|
||||
const exclude = new Set([...PARTIAL_PACKS, ...extraExclude]);
|
||||
const itemsLookup = {}; // key -> translated item, from already-translated *.items.json packs
|
||||
const actorLookup = {}; // key -> translated item, from nested items inside already-translated *.actors.json/*.bestiary.json
|
||||
const byName = {}; // English name -> translated item (fallback for actor-embedded items with random Foundry IDs as keys)
|
||||
|
||||
for (const f of fs.readdirSync(COMPENDIUM)) {
|
||||
if (exclude.has(f)) continue;
|
||||
const full = path.join(COMPENDIUM, f);
|
||||
if (!fs.statSync(full).isFile() || !f.endsWith('.json')) continue;
|
||||
let data;
|
||||
try { data = JSON.parse(fs.readFileSync(full, 'utf8')); } catch { continue; }
|
||||
if (!data.entries) continue;
|
||||
|
||||
if (f.endsWith('.items.json')) {
|
||||
for (const [k, v] of Object.entries(data.entries)) {
|
||||
if (!itemsLookup[k]) itemsLookup[k] = v;
|
||||
}
|
||||
} else if (f.endsWith('.actors.json') || f.endsWith('.bestiary.json')) {
|
||||
for (const actor of Object.values(data.entries)) {
|
||||
for (const [k, v] of Object.entries(actor.items || {})) {
|
||||
if (!actorLookup[k]) actorLookup[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(itemsLookup)) if (!byName[k]) byName[k] = v;
|
||||
for (const [k, v] of Object.entries(actorLookup)) if (!byName[k]) byName[k] = v;
|
||||
|
||||
return { itemsLookup, actorLookup, byName };
|
||||
}
|
||||
|
||||
function translateItems(items, lookups) {
|
||||
const { itemsLookup, actorLookup, byName } = lookups;
|
||||
const out = {};
|
||||
let translated = 0;
|
||||
for (const [k, v] of Object.entries(items)) {
|
||||
if (itemsLookup[k]) { out[k] = JSON.parse(JSON.stringify(itemsLookup[k])); translated++; }
|
||||
else if (actorLookup[k]) { out[k] = JSON.parse(JSON.stringify(actorLookup[k])); translated++; }
|
||||
else if (byName[v.name]) { out[k] = JSON.parse(JSON.stringify(byName[v.name])); translated++; }
|
||||
else { out[k] = v; } // leave untranslated (English original) - never invent new translations
|
||||
}
|
||||
return { out, translated };
|
||||
}
|
||||
|
||||
function runTranslate(srcFile, labelDE) {
|
||||
const lookups = buildLookups();
|
||||
const src = JSON.parse(fs.readFileSync(path.join(TODO, srcFile), 'utf8'));
|
||||
|
||||
const out = { label: labelDE, entries: {} };
|
||||
if (src.folders) out.folders = src.folders;
|
||||
|
||||
let totalItems = 0, translatedItems = 0;
|
||||
for (const [key, actor] of Object.entries(src.entries)) {
|
||||
const entry = { name: actor.name };
|
||||
if (actor.items) {
|
||||
const { out: items, translated } = translateItems(actor.items, lookups);
|
||||
entry.items = items;
|
||||
totalItems += Object.keys(actor.items).length;
|
||||
translatedItems += translated;
|
||||
}
|
||||
if (actor.description !== undefined) entry.description = actor.description;
|
||||
if (actor.gmnotes !== undefined) entry.gmnotes = actor.gmnotes;
|
||||
if (actor.effects !== undefined) entry.effects = actor.effects;
|
||||
out.entries[key] = entry;
|
||||
}
|
||||
|
||||
// structural safety check: item key sets must match exactly
|
||||
for (const k of Object.keys(src.entries)) {
|
||||
const srcKeys = Object.keys(src.entries[k].items || {}).sort();
|
||||
const outKeys = Object.keys(out.entries[k].items || {}).sort();
|
||||
if (JSON.stringify(srcKeys) !== JSON.stringify(outKeys)) {
|
||||
throw new Error('item key mismatch in ' + k);
|
||||
}
|
||||
}
|
||||
|
||||
const destPath = path.join(COMPENDIUM, srcFile);
|
||||
fs.writeFileSync(destPath, JSON.stringify(out, null, 1) + '\n');
|
||||
console.log(`OK ${srcFile} | items total: ${totalItems} | reused: ${translatedItems} | left English: ${totalItems - translatedItems}`);
|
||||
console.log(`-> ${destPath}`);
|
||||
console.log('Note: source file was intentionally NOT removed from "To Do/" since names/descriptions/some items remain English by design.');
|
||||
}
|
||||
|
||||
function runListUntranslated(files) {
|
||||
const lookups = buildLookups();
|
||||
const { itemsLookup, actorLookup, byName } = lookups;
|
||||
const missing = {}; // itemName -> { files: Set, actorCount: Set }
|
||||
|
||||
for (const f of files) {
|
||||
const src = JSON.parse(fs.readFileSync(path.join(TODO, f), 'utf8'));
|
||||
for (const [an, actor] of Object.entries(src.entries)) {
|
||||
for (const [ik, item] of Object.entries(actor.items || {})) {
|
||||
if (itemsLookup[ik] || actorLookup[ik] || byName[item.name]) continue;
|
||||
const label = f.replace(/^wfrp4e-/, '').replace(/\.actors\.json$/, '');
|
||||
if (!missing[item.name]) missing[item.name] = { files: new Set(), actors: new Set() };
|
||||
missing[item.name].files.add(label);
|
||||
missing[item.name].actors.add(an);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Object.entries(missing)
|
||||
.map(([name, v]) => ({ name, files: [...v.files].sort(), actorCount: v.actors.size }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const outPath = path.join(REPO, 'untranslated_ew_items.txt');
|
||||
fs.writeFileSync(outPath, rows.map(r => `${r.name}\t[${r.files.join(', ')}]\t(${r.actorCount} NSC)`).join('\n') + '\n');
|
||||
console.log('Unique untranslated item names:', rows.length);
|
||||
console.log('->', outPath);
|
||||
}
|
||||
|
||||
// --- CLI entry point ---
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv[0] === '--list-untranslated') {
|
||||
runListUntranslated(argv.slice(1));
|
||||
} else if (argv.length === 2) {
|
||||
runTranslate(argv[0], argv[1]);
|
||||
} else {
|
||||
console.error('Usage:');
|
||||
console.error(' node tools/translate_actor_items_from_corpus.js <file.actors.json> "<German label>"');
|
||||
console.error(' node tools/translate_actor_items_from_corpus.js --list-untranslated <file1.actors.json> [file2 ...]');
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user