Skip to content

Commit 1577398

Browse files
authored
Merge pull request #6657 from opencrvs/release-v1.4.1
Release v1.4.1
2 parents a7f72ee + 0d79fd0 commit 1577398

File tree

12 files changed

+644
-662
lines changed

12 files changed

+644
-662
lines changed

packages/client/extract-translations.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ get_abs_filename() {
1212
}
1313

1414
write=false
15+
outdated=false
1516

1617
for i in "$@"; do
1718
case $i in
19+
--outdated)
20+
outdated=true
21+
shift
22+
;;
1823
--write)
1924
write=true
2025
shift
@@ -40,6 +45,11 @@ elif [[ ! -d "${COUNTRY_CONFIG_PATH}" ]]; then
4045
exit 1
4146
fi
4247

48+
if $outdated; then
49+
$(yarn bin)/ts-node --compiler-options='{"module": "commonjs"}' -r tsconfig-paths/register src/extract-translations.ts -- $COUNTRY_CONFIG_PATH --outdated
50+
exit 0
51+
fi
52+
4353
if $write; then
4454
$(yarn bin)/ts-node --compiler-options='{"module": "commonjs"}' -r tsconfig-paths/register src/extract-translations.ts -- $COUNTRY_CONFIG_PATH --write
4555
exit 0

packages/client/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
"@graphql-codegen/introspection": "^3.0.0",
117117
"@graphql-codegen/typescript": "^3.0.0",
118118
"@graphql-codegen/typescript-operations": "^3.0.0",
119+
"@types/csv2json": "^1.4.5",
119120
"@types/enzyme": "^3.1.13",
120121
"@types/fetch-mock": "^7.3.0",
121122
"@types/google-libphonenumber": "^7.4.23",
@@ -137,6 +138,8 @@
137138
"@vitest/coverage-c8": "^0.25.5",
138139
"@wojtekmaj/enzyme-adapter-react-17": "^0.8.0",
139140
"chalk": "^2.4.1",
141+
"csv-stringify": "^6.4.6",
142+
"csv2json": "^2.0.2",
140143
"enzyme": "^3.4.4",
141144
"eslint": "^7.11.0",
142145
"eslint-config-prettier": "^8.3.0",
@@ -163,7 +166,6 @@
163166
"traverse": "^0.6.6",
164167
"ts-node": "^7.0.1",
165168
"typescript": "^4.7.4",
166-
"typescript-react-intl": "^0.3.0",
167169
"vite-plugin-pwa": "^0.16.4",
168170
"vitest": "0.25.5",
169171
"vitest-fetch-mock": "^0.2.1",

packages/client/src/extract-translations.ts

Lines changed: 196 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -11,47 +11,124 @@
1111
/* eslint-disable */
1212
import * as fs from 'fs'
1313
import glob from 'glob'
14-
import main, { Message } from 'typescript-react-intl'
1514
import chalk from 'chalk'
16-
import { ILanguage } from '@client/i18n/reducer'
15+
import csv2json from 'csv2json'
16+
import { stringify, Options } from 'csv-stringify'
17+
import { promisify } from 'util'
18+
import { sortBy } from 'lodash'
19+
import ts from 'typescript'
20+
import { MessageDescriptor } from 'react-intl'
21+
const csvStringify = promisify<Array<Record<string, any>>, Options>(stringify)
22+
23+
export async function writeJSONToCSV(
24+
filename: string,
25+
data: Array<Record<string, any>>
26+
) {
27+
const csv = await csvStringify(data, {
28+
header: true
29+
})
30+
return fs.promises.writeFile(filename, csv, 'utf8')
31+
}
1732

