digitalfabrik/integreat-app

View on GitHub
translations/tools/manage.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import { program } from 'commander'
import { parse } from 'csv-parse/sync'
import { stringify } from 'csv-stringify'
import flat from 'flat'
import fs from 'fs'
import { fromPairs, isEmpty, isEqual, isString, mapValues, merge, sortBy, toPairs, without, zip } from 'lodash'
import path from 'path'

import { TranslationsType } from '../src'
import config from '../src/config'
import { KeyValueType } from '../src/types'

const { unflatten } = flat

const XCODE_LANGUAGES_MAP: Record<string, string> = {
  'sr-Cyrl': 'sr',
  pes: 'fa',
  prs: 'fa-AF',
  kmr: 'ku',
  'zh-CN': 'zh-HANS',
} as const

type TransformationFunctionType = (val: string | KeyValueType, key?: string, obj?: KeyValueType) => string
const mapStringValuesDeep = (obj: KeyValueType, fn: TransformationFunctionType): KeyValueType =>
  mapValues(obj, (val, key) => (!isString(val) ? mapStringValuesDeep(val, fn) : fn(val, key, obj)))

const flattenModules = (modules: KeyValueType): Record<string, string> => flat(modules)

type LanguagePair = [string, string]

const writePairs = (toPath: string, sourceLanguagePairs: LanguagePair[], pairs: LanguagePair[], name: string): void => {
  const output = fs.createWriteStream(`${toPath}/${name}.csv`)
  output.on('close', () => {
    console.log(`Successfully written ${name}.csv.`)
  })
  output.on('error', e => {
    console.log(`Failed to write ${name}.csv ${e}`)
  })
  const zippedLanguagePairs = zip(sourceLanguagePairs, pairs) as [LanguagePair, LanguagePair][]
  const withSourceLanguagePairs = zippedLanguagePairs.map(
    ([[_unusedSourceKey, sourceTranslation], [key, translation]]) => {
      if (!translation) {
        console.log('Missing translation:', key, '[', name, ']')
      }
      return [key, sourceTranslation, translation]
    },
  )
  stringify([['key', 'source_language', 'target_language'], ...withSourceLanguagePairs]).pipe(output)
}

const EMPTY_MODULE = {}

type KeyModuleType = [string, Record<string, KeyValueType>]
type ModuleType = [string, KeyValueType]

const getModulesByLanguage = (keyModuleArray: KeyModuleType[], language: string): ModuleType[] =>
  keyModuleArray.map(([moduleKey, module]) => [moduleKey, module[language] || EMPTY_MODULE])

/**
 * Create a translation skeleton which has all keys set to an empty string
 *
 * @param language The language which serves as the skeleton
 * @param moduleArray The array of modules (containing all languages) with its keys
 * @returns {*}
 */
const createSkeleton = (language: string, moduleArray: KeyModuleType[]): ModuleType[] =>
  getModulesByLanguage(moduleArray, language).map(([moduleKey, module]) => {
    if (module === EMPTY_MODULE) {
      throw new Error(`Module ${moduleKey} is missing in source language!`)
    }

    return [moduleKey, mapStringValuesDeep(module, _unusedTranslation => '')]
  })

const mergeByLanguageModule = (
  byLanguageModule: ModuleType[],
  skeleton: ModuleType[],
  sourceLanguage: string,
): ModuleType[] => {
  const zippedModuleArray = zip(skeleton, byLanguageModule) as [ModuleType, ModuleType][]
  return zippedModuleArray.map(([[_unusedSkModuleKey, skModule], [moduleKey, module]]) => {
    const diff = without(Object.keys(flat(module)), ...Object.keys(flat(skModule)))

    if (!isEmpty(diff)) {
      throw new Error(`The keys [${diff}] are missing in module ${moduleKey} 
                        (with the  source language ${sourceLanguage})!`)
    }

    return [moduleKey, merge({}, skModule, module)]
  })
}

const writeCsvFromJson = (
  json: TranslationsType,
  toPath: string,
  sourceLanguage: string,
  supportedLanguages: string[],
) => {
  const moduleArray = sortBy(toPairs(json), ([moduleKey, _unusedModule]) => moduleKey) // Sort by module key

  const byLanguageModuleArray = fromPairs<ModuleType[]>(
    supportedLanguages
      .filter(language => language !== sourceLanguage) // source language is not a target language
      .map(targetLanguage => [targetLanguage, getModulesByLanguage(moduleArray, targetLanguage)]),
  )
  const skeleton = createSkeleton(sourceLanguage, moduleArray)
  const filledByLanguageModuleArray = mapValues(byLanguageModuleArray, byLanguageModule =>
    mergeByLanguageModule(byLanguageModule, skeleton, sourceLanguage),
  )
  const flattenByLanguage = mapValues(filledByLanguageModuleArray, modules => flattenModules(fromPairs(modules)))
  const flattenSourceLanguage = flattenModules(fromPairs(getModulesByLanguage(moduleArray, sourceLanguage)))
  Object.entries(flattenByLanguage).forEach(([languageKey, modules]) =>
    writePairs(toPath, toPairs(flattenSourceLanguage), toPairs(modules), languageKey),
  )
  console.log(`Keys in source language ${sourceLanguage}: ${Object.keys(flattenSourceLanguage).length}`)
}

