mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +00:00
Add pt-BR to the locale list and everything that hangs off it: the client loader, Intl and date-fns mapping, the reply-language footers of the Coach and insights prompts (English body, pt-BR footer, the same path Korean and Dutch take), the citation-coverage normative words, the no-key fallback copy, the AUDIT-C standard-drink line, country labels, period search, CSV yes/no cells and the Telegram bare-word aliases. The locale enums in the extract, managed-profile and record-settings schemas now derive from the locale list instead of repeating it, and the five notification and login-alert routes share one isLocale guard instead of hand-rolled chains, so a later locale is one edit there. A language tag is matched exactly and case-insensitively before its primary subtag, so pt-BR and pt-PT browsers land on the Portuguese bundle rather than English, and AUDIT-C no longer cuts a region tag to two letters. PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording has not been read against the published Brazilian forms, so readers get the English items plus the existing note.
138 lines
4.5 KiB
JavaScript
138 lines
4.5 KiB
JavaScript
/**
|
|
* Splits an over-long description at its first sentence boundary, in every
|
|
* locale, WITHOUT retranslating anything: the tail is already written in each
|
|
* language, it is just in the wrong slot.
|
|
*
|
|
* node scripts/split-description.mjs --dry <key> [<key> …]
|
|
* node scripts/split-description.mjs drop <key> [<key> …]
|
|
* node scripts/split-description.mjs move <key> <tailKey>
|
|
*
|
|
* drop — the description keeps sentence 1; the tail is deleted.
|
|
* move — the description keeps sentence 1; the tail lands under `tailKey`,
|
|
* to be rendered in the card body as foreground `text-sm`.
|
|
* --dry — print what each locale would become, change nothing.
|
|
*
|
|
* A terminator followed by whitespace and an upper-case letter (or a digit,
|
|
* or an opening quote) is a sentence boundary. `Claude.ai` and `z. B.` do not
|
|
* match: the first has no whitespace, the second continues lower-case.
|
|
*/
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
|
|
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"];
|
|
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
|
|
|
|
/**
|
|
* Abbreviations whose full stop is not a sentence end.
|
|
*
|
|
* The boundary above demands an upper-case letter after the space, which
|
|
* already rejects `z. B.` mid-sentence in German (`B.` continues lower-case
|
|
* more often than not) — but not reliably: "… z. B. Blutdruck." splits after
|
|
* `z.`, because `B` IS upper-case. The same hole exists for `d. h.`, `u. a.`,
|
|
* `e.g.` / `i.e.` before a capitalised noun, and French `p. ex.`. A split
|
|
* there produces a head that ends mid-abbreviation and a tail that starts
|
|
* with a fragment, in a file nobody re-reads afterwards.
|
|
*/
|
|
const ABBREVIATIONS = [
|
|
"bzw.",
|
|
"ca.",
|
|
"vgl.",
|
|
"usw.",
|
|
"e.g.",
|
|
"i.e.",
|
|
"cf.",
|
|
"etc.",
|
|
"ej.",
|
|
"es.",
|
|
"np.",
|
|
"tzn.",
|
|
"ecc.",
|
|
"ex.",
|
|
];
|
|
|
|
/**
|
|
* A single letter followed by a full stop is an abbreviation part, never a
|
|
* sentence: `z. B.`, `d. h.`, `u. a.`, `p. ex.`. Both halves have to be
|
|
* caught, which is why this is a shape rather than a list — catching only
|
|
* `z.` still splits after the `B.`.
|
|
*/
|
|
const SINGLE_LETTER = /(^|\s)\p{L}\.$/u;
|
|
|
|
/** Split on sentence boundaries, skipping the ones an abbreviation created. */
|
|
function splitSentences(text) {
|
|
const rough = text.split(BOUNDARY);
|
|
const out = [];
|
|
for (const part of rough) {
|
|
const previous = out[out.length - 1];
|
|
const lower = previous?.toLowerCase() ?? "";
|
|
const endsInAbbreviation =
|
|
previous !== undefined &&
|
|
(SINGLE_LETTER.test(previous) ||
|
|
ABBREVIATIONS.some(
|
|
(abbr) => lower.endsWith(` ${abbr}`) || lower === abbr,
|
|
));
|
|
// Merge, then let the NEXT iteration re-test the merged tail: `z. B.`
|
|
// needs two merges, and a single pass would stop after the first.
|
|
if (endsInAbbreviation) out[out.length - 1] = `${previous} ${part}`;
|
|
else out.push(part);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function get(bundle, path) {
|
|
return path
|
|
.split(".")
|
|
.reduce((node, part) => (node == null ? node : node[part]), bundle);
|
|
}
|
|
function set(bundle, path, value) {
|
|
const parts = path.split(".");
|
|
const last = parts.pop();
|
|
let node = bundle;
|
|
for (const part of parts) {
|
|
if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
|
|
node = node[part];
|
|
}
|
|
node[last] = value;
|
|
}
|
|
|
|
const [mode, ...rest] = process.argv.slice(2);
|
|
const dry = mode === "--dry";
|
|
const keys = dry ? rest : mode === "move" ? [rest[0]] : rest;
|
|
const tailKey = mode === "move" ? rest[1] : null;
|
|
if (!mode || keys.length === 0) {
|
|
console.error("usage: --dry|drop|move <key> [tailKey]");
|
|
process.exit(1);
|
|
}
|
|
|
|
for (const locale of LOCALES) {
|
|
const file = `messages/${locale}.json`;
|
|
const bundle = JSON.parse(readFileSync(file, "utf8"));
|
|
let changed = false;
|
|
for (const key of keys) {
|
|
const value = get(bundle, key);
|
|
if (typeof value !== "string") {
|
|
console.log(`${locale} ${key}: MISSING`);
|
|
continue;
|
|
}
|
|
const parts = splitSentences(value);
|
|
if (parts.length < 2) {
|
|
console.log(`${locale} ${key}: single sentence, untouched`);
|
|
continue;
|
|
}
|
|
const head = parts[0].trim();
|
|
const tail = parts.slice(1).join(" ").trim();
|
|
if (dry) {
|
|
console.log(
|
|
`${locale} ${key}\n head(${head.length}): ${head}\n tail: ${tail}`,
|
|
);
|
|
continue;
|
|
}
|
|
set(bundle, key, head);
|
|
if (tailKey) set(bundle, tailKey, tail);
|
|
changed = true;
|
|
}
|
|
if (changed) {
|
|
writeFileSync(file, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
|
|
console.log(`${locale}: written`);
|
|
}
|
|
}
|