Skip to content

Commit dcebb3a

Browse files
committed
Add script to check language lists stay in sync
This is also run on commits, so we get alerted when we forget to add new locales
1 parent 475442e commit dcebb3a

6 files changed

Lines changed: 303 additions & 45 deletions

File tree

.github/workflows/analyze.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ on:
55
- '**.dart'
66
- 'pubspec.yaml'
77
- 'analysis_options.yaml'
8+
- 'lib/l10n/**'
9+
- 'ios/Runner/Info.plist'
10+
- 'android/app/src/main/res/xml/locales_config.xml'
811
pull_request:
912
branches: [ master, ]
1013
paths:
1114
- '**/*.dart'
1215
- 'pubspec.yaml'
1316
- 'analysis_options.yaml'
17+
- 'lib/l10n/**'
18+
- 'ios/Runner/Info.plist'
19+
- 'android/app/src/main/res/xml/locales_config.xml'
1420
- '.github/actions/flutter-common/action.yml'
1521
- '.github/workflows/analyze.yml'
1622
workflow_call:
@@ -34,3 +40,6 @@ jobs:
3440

3541
- name: Analyze
3642
run: flutter analyze --no-fatal-infos --no-fatal-warnings
43+
44+
- name: Check locale lists are in sync
45+
run: dart run tool/check_locales.dart
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* This file is part of wger Workout Manager <https://github.com/wger-project>.
3+
* Copyright (c) 2026 - 2026 wger Team
4+
*
5+
* wger Workout Manager is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
/// Native display names for each supported locale, keyed by the locale name
20+
const Map<String, String> languageNativeNames = {
21+
'am': 'አማርኛ',
22+
'ar': 'العربية',
23+
'ca': 'Català',
24+
'cs': 'Čeština',
25+
'de': 'Deutsch',
26+
'el': 'Ελληνικά',
27+
'en': 'English',
28+
'es': 'Español',
29+
'fa': 'فارسی',
30+
'fil': 'Filipino',
31+
'fr': 'Français',
32+
'he': 'עברית',
33+
'hi': 'हिन्दी',
34+
'hr': 'Hrvatski',
35+
'hu': 'Magyar',
36+
'id': 'Bahasa Indonesia',
37+
'it': 'Italiano',
38+
'ja': '日本語',
39+
'ko': '한국어',
40+
'mk': 'Македонски',
41+
'nb': 'Norsk bokmål',
42+
'nl': 'Nederlands',
43+
'pl': 'Polski',
44+
'pt': 'Português',
45+
'pt_BR': 'Português (Brasil)',
46+
'pt_PT': 'Português (Portugal)',
47+
'ro': 'Română',
48+
'ru': 'Русский',
49+
'sk': 'Slovenčina',
50+
'ta': 'தமிழ்',
51+
'th': 'ไทย',
52+
'tr': 'Türkçe',
53+
'uk': 'Українська',
54+
'ur': 'اردو',
55+
'zh': '中文',
56+
'zh_Hant': '繁體中文',
57+
};

lib/widgets/core/settings/language.dart

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* This file is part of wger Workout Manager <https://github.com/wger-project>.
3-
* Copyright (c) 2026 wger Team
3+
* Copyright (c) 2026 - 2026 wger Team
44
*
55
* wger Workout Manager is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU Affero General Public License as published by
@@ -20,52 +20,12 @@ import 'package:flutter/material.dart';
2020
import 'package:provider/provider.dart';
2121
import 'package:wger/helpers/locale.dart';
2222
import 'package:wger/l10n/generated/app_localizations.dart';
23+
import 'package:wger/l10n/language_native_names.dart';
2324
import 'package:wger/providers/user.dart';
2425

25-
/// Native language names for each supported locale. Keys are produced by
26-
/// [encodeLocale] (e.g. `pl`, `pt_BR`, `zh_Hant`).
27-
const Map<String, String> _languageNativeNames = {
28-
'am': 'አማርኛ',
29-
'ar': 'العربية',
30-
'ca': 'Català',
31-
'cs': 'Čeština',
32-
'de': 'Deutsch',
33-
'el': 'Ελληνικά',
34-
'en': 'English',
35-
'es': 'Español',
36-
'fa': 'فارسی',
37-
'fil': 'Filipino',
38-
'fr': 'Français',
39-
'he': 'עברית',
40-
'hi': 'हिन्दी',
41-
'hr': 'Hrvatski',
42-
'hu': 'Magyar',
43-
'id': 'Bahasa Indonesia',
44-
'it': 'Italiano',
45-
'ja': '日本語',
46-
'ko': '한국어',
47-
'mk': 'Македонски',
48-
'nb': 'Norsk bokmål',
49-
'nl': 'Nederlands',
50-
'pl': 'Polski',
51-
'pt': 'Português',
52-
'pt_BR': 'Português (Brasil)',
53-
'pt_PT': 'Português (Portugal)',
54-
'ro': 'Română',
55-
'ru': 'Русский',
56-
'sk': 'Slovenčina',
57-
'ta': 'தமிழ்',
58-
'th': 'ไทย',
59-
'tr': 'Türkçe',
60-
'uk': 'Українська',
61-
'ur': 'اردو',
62-
'zh': '中文',
63-
'zh_Hant': '繁體中文',
64-
};
65-
6626
String _displayNameFor(Locale locale) {
67-
return _languageNativeNames[encodeLocale(locale)] ??
68-
_languageNativeNames[locale.languageCode] ??
27+
return languageNativeNames[encodeLocale(locale)] ??
28+
languageNativeNames[locale.languageCode] ??
6929
encodeLocale(locale);
7030
}
7131

