Files
HealthLog/scripts/split-description.mjs
T
Marc-André Bombeck 272c426bea fix(ui): close the holes the audit found in this work's own guards
Every item here is a check that could pass while the thing it checks is
broken, or a margin the sweep's codemod could not reach.

Guards:

- The access-payload matcher accepted `.level` on ANY object. A chart's zoom
  level or a log record's level would have satisfied it and reported a
  consumer that does not exist. It now names the bindings the entry actually
  arrives under; a third binding is a deliberate edit rather than a silent
  widening. Re-falsified: rename both readers, it goes red.
- The `pl-7` sweep skipped any line CONTAINING `//`, so a class string sharing
  a line with a URL was waved through. Only the part after a real line-comment
  marker is exempt now. Proven by putting `pl-7` on a line with an
  `https://` comment: caught.
- `LEGIT_IDENTICAL` in the English-leak guard had no staleness check, unlike
  both of its siblings, and carried two dead keys — one from a removal long
  before this work, one from the duplicated version fields that came off
  `/admin`. Both pruned, the check added, falsified with a made-up key.
- `split-description.mjs` split inside `z. B.`, `d. h.`, `u. a.` and `p. ex.`
  and produced a head ending mid-abbreviation. Both halves have to be caught,
  which is why the rule is a shape (single letter + stop) rather than a list,
  and the merge re-tests its own tail. Exercised against eight cases including
  the ones that used to break.

Margins the codemod could not see, because the child lives in a different
component from the card that spaces it: six form roots under managed record
settings (one of which also carried the banned `space-y-5` step), the
low-stock status line, the avatar card's explainer, and the backups restore
report. Each was adding a margin on top of the card's own gap.

Two facts the text diet cut too close, both restored inside the guideline:
that an excluded injection site wins over a medication that prefers it, and
that the privacy card's second button leads to a full health record.
2026-08-07 23:20:39 +02: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"];
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`);
}
}