const loadModules = (csvFile: string, csvColumn: string): Record<string, KeyValueType> => {
  // .trim() is needed to strip the BOM
  const inputString = fs
    .readFileSync(csvFile, {
      encoding: 'utf8',
    })
    .trim()
  const records: Record<string, string>[] = parse(inputString, {
    columns: true,
    skip_empty_lines: true,
  })
  const flattened = fromPairs(
    records.map(record => [record.key, record[csvColumn]]).filter(([_unusedKey, translation]) => !!translation),
  )
  return unflatten(flattened)
}

const writeJsonFromCsv = (translations: string, toPath: string, sourceLanguage: string) => {
  fs.readdir(translations, (err, files) => {
    if (err) {
      throw err
    }

    const csvs = files.map(file => `${translations}/${file}`).filter(file => path.extname(file) === '.csv')

    if (isEmpty(csvs)) {
      throw new Error('A minimum of one CSV is required in order to build a JSON!')
    }

    const byLanguageModules = fromPairs(
      csvs.map(csvFile => [path.basename(csvFile, '.csv'), loadModules(csvFile, 'target_language')]),
    )
    const sourceLanguageCsv = csvs[0]
    if (!sourceLanguageCsv) {
      throw new Error('Need at least one csv!')
    }
    const sourceModules = loadModules(sourceLanguageCsv, 'source_language')
    const flatSourceModules: Record<string, string> = flat(sourceModules)
    // Show which source languages differ
    csvs.forEach(csv => {
      const csvModule = loadModules(csv, 'source_language')
      const flatCsv: Record<string, string> = flat(csvModule)
      const differingKey = Object.keys(flatCsv).find(key => flatSourceModules[key] !== flatCsv[key])

      if (differingKey) {
        console.log('differing key: ', differingKey)
        console.log(csvs[0], ': ', flatSourceModules[differingKey])
        console.log(csv, ': ', flatCsv[differingKey])
        console.log()
      }
    })

    if (!csvs.every(csv => isEqual(loadModules(csv, 'source_language'), sourceModules))) {
      throw new Error("The 'source_language' column must be the same in every CSV!")
    }

    const byLanguageModulesWithSourceLanguage = { ...byLanguageModules, [sourceLanguage]: sourceModules }
    // Sort by language key, but sourceLanguage should be first
    const languageKeys = [sourceLanguage, ...Object.keys(byLanguageModules).sort()]
    // Sort by module key
    const moduleKeys = Object.keys(sourceModules).sort()
    const json = fromPairs(
      moduleKeys.map(moduleKey => [
        moduleKey,
        fromPairs(
          languageKeys.map(languageKey => [languageKey, byLanguageModulesWithSourceLanguage[languageKey]?.[moduleKey]]),
        ),
      ]),
    )
    fs.writeFileSync(toPath, `${JSON.stringify(json, null, 2)}\n`, 'utf-8')
    const logMessages = Object.entries(json).map(
      ([moduleKey, module]) =>
        `Languages in module ${moduleKey}: ${Object.keys(module).length} (${Object.keys(module)})`,
    )
    logMessages.forEach(message => console.log(message))
  })
}

program
  .command('convert <translations_file> <toPath> <format>')
  .action((fromPath: string, toPath: string, targetFormat: string) => {
    const { supportedLanguages, sourceLanguage } = config
    const sourceFormat = path.extname(fromPath).replace('.', '') || 'csv'
    const converter: Record<string, () => void> = {
      'json-csv': () => {
        if (!fs.existsSync(toPath)) {
          fs.mkdirSync(toPath)
        }

        const json = JSON.parse(fs.readFileSync(fromPath, 'utf8'))
        writeCsvFromJson(json, toPath, sourceLanguage, Object.keys(supportedLanguages))
      },
      'csv-json': () => {
        writeJsonFromCsv(fromPath, toPath, sourceLanguage)
      },
    }
    const convert = converter[`${sourceFormat.toLowerCase()}-${targetFormat.toLowerCase()}`]

    if (convert) {
      convert()
    } else {
      console.error(`Unable to convert from ${sourceFormat} to ${targetFormat}`)
      process.exit(1)
    }
  })

type WritePlistTranslationsOptions = {
  translations: string
  destination: string
}

const writePlistTranslations = (appName: string, { translations, destination }: WritePlistTranslationsOptions) => {
  const { native: nativeTranslations } = JSON.parse(fs.readFileSync(translations, 'utf-8'))
  const languageCodes = Object.keys(nativeTranslations)
  console.warn('Creating InfoPlist.strings for the languages ', languageCodes)
  languageCodes.forEach(language => {
    const translations = nativeTranslations[language]
    const keys = Object.keys(translations)

    const content = keys
      .map(key => {
        const regex = /{{appName}}/gi
        const value = translations[key].replace(regex, appName)
        return `${key} = "${value}";`
      })
      .join('\n')

    // XCode uses different tags for some languages
    const languageKey = XCODE_LANGUAGES_MAP[language] ?? language
    const path = `${destination}/${languageKey}.lproj/`

    fs.mkdirSync(path, {
      recursive: true,
    })
    fs.writeFileSync(`${path}InfoPlist.strings`, content)
  })
  console.warn('InfoPlist.strings successfully created.')
}

program
  .command('write-plist <appName>')
  .description('setup native translations for ios')
  .requiredOption('--translations <translations>', 'the path to the translations.json file')
  .requiredOption('--destination <destination>', 'the path to put the string resources to')
  .action((appName: string, options: WritePlistTranslationsOptions) => {
    try {
      writePlistTranslations(appName, options)
    } catch (e) {
      console.error(e)
      process.exit(1)
    }
  })
program.parse(process.argv)