diff --git a/Readme.md b/Readme.md index 233f4a2..ab6a268 100644 --- a/Readme.md +++ b/Readme.md @@ -36,7 +36,9 @@ You can find a sample sheet [here](https://docs.google.com/spreadsheets/d/18Zf_X ```json { "scripts": { - "upgrade-wording": "sync-wording --upgrade" + "upgrade-wording": "sync-wording --upgrade", + "add-wording": "sync-wording --add", + "set-wording":"sync-wording --set" } } ``` @@ -93,12 +95,64 @@ Now the tool will warn you when you update wording containing invalid translatio ## Options -This tools support 3 options +This tools support the following options - **`--config`** : Configuration path - **`--upgrade`** : Export sheet in local xlsx file that you can commit for later edit. It prevent risks to have unwanted wording changes when you fix bugs. And then update wording - **`--update`** : Update wording files from local xlsx file - **`--invalid`** : (error|warning) exist with error when invalid translations found or just warn +- **`--add`** : Add one or more wording lines to the remote Google Sheet. Each line is a key followed by one translation per configured language (same order as `languages` in config). To add multiple lines in a single command, repeat the pattern (key + translations). +- **`--set`** : Update one or more existing wording lines in the remote Google Sheet by key. Same argument format as `--add`. The key must already exist in the target sheet. +- **`--sheet`** : Sheet tab name for `--add` / `--set` (default: first entry in `sheetNames`). + +### Add wording lines + +Use `--add` to push new keys and translations directly to the Google Sheet. By default, the first sheet in `sheetNames` is used; pass `--sheet` to target a specific tab. + +Single line (2 languages: fr, en — order matches `languages` in config): + +```bash +sync-wording --add user.email_title "E-mail" "Email" +``` + +Target a specific sheet: + +```bash +sync-wording --sheet MyApp --add user.email_title "E-mail" "Email" +``` + +Multiple lines at once — repeat `key + translations` for each language: + +```bash +sync-wording --add \ + user.email_title "E-mail" "Email" \ + user.phone_title "Téléphone" "Phone" \ + user.address_title "Adresse" "Address" +``` + +Keys are written to `keyColumn` and each translation to its language `column` from the config (e.g. key in `A`, `en` in `C`, `fr` in `D`). + +The number of values after each key must match the number of languages defined in `wording_config.json`. With 2 languages, each line requires 3 arguments (1 key + 2 translations). + +### Update wording lines + +Use `--set` to update translations for existing keys in the sheet selected via `--sheet` or the first entry in `sheetNames`. + +Single line: + +```bash +sync-wording --set user.email_title "Email address" "Adresse e-mail" +``` + +Multiple lines at once: + +```bash +sync-wording --set \ + user.email_title "Email address" "Adresse e-mail" \ + user.phone_title "Phone number" "Numéro de téléphone" +``` + +If a key is not found, the command fails with an error. If the same key exists in multiple sheets, the command also fails to avoid ambiguous updates. ## Complete Configuration diff --git a/package-lock.json b/package-lock.json index a9f4441..a24932e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,7 +120,6 @@ "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -1381,7 +1380,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1544,7 +1542,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "dev": true, - "peer": true, "requires": { "undici-types": ">=7.24.0 <7.24.7" } @@ -2312,8 +2309,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "peer": true + "dev": true }, "undefsafe": { "version": "2.0.5", diff --git a/src/config/WordingConfig.ts b/src/config/WordingConfig.ts index 760aa3e..fe230c9 100644 --- a/src/config/WordingConfig.ts +++ b/src/config/WordingConfig.ts @@ -1,14 +1,19 @@ export interface Validation { - column : string; + column: string; expected: string; } export class LanguageConfig { name: string; column: string; output: string; - validation : Validation | null - - constructor(name: string, config: any, defaultOutputDir: string, validation : Validation | null) { + validation: Validation | null; + + constructor( + name: string, + config: any, + defaultOutputDir: string, + validation: Validation | null, + ) { this.name = name; this.column = config.column; if (config.output) { @@ -39,13 +44,13 @@ export class WordingConfig { languages: LanguageConfig[]; format: string; ignoreEmptyKeys: boolean; - validation : Validation | null; + validation: Validation | null; constructor(jsonConfig: any) { this.wording_file = this.getOrDefault( jsonConfig, "wording_file", - "./wording.xlsx" + "./wording.xlsx", ); this.credentials = this.getOrDefault(jsonConfig, "credentials", ""); @@ -60,26 +65,34 @@ export class WordingConfig { this.output_dir = this.getOrDefault( jsonConfig, "output_dir", - "./src/assets/strings/" + "./src/assets/strings/", ); this.languages = []; this.format = this.getOrDefault(jsonConfig, "format", "json"); - this.ignoreEmptyKeys = this.getOrDefault(jsonConfig, "ignoreEmptyKeys", false); + this.ignoreEmptyKeys = this.getOrDefault( + jsonConfig, + "ignoreEmptyKeys", + false, + ); - this.validation = this.getOrDefault(jsonConfig, "validation", null) + this.validation = this.getOrDefault(jsonConfig, "validation", null); for (const language in jsonConfig.languages) { if (jsonConfig.languages.hasOwnProperty(language)) { const element = jsonConfig.languages[language]; this.languages.push( - new LanguageConfig(language, element, this.output_dir, this.validation) + new LanguageConfig( + language, + element, + this.output_dir, + this.validation, + ), ); } } - } private getOrDefault(source: any, key: string, defaultValue: T): T { diff --git a/src/google/Drive.ts b/src/google/Drive.ts index 0371220..d848980 100644 --- a/src/google/Drive.ts +++ b/src/google/Drive.ts @@ -1,8 +1,14 @@ import fs from "fs"; -import path from "path"; import { google, drive_v3 } from "googleapis"; import { OAuth2Client } from "google-auth-library"; -import { resolve } from "dns"; +import { LanguageConfig } from "../config/WordingConfig"; + +export interface SheetWriteConfig { + keyColumn: string; + languages: LanguageConfig[]; + sheetStartIndex: number; +} + export class Drive { drive: drive_v3.Drive; @@ -32,25 +38,152 @@ export class Drive { ); } - async addLine(spreadsheetId: string, sheetName: string, key: string, ...values: string[]) { + async addLines( + spreadsheetId: string, + sheetName: string, + lines: { key: string; values: string[] }[], + writeConfig: SheetWriteConfig + ) { + const service = google.sheets({ version: "v4", auth: this.auth }); + const result = await service.spreadsheets.values.append({ + spreadsheetId, + range: `${sheetName}!${writeConfig.keyColumn}:${writeConfig.keyColumn}`, + valueInputOption: "RAW", + requestBody: { + values: lines.map((line) => this.buildAppendRow(line, writeConfig)), + }, + }); + + console.log( + `${lines.length} line(s) appended (${result.data.updates?.updatedCells} cells).` + ); + return result; + } + + async updateLines( + spreadsheetId: string, + sheetName: string, + lines: { key: string; values: string[] }[], + writeConfig: SheetWriteConfig + ) { const service = google.sheets({ version: "v4", auth: this.auth }); - try { - const result = await service.spreadsheets.values.append({ - spreadsheetId, - range: sheetName, + const keyRows = await this.findKeyRows( + service, + spreadsheetId, + sheetName, + writeConfig.keyColumn, + writeConfig.sheetStartIndex + ); + + const updates: { range: string; values: string[][] }[] = []; + for (const line of lines) { + const rowIndex = keyRows.get(line.key); + if (!rowIndex) { + throw new Error(`Key not found: "${line.key}"`); + } + + updates.push( + ...this.buildTranslationUpdates(sheetName, rowIndex, line, writeConfig) + ); + } + + const result = await service.spreadsheets.values.batchUpdate({ + spreadsheetId, + requestBody: { valueInputOption: "RAW", - requestBody: { - values: [[key, ...values]], - }, - }); - - console.log(`${result.data.updates?.updatedCells} cells appended.`); - return result; - } catch (err) { - throw err; + data: updates, + }, + }); + + console.log( + `${lines.length} line(s) updated (${result.data.totalUpdatedCells} cells).` + ); + return result; + } + + private validateLine( + line: { key: string; values: string[] }, + writeConfig: SheetWriteConfig + ) { + if (line.values.length !== writeConfig.languages.length) { + throw new Error( + `Invalid values for key "${line.key}": expected ${writeConfig.languages.length} translation(s), got ${line.values.length}` + ); } } + private buildAppendRow( + line: { key: string; values: string[] }, + writeConfig: SheetWriteConfig + ): string[] { + this.validateLine(line, writeConfig); + + const keyColumnIndex = columnToIndex(writeConfig.keyColumn); + const languageColumnIndexes = writeConfig.languages.map((language) => + columnToIndex(language.column) + ); + const maxColumnIndex = Math.max(keyColumnIndex, ...languageColumnIndexes); + + const row = new Array(maxColumnIndex - keyColumnIndex + 1).fill(""); + row[0] = line.key; + writeConfig.languages.forEach((language, index) => { + const columnOffset = columnToIndex(language.column) - keyColumnIndex; + if (columnOffset < 0) { + throw new Error( + `Language column "${language.column}" must not be before keyColumn "${writeConfig.keyColumn}"` + ); + } + + row[columnOffset] = line.values[index]; + }); + + return row; + } + + private buildTranslationUpdates( + sheetName: string, + rowIndex: number, + line: { key: string; values: string[] }, + writeConfig: SheetWriteConfig + ): { range: string; values: string[][] }[] { + this.validateLine(line, writeConfig); + + return writeConfig.languages.map((language, index) => ({ + range: `${sheetName}!${language.column}${rowIndex}`, + values: [[line.values[index]]], + })); + } + + private async findKeyRows( + service: ReturnType, + spreadsheetId: string, + sheetName: string, + keyColumn: string, + startRow: number + ): Promise> { + const response = await service.spreadsheets.values.get({ + spreadsheetId, + range: `${sheetName}!${keyColumn}${startRow}:${keyColumn}`, + }); + + const keyRows = new Map(); + const keys = response.data.values ?? []; + keys.forEach((row, index) => { + const key = row[0]; + if (!key) { + return; + } + + if (keyRows.has(key)) { + throw new Error(`Duplicate key "${key}" found in sheet "${sheetName}"`); + } + + keyRows.set(key, startRow + index); + }); + + return keyRows; + } + async exportAsXlsx(fileId: string, output: string, mimeType: string) { const dest = fs.createWriteStream(output); const res = await this.drive.files.export( @@ -69,3 +202,11 @@ export class Drive { }); } } + +function columnToIndex(column: string): number { + let index = 0; + for (const char of column.toUpperCase()) { + index = index * 26 + (char.charCodeAt(0) - 64); + } + return index - 1; +} diff --git a/src/index.ts b/src/index.ts index 348c4ed..1c51d9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node -import colors from 'colors/safe'; +import colors from "colors/safe"; import { program } from "commander"; +import { WordingConfig } from "./config/WordingConfig"; import { loadConfiguration } from "./config/WordingConfigLoader"; import { AngularJsonWordingExporter } from "./exporters/AngularJsonWordingExporter"; import { FlatJsonWordingExporter } from "./exporters/FlatJsonWordingExportter"; @@ -9,17 +10,33 @@ import { NestedJsonWordingExporter } from "./exporters/NestedJsonWordingExporter import { WordingExporter } from "./exporters/WordingExporter"; import { Drive } from "./google/Drive"; import { GoogleAuth } from "./google/GoogleAuth"; +import { parseWordingLines } from "./utils"; import { WordingLoader, WordingResult } from "./WordingLoader"; console.log("Running sync wording"); program .description("Upgrade application wording from Google Sheet") - .option("--add ", "add a wording line to remote Google Sheet") + .option( + "--add ", + "add wording line(s) to remote Google Sheet (key followed by one value per language; repeat the pattern for multiple lines)", + ) + .option( + "--set ", + "update wording line(s) in remote Google Sheet by key (key followed by one value per language; repeat the pattern for multiple lines)", + ) + .option( + "--sheet ", + "target sheet name for --add / --set (default: first entry in sheetNames)", + ) .option("--config ", "wording config path", "wording_config.json") .option("--upgrade", "upgrade wording from remote Google Sheet") .option("--update", "update wording from local xlsx") - .option("--invalid ", "error|warning on invalid translation", "warning") + .option( + "--invalid ", + "error|warning on invalid translation", + "warning", + ) .option("-v, --verbose", "show verbose logs") .parse(process.argv); @@ -27,26 +44,34 @@ if (!process.argv.slice(2).length) { program.outputHelp(); } -function getExporter(format: String) : WordingExporter { +function getExporter(format: String): WordingExporter { if (format === "angular-json") { - return new AngularJsonWordingExporter() + return new AngularJsonWordingExporter(); } else if (format === "flat-json") { return new FlatJsonWordingExporter(); } return new NestedJsonWordingExporter(); } -function printReport(language: string, result : WordingResult) : void { +function printReport(language: string, result: WordingResult): void { if (result.hasInvalidKeys) { - console.warn(colors.yellow(`Invalid translations found for language : ${language}`)) - result.invalidKeys.forEach((k) => - console.warn(colors.yellow(`\t"${k}"`)) - ) + console.warn( + colors.yellow(`Invalid translations found for language : ${language}`), + ); + result.invalidKeys.forEach((k) => console.warn(colors.yellow(`\t"${k}"`))); } -}; +} const options = program.opts(); +function sheetWriteConfig(config: WordingConfig) { + return { + keyColumn: config.keyColumn, + languages: config.languages, + sheetStartIndex: config.sheetStartIndex, + }; +} + const scopes = [ "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive.file", @@ -59,9 +84,28 @@ loadConfiguration(options.config).then(async (config) => { } if (options.add) { - const [key, ...values] = options.add; + const lines = parseWordingLines(options.add, config.languages.length); + const sheetName = options.sheet ?? config.sheetNames[0]; + + const auth = await new GoogleAuth().authorize(config.credentials, scopes); + await new Drive(auth).addLines( + config.sheetId, + sheetName, + lines, + sheetWriteConfig(config), + ); + } + + if (options.set) { + const lines = parseWordingLines(options.set, config.languages.length); + const sheetName = options.sheet ?? config.sheetNames[0]; const auth = await new GoogleAuth().authorize(config.credentials, scopes); - await new Drive(auth).addLine(config.sheetId, config.sheetNames[0], key, ...values); + await new Drive(auth).updateLines( + config.sheetId, + sheetName, + lines, + sheetWriteConfig(config), + ); } if (options.upgrade) { @@ -71,7 +115,7 @@ loadConfiguration(options.config).then(async (config) => { await new Drive(auth).exportAsXlsx( config.sheetId, config.wording_file, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ); } @@ -80,18 +124,16 @@ loadConfiguration(options.config).then(async (config) => { const loader = new WordingLoader(config); const exporter = getExporter(config.format); - let hasError = false - config.languages.forEach(language => { + let hasError = false; + config.languages.forEach((language) => { const result = loader.loadWording(language, config.ignoreEmptyKeys); exporter.export(language.name, result.wordings, language.output); - printReport(language.name, result) - hasError = hasError || result.hasInvalidKeys + printReport(language.name, result); + hasError = hasError || result.hasInvalidKeys; }); if (hasError && options.invalid === "error") { - process.exitCode = 1 + process.exitCode = 1; } } }); - - diff --git a/src/utils.ts b/src/utils.ts index bdc9d12..e601cfd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,3 +8,21 @@ function concat(first: Map, other: Map): Map { }); return result; } + +export function parseWordingLines( + args: string[], + languageCount: number +): { key: string; values: string[] }[] { + const lineSize = 1 + languageCount; + if (args.length % lineSize !== 0) { + throw new Error( + `Invalid --add arguments: expected groups of ${lineSize} (key + ${languageCount} translation(s)), got ${args.length} value(s)` + ); + } + + const lines: { key: string; values: string[] }[] = []; + for (let i = 0; i < args.length; i += lineSize) { + lines.push({ key: args[i], values: args.slice(i + 1, i + lineSize) }); + } + return lines; +}