18-
interface IReactIntlDescriptions {
19-
[key: string]: string
33+
export async function readCSVToJSON<T>(filename: string) {
34+
return new Promise<T>((resolve, reject) => {
35+
const chunks: string[] = []
36+
fs.createReadStream(filename)
37+
.on('error', reject)
38+
.pipe(
39+
csv2json({
40+
separator: ','
41+
})
42+
)
43+
.on('data', (chunk: string) => chunks.push(chunk))
44+
.on('error', reject)
45+
.on('end', () => {
46+
resolve(JSON.parse(chunks.join('')))
47+
})
48+
})
2049
}
2150

51+
type CSVRow = { id: string; description: string } & Record<string, string>
52+
2253
const write = process.argv.includes('--write')
54+
const outdated = process.argv.includes('--outdated')
55+
2356
const COUNTRY_CONFIG_PATH = process.argv[2]
24-
type LocalisationFile = {
25-
data: Array<{
26-
lang: string
27-
displayName: string
28-
messages: Record<string, string>
29-
}>
30-
}
57+
58+
type LocalisationFile = CSVRow[]
3159

3260
function writeTranslations(data: LocalisationFile) {
33-
fs.writeFileSync(
34-
`${COUNTRY_CONFIG_PATH}/src/api/content/client/client.json`,
35-
JSON.stringify(data, null, 2)
61+
return writeJSONToCSV(
62+
`${COUNTRY_CONFIG_PATH}/src/translations/client.csv`,
63+
data
3664
)
3765
}
3866

3967
function readTranslations() {
40-
return JSON.parse(
41-
fs
42-
.readFileSync(`${COUNTRY_CONFIG_PATH}/src/api/content/client/client.json`)
43-
.toString()
68+
return readCSVToJSON<CSVRow[]>(
69+
`${COUNTRY_CONFIG_PATH}/src/translations/client.csv`
4470
)
4571
}
4672

47-
function isEnglish(obj: ILanguage) {
48-
return obj.lang === 'en-US' || obj.lang === 'en'
73+
function findObjectLiteralsWithIdAndDefaultMessage(
74+
filePath: string,
75+
sourceCode: string
76+
): MessageDescriptor[] {
77+
const sourceFile = ts.createSourceFile(
78+
'temp.ts',
79+
sourceCode,
80+
ts.ScriptTarget.Latest,
81+
true
82+
)
83+
const matches: MessageDescriptor[] = []
84+
85+
function visit(node: ts.Node) {
86+
if (!ts.isObjectLiteralExpression(node)) {
87+
ts.forEachChild(node, visit)
88+
return
89+
}
90+
const idProperty = node.properties.find(
91+
(p) => ts.isPropertyAssignment(p) && p.name.getText() === 'id'
92+
)
93+
const defaultMessageProperty = node.properties.find(
94+
(p) => ts.isPropertyAssignment(p) && p.name.getText() === 'defaultMessage'
95+
)
96+
97+
if (!(idProperty && defaultMessageProperty)) {
98+
ts.forEachChild(node, visit)
99+
return
100+
}
101+
102+
const objectText = node.getText(sourceFile) // The source code representation of the object
103+
104+
try {
105+
const func = new Function(`return (${objectText});`)
106+
const objectValue = func()
107+
matches.push(objectValue)
108+
} catch (error) {
109+
console.log(chalk.yellow.bold('Warning'))
110+
console.error(
111+
`Found a dynamic message identifier in file ${filePath}.`,
112+
'Message identifiers should never be dynamic and should always be hardcoded instead.',
113+
'This enables us to confidently verify that a country configuration has all required keys.',
114+
'\n',
115+
objectText,
116+
'\n'
117+
)
118+
}
119+
120+
ts.forEachChild(node, visit)
121+
}
122+
123+
visit(sourceFile)
124+
125+
return matches
49126
}
50127

51128
async function extractMessages() {
52129
let translations: LocalisationFile
53130
try {
54-
translations = readTranslations()
131+
translations = await readTranslations()
55132
} catch (error: unknown) {
56133
const err = error as Error & { code: string }
57134
if (err.code === 'ENOENT') {
@@ -60,88 +137,118 @@ async function extractMessages() {
60137
`Your environment variables may not be set.
61138
Please add valid COUNTRY_CONFIG_PATH, as an environment variable.
62139
If they are set correctly, then something is wrong with
63-
this file: ${COUNTRY_CONFIG_PATH}/src/api/content/client/client.json`
140+
this file: ${COUNTRY_CONFIG_PATH}/src/translations/client.csv`
64141
)
65142
} else {
66143
console.error(err)
67144
}
68145
process.exit(1)
69146
}
70-
let results: Message[] = []
71-
const pattern = 'src/**/*.@(tsx|ts)'
72-
try {
73-
// eslint-disable-line no-console
74-
console.log('Checking translations in application...')
75-
console.log()
76147

77-
glob(pattern, (err: any, files) => {
78-
if (err) {
79-
throw new Error(err)
80-
}
148+
const knownLanguages =
149+
translations.length > 0
150+
? Object.keys(translations[0]).filter(
151+
(key) => !['id', 'description'].includes(key)
152+
)
153+
: ['en']
81154

82-
files.forEach((f) => {
83-
const contents = fs.readFileSync(f).toString()
84-
results = results.concat(main(contents))
85-
})
155+
console.log('Checking translations in application...')
156+
console.log()
86157

87-
const reactIntlDescriptions: IReactIntlDescriptions = {}
88-
results.forEach((r) => {
89-
reactIntlDescriptions[r.id] = r.description!
90-
})
91-
const englishTranslations = translations.data.find(isEnglish)?.messages
92-
const missingKeys = Object.keys(reactIntlDescriptions).filter(
93-
(key) => !englishTranslations?.hasOwnProperty(key)
94-
)
158+
const files = await promisify(glob)('src/**/*.@(tsx|ts)', {
159+
ignore: ['**/*.test.@(tsx|ts)', 'src/tests/**/*.*']
160+
})
161+
162+
const messagesParsedFromApp: MessageDescriptor[] = files
163+
.map((f) => {
164+
const contents = fs.readFileSync(f).toString()
165+
return findObjectLiteralsWithIdAndDefaultMessage(f, contents)
166+
})
167+
.flat()
168+
169+
const reactIntlDescriptions: Record<string, string> = Object.fromEntries(
170+
messagesParsedFromApp.map(({ id, description }) => [id, description || ''])
171+
)
172+
173+
const missingKeys = Object.keys(reactIntlDescriptions).filter(
174+
(key) => !translations.find(({ id }) => id === key)
175+
)
176+
177+
if (outdated) {
178+
const extraKeys = translations
179+
.map(({ id }) => id)
180+
.filter((key) => !reactIntlDescriptions[key])
181+
182+
console.log(chalk.yellow.bold('Potentially outdated translations'))
183+
console.log(
184+
'The following keys were not found in the code, but are part of the copy file:',
185+
'\n'
186+
)
187+
console.log(extraKeys.join('\n'))
188+
}
95189

96-
if (missingKeys.length > 0) {
97-
// eslint-disable-line no-console
98-
console.log(chalk.red.bold('Missing translations'))
99-
console.log(`You are missing the following content keys from your country configuration package:\n
190+
if (missingKeys.length > 0) {
191+
// eslint-disable-line no-console
192+
console.log(chalk.red.bold('Missing translations'))
193+
console.log(`You are missing the following content keys from your country configuration package:\n
100194
${chalk.white(missingKeys.join('\n'))}\n
101195
Translate the keys and add them to this file:
102-
${chalk.white(`${COUNTRY_CONFIG_PATH}/src/api/content/client/client.json`)}`)
103-
104-
if (write) {
105-
console.log(
106-
`${chalk.yellow('Warning ⚠️:')} ${chalk.white(
107-
'The --write command is experimental and only adds new translations for English.'
108-
)}`
109-
)
110-
111-
const defaultsToBeAdded = missingKeys.map((key) => [
112-
key,
113-
results.find(({ id }) => id === key)?.defaultMessage
114-
])
115-
const newEnglishTranslations: Record<string, string> = {
116-
...englishTranslations,
117-
...Object.fromEntries(defaultsToBeAdded)
118-
}
119-
120-
const english = translations.data.find(isEnglish)!
121-
english.messages = newEnglishTranslations
122-
writeTranslations(translations)
123-
} else {
124-
console.log(`
125-
${chalk.green('Tip 🪄')}: ${chalk.white(
126-
`If you want this command do add the missing English keys for you, run it with the ${chalk.bold(
127-
'--write'
128-
)} flag. Note that you still need to add non-English translations to the file.`
129-
)}`)
130-
}
131-
132-
process.exit(1)
133-
}
134-
135-
fs.writeFileSync(
136-
`${COUNTRY_CONFIG_PATH}/src/api/content/client/descriptions.json`,
137-
JSON.stringify({ data: reactIntlDescriptions }, null, 2)
196+
${chalk.white(`${COUNTRY_CONFIG_PATH}/src/translations/client.csv`)}`)
197+
198+
if (write) {
199+
console.log(
200+
`${chalk.yellow('Warning ⚠️:')} ${chalk.white(
201+
'The --write command is experimental and only adds new translations for English.'
202+
)}`
138203
)
139-
})
140-
} catch (err) {
141-
// eslint-disable-line no-console
142-
console.log(err)
204+
205+
// This is just to ensure that all languages stay in the CVS file
206+
const emptyLanguages = Object.fromEntries(
207+
knownLanguages.map((lang) => [lang, ''])
208+
)
209+
210+
const defaultsToBeAdded = missingKeys.map(
211+
(key): CSVRow => ({
212+
id: key,
213+
description: reactIntlDescriptions[key],
214+
...emptyLanguages,
215+
en:
216+
messagesParsedFromApp
217+
.find(({ id }) => id === key)
218+
?.defaultMessage?.toString() || ''
219+
})
220+
)
221+
222+
const allIds = Array.from(
223+
new Set(
224+
defaultsToBeAdded
225+
.map(({ id }) => id)
226+
.concat(translations.map(({ id }) => id))
227+
)
228+
)
229+
230+
const allTranslations = allIds.map((id) => {
231+
const existingTranslation = translations.find(
232+
(translation) => translation.id === id
233+
)
234+
235+
return (
236+
existingTranslation ||
237+
defaultsToBeAdded.find((translation) => translation.id === id)!
238+
)
239+
})
240+
241+
await writeTranslations(sortBy(allTranslations, (row) => row.id))
242+
} else {
243+
console.log(`
244+
${chalk.green('Tip 🪄')}: ${chalk.white(
245+
`If you want this command to add the missing English keys for you, run it with the ${chalk.bold(
246+
'--write'
247+
)} flag. Note that you still need to add non-English translations to the file.`
248+
)}`)
249+
}
250+
143251
process.exit(1)
144-
return
145252
}
146253
}
147254

0 commit comments

Comments
 (0)