1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-10-31 00:07:48 +02:00

All: Support for plural translations (#9033)

This commit is contained in:
Laurent Cozic
2023-10-11 12:17:46 +03:00
committed by GitHub
parent a98c323bf3
commit 0402fa624d
10 changed files with 213 additions and 54 deletions

View File

@@ -955,6 +955,7 @@ packages/renderer/noteStyle.js
packages/renderer/pathUtils.js
packages/renderer/utils.js
packages/tools/build-release-stats.js
packages/tools/build-translation.js
packages/tools/build-welcome.js
packages/tools/buildServerDocker.test.js
packages/tools/buildServerDocker.js

1
.gitignore vendored
View File

@@ -941,6 +941,7 @@ packages/renderer/noteStyle.js
packages/renderer/pathUtils.js
packages/renderer/utils.js
packages/tools/build-release-stats.js
packages/tools/build-translation.js
packages/tools/build-welcome.js
packages/tools/buildServerDocker.test.js
packages/tools/buildServerDocker.js

View File

@@ -1,4 +1,4 @@
import { closestSupportedLocale } from './locale';
import { closestSupportedLocale, parsePluralForm, setLocale, _n } from './locale';
describe('locale', () => {
@@ -15,4 +15,80 @@ describe('locale', () => {
}
});
it('should translate plurals - en_GB', () => {
setLocale('en_GB');
expect(_n('Copy Shareable Link', 'Copy Shareable Links', 1)).toBe('Copy Shareable Link');
expect(_n('Copy Shareable Link', 'Copy Shareable Links', 2)).toBe('Copy Shareable Links');
});
it('should translate plurals - fr_FR', () => {
setLocale('fr_FR');
expect(_n('Copy Shareable Link', 'Copy Shareable Links', 1)).toBe('Copier lien partageable');
expect(_n('Copy Shareable Link', 'Copy Shareable Links', 2)).toBe('Copier liens partageables');
});
it('should translate plurals - pl_PL', () => {
setLocale('pl_PL');
// Not the best test since 5 is the same as 2, but it's all I could find
expect(_n('Copy Shareable Link', 'Copy Shareable Links', 1)).toBe('Kopiuj udostępnialny link');
expect(_n('Copy Shareable Link', 'Copy Shareable Links', 2)).toBe('Kopiuj udostępnialne linki');
expect(_n('Copy Shareable Link', 'Copy Shareable Links', 5)).toBe('Kopiuj udostępnialne linki');
});
it('should parse the plural form', async () => {
const pluralForms = [
'nplurals=1; plural=0;',
'nplurals=2; plural=(n != 0);',
'nplurals=2; plural=(n != 1);',
'nplurals=2; plural=(n > 1);',
'nplurals=2; plural=(n%10!=1 || n%100==11);',
'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);',
'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);',
'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);',
'nplurals=3; plural=(n==0 ? 0 : n==1 ? 1 : 2);',
'nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2);',
'nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);',
'nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;',
'nplurals=3; plural=(n==1) ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;',
'nplurals=4; plural=(n%100==1 ? 1 : n%100==2 ? 2 : n%100==3 || n%100==4 ? 3 : 0);',
'nplurals=4; plural=(n==1 ? 0 : n==0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3);',
'nplurals=4; plural=(n==1 || n==11) ? 0 : (n==2 || n==12) ? 1 : (n > 2 && n < 20) ? 2 : 3;',
'nplurals=4; plural=(n==1) ? 0 : (n==2) ? 1 : (n != 8 && n != 11) ? 2 : 3;',
'nplurals=4; plural=(n==1) ? 0 : (n==2) ? 1 : (n == 3) ? 2 : 3;',
'nplurals=5; plural=n==1 ? 0 : n==2 ? 1 : (n>2 && n<7) ? 2 :(n>6 && n<11) ? 3 : 4;',
'nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);',
];
const pluralValues = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1],
[2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1],
[2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 1, 1, 1],
[2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2],
[0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2],
[1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2],
[2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2],
[2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2],
[2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2],
[0, 1, 2, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3],
[3, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 0, 1, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
[2, 0, 1, 2, 2, 2, 2, 2, 3, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2],
[3, 0, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
[4, 0, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4],
[0, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4],
];
for (let index = 0; index < pluralForms.length; index++) {
const form = pluralForms[index];
const pluralFn = parsePluralForm(form);
for (let i = 0; i < 128; ++i) {
expect(pluralValues[index][i]).toBe(pluralFn(i));
}
}
});
});

