vojo/scripts/gen-push-strings.mjs

223 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
/*
* Emit Android string resources for push-notification fallback text.
*
* Single source of truth: public/locales/{en,ru}.json under the `Push`
* namespace (the same file i18next loads on web). This script takes the
* Android-relevant subset and writes values{,-ru}/push_strings.xml.
*
* Usage:
* node scripts/gen-push-strings.mjs # default: android/app/build/generated/res/push/manual/
* node scripts/gen-push-strings.mjs --out <dir> # write into <dir>/values{,-ru}/
*
* The Gradle build calls this with --out into build/generated/res/push/<variant>/.
* The no-arg default writes into the build dir too, so generated XMLs never
* appear in src/main/res/.
*
* Why not parse the JSON at runtime in VojoFirebaseMessagingService?
* Because that hot path runs before WebView, before JS, under killed
* process — any JSONObject parse failure becomes a missed push. The
* review agent's verdict (see §5.27): keep Android on aapt-validated
* resources, pay the drift cost at build time, not at push time.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..');
const LOCALES_DIR = path.join(ROOT, 'public', 'locales');
const DEFAULT_OUT = path.join(
ROOT,
'android',
'app',
'build',
'generated',
'res',
'push',
'manual'
);
// Keys from the Push namespace that end up in Android resources.
// SW-only strings (encrypted_message, incoming_call, open_to_answer) are
// consumed by the Service Worker at runtime against the JSON bundle — no
// need to ship them as Android resources.
const ANDROID_KEYS = [
'new_message',
'new_messages',
'invitation',
'invite_body',
'invite_body_no_room',
'invite_body_no_inviter',
'invite_body_generic',
'missed_call',
'missed_call_body',
'channel_group',
'channel_dm',
'channel_dm_description',
'channel_group_room',
'channel_group_room_description',
'self_name',
'action_mark_as_read',
];
// i18next uses named placeholders ({{inviter}}); Android string resources
// use positional formatters (%1$s). Hardcoding the name→position map here
// keeps the Java call signature stable — PushStrings.inviteBody(ctx,
// inviter, roomName) always passes inviter in position 1, roomName in
// position 2, regardless of how the translators order them in the JSON.
// Adding a new placeholder: add it here AND update PushStrings accordingly.
// `caller` reuses position 1: it only appears in missed_call_body, which
// has no other placeholders, so the position assignment is keyed per-key
// in practice — the table just enumerates every placeholder name we accept.
const PLACEHOLDER_POSITIONS = {
inviter: 1,
roomName: 2,
caller: 1,
};
const LANGS = {
en: 'values',
ru: 'values-ru',
};
function convertPlaceholders(text, locale, key) {
const seen = new Set();
const converted = text.replace(/\{\{(\w+)\}\}/g, (_, name) => {
const pos = PLACEHOLDER_POSITIONS[name];
if (!pos) {
throw new Error(
`[${locale}] Push.${key}: unknown placeholder {{${name}}}. ` +
`Add it to PLACEHOLDER_POSITIONS in gen-push-strings.mjs ` +
`and thread the value through PushStrings.java.`
);
}
seen.add(name);
return `%${pos}$s`;
});
return { text: converted, placeholders: seen };
}
// Minimal XML escaping for string resources. Single/double quotes must be
// backslash-escaped per Android resource rules; ampersand/angle brackets
// get entity-encoded. No string in the Push namespace today contains any
// of these, but the check has to exist because translators will edit
// the source JSON and the script must not silently corrupt their copy.
function xmlEscape(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
}
function readBundle(locale) {
const jsonPath = path.join(LOCALES_DIR, `${locale}.json`);
const raw = fs.readFileSync(jsonPath, 'utf8');
const parsed = JSON.parse(raw);
const push = parsed?.Push;
if (!push || typeof push !== 'object') {
throw new Error(`${jsonPath} is missing the "Push" namespace`);
}
return push;
}
function verifyParity(bundles) {
const locales = Object.keys(bundles);
const [first, ...rest] = locales;
const firstKeys = new Set(Object.keys(bundles[first]));
rest.forEach((locale) => {
const keys = new Set(Object.keys(bundles[locale]));
const missingInOther = [...firstKeys].filter((k) => !keys.has(k));
const extraInOther = [...keys].filter((k) => !firstKeys.has(k));
if (missingInOther.length || extraInOther.length) {
throw new Error(
`Push namespace key drift between "${first}" and "${locale}".\n` +
` Missing in ${locale}: ${JSON.stringify(missingInOther)}\n` +
` Extra in ${locale}: ${JSON.stringify(extraInOther)}`
);
}
});
ANDROID_KEYS.forEach((key) => {
locales.forEach((locale) => {
if (typeof bundles[locale][key] !== 'string') {
throw new Error(`Push.${key} missing or non-string in ${locale}.json`);
}
});
// Placeholder tokens must match across locales for any given key —
// a translator adding {{user}} on one side silently produces
// literal-curly-brace output on the other surface.
const tokenSets = locales.map((locale) => {
const text = bundles[locale][key];
const tokens = new Set();
text.replace(/\{\{(\w+)\}\}/g, (_, name) => {
tokens.add(name);
return '';
});
return { locale, tokens };
});
const baseline = tokenSets[0];
tokenSets.slice(1).forEach((entry) => {
const baselineArr = [...baseline.tokens].sort();
const entryArr = [...entry.tokens].sort();
if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) {
throw new Error(
`Placeholder drift on Push.${key}: ` +
`${baseline.locale}=${JSON.stringify(baselineArr)}, ` +
`${entry.locale}=${JSON.stringify(entryArr)}`
);
}
});
});
}
function emitResource(locale, bundle, resDir) {
const lines = [
"<?xml version='1.0' encoding='utf-8'?>",
'<!--',
' AUTO-GENERATED by scripts/gen-push-strings.mjs.',
` DO NOT EDIT — edit public/locales/${locale}.json and rerun`,
' the Gradle build (or `npm run gen:push-strings` manually).',
'-->',
'<resources>',
];
ANDROID_KEYS.forEach((key) => {
const raw = bundle[key];
const { text, placeholders } = convertPlaceholders(raw, locale, key);
const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : '';
lines.push(` <string name="push_${key}"${formattedAttr}>${xmlEscape(text)}</string>`);
});
lines.push('</resources>');
lines.push('');
const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, lines.join('\n'), 'utf8');
return outPath;
}
function main() {
const outIdx = process.argv.indexOf('--out');
if (outIdx !== -1 && !process.argv[outIdx + 1]) {
throw new Error('--out requires a directory argument');
}
const resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT;
const bundles = Object.keys(LANGS).reduce((acc, locale) => {
acc[locale] = readBundle(locale);
return acc;
}, {});
verifyParity(bundles);
Object.keys(LANGS).forEach((locale) => {
const outPath = emitResource(locale, bundles[locale], resDir);
process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`);
});
}
try {
main();
} catch (err) {
process.stderr.write(`gen-push-strings: ${err.message}\n`);
process.exit(1);
}