diff --git a/.eslintignore b/.eslintignore index 15726c77d9..a55a478983 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index c639dc541f..5166fa1b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/lib/locale.test.ts b/packages/lib/locale.test.ts index a8e39d4232..0627ef5ba5 100644 --- a/packages/lib/locale.test.ts +++ b/packages/lib/locale.test.ts @@ -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)); + } + } + }); + }); diff --git a/packages/lib/locale.ts b/packages/lib/locale.ts index d732cac5bb..d9477e523a 100644 --- a/packages/lib/locale.ts +++ b/packages/lib/locale.ts @@ -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> = {}; + +const pluralFunctions_: Record = {}; 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})`; } }; diff --git a/packages/tools/build-translation.js b/packages/tools/build-translation.ts similarity index 86% rename from packages/tools/build-translation.js rename to packages/tools/build-translation.ts index 9819fe9c97..32191060a8 100644 --- a/packages/tools/build-translation.js +++ b/packages/tools/build-translation.ts @@ -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 = {}; 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 { // "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([' ', '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`, '\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); diff --git a/packages/tools/utils/translation.ts b/packages/tools/utils/translation.ts index 62e6a7116f..1a649da034 100644 --- a/packages/tools/utils/translation.ts +++ b/packages/tools/utils/translation.ts @@ -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; +export interface TranslationStatus { + locale?: string; + languageName?: string; + translatorName: string; + percentDone: number; + untranslatedCount: number; + pluralForms?: string; +} + +export type Translations = Record; 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; } } diff --git a/packages/tools/website/utils/applyTranslations.test.ts b/packages/tools/website/utils/applyTranslations.test.ts index 02f3a91a31..6edc7eff4f 100644 --- a/packages/tools/website/utils/applyTranslations.test.ts +++ b/packages/tools/website/utils/applyTranslations.test.ts @@ -7,7 +7,7 @@ describe('applyTranslations', () => { { html: '
Translate me
', translations: { - 'Translate me': 'Traduis moi', + 'Translate me': ['Traduis moi'], }, htmlTranslated: '
\n\nTraduis moi\n\n
', }, @@ -19,14 +19,14 @@ describe('applyTranslations', () => { { html: '

\nFree your notes\n

', translations: { - 'Free your notes': 'Libérez vos notes', + 'Free your notes': ['Libérez vos notes'], }, htmlTranslated: '

\nLibérez vos notes\n

', }, { html: '
Save web pages
as notes
', translations: { - 'Save web pages
as notes': 'Sauvegardez vos pages web
en notes', + 'Save web pages
as notes': ['Sauvegardez vos pages web
en notes'], }, htmlTranslated: '
\nSauvegardez vos pages web
en notes\n
', }, diff --git a/packages/tools/website/utils/applyTranslations.ts b/packages/tools/website/utils/applyTranslations.ts index 5a6b84a8c9..235177872a 100644 --- a/packages/tools/website/utils/applyTranslations.ts +++ b/packages/tools/website/utils/applyTranslations.ts @@ -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 => { +const findTranslation = (englishString: string, translations: Translations): string => { const stringsToTry = unique([ englishString, englishString.replace(//gi, '
'), @@ -26,7 +27,8 @@ const findTranslation = (englishString: string, translations: Record { .replace(/{{> /gi, '{{> '); // Don't break Mustache partials }; -export default (html: string, _languageCode: string, translations: Record) => { +export default (html: string, _languageCode: string, translations: Translations) => { const output: string[] = []; interface State { diff --git a/packages/tools/website/utils/processTranslations.ts b/packages/tools/website/utils/processTranslations.ts index 33c9ed010c..a3eca9981c 100644 --- a/packages/tools/website/utils/processTranslations.ts +++ b/packages/tools/website/utils/processTranslations.ts @@ -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) => { +export default async (englishFilePath: string, translatedFilePath: string, languageCode: string, translations: Translations) => { let content = await readFile(englishFilePath, 'utf8'); content = content.replace('', ``); const translatedContent = await applyTranslations(content, languageCode, translations); diff --git a/packages/tools/website/utils/types.ts b/packages/tools/website/utils/types.ts index 3eb079ed05..a4123f5534 100644 --- a/packages/tools/website/utils/types.ts +++ b/packages/tools/website/utils/types.ts @@ -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; + htmlTranslations: Translations; lang: string; pathPrefix: string; }