Skip to content

Commit 86b7952

Browse files
authored
Merge pull request #3573 from atjn/locale-check
Improve sanity check for locales
2 parents 753780c + 0e53f12 commit 86b7952

File tree

5 files changed

+373
-43
lines changed

5 files changed

+373
-43
lines changed

.eslintrc.js

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ module.exports = {
112112
"getSerial": "readonly",
113113
"getTime": "readonly",
114114
"global": "readonly",
115+
"globalThis": "readonly",
115116
"HIGH": "readonly",
116117
"I2C1": "readonly",
117118
"Infinity": "readonly",

apps/locale/locale.html

+43-29
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,17 @@
2121
<label><input id="customize" type="checkbox" /> Advanced: Customize the date and time formats.</label>
2222
</div>
2323
<p>
24-
<span id="customize-warning"></span>
2524
<table id="examples-short-long"></table>
2625
<table id="examples"></table>
2726
</p>
2827

28+
<p id="customize-warning"></p>
29+
2930
<p>Then click <button id="upload" class="btn btn-primary">Upload</button></p>
3031

3132
<script src="../../core/lib/customize.js"></script>
3233
<script src="../../core/js/utils.js"></script>
34+
<script src="sanitycheck.js"></script>
3335
<script src="locales.js"></script>
3436

3537
<script>
@@ -103,32 +105,6 @@
103105
return '\\x'+(n+256).toString(16).slice(-2);
104106
}
105107

106-
// do some sanity checks
107-
Object.keys(locales).forEach(function(localeName) {
108-
var locale = locales[localeName];
109-
if (locale.trans && !locale.trans.on) console.error(localeName+": If translations are provided, 'on' *must* be included");
110-
if (distanceUnits[locale.distance[0]]===undefined) console.error(localeName+": Unknown distance unit "+locale.distance[0]);
111-
if (distanceUnits[locale.distance[1]]===undefined) console.error(localeName+": Unknown distance unit "+locale.distance[1]);
112-
if (speedUnits[locale.speed]===undefined) console.error(localeName+": Unknown speed unit "+locale.speed);
113-
if (locale.temperature!='°C' && locale.temperature!='°F')
114-
console.error(localeName+": Unknown temperature unit "+locale.temperature);
115-
// Now check that codepage is ok and all chars in translation are in that codepage
116-
const codePageName = "ISO8859-1";
117-
if (locale.codePage) codePageName = locale.codePage;
118-
const codePage = codePages[codePageName];
119-
if (codePage===undefined) console.error(localeName+": Unknown codePage "+codePageName);
120-
function checkChars(v,path) {
121-
if ("object"==typeof v)
122-
Object.keys(v).forEach(k=>checkChars(v[k], path+"."+k));
123-
else if ("string"==typeof v)
124-
for (var i=0;i<v.length;i++)
125-
if (codePageLookup(localeName, codePage, v[i])===undefined)
126-
console.error(` ... in ${path}[${i}]`);
127-
}
128-
checkChars(locale,localeName);
129-
});
130-
131-
132108
function createLocaleModule() {
133109
console.log(`Language ${lang}`);
134110

@@ -269,8 +245,6 @@
269245
}
270246

