Files
HealthLog/scripts/split-description.mjs
T
l1137l 48bfbad4e6 fix(i18n): address the ru review — units, Cyrillic regexes, locale registry
Dose bank
- DRUG_UNIT learns мг / мкг / мл / ЕД / МЕ, so "2,4 мг" anchors a dose again.
- The bank matches verb-first Russian word order too ("принимайте 2 таблетки",
  "пропустите дозу метформина", "начните с 5 мг"), and the Cyrillic drug names
  people actually see on the box; the deferral words ("спросите врача",
  "как назначено") stand those shapes down.

Cyrillic-safe regexes
- \w and \b are ASCII in JavaScript: every stem in the ru bank and in
  population-norms/ru.ts is [а-яё]*, and the word edges are lookarounds
  (?<![а-яёa-z]), so «высокий риск» and «нормальный диапазон» match again.
- coach-prose-grounding: Russian month names in the genitive, a space as the
  thousands separator in parseLocaleNumber and numFrag, and Cyrillic-safe
  edges around the written-date pattern.

Registered everywhere a locale has to be
- targetLanguageName() returns "Russian", so the coach, the period narrative
  and the document descriptions stop saying "write in English".
- i18n-key-tool.mjs, split-description.mjs, LOINC name normalisation (it
  stripped Cyrillic, so no Russian lab name resolved), HOST_TLDS (ru/by/kz/ua),
  no-key-fallbacks (32 tables), country-label, the AUDIT-C standard drink.
- docs/api/openapi.yaml regenerated.

Questionnaires
- The PHQ-9 / GAD-7 source comments no longer claim no validated Russian
  version exists (Zolotareva 2023; Pfizer's official screeners).
- WHO-5 items are gender-neutral: «чувствовал(а)».
- "body fat" and "fat mass" both read «Жировая масса»; body fat is now
  «Процент жировой массы», which is what the two metrics measure.

Tests
- ru in the 13 locale tables, plus mood, onboarding, instruments, clinician
  view, status summary, describe screen, citation coverage and LOINC.
- i18n-no-dot-separator: the ru bundle is exempt from the dash rule (a spaced
  dash is Russian punctuation, not a fragment join); the middle dot still
  fails there.
- Two 800-line baselines raised by the 12 and 5 lines this change adds.

pnpm typecheck, pnpm openapi:check and the full suite (36 316 tests) pass.
2026-11-02 20:22:03 +00:00

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", "ru"];
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`);
}
}