pubspec.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1642,7 +1642,7 @@ packages:
16421642
source: hosted
16431643
version: "1.1.0"
16441644
xml:
1645-
dependency: transitive
1645+
dependency: "direct dev"
16461646
description:
16471647
name: xml
16481648
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"

pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ dev_dependencies:
7777
sdk: flutter
7878
build_runner: ^2.15.0
7979
cider: ^0.2.9
80+
xml: ^6.6.1
8081
drift_dev: ^2.31.0
8182
flutter_lints: ^6.0.0
8283
flutter_native_splash: ^2.4.7

tool/check_locales.dart

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/*
2+
* This file is part of wger Workout Manager <https://github.com/wger-project>.
3+
* Copyright (c) 2026 wger Team
4+
*
5+
* wger Workout Manager is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
library;
20+
21+
import 'dart:io';
22+
23+
import 'package:collection/collection.dart';
24+
import 'package:wger/l10n/language_native_names.dart';
25+
import 'package:xml/xml.dart';
26+
27+
const _listEq = ListEquality<String>();
28+
29+
const _arbDir = 'lib/l10n';
30+
const _iosPlist = 'ios/Runner/Info.plist';
31+
const _androidXml = 'android/app/src/main/res/xml/locales_config.xml';
32+
33+
/// Verifies that every place which enumerates the app's supported locales
34+
/// agrees with the canonical list derived from `lib/l10n/app_*.arb` filenames.
35+
///
36+
/// Checks:
37+
/// - `ios/Runner/Info.plist`
38+
/// - `android/app/src/main/res/xml/locales_config.xml`
39+
/// - `languageNativeNames` in `lib/l10n/language_native_names.dart`
40+
///
41+
/// Run `dart run tool/check_locales.dart` to check,
42+
/// or `dart run tool/check_locales.dart --fix` to rewrite the iOS/Android lists
43+
/// in place. The native-name map stays manual.
44+
void main(List<String> args) {
45+
final fix = args.contains('--fix');
46+
47+
final canonical = _readArbLocales();
48+
if (canonical.isEmpty) {
49+
stderr.writeln('No app_*.arb files found in $_arbDir');
50+
exit(2);
51+
}
52+
53+
// Dash form for iOS/Android, underscore form for Dart.
54+
final dashForm = canonical.map((c) => c.replaceAll('_', '-')).toList()..sort();
55+
final underscoreForm = [...canonical]..sort();
56+
57+
var drift = false;
58+
59+
drift |= _checkPlist(dashForm, fix: fix);
60+
drift |= _checkAndroidXml(dashForm, fix: fix);
61+
drift |= _checkLanguageDartCoverage(underscoreForm);
62+
63+
if (drift) {
64+
if (fix) {
65+
stdout.writeln('\nFixed drifted files. Re-run without --fix to verify.');
66+
} else {
67+
stderr.writeln('\nLocale lists are out of sync. Re-run with --fix to update.');
68+
exit(1);
69+
}
70+
} else {
71+
stdout.writeln('\nAll locale lists match (${canonical.length} locales).');
72+
}
73+
}
74+
75+
/// Canonical locale list from `app_<tag>.arb` filenames. Returns tags in
76+
/// underscore form (e.g. `pt_BR`, `zh_Hant`).
77+
List<String> _readArbLocales() {
78+
final dir = Directory(_arbDir);
79+
if (!dir.existsSync()) {
80+
return const [];
81+
}
82+
final tags = <String>[];
83+
for (final entity in dir.listSync()) {
84+
if (entity is! File) {
85+
continue;
86+
}
87+
final name = entity.uri.pathSegments.last;
88+
final match = RegExp(r'^app_(.+)\.arb$').firstMatch(name);
89+
if (match != null) {
90+
tags.add(match.group(1)!);
91+
}
92+
}
93+
tags.sort();
94+
return tags;
95+
}
96+
97+
bool _checkPlist(List<String> expectedDash, {required bool fix}) {
98+
final file = File(_iosPlist);
99+
if (!file.existsSync()) {
100+
stderr.writeln('[ios] $_iosPlist not found');
101+
return false;
102+
}
103+
final content = file.readAsStringSync();
104+
final doc = XmlDocument.parse(content);
105+
106+
// Plist structure: <plist><dict><key>foo</key><value/>...</dict></plist>.
107+
// Find the <key>CFBundleLocalizations</key> and take its following sibling.
108+
final dict = doc.rootElement.findElements('dict').firstOrNull;
109+
final keyNode = dict
110+
?.findElements('key')
111+
.firstWhereOrNull(
112+
(k) => k.innerText == 'CFBundleLocalizations',
113+
);
114+
final array = keyNode?.nextElementSibling;
115+
if (array == null || array.name.local != 'array') {
116+
stderr.writeln('[ios] CFBundleLocalizations <array> not found in $_iosPlist');
117+
return true;
118+
}
119+
120+
final actual = array.findElements('string').map((e) => e.innerText).toList()..sort();
121+
122+
if (_listEq.equals(actual, expectedDash)) {
123+
stdout.writeln('[ios] OK (${actual.length} locales)');
124+
return false;
125+
}
126+
127+
_reportDiff('ios', actual, expectedDash);
128+
129+
if (fix) {
130+
// Mutate only the <array>'s children
131+
array.children.clear();
132+
array.children.add(XmlText('\n\t\t\t'));
133+
for (final tag in expectedDash) {
134+
array.children.add(XmlElement(XmlName('string'), [], [XmlText(tag)]));
135+
array.children.add(XmlText('\n\t\t\t'));
136+
}
137+
// Trim the trailing inter-child indent and replace with the closing one.
138+
array.children.removeLast();
139+
array.children.add(XmlText('\n\t\t'));
140+
file.writeAsStringSync(doc.toXmlString());
141+
stdout.writeln('[ios] rewrote $_iosPlist');
142+
}
143+
return true;
144+
}
145+
146+
bool _checkAndroidXml(List<String> expectedDash, {required bool fix}) {
147+
final file = File(_androidXml);
148+
if (!file.existsSync()) {
149+
stderr.writeln('[android] $_androidXml not found');
150+
return false;
151+
}
152+
final doc = XmlDocument.parse(file.readAsStringSync());
153+
154+
final actual = doc.rootElement
155+
.findElements('locale')
156+
.map((e) => e.getAttribute('android:name')!)
157+
.toList();
158+
159+
// Convention: `en` first (matches `preferred-supported-locales: [en]` in
160+
// l10n.yaml), then alphabetical.
161+
final expectedOrdered = _enFirst(expectedDash);
162+
163+
if (_listEq.equals(actual, expectedOrdered)) {
164+
stdout.writeln('[android] OK (${actual.length} locales)');
165+
return false;
166+
}
167+
168+
_reportDiff('android', actual..sort(), expectedOrdered.toList()..sort());
169+
170+
if (fix) {
171+
final builder = XmlBuilder();
172+
builder.processing('xml', 'version="1.0" encoding="utf-8"');
173+
builder.element(
174+
'locale-config',
175+
namespaces: {'http://schemas.android.com/apk/res/android': 'android'},
176+
nest: () {
177+
for (final tag in expectedOrdered) {
178+
builder.element('locale', attributes: {'android:name': tag});
179+
}
180+
},
181+
);
182+
file.writeAsStringSync(
183+
builder.buildDocument().toXmlString(pretty: true, indent: ' '),
184+
);
185+
stdout.writeln('[android] rewrote $_androidXml');
186+
}
187+
return true;
188+
}
189+
190+
/// Returns the list with `en` first (if present), the rest alphabetically.
191+
List<String> _enFirst(List<String> tags) {
192+
final rest = [...tags.where((t) => t != 'en')]..sort();
193+
return tags.contains('en') ? ['en', ...rest] : rest;
194+
}
195+
196+
/// The native-name map is hand-curated, since this script doesn't know how
197+
/// to spell `Українська`, we can only check coverage, not auto-fix it.
198+
bool _checkLanguageDartCoverage(List<String> expectedUnderscore) {
199+
final actual = languageNativeNames.keys.toSet();
200+
final expected = expectedUnderscore.toSet();
201+
202+
final missing = expected.difference(actual).toList()..sort();
203+
final extra = actual.difference(expected).toList()..sort();
204+
205+
if (missing.isEmpty && extra.isEmpty) {
206+
stdout.writeln('[dart] OK (${actual.length} entries)');
207+
return false;
208+
}
209+
210+
if (missing.isNotEmpty) {
211+
stderr.writeln('[dart] missing native-name entries: ${missing.join(', ')}');
212+
stderr.writeln(' add them by hand to lib/l10n/language_native_names.dart');
213+
}
214+
if (extra.isNotEmpty) {
215+
stderr.writeln('[dart] stale native-name entries (no matching .arb): ${extra.join(', ')}');
216+
}
217+
return true;
218+
}
219+
220+
void _reportDiff(String label, List<String> actual, List<String> expected) {
221+
final actualSet = actual.toSet();
222+
final expectedSet = expected.toSet();
223+
final missing = expectedSet.difference(actualSet).toList()..sort();
224+
final extra = actualSet.difference(expectedSet).toList()..sort();
225+
if (missing.isNotEmpty) {
226+
stderr.writeln('[$label] missing: ${missing.join(', ')}');
227+
}
228+
if (extra.isNotEmpty) {
229+
stderr.writeln('[$label] extra: ${extra.join(', ')}');
230+
}
231+
}

0 commit comments

Comments
 (0)