271247
var date = new Date();
272-
// TODO: This warning should have a link to an article explaining how the formats work, and how long they are allowed to be
273-
document.getElementById("customize-warning").innerText = customizeLocale ? "⚠️ If you make the formats too long, some apps will not work!" : "";
274248
document.getElementById("examples-short-long").innerHTML = `
275249
<tr><td class="table_t"></td><td style="font-weight:bold">Short</td><td style="font-weight:bold">Long</td></tr>
276250
<tr><td class="table_t">Day</td><td>${exports.dow(date,1)}</td><td>${exports.dow(date,0)}</td></tr>
@@ -332,27 +306,63 @@
332306
document.querySelector("input#short-date-pattern").addEventListener("input", event => {
333307
locale.datePattern["1"] = event.target.value;
334308
document.querySelector("td#short-date-pattern-output").innerText = patternToOutput(event.target.value);
309+
checkCustomLocale();
335310
});
336311
document.querySelector("input#long-date-pattern").addEventListener("input", event => {
337312
locale.datePattern["0"] = event.target.value;
338313
document.querySelector("td#long-date-pattern-output").innerText = patternToOutput(event.target.value);
314+
checkCustomLocale();
339315
});
340316
document.querySelector("input#short-time-pattern").addEventListener("input", event => {
341317
locale.timePattern["1"] = event.target.value;
342318
document.querySelector("td#short-time-pattern-output").innerText = patternToOutput(event.target.value);
319+
checkCustomLocale();
343320
});
344321
document.querySelector("input#long-time-pattern").addEventListener("input", event => {
345322
locale.timePattern["0"] = event.target.value;
346323
document.querySelector("td#long-time-pattern-output").innerText = patternToOutput(event.target.value);
324+
checkCustomLocale();
347325
});
348326
document.querySelector("input#meridian-am").addEventListener("input", event => {
349327
locale.ampm["0"] = event.target.value;
350328
document.querySelector("span#meridian-am-output").innerText = event.target.value;
329+
checkCustomLocale();
351330
});
352331
document.querySelector("input#meridian-pm").addEventListener("input", event => {
353332
locale.ampm["1"] = event.target.value;
354333
document.querySelector("span#meridian-pm-output").innerText = event.target.value;
334+
checkCustomLocale();
355335
});
336+
337+
let isCheckingLocale = false;
338+
// Polyfill for WebKit:
339+
const requestIdleCallback = globalThis.requestIdleCallback || ((func) => {func()});
340+
// Check that a custom locale follows some basic standards
341+
function checkCustomLocale(){
342+
if(isCheckingLocale) return;
343+
isCheckingLocale = true;
344+
setTimeout(() => {
345+
requestIdleCallback(() => {
346+
isCheckingLocale = false;
347+
const result = globalThis.checkLocale(locale, {speedUnits, distanceUnits, codePages, CODEPAGE_CONVERSIONS});
348+
let text = "";
349+
for(const w of [...result.errors, ...result.warnings]){
350+
text += `⚠️ ${w.name} ${w.error}.\n`;
351+
}
352+
const element = document.getElementById("customize-warning");
353+
if(text.length > 0){
354+
text += "\nIf you upload this locale, some apps might no longer work.\nPlease try to resolve the issues before uploading."
355+
element.classList.add("toast");
356+
element.classList.add("toast-error");
357+
}else{
358+
element.classList.remove("toast");
359+
element.classList.remove("toast-error");
360+
}
361+
element.innerText = text;
362+
}, {timeout: 2000})
363+
}, 500);
364+
}
365+
356366
}
357367
return getLocaleModule(false);
358368
}
@@ -416,6 +426,10 @@
416426
}else{
417427
createLocaleModule();
418428
}
429+
const warningsElement = document.getElementById("customize-warning")
430+
warningsElement.innerText = "";
431+
warningsElement.classList.remove("toast");
432+
warningsElement.classList.remove("toast-error");
419433
}
420434
customizeSelector.addEventListener('change', handleCustomizeChange);
421435
function handleCustomizeChange(){

apps/locale/locales.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ var locales = {
536536
temperature: '°C',
537537
ampm: { 0: "öö", 1: "ös" },
538538
timePattern: { 0: "%HH:%MM:%SS", 1: "%HH:%MM" },
539-
datePattern: { 0: "%d %w %Y %A", 1: "%d/%m/%Y" }, // 1 Mart 2020 Pazar // "01/03/2020"
539+
datePattern: { 0: "%d %B %Y %A", 1: "%d/%m/%Y" }, // 1 Mart 2020 Pazar // "01/03/2020"
540540
abmonth: "Oca,Sub,Mar,Nis,May,Haz,Tem,Agu,Eyl,Eki,Kas,Ara",
541541
month: "Ocak,Subat,Mart,Nisan,Mayis,Haziran,Temmuz,Agustos,Eylul,Ekim,Kasim,Aralik",
542542
abday: "Paz,Pzt,Sal,Car,Per,Cum,Cmt",

apps/locale/sanitycheck.js

+233
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/**
2+
* Maps the Espruino datetime format to min and max character lengths.
3+
* Used when determining if a format can produce outputs that are too short or long.
4+
*/
5+
const datetime_length_map = {
6+
// %A, %a, %B, %b vary depending on the locale, so they are calculated later
7+
"%Y": [4, 4],
8+
"%y": [2, 2],
9+
"%m": [2, 2],
10+
"%-m": [1, 2],
11+
"%d": [2, 2],
12+
"%-d": [1, 2],
13+
"%HH": [2, 2],
14+
"%MM": [2, 2],
15+
"%SS": [2, 2],
16+
};
17+
18+
/**
19+
* Takes an Espruino datetime format string and returns the minumum and maximum possible length of characters that the format could use.
20+
*
21+
* @param {string} datetimeEspruino - The datetime Espruino format
22+
* @returns first the minimum possible length, second the maximum possible length.
23+
*/
24+
function getLengthOfDatetimeFormat(name, datetimeEspruino, locale, errors) {
25+
26+
// Generate the length_map based on the actual names in the locale
27+
const length_map = {...datetime_length_map};
28+
for(const [symbol, values] of [
29+
["%A", locale.day],
30+
["%a", locale.abday],
31+
["%B", locale.month],
32+
["%b", locale.abmonth],
33+
]){
34+
const length = [Infinity, 0];
35+
for(const value of values.split(",")){
36+
if(length[0] > value.length) length[0] = value.length;
37+
if(length[1] < value.length) length[1] = value.length;
38+
}
39+
length_map[symbol] = length;
40+
}
41+
42+
// Find the length of the output
43+
let formatLength = [0, 0];
44+
let i = 0;
45+
while (i < datetimeEspruino.length) {
46+
if (datetimeEspruino[i] === "%") {
47+
let match;
48+
for(const symbolLength of [2, 3]){
49+
const length = length_map[datetimeEspruino.substring(i, i+symbolLength)];
50+
if(length){
51+
match = {
52+
length,
53+
symbolLength,
54+
}
55+
}
56+
}
57+
if(match){
58+
formatLength[0] += match.length[0];
59+
formatLength[1] += match.length[1];
60+
i += match.symbolLength;
61+
}else{
62+
errors.push({name, value: datetimeEspruino, lang: locale.lang, error: `uses an unsupported format symbol: ${datetimeEspruino.substring(i, i+3)}`});
63+
formatLength[0]++;
64+
formatLength[1]++;
65+
i++;
66+
}
67+
} else {
68+
formatLength[0]++;
69+
formatLength[1]++;
70+
i++;
71+
}
72+
}
73+
return formatLength;
74+
}
75+
76+
/**
77+
* Checks that a locale conforms to some basic standards.
78+
*
79+
* @param {object} locale - The locale to test.
80+
* @param {object} meta - Meta information that is needed to check if locales are supported.
81+
* @param {object} meta.speedUnits - The table of speed units.
82+
* @param {object} meta.distanceUnits - The table of distance units.
83+
* @param {object} meta.codePages - Custom codepoint mappings.
84+
* @param {object} meta.CODEPAGE_CONVERSIONS - The table of custom codepoint conversions.
85+
* @returns an object with an array of errors and warnings.
86+
*/
87+
function checkLocale(locale, {speedUnits, distanceUnits, codePages, CODEPAGE_CONVERSIONS}){
88+
const errors = [];
89+
const warnings = [];
90+
91+
const speeds = Object.keys(speedUnits);
92+
const distances = Object.keys(distanceUnits);
93+
94+
checkLength("lang", locale.lang, 5, undefined);
95+
checkLength("decimal point", locale.decimal_point, 1, 1);
96+
checkLength("thousands separator", locale.thousands_sep, 1, 1);
97+
checkLength("speed", locale.speed, 2, 4);
98+
checkIsIn("speed", locale.speed, "speedUnits", speeds);
99+
checkLength("distance", locale.distance["0"], 1, 3);
100+
checkLength("distance", locale.distance["1"], 1, 3);
101+
checkIsIn("distance", locale.distance["0"], "distanceUnits", distances);
102+
checkIsIn("distance", locale.distance["1"], "distanceUnits", distances);
103+
checkLength("temperature", locale.temperature, 1, 2);
104+
checkLength("meridian", locale.ampm["0"], 1, 3);
105+
checkLength("meridian", locale.ampm["1"], 1, 3);
106+
warnIfNot("long time format", locale.timePattern["0"], "%HH:%MM:%SS");
107+
warnIfNot("short time format", locale.timePattern["1"], "%HH:%MM");
108+
checkFormatLength("long time", locale.timePattern["0"], 8, 8);
109+
checkFormatLength("short time", locale.timePattern["1"], 5, 5);
110+
checkFormatLength("long date", locale.datePattern["0"], 6, 14);
111+
checkFormatLength("short date", locale.datePattern["1"], 6, 11);
112+
checkArrayLength("short months", locale.abmonth.split(","), 12, 12);
113+
checkArrayLength("long months", locale.month.split(","), 12, 12);
114+
checkArrayLength("short days", locale.abday.split(","), 7, 7);
115+
checkArrayLength("long days", locale.day.split(","), 7, 7);
116+
for (const abmonth of locale.abmonth.split(",")) {
117+
checkLength("short month", abmonth, 2, 4);
118+
}
119+
for (const month of locale.month.split(",")) {
120+
checkLength("month", month, 3, 11);
121+
}
122+
for (const abday of locale.abday.split(",")) {
123+
checkLength("short day", abday, 2, 4);
124+
}
125+
for (const day of locale.day.split(",")) {
126+
checkLength("day", day, 3, 13);
127+
}
128+
checkEncoding(locale);
129+
130+
function checkLength(name, value, min, max) {
131+
if(typeof value !== "string"){
132+
errors.push({name, value, lang: locale.lang, error: `must be defined and must be a string`});
133+
return;
134+
}
135+
if (min && value.length < min) {
136+
errors.push({name, value, lang: locale.lang, error: `must be longer than ${min-1} characters`});
137+
}
138+
if (max && value.length > max) {
139+
errors.push({name, value, lang: locale.lang, error: `must be shorter than ${max+1} characters`});
140+
}
141+
}
142+
function checkArrayLength(name, value, min, max){
143+
if(!Array.isArray(value)){
144+
errors.push({name, value, lang: locale.lang, error: `must be defined and must be an array`});
145+
return;
146+
}
147+
if (min && value.length < min) {
148+
errors.push({name, value, lang: locale.lang, error: `array must be longer than ${min-1} entries`});
149+
}
150+
if (max && value.length > max) {
151+
errors.push({name, value, lang: locale.lang, error: `array must be shorter than ${max+1} entries`});
152+
}
153+
}
154+
function checkFormatLength(name, value, min, max) {
155+
const length = getLengthOfDatetimeFormat(name, value, locale, errors);
156+
if (min && length[0] < min) {
157+
errors.push({name, value, lang: locale.lang, error: `output must be longer than ${min-1} characters`});
158+
}
159+
if (max && length[1] > max) {
160+
errors.push({name, value, lang: locale.lang, error: `output must be shorter than ${max+1} characters`});
161+
}
162+
}
163+
function checkIsIn(name, value, listName, list) {
164+
if (!list.includes(value)) {
165+
errors.push({name, value, lang: locale.lang, error: `must be included in the ${listName} map`});
166+
}
167+
}
168+
function warnIfNot(name, value, expected) {
169+
if (value !== expected) {
170+
warnings.push({name, value, lang: locale.lang, error: `might not work in some apps if it is not "${expected}"`});
171+
}
172+
}
173+
function checkEncoding(object) {
174+
if(!object){
175+
return;
176+
}else if(typeof object === "string"){
177+
for(const char of object){
178+
const charCode = char.charCodeAt();
179+
if (charCode >= 32 && charCode < 128) {
180+
// ASCII - fully supported
181+
continue;
182+
} else if (codePages["ISO8859-1"].map.indexOf(char) >= 0) {
183+
// At upload time, the char can be converted to a custom codepage
184+
continue;
185+
} else if (CODEPAGE_CONVERSIONS[char]) {
186+
// At upload time, the char can be converted to a similar supported char
187+
continue;
188+
}
189+
errors.push({name: `character ${char}`, value: char, lang: locale.lang, error: `is not supported by BangleJS`});
190+
}
191+
}else{
192+
for(const [key, value] of Object.entries(object)){
193+
if(key === "icon") continue;
194+
checkEncoding(value);
195+
}
196+
}
197+
}
198+
199+
return {errors, warnings};
200+
}
201+
202+
/**
203+
* Checks that an array of locales conform to some basic standards.
204+
*
205+
* @param {object[]} locales - The locales to test.
206+
* @param {object} meta.speedUnits - The table of speed units.
207+
* @param {object} meta.distanceUnits - The table of distance units.
208+
* @param {object} meta.codePages - Custom codepoint mappings.
209+
* @param {object} meta.CODEPAGE_CONVERSIONS - The table of custom codepoint conversions.
210+
* @returns an object with an array of errors and warnings.
211+
*/
212+
function checkLocales(locales, meta){
213+
let errors = [];
214+
let warnings = [];
215+
216+
for(const locale of Object.values(locales)){
217+
const result = checkLocale(locale, meta);
218+
errors = [...errors, ...result.errors];
219+
warnings = [...warnings, ...result.warnings];
220+
}
221+
222+
return {errors, warnings};
223+
}
224+
225+
if(typeof module !== "undefined"){
226+
module.exports = {
227+
checkLocale,
228+
checkLocales,
229+
};
230+
}else{
231+
globalThis.checkLocale = checkLocale;
232+
globalThis.checkLocales = checkLocales;
233+
}

0 commit comments

Comments
 (0)