Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
```
Expand Down Expand Up @@ -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

Expand Down
6 changes: 1 addition & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 24 additions & 11 deletions src/config/WordingConfig.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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", "");
Expand All @@ -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<T>(source: any, key: string, defaultValue: T): T {
Expand Down
173 changes: 157 additions & 16 deletions src/google/Drive.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<string>(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<typeof google.sheets>,
spreadsheetId: string,
sheetName: string,
keyColumn: string,
startRow: number
): Promise<Map<string, number>> {
const response = await service.spreadsheets.values.get({
spreadsheetId,
range: `${sheetName}!${keyColumn}${startRow}:${keyColumn}`,
});

const keyRows = new Map<string, number>();
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(
Expand All @@ -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;
}
Loading