View File

@@ -7,6 +7,7 @@ interface StringToStringMap {
interface CodeToCountryMap {
[key: string]: string[];
}
type ParsePluralFormFunction = (n: number)=> number;
const codeToLanguageE_: StringToStringMap = {};
codeToLanguageE_['aa'] = 'Afar';
@@ -436,12 +437,51 @@ const codeToCountry_: CodeToCountryMap = {
let supportedLocales_: any = null;
let localeStats_: any = null;
const loadedLocales_: any = {};
const loadedLocales_: Record<string, Record<string, string[]>> = {};
const pluralFunctions_: Record<string, ParsePluralFormFunction> = {};
const defaultLocale_ = 'en_GB';
let currentLocale_ = defaultLocale_;
// Copied from https://github.com/eugeny-dementev/parse-gettext-plural-form
// along with the tests
export const parsePluralForm = (form: string): ParsePluralFormFunction => {
const pluralFormRegex = /^(\s*nplurals\s*=\s*[0-9]+\s*;\s*plural\s*=\s*(?:\s|[-?|&=!<>+*/%:;a-zA-Z0-9_()])+)$/m;
if (!pluralFormRegex.test(form)) throw new Error(`Plural-Forms is invalid: ${form}`);
if (!/;\s*$/.test(form)) {
form += ';';
}
const code = [
'var plural;',
'var nplurals;',
form,
'return (plural === true ? 1 : plural ? plural : 0);',
].join('\n');
// eslint-disable-next-line no-new-func -- There's a regex to check the form but it's still slighlty unsafe, eventually we should automatically generate all the functions in advance in build-translations.ts
return (new Function('n', code)) as ParsePluralFormFunction;
};
const getPluralFunction = (lang: string) => {
if (!(lang in pluralFunctions_)) {
const locale = closestSupportedLocale(lang);
const stats = localeStats()[locale];
if (!stats.pluralForms) {
pluralFunctions_[lang] = null;
} else {
pluralFunctions_[lang] = parsePluralForm(stats.pluralForms);
}
}
return pluralFunctions_[lang];
};
function defaultLocale() {
return defaultLocale_;
}
@@ -589,18 +629,45 @@ function _(s: string, ...args: any[]): string {
}
function _n(singular: string, plural: string, n: number, ...args: any[]) {
if (n > 1) return _(plural, ...args);
return _(singular, ...args);
if (['en_GB', 'en_US'].includes(currentLocale_)) {
if (n > 1) return _(plural, ...args);
return _(singular, ...args);
} else {
const pluralFn = getPluralFunction(currentLocale_);
const stringIndex = pluralFn ? pluralFn(n) : 0;
const strings = localeStrings(currentLocale_);
const result = strings[singular];
let translatedString = '';
if (result === undefined || !result.join('')) {
translatedString = singular;
} else {
translatedString = stringIndex < result.length ? result[stringIndex] : result[0];
}
try {
return sprintf(translatedString, ...args);
} catch (error) {
return `${translatedString} ${args.join(', ')} (Translation error: ${error.message})`;
}
}
}
const stringByLocale = (locale: string, s: string, ...args: any[]): string => {
const strings = localeStrings(locale);
let result = strings[s];
if (result === '' || result === undefined) result = s;
const result = strings[s];
let translatedString = '';
if (result === undefined || !result.join('')) {
translatedString = s;
} else {
translatedString = result[0];
}
try {
return sprintf(result, ...args);
return sprintf(translatedString, ...args);
} catch (error) {
return `${result} ${args.join(', ')} (Translation error: ${error.message})`;
return `${translatedString} ${args.join(', ')} (Translation error: ${error.message})`;
}
};

View File

@@ -1,5 +1,3 @@
'use strict';
// Dependencies:
//
// sudo apt install gettext sudo apt install translate-toolkit
@@ -7,34 +5,36 @@
// gettext v21+ is required as versions before that have bugs when parsing
// JavaScript template strings which means we would lose translations.
const rootDir = `${__dirname}/../..`;
import markdownUtils from '@joplin/lib/markdownUtils';
import { translationExecutablePath, removePoHeaderDate, mergePotToPo, parsePoFile, parseTranslations, TranslationStatus } from './utils/translation';
import { execCommand, isMac, insertContentIntoFile, filename, dirname, fileExtension } from './tool-utils.js';
import { countryDisplayName, countryCodeOnly } from '@joplin/lib/locale';
import { readdirSync, writeFileSync } from 'fs';
import { readFile } from 'fs/promises';
import { copy, mkdirpSync, remove } from 'fs-extra';
import { GettextExtractor, JsExtractors } from 'gettext-extractor';
const markdownUtils = require('@joplin/lib/markdownUtils').default;
const fs = require('fs-extra');
const { translationExecutablePath, removePoHeaderDate, mergePotToPo, parsePoFile, parseTranslations } = require('./utils/translation');
const rootDir = `${__dirname}/../..`;
const localesDir = `${__dirname}/locales`;
const libDir = `${rootDir}/packages/lib`;
const { execCommand, isMac, insertContentIntoFile, filename, dirname, fileExtension } = require('./tool-utils.js');
const { countryDisplayName, countryCodeOnly } = require('@joplin/lib/locale');
const { GettextExtractor, JsExtractors } = require('gettext-extractor');
function serializeTranslation(translation) {
function serializeTranslation(translation: string) {
const output = parseTranslations(translation);
return JSON.stringify(output, Object.keys(output).sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : +1), ' ');
return JSON.stringify(output, Object.keys(output).sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : +1), '\t');
}
function saveToFile(filePath, data) {
fs.writeFileSync(filePath, data);
function saveToFile(filePath: string, data: string) {
writeFileSync(filePath, data);
}
async function buildLocale(inputFile, outputFile) {
async function buildLocale(inputFile: string, outputFile: string) {
const r = await parsePoFile(inputFile);
const translation = serializeTranslation(r);
saveToFile(outputFile, translation);
return { headers: r.headers };
}
async function createPotFile(potFilePath) {
async function createPotFile(potFilePath: string) {
const excludedDirs = [
'./.git/*',
'./.github/*',
@@ -82,7 +82,7 @@ async function createPotFile(potFilePath) {
// basename, such as "exmaple.js", and "example.ts", we only keep the file
// with ".ts" extension (since the .js should be the compiled file).
const toProcess = {};
const toProcess: Record<string, string> = {};
for (const file of files) {
if (!file) continue;
@@ -172,7 +172,7 @@ async function createPotFile(potFilePath) {
await removePoHeaderDate(potFilePath);
}
function buildIndex(locales, stats) {
function buildIndex(locales: string[], stats: TranslationStatus[]) {
const output = [];
output.push('var locales = {};');
output.push('var stats = {};');
@@ -196,10 +196,10 @@ function buildIndex(locales, stats) {
return output.join('\n');
}
function availableLocales(defaultLocale) {
function availableLocales(defaultLocale: string) {
const output = [defaultLocale];
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
fs.readdirSync(localesDir).forEach((path) => {
readdirSync(localesDir).forEach((path) => {
if (fileExtension(path) !== 'po') return;
const locale = filename(path);
if (locale === defaultLocale) return;
@@ -208,7 +208,7 @@ function availableLocales(defaultLocale) {
return output;
}
function extractTranslator(regex, poContent) {
function extractTranslator(regex: RegExp, poContent: string) {
const translatorMatch = poContent.match(regex);
let translatorName = '';
@@ -225,13 +225,13 @@ function extractTranslator(regex, poContent) {
return translatorName;
}
function translatorNameToMarkdown(translatorName) {
function translatorNameToMarkdown(translatorName: string) {
const matches = translatorName.match(/^(.*?)\s*\((.*)\)$/);
if (!matches) return translatorName;
return `[${markdownUtils.escapeTitleText(matches[1])}](mailto:${markdownUtils.escapeLinkUrl(matches[2])})`;
}
async function translationStatus(isDefault, poFile) {
async function translationStatus(isDefault: boolean, poFile: string): Promise<TranslationStatus> {
// "apt install translate-toolkit" to have pocount
let pocountPath = 'pocount';
if (isMac()) pocountPath = translationExecutablePath('pocount');
@@ -248,7 +248,7 @@ async function translationStatus(isDefault, poFile) {
const untranslatedCount = Number(untranslatedMatches[1]);
let translatorName = '';
const content = await fs.readFile(poFile, 'utf-8');
const content = await readFile(poFile, 'utf-8');
translatorName = extractTranslator(/Last-Translator:\s*?(.*)/, content);
if (!translatorName) {
@@ -276,7 +276,7 @@ async function translationStatus(isDefault, poFile) {
};
}
function flagImageUrl(locale) {
function flagImageUrl(locale: string) {
const baseUrl = 'https://joplinapp.org/images/flags';
if (locale === 'ar') return `${baseUrl}/country-4x3/arableague.png`;
if (locale === 'eu') return `${baseUrl}/es/basque_country.png`;
@@ -292,11 +292,11 @@ function flagImageUrl(locale) {
return `${baseUrl}/country-4x3/${countryCodeOnly(locale).toLowerCase()}.png`;
}
function poFileUrl(locale) {
function poFileUrl(locale: string) {
return `https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/${locale}.po`;
}
function translationStatusToMdTable(status) {
function translationStatusToMdTable(status: TranslationStatus[]) {
const output = [];
output.push(['&nbsp;', 'Language', 'Po File', 'Last translator', 'Percent done'].join(' | '));
output.push(['---', '---', '---', '---', '---'].join('|'));
@@ -308,7 +308,7 @@ function translationStatusToMdTable(status) {
return output.join('\n');
}
async function updateReadmeWithStats(stats) {
async function updateReadmeWithStats(stats: TranslationStatus[]) {
await insertContentIntoFile(
`${rootDir}/README.md`,
'<!-- LOCALE-TABLE-AUTO-GENERATED -->\n',
@@ -317,12 +317,12 @@ async function updateReadmeWithStats(stats) {
);
}
async function translationStrings(poFilePath) {
async function translationStrings(poFilePath: string) {
const r = await parsePoFile(poFilePath);
return Object.keys(r.translations['']);
}
function deletedStrings(oldStrings, newStrings) {
function deletedStrings(oldStrings: string[], newStrings: string[]) {
const output = [];
for (const s1 of oldStrings) {
if (newStrings.includes(s1)) continue;
@@ -342,7 +342,7 @@ async function main() {
if (missingStringsCheckOnly) {
tempPotFilePath = `${localesDir}/joplin-temp-${Math.floor(Math.random() * 10000000)}.pot`;
await fs.copy(potFilePath, tempPotFilePath);
await copy(potFilePath, tempPotFilePath);
potFilePath = tempPotFilePath;
}
@@ -359,7 +359,7 @@ async function main() {
console.info(`Updated pot file. Total strings: ${oldPotStatus.untranslatedCount} => ${newPotStatus.untranslatedCount}`);
if (tempPotFilePath) await fs.remove(tempPotFilePath);
if (tempPotFilePath) await remove(tempPotFilePath);
const deletedCount = oldPotStatus.untranslatedCount - newPotStatus.untranslatedCount;
if (deletedCount >= 5) {
@@ -379,7 +379,7 @@ async function main() {
await execCommand(`cp "${potFilePath}" ` + `"${localesDir}/${defaultLocale}.po"`);
fs.mkdirpSync(jsonLocalesDir, 0o755);
mkdirpSync(jsonLocalesDir, 0o755);
const stats = [];
@@ -392,9 +392,10 @@ async function main() {
const poFilePäth = `${localesDir}/${locale}.po`;
const jsonFilePath = `${jsonLocalesDir}/${locale}.json`;
if (locale !== defaultLocale) await mergePotToPo(potFilePath, poFilePäth);
await buildLocale(poFilePäth, jsonFilePath);
const { headers } = await buildLocale(poFilePäth, jsonFilePath);
const stat = await translationStatus(defaultLocale === locale, poFilePäth);
stat.pluralForms = headers['Plural-Forms'];
stat.locale = locale;
stat.languageName = countryDisplayName(locale);
stats.push(stat);

View File

@@ -2,7 +2,16 @@ import { execCommand, isMac } from '../tool-utils';
import { existsSync, readFile } from 'fs-extra';
const gettextParser = require('gettext-parser');
export type Translations = Record<string, string>;
export interface TranslationStatus {
locale?: string;
languageName?: string;
translatorName: string;
percentDone: number;
untranslatedCount: number;
pluralForms?: string;
}
export type Translations = Record<string, string[]>;
export const removePoHeaderDate = async (filePath: string) => {
let sedPrefix = 'sed -i';
@@ -59,14 +68,14 @@ export const parseTranslations = (gettextTranslations: any) => {
if (!translations.hasOwnProperty(n)) continue;
if (n === '') continue;
const t = translations[n];
let translated = '';
let translated: string[] = [];
if (t.comments && t.comments.flag && t.comments.flag.indexOf('fuzzy') >= 0) {
// Don't include fuzzy translations
} else {
translated = t['msgstr'][0];
translated = t['msgstr'];
}
if (translated) output[n] = translated;
if (translated.length) output[n] = translated;
}
}

View File

@@ -7,7 +7,7 @@ describe('applyTranslations', () => {
{
html: '<div><span translate>Translate me</span></div>',
translations: {
'Translate me': 'Traduis moi',
'Translate me': ['Traduis moi'],
},
htmlTranslated: '<div>\n<span translate>\nTraduis moi\n</span>\n</div>',
},
@@ -19,14 +19,14 @@ describe('applyTranslations', () => {
{
html: '<h1 translate class="text-center">\nFree your <span class="frame-bg frame-bg-blue">notes</span>\n</h1>',
translations: {
'Free your <span class="frame-bg frame-bg-blue">notes</span>': 'Libérez vos <span class="frame-bg frame-bg-blue">notes</span>',
'Free your <span class="frame-bg frame-bg-blue">notes</span>': ['Libérez vos <span class="frame-bg frame-bg-blue">notes</span>'],
},
htmlTranslated: '<h1 translate class="text-center">\nLibérez vos <span class="frame-bg frame-bg-blue">notes</span>\n</h1>',
},
{
html: '<div translate>Save <span class="frame-bg frame-bg-blue">web pages</span> <br />as notes</div>',
translations: {
'Save <span class="frame-bg frame-bg-blue">web pages</span> <br>as notes': 'Sauvegardez vos <span class="frame-bg frame-bg-blue">pages web</span> <br>en notes',
'Save <span class="frame-bg frame-bg-blue">web pages</span> <br>as notes': ['Sauvegardez vos <span class="frame-bg frame-bg-blue">pages web</span> <br>en notes'],
},
htmlTranslated: '<div translate>\nSauvegardez vos <span class="frame-bg frame-bg-blue">pages web</span> <br>en notes\n</div>',
},

View File

@@ -1,5 +1,6 @@
import { unique } from '@joplin/lib/ArrayUtils';
import { attributesHtml, isSelfClosingTag } from '@joplin/renderer/htmlUtils';
import { Translations } from '../../utils/translation';
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = new Entities().encode;
const htmlparser2 = require('@joplin/fork-htmlparser2');
@@ -15,7 +16,7 @@ const trimHtml = (content: string) => {
.replace(/\t+$/, '');
};
const findTranslation = (englishString: string, translations: Record<string, string>): string => {
const findTranslation = (englishString: string, translations: Translations): string => {
const stringsToTry = unique([
englishString,
englishString.replace(/<br\/>/gi, '<br>'),
@@ -26,7 +27,8 @@ const findTranslation = (englishString: string, translations: Record<string, str
]) as string[];
for (const stringToTry of stringsToTry) {
if (translations[stringToTry]) return translations[stringToTry];
// Note that we don't currently support plural forms for the website
if (translations[stringToTry] && translations[stringToTry].length) return translations[stringToTry][0];
}
return englishString;
@@ -38,7 +40,7 @@ const encodeHtml = (decodedText: string): string => {
.replace(/{{&gt; /gi, '{{> '); // Don't break Mustache partials
};
export default (html: string, _languageCode: string, translations: Record<string, string>) => {
export default (html: string, _languageCode: string, translations: Translations) => {
const output: string[] = [];
interface State {

View File

@@ -1,8 +1,9 @@
import { mkdirp, readFile, writeFile } from 'fs-extra';
import { dirname } from 'path';
import { Translations } from '../../utils/translation';
import applyTranslations from './applyTranslations';
export default async (englishFilePath: string, translatedFilePath: string, languageCode: string, translations: Record<string, string>) => {
export default async (englishFilePath: string, translatedFilePath: string, languageCode: string, translations: Translations) => {
let content = await readFile(englishFilePath, 'utf8');
content = content.replace('<html lang="en-gb">', `<html lang="${languageCode}">`);
const translatedContent = await applyTranslations(content, languageCode, translations);

View File

@@ -1,5 +1,6 @@
import { Plan, StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
import { Sponsors } from '../../utils/loadSponsors';
import { Translations } from '../../utils/translation';
import { OpenGraphTags } from './openGraph';
export enum Env {
@@ -8,7 +9,7 @@ export enum Env {
}
export interface Locale {
htmlTranslations: Record<string, string>;
htmlTranslations: Translations;
lang: string;
pathPrefix: string;
}