Skip to content

Commit 34ac45e

Browse files
Merge pull request #24 from Serchinastico/format-command
Format command
2 parents c1fb062 + 5039b40 commit 34ac45e

8 files changed

Lines changed: 227 additions & 10 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@ linguito translate ~/Development/my-app --llm
8181
linguito translate ~/Development/my-app --llm --interactive
8282
```
8383

84+
### `linguito format`
85+
86+
Reads and cleans the project's catalog files to ensure consistency and readability. It removes empty translations and normalizes the format of the catalog files, making them easier to maintain. This command is particularly useful before committing translation changes to version control or when preparing files for translation work.
87+
88+
#### Examples
89+
90+
```shell
91+
# Format catalog files in the current directory project
92+
linguito format
93+
94+
# Or specify a custom project directory
95+
linguito format ~/Development/my-app
96+
```
8497

8598
## Development
8699

src/commands/format.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {Args} from '@oclif/core'
2+
3+
import BaseCommand from '@/lib/command/base.js'
4+
import {ConfigParser} from '@/lib/lingui/parser.js'
5+
import {Translations} from '@/lib/lingui/translations.js'
6+
7+
export default class Format extends BaseCommand {
8+
static args = {
9+
projectDir: Args.file({default: '.', description: 'Project root directory', required: false}),
10+
}
11+
static description = `Reads and cleans the project's '.po' catalog files to ensure there are no empty translations.`
12+
static examples = [`<%= config.bin %> <%= command.id %> ./my-app`]
13+
static summary = 'Cleans commented translations in catalog files.'
14+
15+
async run() {
16+
const {args} = await this.parse(Format)
17+
const {projectDir} = args
18+
19+
const {directory, file: linguiConfigFilePath} = await this.getConfigFile(projectDir)
20+
const linguiConfigFileParser = new ConfigParser(directory)
21+
const translationsChecker = new Translations(directory)
22+
23+
const catalogFiles = await linguiConfigFileParser.parse(linguiConfigFilePath)
24+
await translationsChecker.format(catalogFiles)
25+
}
26+
}

src/lib/lingui/gettext-parser.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import fs from 'node:fs/promises'
2+
3+
import {invariant} from '@/lib/command/invariant'
4+
5+
type Translation = {
6+
comments: string[]
7+
msgid: string[]
8+
msgstr: string[]
9+
}
10+
11+
export class GetTextParser {
12+
constructor() {}
13+
14+
async parse(catalogFilePath: string): Promise<Translation[]> {
15+
const catalogFile = await fs.readFile(catalogFilePath, 'utf-8')
16+
const lines = catalogFile.split('\n')
17+
18+
const translations: Translation[] = []
19+
let currentTranslation: Translation | undefined = undefined
20+
21+
for (const line of lines) {
22+
if (line.startsWith('#')) {
23+
if (currentTranslation && currentTranslation.msgstr.length > 0) {
24+
translations.push(currentTranslation)
25+
currentTranslation = {comments: [line], msgid: [], msgstr: []}
26+
} else if (currentTranslation) {
27+
currentTranslation?.comments.push(line)
28+
} else {
29+
currentTranslation = {comments: [line], msgid: [], msgstr: []}
30+
}
31+
} else if (line.startsWith('msgid')) {
32+
if (currentTranslation && currentTranslation.msgstr.length > 0) {
33+
translations.push(currentTranslation)
34+
currentTranslation = {comments: [], msgid: [line], msgstr: []}
35+
} else if (currentTranslation) {
36+
currentTranslation.msgid.push(line)
37+
} else {
38+
currentTranslation = {comments: [], msgid: [line], msgstr: []}
39+
}
40+
} else if (line.startsWith('msgstr')) {
41+
invariant(currentTranslation !== undefined, 'internal_error')
42+
43+
currentTranslation.msgstr.push(line)
44+
} else if (line.startsWith('"')) {
45+
invariant(currentTranslation !== undefined, 'internal_error')
46+
47+
if (currentTranslation.msgstr.length > 0) {
48+
currentTranslation.msgstr.push(line)
49+
} else {
50+
currentTranslation.msgid.push(line)
51+
}
52+
} else if (line.trim().length === 0) {
53+
if (currentTranslation) {
54+
translations.push(currentTranslation)
55+
currentTranslation = undefined
56+
}
57+
}
58+
}
59+
60+
return translations
61+
}
62+
}

