mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-09-15 07:10:01 -04:00
366 lines
11 KiB
JavaScript
366 lines
11 KiB
JavaScript
/* eslint no-console: 0 */
|
|
|
|
import {
|
|
parse as parseFtl,
|
|
Resource,
|
|
serialize as serializeFtl,
|
|
} from "@fluent/syntax";
|
|
import { format, parseISO } from "date-fns";
|
|
import { enUS } from "date-fns/locale";
|
|
import fluent from "fluent_conv";
|
|
import fs from "fs";
|
|
import fsp from "fs/promises";
|
|
import { glob } from "glob";
|
|
import difference from "lodash/difference";
|
|
import flatten from "lodash/flatten";
|
|
import sortBy from "lodash/sortBy";
|
|
import uniq from "lodash/uniq";
|
|
import path from "path";
|
|
import util from "util";
|
|
|
|
const I18N_BASE_PATH = path.join( __dirname, "..", "..", "src", "i18n" );
|
|
|
|
// Exceptions to the rule that all locales should be specified as two-letter
|
|
// language code only
|
|
const SUPPORTED_REGIONAL_LOCALES = [
|
|
"en-GB",
|
|
"en-NZ",
|
|
"es-AR",
|
|
"es-CO",
|
|
"es-CR",
|
|
"es-MX",
|
|
"fr-CA",
|
|
"pt-BR",
|
|
"zh-CN",
|
|
"zh-HK",
|
|
"zh-TW",
|
|
];
|
|
|
|
// Prepends an FTL translation with a checkmark for testing
|
|
function checkifyText( ftlTxt ) {
|
|
if ( ftlTxt.indexOf( "<0>" ) >= 0 ) {
|
|
return ftlTxt.replace( "<0>", "<0>✅" );
|
|
}
|
|
return `✅${ftlTxt}`;
|
|
}
|
|
|
|
// This will create a version of localizations that has a ✅ in
|
|
// front of all text, as a way to see if there are untranslated strings
|
|
function checkifyLocalizations( localizations ) {
|
|
// Mostly date format strings that will break with extra stuff in them
|
|
const keysToSkip = [
|
|
"date-month-year",
|
|
"Date-short-format",
|
|
"Date-this-year",
|
|
"date-format-short",
|
|
"datetime-format-short",
|
|
];
|
|
return Object.keys( localizations ).reduce( ( memo, key ) => {
|
|
memo[key] = localizations[key];
|
|
if ( keysToSkip.indexOf( key ) >= 0 ) {
|
|
return memo;
|
|
}
|
|
if ( memo[key].val ) {
|
|
memo[key].val = checkifyText( memo[key].val );
|
|
} else {
|
|
memo[key] = checkifyText( memo[key] );
|
|
}
|
|
return memo;
|
|
}, {} );
|
|
}
|
|
|
|
// Paths to all existing localizations
|
|
async function l10nFtlPaths() {
|
|
return glob( path.join( I18N_BASE_PATH, "l10n", "*.ftl" ) );
|
|
}
|
|
|
|
// Convert a single FTL file to JSON
|
|
const jsonifyPath = async ( inPath, outPath, options = { } ) => {
|
|
let ftlTxt;
|
|
try {
|
|
ftlTxt = await fsp.readFile( inPath );
|
|
} catch ( readFileErr ) {
|
|
console.error( `Could not read ${inPath}, skipping...` );
|
|
if ( options.debug ) {
|
|
console.error( "Failed to read input file with error:" );
|
|
console.error( readFileErr );
|
|
console.error( readFileErr.stack );
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Allow us to await a result to this callback-based method so we can return
|
|
// true when we know it succeeded
|
|
fluent.ftl2js[util.promisify.custom] = (
|
|
str,
|
|
params = {},
|
|
) => new Promise( ( resolve, reject ) => {
|
|
fluent.ftl2js(
|
|
str,
|
|
( err, res ) => {
|
|
if ( err ) {
|
|
reject( err );
|
|
} else {
|
|
resolve( res );
|
|
}
|
|
},
|
|
params,
|
|
);
|
|
} );
|
|
const ftl2js = util.promisify( fluent.ftl2js );
|
|
const localizations = await ftl2js( ftlTxt.toString( ), { respectComments: false } );
|
|
const massagedLocalizations = options.checkify
|
|
? checkifyLocalizations( localizations )
|
|
: localizations;
|
|
try {
|
|
await fsp.writeFile( outPath, `${JSON.stringify( massagedLocalizations, null, 2 )}\n` );
|
|
} catch ( writeFileErr ) {
|
|
console.error( `Failed to write ${outPath} with error:` );
|
|
console.error( writeFileErr );
|
|
console.error( writeFileErr.stack );
|
|
process.exit( );
|
|
}
|
|
return true;
|
|
};
|
|
|
|
// Assume all existing localized locales are supported
|
|
const supportedLocales = async ( ) => {
|
|
const paths = await l10nFtlPaths( );
|
|
return paths.map( f => path.basename( f, ".ftl" ) );
|
|
};
|
|
|
|
// Convert all valid localizations from Fluent to Fluent JSON
|
|
const jsonifyLocalizations = async ( options = {} ) => {
|
|
const locales = await supportedLocales( );
|
|
const converted = [];
|
|
const failed = [];
|
|
// Copy the source ftl to en.ftl, because en is the default locale and
|
|
// should not really be translated. This also lets you update strings.ftl
|
|
// and run this script without worrying about having at least one
|
|
// localization
|
|
fs.copyFileSync(
|
|
path.join( I18N_BASE_PATH, "strings.ftl" ),
|
|
path.join( I18N_BASE_PATH, "l10n", "en.ftl" ),
|
|
);
|
|
// For each locale, convert the .ftl files to .ftl.json files
|
|
await Promise.all( locales.map( async locale => {
|
|
const inPath = path.join( I18N_BASE_PATH, "l10n", `${locale}.ftl` );
|
|
const outPath = path.join( I18N_BASE_PATH, "l10n", `${locale}.ftl.json` );
|
|
if ( await jsonifyPath( inPath, outPath, options ) ) {
|
|
converted.push( locale );
|
|
} else {
|
|
failed.push( locale );
|
|
}
|
|
} ) );
|
|
console.log( `✅ Converted: ${converted.sort( ).join( ", " )}` );
|
|
if ( failed.length > 0 ) {
|
|
console.error( `❌ Failed: ${failed.sort( ).join( ", " )}` );
|
|
}
|
|
};
|
|
|
|
function isTranslationContainingDateFormat( item ) {
|
|
if ( item.type !== "Message" ) {
|
|
return false;
|
|
}
|
|
|
|
if ( item.id.name.startsWith( "date-format" ) ) {
|
|
return true;
|
|
}
|
|
|
|
return item.id.name.startsWith( "datetime-format" );
|
|
}
|
|
|
|
async function validateFtlFile( ftlPath, options = {} ) {
|
|
const ftlTxt = await fsp.readFile( ftlPath );
|
|
const ftl = parseFtl( ftlTxt.toString( ) );
|
|
const errors = [];
|
|
// Chalk does not expose a CommonJS module, so we have to do this
|
|
const { default: chalk } = await import( "chalk" );
|
|
const keys = {};
|
|
ftl.body.forEach( item => {
|
|
if ( item.type === "GroupComment" ) {
|
|
errors.push(
|
|
`Group comments are not allowed: ${chalk.gray( `## ${item.content.slice( 0, 100 )}` )}`,
|
|
);
|
|
}
|
|
if ( item.type === "Junk" ) {
|
|
let error = `Invalid Fluent syntax: ${chalk.gray( item.content.slice( 0, 100 ).trim( ) )}`;
|
|
if ( item.annotations ) {
|
|
error += item.annotations.map( anno => `\n\t${anno.message}` );
|
|
}
|
|
errors.push( error );
|
|
}
|
|
if ( item.type === "Message" ) {
|
|
if ( keys[item.id.name] ) {
|
|
errors.push( `Duplicate key: ${item.id.name}` );
|
|
} else {
|
|
keys[item.id.name] = true;
|
|
}
|
|
}
|
|
if ( isTranslationContainingDateFormat( item ) ) {
|
|
item.value.elements.forEach( textElement => {
|
|
try {
|
|
format( parseISO( "1970-01-01T00:00:00Z" ), textElement.value, {
|
|
locale: enUS,
|
|
} );
|
|
} catch ( error ) {
|
|
errors.push( `${item.id.name} = ${textElement.value}: ${error.message}` );
|
|
}
|
|
} );
|
|
}
|
|
} );
|
|
if ( errors.length > 0 ) {
|
|
console.error( `❌ ${errors.length} errors found in ${ftlPath}:` );
|
|
errors.forEach( error => {
|
|
console.error( chalk.red( "[Error]" ), error );
|
|
} );
|
|
if ( options.noExit ) {
|
|
return false;
|
|
}
|
|
process.exit( 1 );
|
|
}
|
|
if ( !options.quiet ) {
|
|
console.log( `✅ ${ftlPath} validated` );
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function validate() {
|
|
// Validate source strings
|
|
await validateFtlFile( path.join( I18N_BASE_PATH, "strings.ftl" ) );
|
|
// Validate translations
|
|
const l10nPaths = await l10nFtlPaths( );
|
|
const results = await Promise.allSettled( l10nPaths.map(
|
|
ftlPath => validateFtlFile( ftlPath, { quiet: true, noExit: true } ),
|
|
) );
|
|
if ( results.find( r => r.value === false ) ) {
|
|
process.exit( 1 );
|
|
} else {
|
|
console.log( "✅ l10n FTL validated" );
|
|
}
|
|
}
|
|
|
|
async function normalizeFtlFile( ftlPath, options = {} ) {
|
|
const isSource = !ftlPath.match( /l10n/ );
|
|
const ftlTxt = await fsp.readFile( ftlPath );
|
|
const ftl = parseFtl( ftlTxt.toString( ) );
|
|
const resourceComments = [];
|
|
const messages = [];
|
|
// Extract the elements that matter
|
|
ftl.body.forEach( item => {
|
|
if ( isSource && item.type === "ResourceComment" ) resourceComments.push( item );
|
|
if ( item.type === "Message" ) {
|
|
if ( !isSource ) item.comment = null;
|
|
messages.push( item );
|
|
}
|
|
} );
|
|
// Alphabetize the messages
|
|
const sortedMessages = messages.sort( ( msg1, msg2 ) => {
|
|
if ( msg1.id.name.toLowerCase( ) < msg2.id.name.toLowerCase( ) ) return -1;
|
|
if ( msg1.id.name.toLowerCase( ) > msg2.id.name.toLowerCase( ) ) return 1;
|
|
return 0;
|
|
} );
|
|
// Make a new Resource to serialize, ensuring all ResourceComments are at
|
|
// the top
|
|
const newResource = new Resource( [
|
|
...resourceComments,
|
|
...sortedMessages,
|
|
] );
|
|
let newFtlTxt = serializeFtl( newResource );
|
|
if ( !isSource ) {
|
|
const warning = `
|
|
### DO NOT EDIT THIS FILE!
|
|
###
|
|
### This file came from Crowdin and was normalized by ${path.basename( __filename )}.
|
|
### It will be overwritten during the next translation sync. To add
|
|
### translations, go to https://crowdin.com/editor/inaturalistios/724
|
|
###`.trim();
|
|
newFtlTxt = `${warning}\n\n${newFtlTxt}`;
|
|
}
|
|
await fsp.writeFile( ftlPath, newFtlTxt );
|
|
if ( !options.quiet ) {
|
|
console.log( `✅ ${ftlPath} normalized` );
|
|
}
|
|
}
|
|
|
|
async function normalize( ) {
|
|
await normalizeFtlFile( path.join( I18N_BASE_PATH, "strings.ftl" ) );
|
|
const l10nPaths = await l10nFtlPaths( );
|
|
await Promise.all( l10nPaths.map( ftlPath => normalizeFtlFile( ftlPath, { quiet: true } ) ) );
|
|
console.log( "✅ l10n FTL normalized" );
|
|
}
|
|
|
|
async function getKeys( ) {
|
|
const stringsPath = path.join( I18N_BASE_PATH, "strings.ftl" );
|
|
const ftlTxt = await fsp.readFile( stringsPath );
|
|
const ftl = parseFtl( ftlTxt.toString( ) );
|
|
return ftl.body.filter( item => item.type === "Message" ).map( msg => msg.id.name );
|
|
}
|
|
|
|
async function getKeysInUse( ) {
|
|
const paths = await glob( path.join( I18N_BASE_PATH, "..", "**", "*.{js,ts,jsx,tsx}" ) );
|
|
const allKeys = await Promise.all( paths.map( async srcPath => {
|
|
const src = await fsp.readFile( srcPath );
|
|
const tMatches = [...src.toString( ).matchAll( /[^a-z]t\(\s*["']([\w-_\s]+?)["']/g )];
|
|
const i18nkeyMatches = [...src.toString( ).matchAll( /i18nKey=["']([\w-_\s]+?)["']/g )];
|
|
return [...tMatches, ...i18nkeyMatches].map( match => match[1] );
|
|
} ) );
|
|
return sortBy( uniq( flatten( allKeys ) ) );
|
|
}
|
|
|
|
async function unused( ) {
|
|
const keys = await getKeys( );
|
|
const keysInUse = await getKeysInUse( );
|
|
const unusedKeys = difference( keys, keysInUse );
|
|
if ( unusedKeys.length === 0 ) {
|
|
console.log( "✅ No unused keys" );
|
|
} else {
|
|
console.error( `❌ ${unusedKeys.length} unused keys found: ${unusedKeys}` );
|
|
process.exit( 1 );
|
|
}
|
|
}
|
|
|
|
// Look for keys in the code that aren't in the source strings
|
|
async function untranslatable( ) {
|
|
const keys = await getKeys( );
|
|
const keysInUse = await getKeysInUse( );
|
|
const untranslatableKeys = difference( keysInUse, keys );
|
|
if ( untranslatableKeys.length === 0 ) {
|
|
console.log( "✅ No keys missing in strings.ftl" );
|
|
} else {
|
|
console.error(
|
|
`❌ ${untranslatableKeys.length} keys in use missing from strings.ftl: ${untranslatableKeys}`,
|
|
);
|
|
process.exit( 1 );
|
|
}
|
|
}
|
|
|
|
// Ensure localization file names match iNat convention and not Crowdin, e.g.
|
|
// we use "fr" instead of "fr-FR"
|
|
async function normalizeFileNames( ) {
|
|
const paths = await l10nFtlPaths();
|
|
return Promise.all( paths.map( async l10nPath => {
|
|
const locale = path.basename( l10nPath, ".ftl" );
|
|
const [lng, region] = locale.split( "-" );
|
|
// No need to move anything if there's no region
|
|
if ( !region ) return;
|
|
|
|
// We need to keep some regions
|
|
if ( SUPPORTED_REGIONAL_LOCALES.indexOf( locale ) >= 0 ) return;
|
|
|
|
// Everything else needs to be regionless
|
|
const newPath = path.join( path.dirname( l10nPath ), `${lng}.ftl` );
|
|
await fsp.rename( l10nPath, newPath );
|
|
} ) );
|
|
}
|
|
|
|
export {
|
|
jsonifyLocalizations,
|
|
normalize,
|
|
normalizeFileNames,
|
|
supportedLocales,
|
|
untranslatable,
|
|
unused,
|
|
validate,
|
|
};
|