Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add sync script #180

Merged
merged 8 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ Translations for cards and metadata are sourced from the [arkhamdb-json-data](ht

### Updating translations

1. Run `npm run i18n:sync-keys` to sync newly added translation keys to your locale.
2. Run `npm run i18n:pull-data` to sync translations from ArkhamCards.
1. Run `npm run i18n:sync` to sync newly added translation keys to your locale.
2. Run `npm run i18n:pull` to sync translations from ArkhamCards.
3. Update the translation file and open a PR.

## Architecture
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
"dev": "vite",
"dev:functions": "wrangler pages dev",
"fmt": "biome check --write",
"i18n:sync-keys": "npx i18next-json-sync --files '**/src/locales/*.json' --primary en && npm run fmt",
"i18n:pull-data": "npx tsx ./scripts/i18n-utils && npm run fmt",
"i18n:sync": "npx tsx ./scripts/i18n-sync && npm run fmt",
"i18n:pull": "npx tsx ./scripts/i18n-pull && npm run fmt",
"lint": "biome check",
"knip": "npx knip",
"postinstall": "patch-package",
Expand Down
60 changes: 34 additions & 26 deletions scripts/i18n-utils.ts → scripts/i18n-pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import type { Card } from "../src/store/services/queries.types";
import { cardUses } from "../src/utils/card-utils";
import { capitalize } from "../src/utils/formatting";

type JsonObject = { [key: string]: JsonValue };
type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject;

const [cards, en] = await Promise.all([queryCards(), readLocale("en")]);

const uses = listUses(cards);
Expand Down Expand Up @@ -47,40 +50,43 @@ for (const lng of translations) {

const locale = await readLocale(lng);

for (const key of Object.keys(en.translation.common.uses)) {
const arkhamCardsTranslation =
arkhamCardsLocale.translations[""][capitalize(key)]?.msgstr[0];
patchLocale(
lng,
(key) => arkhamCardsLocale.translations[""][capitalize(key)],
() => en.translation.common.uses,
);

if (arkhamCardsTranslation) {
locale.translation.common.uses[key] = arkhamCardsTranslation;
} else {
console.log(`[${lng}] ArkhamCards missing translation for ${key}`);
}
}
patchLocale(
lng,
(key) => arkhamCardsLocale.translations["trait"][key],
() => en.translation.common.traits,
);

for (const key of Object.keys(en.translation.common.traits)) {
const arkhamCardsTranslation =
arkhamCardsLocale.translations["trait"][key]?.msgstr[0];
patchLocale(
lng,
(key) => arkhamCardsLocale.translations["deck_option"]?.[key],
() => en.translation.common.deck_options,
);

if (arkhamCardsTranslation) {
locale.translation.common.traits[key] = arkhamCardsTranslation;
} else {
console.log(`[${lng}] ArkhamCards missing translation for ${key}`);
}
}
await writeLocale(lng, locale);
}

function patchLocale(
lng: string,
poResolver: (key: string) => { msgid: string; msgstr: string[] } | undefined,
i18NextResolver: () => JsonObject,
) {
const obj = i18NextResolver();

for (const key of Object.keys(en.translation.common.deck_options)) {
const arkhamCardsTranslation =
arkhamCardsLocale.translations[""][key]?.msgstr[0];
for (const key of Object.keys(obj)) {
const translation = poResolver(key)?.msgstr[0];

if (arkhamCardsTranslation) {
locale.translation.common.deck_options[key] = arkhamCardsTranslation;
if (translation) {
obj[key] = translation;
} else {
console.log(`[${lng}] ArkhamCards missing translation for ${key}`);
}
}

await writeLocale(lng, locale);
}

async function cloneRepo() {
Expand Down Expand Up @@ -111,7 +117,7 @@ async function queryCards() {
return Object.values(
applyLocalData({
cards: apiCards,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
// biome-ignore lint/suspicious/noExplicitAny: safe.
} as any).cards,
);
}
Expand Down Expand Up @@ -150,6 +156,8 @@ function listDeckOptions(cards: Card[]) {
"Deck must have at least 10 skill cards.",
"Atleast constraint violated.",
"Too many off-class cards.",
"Too many off-class cards for Versatile.",
"You cannot have assets that take up an ally slot.",
];

return Array.from(
Expand Down
99 changes: 99 additions & 0 deletions scripts/i18n-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import assert from "node:assert";
import fs from "node:fs";
import path from "node:path";

type JsonObject = { [key: string]: JsonValue };
type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject;

syncLocales("en");

function syncLocales(primary: string) {
const primaryLocale = readLocale(`${primary}.json`);

const localePaths = fs.readdirSync(path.join(process.cwd(), "./src/locales"));

for (const localePath of localePaths) {
if (localePath === `${primary}.json`) continue;

const locale = readLocale(localePath);

const synced = sync(primaryLocale, locale);
const formatted = JSON.stringify(sortJSON(synced), null, 2);

fs.writeFileSync(
path.join(process.cwd(), `./src/locales/${localePath}`),
formatted,
);
}
}

function sync(primary: JsonObject, locale: JsonObject) {
const newLocale = {};

for (const key in primary) {
if (Array.isArray(primary[key])) {
throw new Error("Arrays are not supported in locale files.");
}

if (isObject(primary[key])) {
newLocale[key] = sync(
primary[key],
isObject(locale[key]) ? locale[key] : {},
);
} else {
let found = 0;

for (const k of getPlurals(key)) {
if (locale[k]) {
assert(
typeof locale[k] === "string",
`Invalid value for ${k}: should be a string.`,
);
found += 1;
newLocale[k] = locale[k];
}
}

if (!found) {
newLocale[key] = primary[key];
}
}
}

return newLocale;
}

function readLocale(filename: string) {
const filePath = path.join(process.cwd(), `./src/locales/${filename}`);
const contents = fs.readFileSync(filePath, "utf-8");
return JSON.parse(contents);
}

function isObject(x: unknown): x is JsonObject {
return typeof x === "object" && x !== null;
}

function getPlurals(key: string) {
const pluralKeys = ["zero", "one", "two", "few", "many", "other"];

const root = pluralKeys.some((pluralKey) => key.endsWith(`_${pluralKey}`))
? key.slice(0, key.lastIndexOf("_"))
: key;

const keys = pluralKeys.map((pluralKey) => `${root}_${pluralKey}`);
keys.unshift(root);
return keys;
}

function sortJSON(obj: JsonValue) {
if (typeof obj !== "object" || obj === null) return obj;

if (Array.isArray(obj)) return obj.map(sortJSON);

return Object.keys(obj)
.sort()
.reduce((acc, key) => {
acc[key] = sortJSON(obj[key]);
return acc;
}, {});
}
1 change: 1 addition & 0 deletions src/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,7 @@
},
"help": {
"about": "이 사이트에 대하여",
"changelog": "Changelog",
"collection_stats": "보유 게임 상태",
"shortcuts": {
"group_card_list": "카드 목록",
Expand Down
25 changes: 13 additions & 12 deletions src/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,14 @@
},
"back": "Wstecz",
"cancel": "Anuluj",
"card_one": "karta",
"card_few": "karty",
"card_many": "kart",
"card_one": "karta",
"card_other": "karty",
"clear": "Wyczyść",
"clue_one": "Wskazówka",
"clue_few": "Wskazówki",
"clue_many": "Wskazówek",
"clue_one": "Wskazówka",
"clue_other": "Wskazówki",
"cost": {
"none": "Brak kosztu",
Expand All @@ -87,9 +87,9 @@
},
"customizable": "Przystosowalna",
"customizations": "Usprawnienia",
"cycle_one": "cykl",
"cycle_few": "cykle",
"cycle_many": "cykli",
"cycle_one": "cykl",
"cycle_other": "cykle",
"deck_options": {
"Atleast constraint violated.": "Ograniczenie \"przynajmniej\" naruszone.",
Expand Down Expand Up @@ -157,9 +157,9 @@
"survivor": "Ocalały"
},
"fast": "Szybka",
"icon_one": "ikona umiejętności",
"icon_few": "ikony umiejętności",
"icon_many": "ikon umiejętności",
"icon_one": "ikona umiejętności",
"icon_other": "ikony umiejętności",
"level": {
"base": "Poziom 0 / Null",
Expand All @@ -172,9 +172,9 @@
"none": "Brak",
"nonexceptional": "Nie-wyjątkowa",
"ok": "OK",
"pack_one": "zestaw",
"pack_few": "zestawy",
"pack_many": "zestawów",
"pack_one": "zestaw",
"pack_other": "zestawy",
"packs_new_format": {
"encounter": "Rozszerzenie kampanijne",
Expand Down Expand Up @@ -235,9 +235,9 @@
"taboo_none": "żadna",
"taboo_unchained": "Kosztuje {{xp}} doświadczenia mniej.",
"total": "łącznie",
"trait_one": "Cecha",
"trait_few": "Cechy",
"trait_many": "Cech",
"trait_one": "Cecha",
"trait_other": "Cechy",
"traits": {
"???": "???",
Expand Down Expand Up @@ -654,9 +654,9 @@
"limited_slots": "Limitowane sloty",
"stats": {
"deck_size": "Rozmiar talii",
"ignored_one": "{{count}} kopia nie wlicza się do rozmiaru talii.",
"ignored_few": "{{count}} kopie nie wliczają się do rozmiaru talii.",
"ignored_many": "{{count}} kopii nie wlicza się do rozmiaru talii.",
"ignored_one": "{{count}} kopia nie wlicza się do rozmiaru talii.",
"ignored_other": "{{count}} kopie nie wliczają się do rozmiaru talii.",
"unowned": "Unavailable: {{count}} of {{total}} cards",
"uses_parallel": "Uses a parallel side",
Expand Down Expand Up @@ -696,8 +696,8 @@
"connect_arkhamdb": "Połącz z ArkhamDB",
"count": "{{count}} talie",
"count_few": "{{count}} talie",
"count_many": "{{count}} talii",
"count_hidden": " ({{count}} ukryte)",
"count_many": "{{count}} talii",
"deck_url_or_id": "URL lub ID talii",
"delete_all": "Usuń wszystkie talie lokalne",
"delete_all_confirm": "Czy jesteś pewien, że chcesz usunąć wszystkie talie lokalne ze swojej kolekcji?",
Expand Down Expand Up @@ -792,9 +792,9 @@
"create_local_copy_help": "Utwórz lokalną kopię tej talii. Zastosuje to wszystkie zaległe edycje i umożliwi ich zapisanie. Ulepszenia i historia talii nie zostaną przeniesione.",
"customizable": {
"skill_placeholder": "Wybierz umiejętność...",
"traits_placeholder_one": "Wybierz cechę...",
"traits_placeholder_few": "Wybierz cechy...",
"traits_placeholder_many": "Wybierz cechy...",
"traits_placeholder_one": "Wybierz cechę...",
"traits_placeholder_other": "Wybierz cechy..."
},
"discard": "Odrzuć zmiany",
Expand Down Expand Up @@ -999,6 +999,7 @@
},
"help": {
"about": "O tej stronie",
"changelog": "Changelog",
"collection_stats": "Statystyki kolekcji",
"shortcuts": {
"group_card_list": "Lista kart",
Expand Down Expand Up @@ -1049,10 +1050,10 @@
},
"group_by": "Grupuj według",
"nav": {
"card_count_one": "{{count}} karta",
"card_count_few": "{{count}} karty",
"card_count_many": "{{count}} kart",
"card_count_hidden": "({{count}} ukrytych przez filtry)",
"card_count_many": "{{count}} kart",
"card_count_one": "{{count}} karta",
"display": "Wyświetlanie listy",
"display_as_detailed": "Szczegółowy podgląd karty",
"display_as_list": "Tylko nazwa karty",
Expand Down Expand Up @@ -1182,9 +1183,9 @@
},
"ui": {
"cards_combobox": {
"placeholder_one": "Wybierz kartę...",
"placeholder_few": "Wybierz karty...",
"placeholder_many": "Wybierz karty...",
"placeholder_one": "Wybierz kartę...",
"placeholder_other": "Wybierz karty..."
},
"collapsible": {
Expand Down
Loading