From fae84f755b607929fe1360341c1ce3fc7b1b129e Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:19:16 +0000 Subject: [PATCH 01/12] feat: add script to verify custom terminology in i18n files --- i18n/en.pot | 4 +- package.json | 1 + scripts/verifyCustomTerminology.mjs | 136 ++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 scripts/verifyCustomTerminology.mjs diff --git a/i18n/en.pot b/i18n/en.pot index f5194519ff..88beecc9f6 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-02T14:28:34.956Z\n" -"PO-Revision-Date: 2026-09-02T14:28:34.957Z\n" +"POT-Creation-Date: 2026-09-03T08:19:18.544Z\n" +"PO-Revision-Date: 2026-09-03T08:19:18.544Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/package.json b/package.json index 62067aa57f..80aa4dc27e 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "verifyCacheVersion": "node scripts/verifyCacheVersion.js", "postinstall": "husky && patch-package && node scripts/createSymlinkToInternalPackages.mjs", "i18n:add": "d2-app-scripts i18n extract && git add ./i18n/", + "i18n:verify": "node scripts/verifyCustomTerminology.mjs", "clean": "./scripts/clean.sh && yarn workspaces run clean" }, "devDependencies": { diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs new file mode 100644 index 0000000000..24c7266e4c --- /dev/null +++ b/scripts/verifyCustomTerminology.mjs @@ -0,0 +1,136 @@ +/* + * Verifies that i18n/en.pot contains no msgid where a customisable domain term + * appears outside a {{...}}-template placeholder. + * + * Custom terms (enrollment, event, program stage, note, relationship, attribute, + * organisation unit / registering unit, follow-up, tracked entity type name) can + * be overridden per program at runtime. If a msgid embeds one of these terms as + * plain English, the string cannot be localised via the Program.*Label mechanism + * and non-English users see the wrong term. + * + * Runs as `yarn i18n:verify`; wired into .husky/pre-push and CI alongside lint + * and tsc. Exit 0 = pass, exit 1 = violations found. + */ +/* eslint-disable no-console */ + +import { readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const POT = fileURLToPath(new URL('../i18n/en.pot', import.meta.url)); + +const CUSTOM_TERMS = [ + { words: ['enrollment'], suggestion: '{{enrollmentLabel}}' }, + { words: ['enrollments'], suggestion: '{{enrollmentsLabel}}' }, + { words: ['event'], suggestion: '{{eventLabel}}' }, + { words: ['events'], suggestion: '{{eventsLabel}}' }, + { words: ['program stage'], suggestion: '{{programStageLabel}}' }, + { words: ['program stages'], suggestion: '{{programStagesLabel}}' }, + { words: ['note'], suggestion: '{{noteLabel}}' }, + { words: ['notes'], suggestion: 'no plural custom label — allowlist if intentional' }, + { words: ['relationship'], suggestion: '{{relationshipLabel}}' }, + { words: ['relationships'], suggestion: 'no plural custom label — allowlist if intentional' }, + { words: ['attribute'], suggestion: '{{attributeLabel}}' }, + { words: ['attributes'], suggestion: 'no plural custom label — allowlist if intentional' }, + { words: ['tracked entity attribute', 'tracked entity attributes'], suggestion: '{{attributeLabel}}' }, + { words: ['tracked entity type', 'tracked entity types'], suggestion: '{{trackedEntityTypesLabel}} or displayName' }, + { words: ['org unit'], suggestion: '{{orgUnitLabel}}' }, + { words: ['organisation unit'], suggestion: '{{orgUnitLabel}}' }, + { words: ['registering unit'], suggestion: '{{orgUnitLabel}}' }, + { words: ['follow-up', 'followup'], suggestion: '{{followUpLabel}}' }, +].sort((a, b) => Math.max(...b.words.map(w => w.length)) - Math.max(...a.words.map(w => w.length))); + +// Bare msgids that customLabels.ts emits via i18n.t() as base translations. +// They cannot be templated — they ARE the fallback template — so must not be flagged. +const FALLBACKS = new Set([ + 'enrollment', 'enrollments', + 'event', 'events', + 'program stage', 'program stages', + 'note', 'relationship', 'attribute', + 'organisation unit', 'follow-up', +]); + +const ALLOWLIST = new Set([ + // No plural custom label for `attribute` in the DHIS2 model. + 'Search by attributes', + // Plural `relationship` has no custom label; admin phrasing. + 'Ambiguous relationships, contact system administrator', +]); + +// pot format may split long msgids across a `msgid ""` line and one or more +// continuation `"..."` lines; concatenate them into a single value. +function extractMsgids(potContents) { + const lines = potContents.split('\n'); + const msgids = []; + let i = 0; + while (i < lines.length) { + const match = lines[i].match(/^msgid "(.*)"$/); + if (match) { + let value = match[1]; + let j = i + 1; + while (j < lines.length && /^"(.*)"$/.test(lines[j])) { + value += lines[j].match(/^"(.*)"$/)[1]; + j += 1; + } + if (value !== '') msgids.push({ value, line: i + 1 }); + i = j; + } else { + i += 1; + } + } + return msgids; +} + +function findViolations(msgid) { + // Strip placeholders first so `\bevent\b` doesn't match inside `{{eventLabel}}`. + const stripped = msgid.replace(/\{\{\s*[^}]*\}\}/g, ''); + const hits = []; + for (const { words, suggestion } of CUSTOM_TERMS) { + for (const word of words) { + // Word-boundary + case-insensitive so `event` doesn't match `eventName`. + const re = new RegExp(`\\b${word}\\b`, 'i'); + if (re.test(stripped)) { + hits.push({ word, suggestion }); + break; + } + } + } + return hits; +} + +function reportViolations(violations) { + const relPot = path.relative(process.cwd(), POT); + console.error('i18n:verify — custom-terminology violations found in en.pot:\n'); + for (const v of violations) { + console.error(` ${relPot}:${v.line}`); + console.error(` msgid: "${v.msgid}"`); + for (const h of v.hits) { + console.error(` ✗ "${h.word}" → use ${h.suggestion}`); + } + console.error(''); + } + console.error(`Total: ${violations.length} msgid(s) with untemplated custom terms.\n`); + console.error('Fix by wrapping the offending word in a custom-terminology template. Example:'); + console.error(" BEFORE: i18n.t('Delete event')"); + console.error(" AFTER: customTerms.i18n.t('Delete {{eventLabel}}', { eventLabel })\n"); + console.error('If a hit is a genuine exception, add the exact msgid to the ALLOWLIST'); + console.error('in scripts/verifyCustomTerminology.mjs.'); +} + +function main() { + const msgids = extractMsgids(readFileSync(POT, 'utf8')); + const violations = msgids + .filter(({ value }) => !FALLBACKS.has(value) && !ALLOWLIST.has(value)) + .map(({ value, line }) => ({ msgid: value, line, hits: findViolations(value) })) + .filter(({ hits }) => hits.length > 0); + + if (violations.length === 0) { + console.log('i18n:verify — no custom-terminology violations in en.pot ✓'); + return; + } + + reportViolations(violations); + process.exit(1); +} + +main(); From c46734876733b05a85bab982c08b6e1682eb53bb Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:10:20 +0000 Subject: [PATCH 02/12] fix: update suggestions for plural custom labels in terminology verification script --- i18n/en.pot | 4 ++-- scripts/verifyCustomTerminology.mjs | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 88beecc9f6..3e8d629d9e 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-03T08:19:18.544Z\n" -"PO-Revision-Date: 2026-09-03T08:19:18.544Z\n" +"POT-Creation-Date: 2026-09-03T13:10:22.873Z\n" +"PO-Revision-Date: 2026-09-03T13:10:22.873Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index 24c7266e4c..c39b7b4514 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -27,11 +27,11 @@ const CUSTOM_TERMS = [ { words: ['program stage'], suggestion: '{{programStageLabel}}' }, { words: ['program stages'], suggestion: '{{programStagesLabel}}' }, { words: ['note'], suggestion: '{{noteLabel}}' }, - { words: ['notes'], suggestion: 'no plural custom label — allowlist if intentional' }, + { words: ['notes'], suggestion: '{{notesLabel}}' }, { words: ['relationship'], suggestion: '{{relationshipLabel}}' }, - { words: ['relationships'], suggestion: 'no plural custom label — allowlist if intentional' }, + { words: ['relationships'], suggestion: '{{relationshipsLabel}}' }, { words: ['attribute'], suggestion: '{{attributeLabel}}' }, - { words: ['attributes'], suggestion: 'no plural custom label — allowlist if intentional' }, + { words: ['attributes'], suggestion: '{{attributesLabel}}' }, { words: ['tracked entity attribute', 'tracked entity attributes'], suggestion: '{{attributeLabel}}' }, { words: ['tracked entity type', 'tracked entity types'], suggestion: '{{trackedEntityTypesLabel}} or displayName' }, { words: ['org unit'], suggestion: '{{orgUnitLabel}}' }, @@ -46,7 +46,9 @@ const FALLBACKS = new Set([ 'enrollment', 'enrollments', 'event', 'events', 'program stage', 'program stages', - 'note', 'relationship', 'attribute', + 'note', 'notes', + 'relationship', 'relationships', + 'attribute', 'attributes', 'organisation unit', 'follow-up', ]); From d68ff8324d2f3663a71384311e6053fd4ccd6a75 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:22:25 +0000 Subject: [PATCH 03/12] fix: sonar qube --- i18n/en.pot | 4 ++-- scripts/verifyCustomTerminology.mjs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b9687267a9..32b4e4db17 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-03T13:20:02.667Z\n" -"PO-Revision-Date: 2026-09-03T13:20:02.667Z\n" +"POT-Creation-Date: 2026-09-03T13:22:27.269Z\n" +"PO-Revision-Date: 2026-09-03T13:22:27.269Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index c39b7b4514..8c885fa1e5 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -13,9 +13,9 @@ */ /* eslint-disable no-console */ -import { readFileSync } from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const POT = fileURLToPath(new URL('../i18n/en.pot', import.meta.url)); @@ -85,12 +85,12 @@ function extractMsgids(potContents) { function findViolations(msgid) { // Strip placeholders first so `\bevent\b` doesn't match inside `{{eventLabel}}`. - const stripped = msgid.replace(/\{\{\s*[^}]*\}\}/g, ''); + const stripped = msgid.replace(/\{\{[^}]*\}\}/g, ''); const hits = []; for (const { words, suggestion } of CUSTOM_TERMS) { for (const word of words) { // Word-boundary + case-insensitive so `event` doesn't match `eventName`. - const re = new RegExp(`\\b${word}\\b`, 'i'); + const re = new RegExp(String.raw`\b${word}\b`, 'i'); if (re.test(stripped)) { hits.push({ word, suggestion }); break; From 6d3fff4d9626df2bcd9772db5648e6c3fa7cd7f1 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:47:48 +0000 Subject: [PATCH 04/12] feat: add i18n verification step to CI and pre push --- .github/workflows/verify-app.yml | 17 +++++++++++++++++ .husky/pre-push | 2 +- i18n/en.pot | 4 ++-- scripts/verifyCustomTerminology.mjs | 26 +++++++------------------- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/.github/workflows/verify-app.yml b/.github/workflows/verify-app.yml index 9e3b485fc9..efdfd9e6e1 100644 --- a/.github/workflows/verify-app.yml +++ b/.github/workflows/verify-app.yml @@ -41,6 +41,21 @@ jobs: - name: Lint run: yarn linter:check + + i18n: + runs-on: ubuntu-latest + needs: install + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + cache: '' + + - uses: dhis2/action-safe-yarn-install-with-cache@v1 + + - name: Verify custom terminology in i18n/en.pot + run: yarn i18n:verify typescript: runs-on: ubuntu-latest @@ -56,6 +71,8 @@ jobs: - name: TypeScript run: yarn tsc:check + + unit-tests: runs-on: ubuntu-latest diff --git a/.husky/pre-push b/.husky/pre-push index 2333da579d..0769f91379 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -yarn linter:check && yarn tsc:check +yarn linter:check && yarn tsc:check && yarn i18n:verify diff --git a/i18n/en.pot b/i18n/en.pot index 32b4e4db17..d8c3e445fb 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-03T13:22:27.269Z\n" -"PO-Revision-Date: 2026-09-03T13:22:27.269Z\n" +"POT-Creation-Date: 2026-09-03T13:47:50.727Z\n" +"PO-Revision-Date: 2026-09-03T13:47:50.727Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index 8c885fa1e5..388a4b8b91 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -1,15 +1,12 @@ /* - * Verifies that i18n/en.pot contains no msgid where a customisable domain term + * Verifies that i18n/en.pot contains no msgid where custom terminology * appears outside a {{...}}-template placeholder. * * Custom terms (enrollment, event, program stage, note, relationship, attribute, - * organisation unit / registering unit, follow-up, tracked entity type name) can - * be overridden per program at runtime. If a msgid embeds one of these terms as - * plain English, the string cannot be localised via the Program.*Label mechanism - * and non-English users see the wrong term. - * - * Runs as `yarn i18n:verify`; wired into .husky/pre-push and CI alongside lint - * and tsc. Exit 0 = pass, exit 1 = violations found. + * organisation unit, follow-up) needs to be inside {{...}}-template placeholder + * to be overridden per program at runtime. + + * Runs with `yarn i18n:verify` */ /* eslint-disable no-console */ @@ -33,15 +30,12 @@ const CUSTOM_TERMS = [ { words: ['attribute'], suggestion: '{{attributeLabel}}' }, { words: ['attributes'], suggestion: '{{attributesLabel}}' }, { words: ['tracked entity attribute', 'tracked entity attributes'], suggestion: '{{attributeLabel}}' }, - { words: ['tracked entity type', 'tracked entity types'], suggestion: '{{trackedEntityTypesLabel}} or displayName' }, { words: ['org unit'], suggestion: '{{orgUnitLabel}}' }, { words: ['organisation unit'], suggestion: '{{orgUnitLabel}}' }, { words: ['registering unit'], suggestion: '{{orgUnitLabel}}' }, { words: ['follow-up', 'followup'], suggestion: '{{followUpLabel}}' }, ].sort((a, b) => Math.max(...b.words.map(w => w.length)) - Math.max(...a.words.map(w => w.length))); -// Bare msgids that customLabels.ts emits via i18n.t() as base translations. -// They cannot be templated — they ARE the fallback template — so must not be flagged. const FALLBACKS = new Set([ 'enrollment', 'enrollments', 'event', 'events', @@ -53,14 +47,10 @@ const FALLBACKS = new Set([ ]); const ALLOWLIST = new Set([ - // No plural custom label for `attribute` in the DHIS2 model. - 'Search by attributes', - // Plural `relationship` has no custom label; admin phrasing. - 'Ambiguous relationships, contact system administrator', + // Nothing added yet. ]); -// pot format may split long msgids across a `msgid ""` line and one or more -// continuation `"..."` lines; concatenate them into a single value. +// POT may split long msgids across multiple lines; concatenate them. function extractMsgids(potContents) { const lines = potContents.split('\n'); const msgids = []; @@ -84,12 +74,10 @@ function extractMsgids(potContents) { } function findViolations(msgid) { - // Strip placeholders first so `\bevent\b` doesn't match inside `{{eventLabel}}`. const stripped = msgid.replace(/\{\{[^}]*\}\}/g, ''); const hits = []; for (const { words, suggestion } of CUSTOM_TERMS) { for (const word of words) { - // Word-boundary + case-insensitive so `event` doesn't match `eventName`. const re = new RegExp(String.raw`\b${word}\b`, 'i'); if (re.test(stripped)) { hits.push({ word, suggestion }); From 5f010044b0f98295797c5d3e681edbcb14ba0cf8 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:06:37 +0000 Subject: [PATCH 05/12] fix: sonar qube --- i18n/en.pot | 4 ++-- scripts/verifyCustomTerminology.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 23e8bee1b9..4e160ef507 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-03T14:03:04.207Z\n" -"PO-Revision-Date: 2026-09-03T14:03:04.207Z\n" +"POT-Creation-Date: 2026-09-03T14:06:39.679Z\n" +"PO-Revision-Date: 2026-09-03T14:06:39.679Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index 360eec6c09..1a8fd8aa72 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -77,7 +77,7 @@ function extractMsgids(potContents) { } function findViolations(msgid) { - const stripped = msgid.replace(/\{\{[^}]*\}\}/g, ''); + const stripped = msgid.replace(/\{\{[^{}]*\}\}/g, ''); const hits = []; for (const { words, suggestion } of CUSTOM_TERMS) { for (const word of words) { From 11c1a89a0bf8d0fd5a65fce99dc608c7aff519a2 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:42:48 +0000 Subject: [PATCH 06/12] fix: (review) devin comments --- .github/workflows/verify-app.yml | 6 ++---- i18n/en.pot | 4 ++-- package.json | 2 +- scripts/verifyCustomTerminology.mjs | 10 ++++------ 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/workflows/verify-app.yml b/.github/workflows/verify-app.yml index efdfd9e6e1..925405b9eb 100644 --- a/.github/workflows/verify-app.yml +++ b/.github/workflows/verify-app.yml @@ -41,7 +41,7 @@ jobs: - name: Lint run: yarn linter:check - + i18n: runs-on: ubuntu-latest needs: install @@ -71,8 +71,6 @@ jobs: - name: TypeScript run: yarn tsc:check - - unit-tests: runs-on: ubuntu-latest @@ -177,7 +175,7 @@ jobs: build: runs-on: ubuntu-latest - needs: [lint, typescript, unit-tests] + needs: [lint, typescript, unit-tests, i18n] if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: - uses: actions/checkout@v6 diff --git a/i18n/en.pot b/i18n/en.pot index 4e160ef507..ce9b89f5d1 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-03T14:06:39.679Z\n" -"PO-Revision-Date: 2026-09-03T14:06:39.679Z\n" +"POT-Creation-Date: 2026-09-04T07:42:49.968Z\n" +"PO-Revision-Date: 2026-09-04T07:42:49.968Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/package.json b/package.json index 80aa4dc27e..849f3ef91e 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "verifyCacheVersion": "node scripts/verifyCacheVersion.js", "postinstall": "husky && patch-package && node scripts/createSymlinkToInternalPackages.mjs", "i18n:add": "d2-app-scripts i18n extract && git add ./i18n/", - "i18n:verify": "node scripts/verifyCustomTerminology.mjs", + "i18n:verify": "d2-app-scripts i18n extract && node scripts/verifyCustomTerminology.mjs", "clean": "./scripts/clean.sh && yarn workspaces run clean" }, "devDependencies": { diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index 1a8fd8aa72..e9959f9260 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -17,8 +17,8 @@ import { fileURLToPath } from 'node:url'; const POT = fileURLToPath(new URL('../i18n/en.pot', import.meta.url)); const CUSTOM_TERMS = [ - { words: ['enrollment'], suggestion: '{{enrollmentLabel}}' }, - { words: ['enrollments'], suggestion: '{{enrollmentsLabel}}' }, + { words: ['enrollment', 'enrolment'], suggestion: '{{enrollmentLabel}}' }, + { words: ['enrollments', 'enrolments'], suggestion: '{{enrollmentsLabel}}' }, { words: ['event'], suggestion: '{{eventLabel}}' }, { words: ['events'], suggestion: '{{eventsLabel}}' }, { words: ['program stage'], suggestion: '{{programStageLabel}}' }, @@ -30,10 +30,8 @@ const CUSTOM_TERMS = [ { words: ['attribute'], suggestion: '{{attributeLabel}}' }, { words: ['attributes'], suggestion: '{{attributesLabel}}' }, { words: ['tracked entity attribute', 'tracked entity attributes'], suggestion: '{{attributeLabel}}' }, - { words: ['org unit'], suggestion: '{{orgUnitLabel}}' }, - { words: ['organisation unit'], suggestion: '{{orgUnitLabel}}' }, - { words: ['registering unit'], suggestion: '{{orgUnitLabel}}' }, - { words: ['follow-up', 'followup'], suggestion: '{{followUpLabel}}' }, + { words: ['organisation unit', 'org unit', 'organization unit', 'registering unit'], suggestion: '{{orgUnitLabel}}' }, + { words: ['follow-up', 'followup', 'follow up'], suggestion: '{{followUpLabel}}' }, ].sort((a, b) => Math.max(...b.words.map(w => w.length)) - Math.max(...a.words.map(w => w.length))); const FALLBACKS = new Set([ From bba852ba94d696077c2b6e87546f0fc4e339263e Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:18:47 +0000 Subject: [PATCH 07/12] feat: update custom terminology checks for tracked entity attributes and improve log output --- i18n/en.pot | 4 ++-- scripts/verifyCustomTerminology.mjs | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index d623accffb..0e3c6ce139 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-07T09:56:21.750Z\n" -"PO-Revision-Date: 2026-09-07T09:56:21.750Z\n" +"POT-Creation-Date: 2026-09-07T10:18:48.993Z\n" +"PO-Revision-Date: 2026-09-07T10:18:48.993Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index e9959f9260..5b88e51785 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -29,7 +29,6 @@ const CUSTOM_TERMS = [ { words: ['relationships'], suggestion: '{{relationshipsLabel}}' }, { words: ['attribute'], suggestion: '{{attributeLabel}}' }, { words: ['attributes'], suggestion: '{{attributesLabel}}' }, - { words: ['tracked entity attribute', 'tracked entity attributes'], suggestion: '{{attributeLabel}}' }, { words: ['organisation unit', 'org unit', 'organization unit', 'registering unit'], suggestion: '{{orgUnitLabel}}' }, { words: ['follow-up', 'followup', 'follow up'], suggestion: '{{followUpLabel}}' }, ].sort((a, b) => Math.max(...b.words.map(w => w.length)) - Math.max(...a.words.map(w => w.length))); @@ -121,9 +120,7 @@ function main() { .filter(({ hits }) => hits.length > 0); if (violations.length === 0) { - console.log(`\n${DIVIDER}`); - console.log(' i18n:verify — no custom-terminology violations in en.pot ✓'); - console.log(`${DIVIDER}\n`); + console.log('i18n:verify — no custom-terminology violations in en.pot'); return; } From 3e6d486236cf3cb79149a18d03af4dfaeffea702 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:37:28 +0000 Subject: [PATCH 08/12] fix: update allow list --- i18n/en.pot | 4 ++-- scripts/verifyCustomTerminology.mjs | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 797440579c..27d97750f2 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-10T13:34:40.498Z\n" -"PO-Revision-Date: 2026-09-10T13:34:40.498Z\n" +"POT-Creation-Date: 2026-09-10T13:37:30.078Z\n" +"PO-Revision-Date: 2026-09-10T13:37:30.078Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index 5b88e51785..b3b083c322 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -48,6 +48,10 @@ const ALLOWLIST = new Set([ 'This is not an event program or the metadata is corrupt. See log for details.', // "event program" = DHIS2 programType, not user's event terminology '{{programName}} is an event program and does not have {{enrollmentsLabel}}.', + // NoSelectionsInfoBox — only renders when no program is selected, so no program-specific label to use + 'Choose a program and organisation unit to see existing data and create new records.', + // ProgramList — program picker shows generic label rather than any single program's custom label + 'Some programs are being filtered by the chosen organisation unit', ]); // POT may split long msgids across multiple lines; concatenate them. From 7dd5dbfb5193380d2ee1d18d04c9a4ab826e1010 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:44:57 +0000 Subject: [PATCH 09/12] fix: simplify error message in terminology verification --- i18n/en.pot | 4 ++-- scripts/verifyCustomTerminology.mjs | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 27d97750f2..d2de9c6387 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-10T13:37:30.078Z\n" -"PO-Revision-Date: 2026-09-10T13:37:30.078Z\n" +"POT-Creation-Date: 2026-09-10T13:44:58.996Z\n" +"PO-Revision-Date: 2026-09-10T13:44:58.996Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index b3b083c322..ec0b238804 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -106,11 +106,7 @@ function reportViolations(violations) { console.error(''); } console.error(`\x1b[1;31m${violations.length} custom-terminology violation(s) in en.pot.\x1b[0m\n`); - console.error('Fix by wrapping the offending word in a custom-terminology template. Example:'); - console.error(" BEFORE: i18n.t('Delete event')"); - console.error(" AFTER: i18n.t('Delete {{eventLabel}}', { eventLabel })"); - console.error(" Where `eventLabel = useTermLabel('event', { programId })`"); - console.error(' (or getTermLabel outside React).\n'); + console.error('Fix by wrapping the offending word in a custom-terminology template.\n'); console.error('If a hit is a genuine exception, add the exact msgid to the ALLOWLIST'); console.error('in scripts/verifyCustomTerminology.mjs.'); console.error(`\n${DIVIDER}\n`); From f54aa00ff8e5ced64a89e4c39f1e76950a08d026 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:03:51 +0000 Subject: [PATCH 10/12] fix: improve msgid extraction logic in terminology verification --- i18n/en.pot | 6 ++-- scripts/verifyCustomTerminology.mjs | 50 ++++++++++++++--------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index d2de9c6387..546af35287 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-10T13:44:58.996Z\n" -"PO-Revision-Date: 2026-09-10T13:44:58.996Z\n" +"POT-Creation-Date: 2026-09-16T19:03:52.451Z\n" +"PO-Revision-Date: 2026-09-16T19:03:52.451Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1008,7 +1008,7 @@ msgstr "Search by {{attributesLabel}}" msgid "Fill in at least {{count}} {{attributeLabel}} to search" msgid_plural "Fill in at least {{count}} {{attributeLabel}} to search" msgstr[0] "Fill in at least {{count}} {{attributeLabel}} to search" -msgstr[1] "Fill in at least {{count}} attributes to search" +msgstr[1] "Fill in at least {{count}} {{attributeLabel}} to search" msgid "Search {{attributeName}}" msgstr "Search {{attributeName}}" diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index ec0b238804..564585c04c 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -54,27 +54,26 @@ const ALLOWLIST = new Set([ 'Some programs are being filtered by the chosen organisation unit', ]); -// POT may split long msgids across multiple lines; concatenate them. -function extractMsgids(potContents) { - const lines = potContents.split('\n'); - const msgids = []; - let i = 0; - while (i < lines.length) { - const match = lines[i].match(/^msgid "(.*)"$/); - if (match) { - let value = match[1]; - let j = i + 1; - while (j < lines.length && /^"(.*)"$/.test(lines[j])) { - value += lines[j].match(/^"(.*)"$/)[1]; - j += 1; - } - if (value !== '') msgids.push({ value, line: i + 1 }); - i = j; - } else { - i += 1; +function extractStrings(potContents) { + const header = /^(msgid|msgstr(?:\[\d+\])?) "(.*)"$/; + const continuation = /^"(.*)"$/; + + const entries = []; + potContents.split('\n').forEach((line, idx) => { + const h = line.match(header); + if (h) { + entries.push({ kind: h[1], value: h[2], line: idx + 1 }); + return; } - } - return msgids; + const c = line.match(continuation); + if (c && entries.length) entries[entries.length - 1].value += c[1]; + }); + + let currentMsgid = null; + return entries.flatMap(({ kind, value, line }) => { + if (kind === 'msgid') currentMsgid = value; + return value && currentMsgid ? [{ value, line, msgid: currentMsgid, kind }] : []; + }); } function findViolations(msgid) { @@ -99,7 +98,8 @@ function reportViolations(violations) { console.error(`\n${DIVIDER}\n`); for (const v of violations) { console.error(` ${relPot}:${v.line}`); - console.error(` msgid: "${v.msgid}"`); + console.error(` msgid: "${v.msgid}"`); + if (v.value !== v.msgid) console.error(` ${v.kind.padEnd(11)} "${v.value}"`); for (const h of v.hits) { console.error(` ✗ "${h.word}" → use ${h.suggestion}`); } @@ -113,10 +113,10 @@ function reportViolations(violations) { } function main() { - const msgids = extractMsgids(readFileSync(POT, 'utf8')); - const violations = msgids - .filter(({ value }) => !FALLBACKS.has(value) && !ALLOWLIST.has(value)) - .map(({ value, line }) => ({ msgid: value, line, hits: findViolations(value) })) + const strings = extractStrings(readFileSync(POT, 'utf8')); + const violations = strings + .filter(({ msgid, value }) => !ALLOWLIST.has(msgid) && !FALLBACKS.has(value)) + .map(({ value, line, msgid, kind }) => ({ msgid, value, line, kind, hits: findViolations(value) })) .filter(({ hits }) => hits.length > 0); if (violations.length === 0) { From 2efdd2858f0bb1206e6afee93dddb2a0ff125c4e Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:06:08 +0000 Subject: [PATCH 11/12] fix: (review) remove sorting in terminology verification --- i18n/en.pot | 4 ++-- scripts/verifyCustomTerminology.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 546af35287..03ad77eb95 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-16T19:03:52.451Z\n" -"PO-Revision-Date: 2026-09-16T19:03:52.451Z\n" +"POT-Creation-Date: 2026-09-16T19:06:09.379Z\n" +"PO-Revision-Date: 2026-09-16T19:06:09.379Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index 564585c04c..2a8c839a0c 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -31,7 +31,7 @@ const CUSTOM_TERMS = [ { words: ['attributes'], suggestion: '{{attributesLabel}}' }, { words: ['organisation unit', 'org unit', 'organization unit', 'registering unit'], suggestion: '{{orgUnitLabel}}' }, { words: ['follow-up', 'followup', 'follow up'], suggestion: '{{followUpLabel}}' }, -].sort((a, b) => Math.max(...b.words.map(w => w.length)) - Math.max(...a.words.map(w => w.length))); +]; const FALLBACKS = new Set([ 'enrollment', 'enrollments', From 58d9cf9e076fd9886826bed5271e63a136e5b334 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:12:59 +0000 Subject: [PATCH 12/12] fix: (SonarQube) use modern syntax for accessing last entry in msgid extraction --- i18n/en.pot | 6 +++--- scripts/verifyCustomTerminology.mjs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 03ad77eb95..2d3a92ef20 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-16T19:06:09.379Z\n" -"PO-Revision-Date: 2026-09-16T19:06:09.379Z\n" +"POT-Creation-Date: 2026-09-16T19:13:00.453Z\n" +"PO-Revision-Date: 2026-09-16T19:13:00.453Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1008,7 +1008,7 @@ msgstr "Search by {{attributesLabel}}" msgid "Fill in at least {{count}} {{attributeLabel}} to search" msgid_plural "Fill in at least {{count}} {{attributeLabel}} to search" msgstr[0] "Fill in at least {{count}} {{attributeLabel}} to search" -msgstr[1] "Fill in at least {{count}} {{attributeLabel}} to search" +msgstr[1] "Fill in at least {{count}} attributes to search" msgid "Search {{attributeName}}" msgstr "Search {{attributeName}}" diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs index 2a8c839a0c..f8dae484cd 100644 --- a/scripts/verifyCustomTerminology.mjs +++ b/scripts/verifyCustomTerminology.mjs @@ -66,7 +66,7 @@ function extractStrings(potContents) { return; } const c = line.match(continuation); - if (c && entries.length) entries[entries.length - 1].value += c[1]; + if (c && entries.length) entries.at(-1).value += c[1]; }); let currentMsgid = null;