src/lib/lingui/translations.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ import fs from 'node:fs/promises'
44
import path from 'node:path'
55

66
import {FilledTranslation, MissingTranslation} from '@/lib/common/types.js'
7+
import {GetTextParser} from '@/lib/lingui/gettext-parser'
78

89
export class Translations {
9-
constructor(private projectDir: string) {}
10+
private parser: GetTextParser
11+
12+
constructor(private projectDir: string) {
13+
this.parser = new GetTextParser()
14+
}
1015

1116
async addMissing(translations: FilledTranslation[]) {
1217
const translationsByCatalog = groupBy(translations, (translation) => translation.file)
@@ -25,7 +30,20 @@ export class Translations {
2530
}
2631
}
2732

28-
async getMissing(catalogFiles: string[]) {
33+
async format(catalogFiles: string[]) {
34+
for (const catalogFile of catalogFiles) {
35+
const translations = await this.parser.parse(catalogFile)
36+
37+
const rebuiltContent = translations
38+
.filter((translation) => translation.msgid.length > 0)
39+
.map((translation) => [...translation.comments, ...translation.msgid, ...translation.msgstr, ''].join('\n'))
40+
.join('\n')
41+
42+
await fs.writeFile(catalogFile, rebuiltContent)
43+
}
44+
}
45+
46+
async getMissing(catalogFiles: string[]): Promise<MissingTranslation[]> {
2947
const missingTranslations: MissingTranslation[] = []
3048

3149
for (const catalogFile of catalogFiles) {

test/commands/format.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {runCommand} from '@oclif/test'
2+
import {expect} from 'chai'
3+
import fs from 'node:fs/promises'
4+
import {afterEach, beforeEach, describe, it, vi} from 'vitest'
5+
6+
import {backupCatalogTestFiles, restoreCatalogTestFiles} from '../lib/fs.js'
7+
8+
describe('format', () => {
9+
beforeEach(async () => {
10+
await backupCatalogTestFiles('all-translations-included')
11+
await backupCatalogTestFiles('commented-translations')
12+
vi.restoreAllMocks()
13+
})
14+
15+
afterEach(async () => {
16+
await restoreCatalogTestFiles('all-translations-included')
17+
await restoreCatalogTestFiles('commented-translations')
18+
})
19+
it('no changes when runs format command in a well-formed project', async () => {
20+
const enCatalogFile = await fs.readFile('test/fixtures/all-translations-included/messages.en.po', 'utf-8')
21+
22+
const {error} = await runCommand('format test/fixtures/all-translations-included')
23+
24+
const newEnCatalogFile = await fs.readFile('test/fixtures/all-translations-included/messages.en.po', 'utf-8')
25+
26+
expect(error).to.be.undefined
27+
expect(newEnCatalogFile).to.equal(enCatalogFile)
28+
})
29+
30+
it('removes commented translations', async () => {
31+
const enCatalogFile = await fs.readFile('test/fixtures/commented-translations/messages.en.po', 'utf-8')
32+
33+
const {error} = await runCommand('format test/fixtures/commented-translations')
34+
35+
const newEnCatalogFile = await fs.readFile('test/fixtures/commented-translations/messages.en.po', 'utf-8')
36+
37+
expect(error).to.be.undefined
38+
expect(newEnCatalogFile).to.not.equal(enCatalogFile)
39+
expect(enCatalogFile).to.contain('#~ msgstr "{0} of {1}"')
40+
expect(newEnCatalogFile).to.not.contain('#~ msgstr "{0} of {1}"')
41+
})
42+
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** @type {import("@lingui/conf").LinguiConfig} */
2+
export const locales = ['en']
3+
export const catalogs = [
4+
{
5+
include: ['src'],
6+
path: '<rootDir>/messages.{locale}',
7+
},
8+
]
9+
export const format = 'po'
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
msgid ""
2+
msgstr ""
3+
"POT-Creation-Date: 2024-09-13 14:14+0200\n"
4+
"MIME-Version: 1.0\n"
5+
"Content-Type: text/plain; charset=utf-8\n"
6+
"Content-Transfer-Encoding: 8bit\n"
7+
"X-Generator: @lingui/cli\n"
8+
"Language: en\n"
9+
"Project-Id-Version: \n"
10+
"Report-Msgid-Bugs-To: \n"
11+
"PO-Revision-Date: \n"
12+
"Last-Translator: \n"
13+
"Language-Team: \n"
14+
"Plural-Forms: \n"
15+
16+
#: src/features/tools/hooks/pdf/usePdfExporter.ts:63
17+
msgid "{0} - Manual"
18+
msgstr "{0} - Manual"
19+
20+
#: src/features/image-viewer/components/Header.tsx:24
21+
#: src/features/image-viewer/components/Footer.tsx:34
22+
#~ msgid "{0} of {1}"
23+
#~ msgstr "{0} of {1}"
24+
25+
#: src/features/settings/components/AppPurchasePrompt.tsx:18
26+
#: src/features/image-viewer/components/AppPurchasePrompt.tsx:34
27+
msgid "<0><1>Support us in this project and get the app with no limitations </1><2>forever</2><3>.</3></0>"
28+
msgstr "<0><1>Support us in this project and get the app with no limitations </1><2>forever</2><3>.</3></0>"

test/lib/fs.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
1+
import {CopyOptions} from 'node:fs'
12
import fs from 'node:fs/promises'
23

4+
const cpIfExists = async (source: string, destination: string, options: CopyOptions = {}) => {
5+
try {
6+
await fs.access(source)
7+
await fs.cp(source, destination, options)
8+
} catch {
9+
/* empty */
10+
}
11+
}
12+
13+
const rmIfExists = async (path: string) => {
14+
try {
15+
await fs.access(path)
16+
await fs.rm(path)
17+
} catch {
18+
/* empty */
19+
}
20+
}
21+
322
/**
423
* Asynchronously creates backups of test fixture files for the specified test case.
524
*
@@ -14,10 +33,10 @@ import fs from 'node:fs/promises'
1433
* to back up (e.g., 'missing-translations').
1534
* @returns A promise that resolves when all backup operations are complete.
1635
*/
17-
export const backupCatalogTestFiles = async (fixture: 'missing-translations') => {
36+
export const backupCatalogTestFiles = async (fixture: string) => {
1837
await Promise.all([
19-
fs.cp(`test/fixtures/${fixture}/messages.en.po`, `test/fixtures/${fixture}/messages.en.po.backup`, {}),
20-
fs.cp(`test/fixtures/${fixture}/messages.es.po`, `test/fixtures/${fixture}/messages.es.po.backup`, {}),
38+
cpIfExists(`test/fixtures/${fixture}/messages.en.po`, `test/fixtures/${fixture}/messages.en.po.backup`),
39+
cpIfExists(`test/fixtures/${fixture}/messages.es.po`, `test/fixtures/${fixture}/messages.es.po.backup`),
2140
])
2241
}
2342

@@ -32,14 +51,14 @@ export const backupCatalogTestFiles = async (fixture: 'missing-translations') =>
3251
* Example: 'missing-translations'
3352
* @returns A promise that resolves once the restoration process is complete.
3453
*/
35-
export const restoreCatalogTestFiles = async (fixture: 'missing-translations') => {
54+
export const restoreCatalogTestFiles = async (fixture: string) => {
3655
await Promise.all([
37-
fs.cp(`test/fixtures/${fixture}/messages.en.po.backup`, `test/fixtures/${fixture}/messages.en.po`, {}),
38-
fs.cp(`test/fixtures/${fixture}/messages.es.po.backup`, `test/fixtures/${fixture}/messages.es.po`, {}),
56+
cpIfExists(`test/fixtures/${fixture}/messages.en.po.backup`, `test/fixtures/${fixture}/messages.en.po`),
57+
cpIfExists(`test/fixtures/${fixture}/messages.es.po.backup`, `test/fixtures/${fixture}/messages.es.po`),
3958
])
4059

4160
await Promise.all([
42-
fs.rm(`test/fixtures/${fixture}/messages.en.po.backup`),
43-
fs.rm(`test/fixtures/${fixture}/messages.es.po.backup`),
61+
rmIfExists(`test/fixtures/${fixture}/messages.en.po.backup`),
62+
rmIfExists(`test/fixtures/${fixture}/messages.es.po.backup`),
4463
])
4564
}

0 commit comments

Comments
 (0)