-
Notifications
You must be signed in to change notification settings - Fork 15
i18n Audit Developer Guide
scripts/i18n_audit.php compares any locale against English and reports what is wrong with it.
Related: Internationalisation for how translations work, and Adding a Language for the process of creating one.
A locale does not announce that it has fallen behind. A missing key falls back to English and renders perfectly, so a half-translated module looks finished until somebody who actually reads that language opens it. English keeps moving; twenty-three other locales do not move with it.
The only way to know is to compare, and comparing 9,290 strings across 23 locales by hand is not a thing anybody does twice.
This was written after a simple question β "the German files could be really out of date, can you check?" β turned out to need a real answer rather than a spot check. German was at 47.2%. So were fourteen other locales.
php scripts/i18n_audit.php de # one locale, full detail
php scripts/i18n_audit.php de --keys # β¦and list every missing key
php scripts/i18n_audit.php --all # every locale, one line eachRead-only. It reads lang/ and writes nothing.
--all output, sorted worst first:
LOCALE COVER FILES KEYS PLACE EXTRA SAME-AS-EN
--------------------------------------------------------------
gu 43.2% 8 5277 0 0 44
de 47.2% 7 4901 0 0 159
uk 78.8% 1 1967 0 30 104
pt-BR 97.3% 1 251 3 14 192
| Column | Meaning |
|---|---|
COVER |
percentage of English strings present |
FILES |
whole namespaces absent β the entire module renders in English |
KEYS |
total English strings not present, including those inside missing files |
PLACE |
placeholder mismatches |
EXTRA |
keys English no longer has |
SAME-AS-EN |
values byte-identical to English |
A whole namespace absent. The worst kind, and the easiest to miss, because the module works β it is simply in English.
Individual strings absent from a file that otherwise exists. These produce English words in the middle of a translated sentence.
β οΈ A translation that drops{name}, or swaps%dand%s, is WORSE than a missing translation. A missing string falls back to English and looks obviously untranslated. A broken placeholder renders β so nobody reports it β and it either prints the wrong value or shows a raw token to the user.
Checked both ways: a placeholder the translator invented is as broken as one they lost.
Two styles are in use and a locale can be perfect in one and broken in the other:
-
{name}β the modern form. Compared as a sorted set, since order does not matter. -
%s%d%1$sβ the older form, still used across the tickets strings. Compared in sequence, because a swapped%dand%spasses any check that merely counts them.
Keys English no longer has. Dead weight, and a reliable sign the locale was written against an older version.
Reported as a number to look at, never as an error β plenty of strings are legitimately identical. A large number usually means a file was copied and never translated.
The first version reported German as having two broken placeholders. Both were wrong:
'percent_uploaded' => '{pct}% uploaded' // en
'percent_uploaded' => '{pct}% hochgeladen' // deThe printf pattern included the space flag β % d is legal printf β so "% u" in "% uploaded" matched as a specifier, and "90% of" versus "90 % der" matched as another. Both were ordinary prose containing a percent sign.
The space flag is now excluded:
preg_match_all('/%(?:\d+\$)?[-+0#]*[\d.]*[bcdeEfFgGosuxX]|%%/', $s, $printf);
// β no space in the flag setπ The lesson is not about regex. A checker that cries wolf gets ignored, and then the real mismatch it eventually finds gets ignored too. Prefer missing an exotic case to reporting a common one wrongly. After the change, false positives went from several per locale to zero, and the findings that survived were all genuine.
{{doubled braces}} are documentation examples of the workflow-variable syntax, not substitution tokens. The audit flags them when a translator translates the word inside. That is usually fine. Eyeball, do not auto-fix.
| Step | What happens |
|---|---|
| Flatten | Each lang file is included and flattened to dot paths β ['a' => ['b' => 'x']] becomes a.b. Comparing nested arrays directly would report a whole branch as one difference |
| Compare | Every English key is looked for in the locale; every locale key is looked for in English |
| Placeholders | Extracted from both values and compared as described above |
| Score |
COVER counts strings inside missing FILES as missing too, or a locale that is missing eight whole modules would score well on the files it does have |
looksDeliberatelyIdentical() suppresses the identical-to-English count for empty strings, one- and two-character values, pure punctuation, and a list of brand and protocol names nobody translates (FreeITSM, URL, API, SLA, Slackβ¦). Extend that list rather than living with the noise.
Colour key: π οΈ tool Β· π data Β· π docs
| π¨ | File | What it does |
|---|---|---|
| π οΈ | scripts/i18n_audit.php |
The whole thing. ~250 lines, no dependencies, read-only |
| π | lang/en/*.php |
The reference. 24 namespaces, 9,290 strings |
| π | lang/<locale>/*.php |
Compared against it |
| π | This page |
- Before adding a language β see what "complete" actually means today
- Before a release β catch a locale that a new module left behind
- After adding any new English strings β every locale just got slightly worse, and this says by how much
- When somebody says a translation looks wrong β placeholder mismatches are the usual cause, and they are invisible by inspection
β οΈ It cannot tell you whether a translation is good, only whether it is there and structurally sound. A locale can score 100% and read like a machine wrote it.
scripts/i18n_audit.php in full
<?php
/**
* i18n audit β compare a locale against English and report what is wrong with it.
*
* php scripts/i18n_audit.php de one locale, full detail
* php scripts/i18n_audit.php --all every locale, one summary line each
* php scripts/i18n_audit.php de --keys also list every missing key
*/
$root = dirname(__DIR__);
$langDir = $root . '/lang';
$args = array_slice($argv, 1);
$flags = array_values(array_filter($args, fn($a) => str_starts_with($a, '--')));
$locales = array_values(array_filter($args, fn($a) => !str_starts_with($a, '--')));
$showKeys = in_array('--keys', $flags, true);
$all = in_array('--all', $flags, true);
$available = array_values(array_filter(scandir($langDir), function ($d) use ($langDir) {
return $d !== '.' && $d !== '..' && $d !== 'en' && is_dir($langDir . '/' . $d);
}));
sort($available);
if ($all) {
$locales = $available;
} elseif (!$locales) {
fwrite(STDERR, "Usage: php scripts/i18n_audit.php <locale>|--all [--keys]\n");
exit(1);
}
/** Flatten a nested lang array into dot paths. */
function flatten(array $a, string $prefix = ''): array
{
$out = [];
foreach ($a as $k => $v) {
$key = $prefix === '' ? (string) $k : $prefix . '.' . $k;
if (is_array($v)) { $out += flatten($v, $key); } else { $out[$key] = (string) $v; }
}
return $out;
}
/** Every placeholder in a string. Named tokens sorted; printf specs in sequence. */
function placeholders(string $s): array
{
preg_match_all('/\{[a-zA-Z0-9_]+\}/', $s, $named);
$named = $named[0];
sort($named);
// β οΈ No space in the flag set β see section 4.
preg_match_all('/%(?:\d+\$)?[-+0#]*[\d.]*[bcdeEfFgGosuxX]|%%/', $s, $printf);
return ['named' => $named, 'printf' => $printf[0]];
}
/** Strings that are legitimately the same in most languages. */
function looksDeliberatelyIdentical(string $v): bool
{
$t = trim($v);
if ($t === '') return true;
if (mb_strlen($t) <= 2) return true;
if (preg_match('/^[\d\s\p{P}\p{S}]+$/u', $t)) return true;
$brands = ['FreeITSM', 'Email', 'URL', 'API', 'SSO', 'LDAP', 'SLA', 'CMDB', 'PDF', 'CSV',
'JSON', 'XML', 'Slack', 'Teams', 'Jira', 'Intune', 'Microsoft', 'Google'];
return in_array($t, $brands, true);
}
$enNs = array_map(fn($f) => basename($f, '.php'), glob($langDir . '/en/*.php'));
sort($enNs);
$en = [];
foreach ($enNs as $ns) {
$data = include $langDir . '/en/' . $ns . '.php';
$en[$ns] = is_array($data) ? flatten($data) : [];
}
$enTotal = array_sum(array_map('count', $en));
foreach ($locales as $loc) {
$dir = $langDir . '/' . $loc;
$missingFiles = $missingKeys = $extraKeys = $phProblems = [];
$untranslated = 0;
foreach ($enNs as $ns) {
$path = $dir . '/' . $ns . '.php';
if (!is_file($path)) { $missingFiles[] = $ns; continue; }
$data = @include $path;
if (!is_array($data)) continue;
$loc_ = flatten($data);
foreach ($en[$ns] as $key => $enVal) {
$full = $ns . '.' . $key;
if (!array_key_exists($key, $loc_)) { $missingKeys[] = $full; continue; }
$lv = $loc_[$key];
if ($lv === $enVal && !looksDeliberatelyIdentical($enVal)) $untranslated++;
$a = placeholders($enVal);
$b = placeholders($lv);
if ($a['named'] !== $b['named'] || $a['printf'] !== $b['printf']) {
$phProblems[] = $full;
}
}
foreach ($loc_ as $key => $v) {
if (!array_key_exists($key, $en[$ns])) $extraKeys[] = $ns . '.' . $key;
}
}
$missingFromFiles = 0;
foreach ($missingFiles as $ns) $missingFromFiles += count($en[$ns]);
$totalMissing = count($missingKeys) + $missingFromFiles;
$pct = round(100 * ($enTotal - $totalMissing) / $enTotal, 1);
// β¦reporting omitted here for brevity; see the file itself for the
// section-by-section output and the --all summary table.
}The copy above is trimmed to the logic. The file in the repository is the authority β it also carries the per-section reporting, the --all summary table, and the comments explaining each decision.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)