diff --git a/Readme.md b/Readme.md index 233f4a2..681f00b 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,10 @@ # Sync Wording -This tool allow you to manage app's wording with simple Google Sheet file. Just create a sheet with columns for keys and wording. This tool will generate wording files. Your product owner will be able to edit himself apllication's wording +This tool lets you manage your app's wording from a simple Google Sheet. Create a sheet with one column for keys and one column per language, and the tool generates your local translation files. Your product owner can edit the application's wording directly in the sheet. + +The Google Sheet is the **single source of truth**: the generated files are build output and should never be edited by hand β€” they are overwritten on every sync. + +> **Already using 1.x?** 2.0 is a low-risk, backward-compatible upgrade β€” and you can do it with a single copy-paste prompt for your coding agent: see [πŸš€ Migrate in one prompt](#migrate-in-one-prompt). ## Quick Start @@ -36,28 +40,27 @@ 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" } } ``` +- Authenticate once with `npx sync-wording auth` β€” it opens your browser to grant access on Google Sheet and stores a token locally - Then run `npm run upgrade-wording` -It will ask you to grant access on Google Sheet - -```bash -> Task :app:downloadWording -Please open the following address in your browser: - https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive.readonly&response_type=code&client_id=1309740887-6u609jvssi5c2e56vd5n5dc4drgsc906.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A8181%2Foauth2callback - -``` +It will update wording files : `${output_dir}/en.json` and `${output_dir}/fr.json` -- Open url in your browser -- Grant access +> Sync commands are non-interactive by design (CI and AI-agent friendly): if no +> token is stored they fail with exit code 3 and a message pointing to +> `sync-wording auth`, instead of opening a browser. -[Authorization Sample] +You can also bootstrap everything with one command: -It will update wording files : `${output_dir}/en.json` and `${output_dir}/fr.json` +```bash +npx sync-wording init --sheet-id --languages en:B,fr:C +``` ## Wording validation @@ -93,38 +96,120 @@ Now the tool will warn you when you update wording containing invalid translatio ## Options -This tools support 3 options +This tool supports 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 +- **`--target`** : Restrict the command to a single named target (see [Multiple targets](#multiple-targets)) +- **`--upgrade`** : Download the sheet into a local xlsx cache, then regenerate the wording files. Commit the xlsx so wording stays pinned β€” unrelated changes on the sheet won't leak into a bugfix build +- **`--update`** : Regenerate the wording files from the local xlsx cache, without downloading (offline, no auth needed) +- **`--check`** : Validate the configuration and report what would be written (key counts, invalid translations, output paths) without writing anything +- **`--invalid`** : (error|warning) exit with error when invalid translations are found, or just warn (default: `warning`) +- **`--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. +- **`--remove`** : Remove one or more wording lines from the remote Google Sheet by key. Deletes the whole row. Same key resolution as `--set`. +- **`--sheet-name`** : Sheet tab for `--add` / `--set` / `--remove`; must belong to the selected target. Default: `--add` uses the first entry in `sheetNames`, `--set` / `--remove` search all the target's sheets. +- **`--verbose`, `-v`** : Print the resolved configuration before running (useful to debug a config or a target selection) + +And the following commands: + +- **`auth`** : Interactive Google login (opens a browser, stores a token for later non-interactive runs) +- **`init`** : Scaffold `wording_config.json` and wire AI agent skill references (see [AI Agent Integration](#ai-agent-integration)). Config-shaping flags: `--sheet-id`, `--languages `, `--sheet-names`, `--format`, `--output-dir`, `--key-column`, `--target`. Run interactively (it prompts for anything missing) or pass the flags for a non-interactive scaffold. Agent-wiring flags: `--skip-agents`, `--write-agents` + +### Exit codes + +| Code | Meaning | +| ---- | ------- | +| 0 | Success | +| 1 | Invalid translations found (with `--invalid error`) | +| 2 | Configuration error (missing/invalid config, unknown target, missing sheet) | +| 3 | Authentication error β€” run `sync-wording auth` | +| 10 | Unexpected error | + +### 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-name` to target a specific tab (it must belong to the selected target, otherwise the command fails to avoid writing with the wrong column mapping). + +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-name 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. The key is searched in all the target's sheets (`sheetNames`, or every tab of the document if not set); pass `--sheet-name` to narrow the search to a single tab. + +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 listing the searched sheets. If the key being updated exists in **several** sheets, the command refuses to guess and asks for `--sheet-name` β€” but duplicates of *other* keys never block the update (duplicates across sheets are legal: the last sheet wins at sync time). + +### Remove wording lines + +Use `--remove` to delete rows from the sheet by key. Key resolution works exactly like `--set`: the key is searched in all the target's sheets, `--sheet-name` narrows the search, and an ambiguous key (present in several sheets) is refused. + +```bash +sync-wording --remove user.email_title +sync-wording --remove user.email_title user.phone_title # multiple keys at once +``` + +The whole row is deleted from the spreadsheet β€” this is not recoverable from the tool, so double-check the keys. Run `--upgrade` afterwards to regenerate the local files. ## Complete Configuration ```text { - "credentials": "credentials.json", // Optional, json google api service credentials, default : use embedded credentials - "wording_file": "wording.xlsx", // Optional, local xlsx wording file path + "credentials": "credentials.json", // Optional, json google api service credentials, default: use embedded credentials + "wording_file": "wording.xlsx", // Optional, local xlsx cache path, default: "./wording.xlsx" "sheetId": "THE SHEET ID", // *Required* - "shhetNames": ["commons", "app"], // Optional, default: use all sheets - "sheetStartIndex": 2, // Optional, start row index, default : 2 - "keyColumn": "A", // Optional, default : "A" - "format" : "json", // Optional, json output format (json|flat-json|angular-json), default: "json" - "ignoreEmptyKeys" : false // Optional, whether or not empty keys should be kept, default: false - "validation" : { // Optional, global configuration to validate wording - "column" : "E" - "expected" : "OK" - } - "output_dir": "src/assets/strings/", + "sheetNames": ["commons", "app"], // Optional, sheet tabs to read, default: use all sheets + "sheetStartIndex": 2, // Optional, first data row index, default: 2 (row 1 = headers) + "keyColumn": "A", // Optional, column holding the keys, default: "A" + "format": "json", // Optional, output format (json|flat-json|angular-json), default: "json" + "ignoreEmptyKeys": false, // Optional, skip keys with empty values, default: false + "validation": { // Optional, document-level validation rule + "column": "E", + "expected": "OK" + }, + "output_dir": "src/assets/strings/", // Where files are written, one .json per language "languages": { "en": { "output": "src/assets/strings/default.json", // Optional, default: "${output_dir}/${language_name}.json" "column": "B", - "validation" : { // Optional, local configuration to validate wording - "column" : "E" - "expected" : "OK" + "validation": { // Optional, per-language validation rule (overrides document-level) + "column": "E", + "expected": "OK" } }, "fr": { @@ -136,13 +221,131 @@ This tools support 3 options } ``` +> Note: `sheetNames`, `sheetStartIndex`, `keyColumn`, `format`, `output_dir`, +> `ignoreEmptyKeys` and `validation` are **target-level** fields. In a flat +> config they sit at the top level; with [multiple targets](#multiple-targets) +> they can be set per target, and document-level values act as defaults. + +## Output formats + +The `format` field controls how each `.json` is generated. With keys +`user.firstname_title` / `home.title`: + +**`json`** (default) β€” keys split on `.` into a nested object: + +```json +{ "user": { "firstname_title": "Firstname" }, "home": { "title": "Home" } } +``` + +**`flat-json`** β€” keys kept flat, exactly as written in the sheet: + +```json +{ "user.firstname_title": "Firstname", "home.title": "Home" } +``` + +**`angular-json`** β€” flat translations wrapped with the locale (Angular i18n): + +```json +{ "locale": "en", "translations": { "user.firstname_title": "Firstname", "home.title": "Home" } } +``` + +## Multiple targets + +A single Google Sheet document often serves several purposes: app strings, +accessibility keys, iOS permission messages... Instead of maintaining several +config files, declare named `targets` sharing the same document: + +```json +{ + "sheetId": "THE SHEET ID", + "languages": { + "en": { "column": "B" }, + "fr": { "column": "C" } + }, + "targets": { + "app": { + "sheetNames": ["Commons", "MyApp"], + "format": "angular-json", + "output_dir": "src/assets/i18n/" + }, + "accessibility": { + "sheetNames": ["Accessibility"], + "format": "flat-json", + "output_dir": "src/assets/a11y/" + } + } +} +``` + +- Document-level values (`languages`, `keyColumn`, `validation`, ...) act as defaults; each target can override them +- `sync-wording --upgrade` downloads the document **once** and syncs every target +- `sync-wording --upgrade --target app` syncs a single target +- A flat configuration (no `targets`) keeps working unchanged β€” it behaves as a single target named `default` +- `sync-wording init --target --languages ... --sheet-names ...` adds a target to an existing config (and migrates a flat config automatically) +- `--config` remains available for the real multi-document case (two different Google Sheets) + +## AI Agent Integration + +The package ships a skill that teaches AI coding agents (Claude Code, Cursor, +Copilot, ...) how to operate this tool: commands, exit codes, configuration +semantics and workflows. + +```bash +npx sync-wording init +``` + +wires everything up: + +- adds a lazy pointer to `node_modules/@betomorrow/sync-wording/skills/sync-wording/SKILL.md` in the project's umbrella agent files. It **appends** to `AGENTS.md` (the cross-agent standard read by Codex, Cursor, Amp, Zed, ...) and `CLAUDE.md` (Claude Code) when they exist, plus `.github/copilot-instructions.md` when a `.github` folder is present. The skill is read on demand when a wording task comes up, so it never weighs on unrelated sessions +- never seeds a top-level agent file from scratch on its own: if no `AGENTS.md` exists, `init` asks before creating one (interactive) or prints the exact snippet to add (non-interactive). Force creation with `--write-agents`. `CLAUDE.md` is never created from scratch, since Claude Code also reads `AGENTS.md` +- validates the configuration (`--check`) so a broken setup surfaces right away instead of at the first sync + +The skill instructs agents to read `wording_config.json` live, so it never +goes stale when your configuration changes. Use `--skip-agents` to opt out. + +### Project-specific skills + +The packaged skill covers *how to operate the tool*. To capture *how your project +does wording* (key conventions, the translation hook, "turn hardcoded strings into +keys" workflows), add a project skill alongside it β€” it should **point to** the +packaged skill rather than duplicate its commands, so it stays small and never +drifts. See [`examples/skills/`](examples/skills/) for ready-to-adapt templates. + +## Upgrading from 1.x + +2.0 is a low-risk upgrade β€” your config, stored token and existing commands all keep working (the one behavioral change is explicit auth, see below). The fastest path, especially through a coding agent: + +### Migrate in one prompt + +A pre-2.0 project has no pointer to the packaged skill yet, so hand your coding agent this prompt β€” it does the whole upgrade: + +> Upgrade the `@betomorrow/sync-wording` dev dependency to the latest 2.x and run `npx sync-wording init`. Keep a single `wording_config.json` as-is β€” don't convert it to multiple targets just to modernize it. But if the project has several config files pointing at the same Google Sheet (check `package.json` scripts and CI for `--config`), propose consolidating them into one multi-target config β€” show me a diff, update every invocation site, and only proceed after I confirm. If a sync later fails with exit code 3, tell me to run `npx sync-wording auth`. After init, read the skill it wires (`node_modules/@betomorrow/sync-wording/skills/sync-wording/SKILL.md`) for the 2.x workflows. Finally, suggest (don't impose, ask me first) creating a project-specific wording skill by adapting a template from `node_modules/@betomorrow/sync-wording/examples/skills/` with this project's languages, paths, hook and key conventions β€” these are templates to refine, not skills to use as-is. This is a backward-compatible upgrade; for the full notes see the "Upgrading from 1.x" section of the package README. + +Once `init` has wired the skill, future agent sessions discover it on their own. + +Prefer to do it by hand? It is non-destructive: + +```bash +npm install @betomorrow/sync-wording@latest --save-dev +npx sync-wording init # keeps your config, wires the agent skill, validates, prints an upgrade notice +``` + +### What changes (and what doesn't) + +- **Config** β€” the flat `wording_config.json` from 1.x is fully supported as-is (it behaves as a single `default` target). No conversion needed. +- **Token** β€” same `.google_access_token.json`; already-authenticated projects and CI keep working without re-auth. +- **Commands** β€” `--upgrade`, `--update`, `--add`, `--invalid`, `--verbose`, `--config` are unchanged (`--add` now also accepts multiple lines). +- **Auth is now explicit** β€” the one behavioral change. 1.x opened a browser automatically on the first sync; 2.x sync commands are non-interactive and fail with **exit code 3** instead, pointing to `sync-wording auth`. Run `npx sync-wording auth` once (or pre-provision the token in CI). + ## Note -This tool includes Google Projet credentials for convenience use but you can setup your own projet. Create new project in [GCP Console](https://console.cloud.google.com) then enable **Drive API** in _API library_ and create and download credentials. +This tool includes Google Project credentials for convenience, but you can set up your own project. Create a new project in [GCP Console](https://console.cloud.google.com), then enable **Drive API** and **Sheets API** in the _API library_, and create and download credentials. + +The tool requests two OAuth scopes (least privilege): `drive.readonly` (to export the sheet as xlsx) and `spreadsheets` (to add/update/remove wording lines). ## Development -Current repository use this Sheet : https://docs.google.com/spreadsheets/d/18Zf_XSU80j_I_VOp9Z4ShdOeUydR6Odyty-ExGBZaz4/edit#gid=0 +The current repository uses this Sheet: https://docs.google.com/spreadsheets/d/18Zf_XSU80j_I_VOp9Z4ShdOeUydR6Odyty-ExGBZaz4/edit#gid=0 Build and install locally diff --git a/examples/skills/README.md b/examples/skills/README.md new file mode 100644 index 0000000..7eab30e --- /dev/null +++ b/examples/skills/README.md @@ -0,0 +1,47 @@ +# Example project wording skills + +`sync-wording`'s **packaged skill** +(`node_modules/@betomorrow/sync-wording/skills/sync-wording/SKILL.md`) teaches an +agent *how to operate the tool* β€” commands, configuration, exit codes, workflows. +It is generic, versioned with the library, and `sync-wording init` wires a lazy +pointer to it from your `AGENTS.md` / `CLAUDE.md`. + +A **project skill** is the complement: it captures *how your project does +wording* β€” things the library cannot know: + +- your languages and column order, output paths, format; +- your key-naming convention; +- your translation hook (`useI18n`, a `t()` function, an i18n service…); +- project workflows (e.g. "turn hardcoded strings into keys"). + +The two are orthogonal and both worthwhile. + +## The golden rule: delegate, don't duplicate + +An efficient project skill **does not repeat the tool's command reference** β€” it +points to the packaged skill for that and keeps only project knowledge. Why: + +- the skill loads on **every** wording task, so keeping it small protects the + agent's context window; +- duplicated command docs **drift** when the tool evolves (a skill that still says + "editing keys is not supported" after `--set` shipped is worse than no skill). + +`init` already scaffolds a minimal `.claude/skills/wording/SKILL.md` stub that +points to the packaged skill. These examples show how to enrich that stub. + +## Examples + +- **`wording-project-context/`** β€” the minimal useful skill: project context + + delegation. Start here. +- **`wording-from-hardcoded-strings/`** β€” a richer workflow: detect hardcoded + strings, propose keys, write locally, refactor, verify, then sync. +- **`audit-wording-usage/`** β€” find unused keys and remaining hardcoded strings. + +The first two are two approaches to the same thing β€” your project's main wording +skill; pick one and name it `wording` in your project. The audit skill is +complementary (and triggers only on an explicit `/wording-audit`). + +Copy one into `.claude/skills//SKILL.md`, replace every `` with +your project's values, and trim what you don't need. If a skill grows past ~100 +lines, split reference material (e.g. key conventions) into a sibling file and +link to it. diff --git a/examples/skills/audit-wording-usage/SKILL.md b/examples/skills/audit-wording-usage/SKILL.md new file mode 100644 index 0000000..d2224dc --- /dev/null +++ b/examples/skills/audit-wording-usage/SKILL.md @@ -0,0 +1,37 @@ +--- +name: wording-audit +description: Manual i18n wording audit for , run ONLY when the user explicitly invokes /wording-audit. Does not auto-activate. Finds unused keys, remaining hardcoded strings and missing translations. +--- + +# /wording-audit β€” + +Find wording problems. For sync-wording commands (e.g. `--set`/`--remove` to fix +them), read `node_modules/@betomorrow/sync-wording/skills/sync-wording/SKILL.md`. + +**Trigger:** only on an explicit `/wording-audit` invocation. This skill must not +auto-activate during ordinary wording or coding tasks β€” auditing is a deliberate, +on-demand action. + +## Project context + +- Keys live in `` (), consumed via ``. +- Source code to scan: ``. +- Key naming convention: ``. + +## Checks + +1. **Unused keys** β€” for each key in the file, grep `` + for the key string. Also account for dynamic usage (``) that + builds keys at runtime. Report keys with no hit as **candidate** unused β€” + never delete automatically; dynamic usage is easy to miss. +2. **Remaining hardcoded strings** β€” scan components for literal user-facing text + not wrapped in `` (JSX text nodes, `title=`/`label=`/`placeholder=` + props). Report with `file:line`. +3. **Missing translations** β€” keys present in the file but + empty/absent in another language file. + +## Output + +A report grouped by check, with `file:line` and the offending key/string. Propose +fixes β€” create a key, remove an unused one (`--remove`), fill a missing +translation (`--set`) β€” but apply them only after the user confirms. diff --git a/examples/skills/wording-from-hardcoded-strings/SKILL.md b/examples/skills/wording-from-hardcoded-strings/SKILL.md new file mode 100644 index 0000000..2730e5e --- /dev/null +++ b/examples/skills/wording-from-hardcoded-strings/SKILL.md @@ -0,0 +1,49 @@ +--- +name: wording +description: Create i18n keys from hardcoded strings in and sync them. Use when hardcoded UI strings appear, a feature needs translation keys, or the user runs /wording. +--- + +# /wording β€” + +Turn hardcoded strings into i18n keys and sync them to the source spreadsheet. + +For sync-wording commands, configuration and exit codes, read +`node_modules/@betomorrow/sync-wording/skills/sync-wording/SKILL.md`. This skill +adds the project context and the key-creation workflow only. + +## Project context + +- **Languages (column order):** β€” values for `--add` must be + given in this exact order. +- **Config:** `` β€” +- **Format:** +- **Usage in code:** `` (import: `<…>`) +- **Key naming convention:** `` β€” examples: + ``, `` + +## Workflow + +1. **Analyze** β€” find hardcoded user-facing strings in the given files (JSX text, + `title=`/`label=`/`placeholder=` props, error messages). Check the + file for an existing key that already covers the need + before creating a new one. +2. **Propose** β€” for each new string, a convention-following key + translations in + all languages (reference language first). Show a table and **ask for + confirmation** before writing anything. +3. **Write locally** β€” insert the keys into the per-language files, keeping them + sorted. +4. **Refactor** β€” replace the hardcoded strings with ``; add the + import/hook if missing. +5. **Verify** β€” `` confirms the new keys + resolve. This needs only the local files, not the sheet. Proceed only when green. +6. **Sync** β€” the sheet write is the only irreversible, shared step, so do it last: + ```bash + npx sync-wording <--target X> --add "" + ``` + Several keys can go in one command (repeat `key + values`). If it exits with + code 3, run `npx sync-wording auth` once (browser, interactive). + +## Edit / remove existing keys + +`--set` updates a key in place; `--remove` deletes its row (confirm first). See +the packaged skill for exact semantics. diff --git a/examples/skills/wording-project-context/SKILL.md b/examples/skills/wording-project-context/SKILL.md new file mode 100644 index 0000000..8dece2c --- /dev/null +++ b/examples/skills/wording-project-context/SKILL.md @@ -0,0 +1,28 @@ +--- +name: wording-project-context +description: Project wording context for . Use for any i18n/translation, wording or sync task. Pairs with the packaged sync-wording skill. +--- + +# /wording β€” + +For sync-wording commands, configuration semantics, exit codes and workflows, +read `node_modules/@betomorrow/sync-wording/skills/sync-wording/SKILL.md`. +This skill only adds -specific context. + +## Project context + +- **Languages (Google Sheet column order):** +- **Config:** `` β€” +- **Format:** +- **How translations are consumed in code:** + ```ts + + ``` +- **Key naming convention:** + +## Notes + +- The Google Sheet is the source of truth; generated files are build output β€” + never edit them by hand (a sync overwrites them). +- 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/package.json b/package.json index 57142ef..c11075a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@betomorrow/sync-wording", - "version": "1.2.8", + "version": "2.0.0", "description": "Provide tool to retrieve app wording from Google Sheet and process it to generate i18n json files", "main": "lib/index.js", "bin": "lib/index.js", @@ -37,6 +37,8 @@ "typescript": "^6.0.3" }, "files": [ - "lib/" + "lib/", + "skills/", + "examples/" ] } diff --git a/skills/sync-wording/SKILL.md b/skills/sync-wording/SKILL.md new file mode 100644 index 0000000..c614ad7 --- /dev/null +++ b/skills/sync-wording/SKILL.md @@ -0,0 +1,155 @@ +--- +name: sync-wording +description: Sync application wordings (i18n) from a Google Sheet to local translation files using the @betomorrow/sync-wording CLI. Use when asked to update, sync, pull, add or fix translations, wordings, or i18n keys. +--- + +# sync-wording + +Sync app wordings from a Google Sheet to local i18n files. The Google Sheet +is the source of truth; generated files must never be edited by hand. + +## When to use + +- The user asks to update, sync, refresh, or pull translations/wordings +- The user asks to add or change a translation key +- A `wording_config.json` (or a file passed via `--config`) exists in the project + +## Commands + +```bash +npx sync-wording --upgrade # download the sheet as xlsx, then generate all files +npx sync-wording --update # regenerate files from the local xlsx (no download) +npx sync-wording --check # validate config and report what would be written (writes nothing) +npx sync-wording --target ... # restrict any command to one target +npx sync-wording --add # append key + one value per language to the sheet +npx sync-wording --set # update an existing key in the sheet +npx sync-wording --remove # delete row(s) from the sheet by key β€” destructive +npx sync-wording --sheet-name ... # pick a sheet tab for --add / --set / --remove + +npx sync-wording auth # interactive Google login (stores a token, run once) +npx sync-wording init # scaffold config and agent wiring +``` + +Prefer `--check` before a real sync to validate the setup, and read the +`Summary:` block printed at the end of a sync to verify the result. + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | Invalid translations found (with `--invalid error`) | +| 2 | Configuration error (missing/invalid config, unknown target, missing sheet) | +| 3 | Authentication error β€” run `npx sync-wording auth` | +| 10 | Unexpected error | + +## Configuration + +Read `wording_config.json` at the project root β€” it is the live source of +truth, never assume its content. Two shapes exist: + +**Flat (single target):** + +```json +{ + "sheetId": "", + "sheetNames": ["Commons", "MyApp"], + "format": "angular-json", + "output_dir": "src/assets/i18n/", + "languages": { "fr": { "column": "D" }, "en": { "column": "C" } } +} +``` + +**Multi-target (one document, several syncs β€” e.g. app strings, accessibility +keys, iOS permission messages):** + +```json +{ + "sheetId": "", + "targets": { + "app": { "sheetNames": ["MyApp"], "format": "angular-json", "output_dir": "src/assets/i18n/", "languages": { "fr": { "column": "D" } } }, + "accessibility": { "sheetNames": ["Accessibility"], "format": "flat-json", "output_dir": "src/assets/a11y/", "languages": { "fr": { "column": "C" } } } + } +} +``` + +Field semantics: + +| Field | Level | Meaning | +|---|---|---| +| `sheetId` | document | Google Sheet id (from its URL). Required | +| `credentials` | document | path to a Google OAuth client file; embedded credentials by default | +| `wording_file` | document | local xlsx cache path, default `./wording.xlsx` | +| `sheetNames` | target | sheet tabs to read, merged in order β€” **on duplicate keys, the last sheet wins**. Default: all tabs | +| `sheetStartIndex` | target | first data row, default 2 (row 1 = headers) | +| `keyColumn` | target | column holding the keys, default `A` | +| `format` | target | `json` (nested), `flat-json`, or `angular-json` | +| `output_dir` | target | where files are written, one `.json` per language | +| `languages..column` | target | sheet column for that language | +| `languages..output` | target | optional per-language output path override | +| `ignoreEmptyKeys` | target | skip keys with empty values, default false | +| `validation` | target | `{ "column": "E", "expected": "OK" }` β€” keys whose validation cell differs are reported invalid | + +Document-level values act as defaults for all targets; a target can override +any of them. A flat config is equivalent to a single target named `default`. + +**Discovery:** a project may use several Google Sheet documents β€” check +`package.json` scripts for `sync-wording --config ` invocations +before assuming `wording_config.json` is the only configuration. + +## Workflows + +**Routine sync** (most common request): +1. `npx sync-wording --upgrade` +2. Verify the `Summary:` block; report invalid keys to the user if any +3. Commit the regenerated files together with the updated `wording.xlsx` + +**Add a key:** `npx sync-wording --add my.key "English value" "Valeur franΓ§aise"` +β€” one value per configured language, in the order they appear in the config. +With multiple targets, add `--target `. Then run `--upgrade` to +regenerate local files. + +**Remove a key:** `npx sync-wording --remove my.key` β€” deletes the whole row +from the spreadsheet. This is destructive and not recoverable from the tool: +confirm with the user before removing, and run `--upgrade` afterwards to +regenerate local files. + +**Sheet tab selection for `--add`/`--set`/`--remove`:** `--add` writes to the +first `sheetNames` entry of the target unless `--sheet-name ` picks +another; `--set` and `--remove` search the key in all the target's sheets +unless `--sheet-name` narrows it to one. If the targeted key exists in several +sheets, the command refuses to guess and asks for `--sheet-name` (duplicates +of other keys never block the operation). `--sheet-name` must belong to the +selected target β€” the tool rejects a tab from another target (exit 2), +because it would write translations with the wrong column mapping. + +**Add a language:** add the entry under `languages` in the config (column from +the sheet), then `--update` or `--upgrade`. + +**First setup:** `npx sync-wording init --sheet-id --languages fr:D,en:C`, +then `npx sync-wording auth` (interactive β€” the user must do this step), +then `npx sync-wording --check`. + +## Upgrading from 1.x + +Run `npx sync-wording init` β€” non-destructive (keeps the config, wires the +skill, validates). Keep the flat config as-is; do not convert it to `targets`. +Auth is now explicit: exit 3 β†’ run `npx sync-wording auth`. Full guide in the +project README. + +While upgrading, check whether the project has several config files syncing +the **same** `sheetId` (grep the repo β€” package.json scripts, CI, Makefiles β€” +for `--config `). If so, offer to consolidate them into one multi-target +config: the document is downloaded once and `--target` runs one or all. Only +with the user's confirmation, and you MUST replace every `--config ` call +with `--target ` and remove the merged files, or builds break. Configs on +different documents stay separate; a single flat config stays as-is. + +## Troubleshooting + +- **Exit 3 / authentication error**: a stored token is missing. Ask the user to + run `npx sync-wording auth` β€” it opens a browser and cannot run unattended. +- **Exit 2 / sheet not found**: the tab names in `sheetNames` don't match the + document; the error lists the available tabs. +- **Invalid translations reported**: cells in the validation column don't match + the expected value. This is a content issue for the wording owner, not a bug. diff --git a/src/WordingLoader.ts b/src/WordingLoader.ts index 85b6db9..0780d23 100644 --- a/src/WordingLoader.ts +++ b/src/WordingLoader.ts @@ -1,5 +1,7 @@ +import fs from "fs"; import xlsx, { WorkBook, WorkSheet } from "xlsx"; -import { LanguageConfig, WordingConfig } from "./config/WordingConfig"; +import { LanguageConfig, TargetConfig } from "./config/WordingConfig"; +import { ConfigError } from "./errors"; interface Entry { key: string; @@ -7,73 +9,96 @@ interface Entry { } export class WordingResult { - - public readonly hasInvalidKeys : boolean; - public readonly invalidKeys : string[]; + public readonly hasInvalidKeys: boolean; + public readonly invalidKeys: string[]; constructor( - public wordings : Map, - public validations : Map + public wordings: Map, + public validations: Map, ) { - const invalidKeys : string[] = [] + const invalidKeys: string[] = []; this.validations.forEach((value, key) => { if (!value) { - invalidKeys.push(key) + invalidKeys.push(key); } }); this.invalidKeys = invalidKeys; - this.hasInvalidKeys = invalidKeys.length > 0 + this.hasInvalidKeys = invalidKeys.length > 0; } } export class WordingLoader { - private workbook: WorkBook; - private sheetNames: string[]; - constructor( - private config : WordingConfig, - ) { - this.workbook = xlsx.readFile(this.config.wording_file); - if (this.config.sheetNames && this.config.sheetNames.length > 0) { - this.sheetNames = this.config.sheetNames; - } else { - this.sheetNames = this.workbook.SheetNames; + constructor(wordingFile: string) { + if (!fs.existsSync(wordingFile)) { + throw new ConfigError( + `Wording file "${wordingFile}" not found. Run "npx sync-wording --upgrade" to download it from Google Sheet.`, + ); } + this.workbook = xlsx.readFile(wordingFile); } - loadWording(language: LanguageConfig, ignoreEmptyKeys: boolean): WordingResult { + resolveSheetNames(target: TargetConfig): string[] { + if (target.sheetNames && target.sheetNames.length > 0) { + const missing = target.sheetNames.filter( + (name) => !this.workbook.Sheets[name], + ); + if (missing.length > 0) { + throw new ConfigError( + `Sheet(s) not found for target "${target.name}": ${missing.join(", ")}. ` + + `Available sheets: ${this.workbook.SheetNames.join(", ")}`, + ); + } + return target.sheetNames; + } + return this.workbook.SheetNames; + } + + loadWording(target: TargetConfig, language: LanguageConfig): WordingResult { const wordings = new Map(); const validations = new Map(); - for (const sheetName of this.sheetNames) { + for (const sheetName of this.resolveSheetNames(target)) { const sheet = this.workbook.Sheets[sheetName]; - this.addWording(sheet, language, wordings, validations, ignoreEmptyKeys); + this.addWording(sheet, target, language, wordings, validations); } return new WordingResult(wordings, validations); } private addWording( sheet: WorkSheet, + target: TargetConfig, language: LanguageConfig, wordings: Map, validations: Map, - ignoreEmptyKeys: boolean ) { - let row: Entry | undefined; - let rowIndex = this.config.sheetStartIndex; - while ((row = this.readRow(sheet, this.config.keyColumn, language.column, rowIndex)) !== undefined) { - - if (!ignoreEmptyKeys) { + // Scan the whole used range: an empty spacer row must not silently + // truncate everything below it. + const range = xlsx.utils.decode_range(sheet["!ref"] ?? "A1:A1"); + const lastRow = range.e.r + 1; + + for ( + let rowIndex = target.sheetStartIndex; + rowIndex <= lastRow; + rowIndex++ + ) { + const row = this.readRow(sheet, target.keyColumn, language.column, rowIndex); + if (row === undefined) { + continue; + } + + if (!target.ignoreEmptyKeys) { wordings.set(row.key, row.value); } else if (row.value != undefined && row.value.length > 0) { wordings.set(row.key, row.value); } if (language.validation && language.validation?.column) { - const isValid = this.readRow(sheet, this.config.keyColumn, language.validation?.column, rowIndex)?.value === language.validation.expected; + const isValid = + this.readRow(sheet, target.keyColumn, language.validation?.column, rowIndex) + ?.value === language.validation.expected; validations.set(row.key, isValid); } - rowIndex++; } } @@ -81,7 +106,7 @@ export class WordingLoader { sheet: WorkSheet, keyColumn: string, column: string, - rowIndex: number + rowIndex: number, ): Entry | undefined { const key = sheet[`${keyColumn}${rowIndex}`]; const value = sheet[`${column}${rowIndex}`]; @@ -90,7 +115,7 @@ export class WordingLoader { } return { key: key.v, - value: value ? value.v : "" + value: value ? value.v : "", }; } } diff --git a/src/commands/check.ts b/src/commands/check.ts new file mode 100644 index 0000000..f7b6932 --- /dev/null +++ b/src/commands/check.ts @@ -0,0 +1,61 @@ +import colors from "colors/safe"; +import fs from "fs"; +import { TargetConfig, WordingConfig } from "../config/WordingConfig"; +import { WordingLoader } from "../WordingLoader"; + +export interface CheckReport { + hasInvalidKeys: boolean; +} + +/** + * Validates the configuration and reports what a sync would produce, + * without writing any output file. Reads the local xlsx if present. + */ +export function runCheck( + config: WordingConfig, + targets: TargetConfig[], +): CheckReport { + console.log(`sheetId: ${config.sheetId}`); + + const xlsxExists = fs.existsSync(config.wording_file); + console.log( + `wording file: ${config.wording_file} ${xlsxExists ? "(present)" : "(missing β€” run --upgrade to download it)"}`, + ); + + let hasInvalidKeys = false; + const loader = xlsxExists ? new WordingLoader(config.wording_file) : null; + + for (const target of targets) { + console.log(`\ntarget "${target.name}":`); + console.log( + ` sheets: ${target.sheetNames ? target.sheetNames.join(", ") : "(all)"}`, + ); + console.log(` format: ${target.format}`); + + for (const language of target.languages) { + if (loader) { + const result = loader.loadWording(target, language); + const invalid = result.invalidKeys.length; + const invalidNote = invalid > 0 ? `, ${invalid} invalid` : ""; + console.log( + ` ${language.name}: ${result.wordings.size} keys${invalidNote} β†’ ${language.output}`, + ); + if (result.hasInvalidKeys) { + hasInvalidKeys = true; + result.invalidKeys.forEach((k) => + console.log(colors.yellow(` invalid: "${k}"`)), + ); + } + } else { + console.log(` ${language.name}: column ${language.column} β†’ ${language.output}`); + } + } + } + + if (hasInvalidKeys) { + console.log(colors.yellow(`\nCheck completed with invalid translations`)); + } else { + console.log(colors.green(`\nCheck OK`)); + } + return { hasInvalidKeys }; +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..e3bf283 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,384 @@ +import colors from "colors/safe"; +import fs from "fs"; +import readline from "readline/promises"; +import { ConfigError } from "../errors"; +import { + AGENTS_MD_CREATE, + COPILOT_LINE, + PACKAGED_SKILL_PATH, + WORDING_SECTION, +} from "./templates"; + +export interface InitOptions { + config: string; + sheetId?: string; + languages?: string; + sheetNames?: string; + format?: string; + outputDir?: string; + keyColumn?: string; + target?: string; + skipAgents?: boolean; + writeAgents?: boolean; +} + +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +/** + * Accepts either a bare sheet id or a full Google Sheet URL + * (https://docs.google.com/spreadsheets/d//edit...). + */ +function extractSheetId(input: string): string { + const match = input.match(/\/d\/([a-zA-Z0-9-_]+)/); + return match ? match[1] : input; +} + +async function promptMissingOptions(options: InitOptions): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + while (!options.sheetId) { + const answer = ( + await rl.question("Google Sheet id (or full URL): ") + ).trim(); + if (answer) { + options.sheetId = extractSheetId(answer); + } + } + + while (!options.languages) { + const answer = ( + await rl.question("Languages as language:column pairs (e.g. fr:D,en:C): ") + ).trim(); + try { + parseLanguages(answer); + options.languages = answer; + } catch (e: any) { + console.log(colors.yellow(e.message)); + } + } + + if (options.sheetNames === undefined) { + const answer = ( + await rl.question("Sheet tabs to read, comma-separated (empty = all tabs): ") + ).trim(); + if (answer) { + options.sheetNames = answer; + } + } + + if (!options.format) { + const formats = ["json", "flat-json", "angular-json"]; + while (true) { + const answer = ( + await rl.question(`Output format [${formats.join("|")}] (json): `) + ).trim(); + if (!answer || formats.includes(answer)) { + if (answer && answer !== "json") { + options.format = answer; + } + break; + } + console.log(colors.yellow(`Unknown format "${answer}"`)); + } + } + + if (!options.outputDir) { + const answer = ( + await rl.question("Output directory (./src/assets/strings/): ") + ).trim(); + if (answer) { + options.outputDir = answer; + } + } + } finally { + rl.close(); + } +} + +export async function runInit(options: InitOptions): Promise { + const configExists = fs.existsSync(options.config); + // An existing config that was never wired to the skill means this is very + // likely a project upgrading from 1.x β€” capture it before we wire anything. + const isUpgrade = configExists && !isSkillAlreadyWired(); + + if (!configExists) { + if (isInteractive() && (!options.sheetId || !options.languages)) { + console.log( + `No configuration found at "${options.config}" β€” let's create it.`, + ); + await promptMissingOptions(options); + } + createConfig(options); + } else if (options.target) { + addTarget(options); + } else { + console.log(`${options.config} already exists, leaving it untouched`); + } + + if (!options.skipAgents) { + await wireAgentEntryPoints(options); + } + + // The notice belongs to the agent-integration flow: skip it entirely when + // the user opted out (--skip-agents), otherwise it would never dedup (the + // skill is never wired) and would reprint on every run. + if (isUpgrade && !options.skipAgents && !options.target) { + printUpgradeNotice(); + } + + console.log(colors.green("Init done.")); +} + +/** + * True if any umbrella file already points to the packaged skill β€” used to + * tell a fresh setup apart from a project upgrading from an earlier version. + */ +function isSkillAlreadyWired(): boolean { + for (const file of ["AGENTS.md", "CLAUDE.md"]) { + if ( + fs.existsSync(file) && + fs.readFileSync(file).toString().includes(PACKAGED_SKILL_PATH) + ) { + return true; + } + } + return false; +} + +/** + * Shown once when init runs against a pre-existing, not-yet-wired project. + * Reassures about backward compatibility (so neither the user nor an agent + * rewrites a working setup) and surfaces what 2.x adds. + */ +function printUpgradeNotice(): void { + console.log( + colors.cyan( + "\nExisting project detected β€” looks like an upgrade to sync-wording 2.x.", + ) + + "\n - Your wording_config.json works as-is: the flat (single-target) shape is" + + "\n still fully supported. Do NOT convert it to multiple targets unless you" + + "\n actually need several syncs from one document." + + "\n - Auth is now explicit and non-interactive. Any token you already stored" + + '\n keeps working; if a sync fails with exit 3, run "npx sync-wording auth" once.' + + "\n - New in 2.x: --check (dry-run validation), --set / --remove, multiple" + + "\n targets, and the AI agent skill pointer handled above.", + ); +} + + +function buildTargetJson(options: InitOptions): any { + const target: any = {}; + if (options.sheetNames) { + target.sheetNames = options.sheetNames.split(",").map((s) => s.trim()); + } + if (options.format) { + target.format = options.format; + } + if (options.outputDir) { + target.output_dir = options.outputDir; + } + if (options.keyColumn) { + target.keyColumn = options.keyColumn; + } + target.languages = parseLanguages(options.languages); + return target; +} + +function parseLanguages(spec: string | undefined): any { + if (!spec) { + throw new ConfigError( + `Missing --languages. Expected format: --languages fr:D,en:C (language:column pairs)`, + ); + } + const languages: any = {}; + for (const pair of spec.split(",")) { + const [name, column] = pair.split(":").map((s) => s.trim()); + if (!name || !column) { + throw new ConfigError( + `Invalid --languages entry "${pair}". Expected format: fr:D,en:C`, + ); + } + languages[name] = { column }; + } + return languages; +} + +function createConfig(options: InitOptions): void { + if (!options.sheetId) { + throw new ConfigError( + `No configuration found at "${options.config}".\n` + + ` - To create one: sync-wording init --sheet-id --languages fr:D,en:C\n` + + ` - If one exists elsewhere: sync-wording init --config `, + ); + } + + const target = buildTargetJson(options); + let config: any; + if (options.target) { + config = { + sheetId: options.sheetId, + targets: { [options.target]: target }, + }; + } else { + config = { sheetId: options.sheetId, ...target }; + } + + writeJson(options.config, config); + console.log(`${options.config} created`); +} + +function addTarget(options: InitOptions): void { + let json: any; + try { + json = JSON.parse(fs.readFileSync(options.config).toString()); + } catch (e: any) { + throw new ConfigError( + `Can't parse "${options.config}": ${e.message}`, + ); + } + + if (!json.targets) { + // Migrate the flat shape: move target-level fields into targets.default, + // keep document-level fields (sheetId, credentials, wording_file) at the top. + const documentKeys = ["sheetId", "credentials", "wording_file"]; + const defaultTarget: any = {}; + for (const key of Object.keys(json)) { + if (!documentKeys.includes(key)) { + defaultTarget[key] = json[key]; + delete json[key]; + } + } + json.targets = { default: defaultTarget }; + console.log(`Migrated flat configuration to a "default" target`); + } + + if (json.targets[options.target!]) { + throw new ConfigError( + `Target "${options.target}" already exists in ${options.config}`, + ); + } + + json.targets[options.target!] = buildTargetJson(options); + writeJson(options.config, json); + console.log(`Target "${options.target}" added to ${options.config}`); +} + +function writeJson(filePath: string, json: any): void { + fs.writeFileSync(filePath, JSON.stringify(json, null, 2) + "\n"); +} + +async function wireAgentEntryPoints(options: InitOptions): Promise { + // AGENTS.md: cross-agent standard (Codex, Cursor, Amp, Zed, ...) β€” also + // read by Claude Code and Cursor (since 1.3), so it has the widest reach. + // Append to an existing file (low intrusion); never seed a project's + // top-level agent guide from scratch silently β€” that is an explicit choice. + if (!appendPointer("AGENTS.md")) { + await offerAgentsFile(options); + } + + // Claude Code: same lazy pointer, but only when CLAUDE.md already exists. + // We never create it from scratch β€” Claude Code reads AGENTS.md too. + // The marker is the bare path so a CLAUDE.md symlinked to AGENTS.md + // is detected as already wired and never double-appended. + appendPointer("CLAUDE.md"); + + // GitHub Copilot: only if the project already uses it (.github present). + if (fs.existsSync(".github")) { + ensureLine( + ".github/copilot-instructions.md", + PACKAGED_SKILL_PATH, + `\n${COPILOT_LINE}`, + COPILOT_LINE, + ); + } +} + +/** + * Append the lazy skill pointer to an umbrella file, but only if it exists. + * Returns whether the file was present (whether or not it needed the pointer). + */ +function appendPointer(filePath: string): boolean { + if (!fs.existsSync(filePath)) { + return false; + } + const content = fs.readFileSync(filePath).toString(); + if (!content.includes(PACKAGED_SKILL_PATH)) { + fs.appendFileSync(filePath, WORDING_SECTION); + console.log(`${filePath} updated with skill reference`); + } + return true; +} + +/** + * No AGENTS.md yet. Creating one appropriates the project's top-level agent + * guide, so require an explicit opt-in: --write-agents, or an interactive + * yes. Otherwise print the snippet so the agent running init (or the user) + * can wire it deliberately β€” discoverability without intrusion. + */ +async function offerAgentsFile(options: InitOptions): Promise { + if (options.writeAgents) { + createAgentsFile(); + return; + } + if (isInteractive()) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = ( + await rl.question( + "No AGENTS.md found. Create one with a wording pointer? [y/N] ", + ) + ) + .trim() + .toLowerCase(); + if (["y", "yes", "o", "oui"].includes(answer)) { + createAgentsFile(); + return; + } + } finally { + rl.close(); + } + } + printAgentsSnippet(); +} + +function createAgentsFile(): void { + fs.writeFileSync("AGENTS.md", AGENTS_MD_CREATE); + console.log(`AGENTS.md created with skill reference`); +} + +function printAgentsSnippet(): void { + console.log( + colors.yellow( + "No AGENTS.md found β€” not creating one (init stays non-intrusive).", + ) + + "\nTo let AI agents discover wording sync, add this to AGENTS.md:\n" + + WORDING_SECTION, + ); +} + +function ensureLine( + filePath: string, + marker: string, + appendContent: string, + createContent: string, +): void { + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath).toString(); + if (content.includes(marker)) { + return; + } + fs.appendFileSync(filePath, appendContent); + console.log(`${filePath} updated with skill reference`); + } else { + fs.writeFileSync(filePath, createContent); + console.log(`${filePath} created with skill reference`); + } +} diff --git a/src/commands/templates.ts b/src/commands/templates.ts new file mode 100644 index 0000000..28c8604 --- /dev/null +++ b/src/commands/templates.ts @@ -0,0 +1,24 @@ +/** + * All file content generated by `init` lives here, separated from the + * command logic. Deliberately compiled-in rather than shipped as template + * files: no runtime path resolution, nothing to forget in "files", + * works identically under npm, npx, yarn portal and PnP. + */ + +export const PACKAGED_SKILL_PATH = + "node_modules/@betomorrow/sync-wording/skills/sync-wording/SKILL.md"; + +/** Section appended to AGENTS.md / CLAUDE.md. A lazy pointer, not an + * @-import: the skill is read on demand, never inlined in every session. */ +export const WORDING_SECTION = ` +## Wording (i18n) + +Wording sync is handled by @betomorrow/sync-wording. +For any translation/wording task, read ${PACKAGED_SKILL_PATH} first. +`; + +export const AGENTS_MD_CREATE = `# Project guide for AI agents +${WORDING_SECTION}`; + +export const COPILOT_LINE = `For wording/i18n sync tasks, follow the guide in ${PACKAGED_SKILL_PATH} +`; diff --git a/src/config/WordingConfig.ts b/src/config/WordingConfig.ts index 760aa3e..34ba7f1 100644 --- a/src/config/WordingConfig.ts +++ b/src/config/WordingConfig.ts @@ -1,16 +1,26 @@ +import { ConfigError } from "../errors"; + 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 (!this.column) { + throw new ConfigError(`Language "${name}" has no "column" defined`); + } if (config.output) { this.output = config.output; } else { @@ -28,64 +38,122 @@ export class LanguageConfig { } } -export class WordingConfig { - wording_file: string; - credentials: string; - sheetId: string; - sheetNames: string[]; +/** + * Reads `key` from the first source that defines it, so a target value + * overrides a document-level value, which overrides the built-in default. + */ +function getOrDefault(sources: any[], key: string, defaultValue: T): T { + for (const source of sources) { + if (source && Object.prototype.hasOwnProperty.call(source, key)) { + return source[key] as T; + } + } + return defaultValue; +} + +export class TargetConfig { + name: string; + sheetNames: string[] | undefined; sheetStartIndex: number; keyColumn: string; output_dir: string; - languages: LanguageConfig[]; format: string; ignoreEmptyKeys: boolean; - validation : Validation | null; - - constructor(jsonConfig: any) { - this.wording_file = this.getOrDefault( - jsonConfig, - "wording_file", - "./wording.xlsx" - ); - - this.credentials = this.getOrDefault(jsonConfig, "credentials", ""); - - this.sheetId = jsonConfig.sheetId; - this.sheetNames = jsonConfig.sheetNames; - - this.sheetStartIndex = this.getOrDefault(jsonConfig, "sheetStartIndex", 2); + validation: Validation | null; + languages: LanguageConfig[]; - this.keyColumn = this.getOrDefault(jsonConfig, "keyColumn", "A"); + constructor(name: string, targetJson: any, documentJson: any) { + const sources = [targetJson, documentJson]; - this.output_dir = this.getOrDefault( - jsonConfig, + this.name = name; + this.sheetNames = getOrDefault( + sources, + "sheetNames", + undefined, + ); + this.sheetStartIndex = getOrDefault(sources, "sheetStartIndex", 2); + this.keyColumn = getOrDefault(sources, "keyColumn", "A"); + this.output_dir = getOrDefault( + sources, "output_dir", - "./src/assets/strings/" + "./src/assets/strings/", + ); + this.format = getOrDefault(sources, "format", "json"); + this.ignoreEmptyKeys = getOrDefault(sources, "ignoreEmptyKeys", false); + this.validation = getOrDefault( + sources, + "validation", + null, ); - this.languages = []; - - this.format = this.getOrDefault(jsonConfig, "format", "json"); - - this.ignoreEmptyKeys = this.getOrDefault(jsonConfig, "ignoreEmptyKeys", false); - - this.validation = this.getOrDefault(jsonConfig, "validation", null) + const languagesJson = getOrDefault(sources, "languages", null); + if (!languagesJson || Object.keys(languagesJson).length === 0) { + throw new ConfigError(`Target "${name}" has no "languages" defined`); + } - for (const language in jsonConfig.languages) { - if (jsonConfig.languages.hasOwnProperty(language)) { - const element = jsonConfig.languages[language]; + this.languages = []; + for (const language in languagesJson) { + if (languagesJson.hasOwnProperty(language)) { this.languages.push( - new LanguageConfig(language, element, this.output_dir, this.validation) + new LanguageConfig( + language, + languagesJson[language], + this.output_dir, + this.validation, + ), ); } } + } +} + +export class WordingConfig { + wording_file: string; + credentials: string; + sheetId: string; + targets: TargetConfig[]; + + constructor(jsonConfig: any) { + this.wording_file = getOrDefault( + [jsonConfig], + "wording_file", + "./wording.xlsx", + ); + this.credentials = getOrDefault([jsonConfig], "credentials", ""); + this.sheetId = jsonConfig.sheetId; + if (!this.sheetId) { + throw new ConfigError(`"sheetId" is required`); + } + if (jsonConfig.targets) { + const names = Object.keys(jsonConfig.targets); + if (names.length === 0) { + throw new ConfigError(`"targets" is defined but empty`); + } + this.targets = names.map( + (name) => new TargetConfig(name, jsonConfig.targets[name], jsonConfig), + ); + } else { + // Flat configuration (legacy shape): a single implicit target. + this.targets = [new TargetConfig("default", jsonConfig, {})]; + } } - private getOrDefault(source: any, key: string, defaultValue: T): T { - if (source.hasOwnProperty(key)) { - return source[key] as T; + selectTargets(name: string | undefined): TargetConfig[] { + if (!name) { + return this.targets; } - return defaultValue; + const target = this.targets.find((t) => t.name === name); + if (!target) { + const available = this.targets.map((t) => t.name).join(", "); + throw new ConfigError( + `Unknown target "${name}". Available targets: ${available}`, + ); + } + return [target]; + } + + hasMultipleTargets(): boolean { + return this.targets.length > 1; } } diff --git a/src/config/WordingConfigLoader.ts b/src/config/WordingConfigLoader.ts index d108e28..c1383ec 100644 --- a/src/config/WordingConfigLoader.ts +++ b/src/config/WordingConfigLoader.ts @@ -1,15 +1,26 @@ import fs from "fs"; +import { ConfigError } from "../errors"; import { WordingConfig } from "./WordingConfig"; export function loadConfiguration(configPath: string): Promise { - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { fs.readFile(configPath, (err, config) => { if (err) { - console.log("Can't read configuration", err); - throw Error("Can't read configuration") - } else { - const result = new WordingConfig(JSON.parse(config.toString())); - resolve(result); + reject( + new ConfigError( + `Can't read configuration file "${configPath}": ${err.message}. Run "npx sync-wording init" to create one.`, + ), + ); + return; + } + try { + resolve(new WordingConfig(JSON.parse(config.toString()))); + } catch (e: any) { + if (e instanceof ConfigError) { + reject(new ConfigError(`Invalid configuration "${configPath}": ${e.message}`)); + } else { + reject(new ConfigError(`Invalid JSON in "${configPath}": ${e.message}`)); + } } }); }); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..65b59b8 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,21 @@ +export const EXIT_CODES = { + OK: 0, + INVALID_WORDINGS: 1, + CONFIG_ERROR: 2, + AUTH_ERROR: 3, + UNEXPECTED_ERROR: 10, +} as const; + +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +export class AuthRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthRequiredError"; + } +} diff --git a/src/exporters/AngularJsonWordingExporter.ts b/src/exporters/AngularJsonWordingExporter.ts index 56aabf8..6c2a764 100644 --- a/src/exporters/AngularJsonWordingExporter.ts +++ b/src/exporters/AngularJsonWordingExporter.ts @@ -26,7 +26,6 @@ export class AngularJsonWordingExporter implements WordingExporter { if (err) { reject(err); } else { - console.log(`${output} updated`); resolve(); } }); diff --git a/src/exporters/FlatJsonWordingExportter.ts b/src/exporters/FlatJsonWordingExportter.ts index ad2f679..9400a94 100644 --- a/src/exporters/FlatJsonWordingExportter.ts +++ b/src/exporters/FlatJsonWordingExportter.ts @@ -22,7 +22,6 @@ export class FlatJsonWordingExporter implements WordingExporter { if (err) { reject(err); } else { - console.log(`${output} updated`); resolve(); } }); diff --git a/src/exporters/NestedJsonWordingExporter.ts b/src/exporters/NestedJsonWordingExporter.ts index 62a2ea4..c3a93d3 100644 --- a/src/exporters/NestedJsonWordingExporter.ts +++ b/src/exporters/NestedJsonWordingExporter.ts @@ -40,7 +40,6 @@ export class NestedJsonWordingExporter implements WordingExporter { if (err) { reject(err); } else { - console.log(`${output} updated`); resolve(); } }); diff --git a/src/google/Drive.ts b/src/google/Drive.ts index 0371220..2225c89 100644 --- a/src/google/Drive.ts +++ b/src/google/Drive.ts @@ -1,71 +1,359 @@ 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"; +import { ConfigError } from "../errors"; + +export interface SheetWriteConfig { + keyColumn: string; + languages: LanguageConfig[]; + sheetStartIndex: number; +} + +export interface KeyLocation { + sheetName: string; + rowIndex: number; +} + +/** + * Lazy ambiguity resolution: duplicate keys across sheets are legal for + * reading (last sheet wins at sync time), so they only fail --set when the + * duplicated key is the one being updated β€” never while updating another key. + */ +export function pickKeyLocation( + key: string, + locations: KeyLocation[] | undefined, + searchedSheets: string[] +): KeyLocation { + if (!locations || locations.length === 0) { + throw new ConfigError( + `Key not found: "${key}" (searched sheets: ${searchedSheets.join(", ")})` + ); + } + if (locations.length > 1) { + const sheets = [...new Set(locations.map((l) => l.sheetName))]; + if (sheets.length > 1) { + throw new ConfigError( + `Ambiguous key "${key}": found in sheets ${sheets.join(", ")}. ` + + `Pass --sheet-name to pick one` + ); + } + throw new ConfigError( + `Duplicate key "${key}": found ${locations.length} times in sheet "${sheets[0]}". ` + + `Fix the duplicate rows in the spreadsheet first` + ); + } + return locations[0]; +} + export class Drive { drive: drive_v3.Drive; constructor(private readonly auth: OAuth2Client) { this.drive = google.drive({ version: "v3", auth }); + } + + async addLines( + spreadsheetId: string, + sheetName: string, + lines: { key: string; values: string[] }[], + writeConfig: SheetWriteConfig + ) { + const service = google.sheets({ version: "v4", auth: this.auth }); + + // Anchor the write on keyColumn / sheetStartIndex β€” the same row model as + // reads, --set and --remove. We do NOT use values.append: it auto-detects a + // "table" from the range and writes from that table's first column, which + // misaligns the row when keyColumn is not the sheet's first column, and it + // ignores sheetStartIndex. Writing each cell at its explicit address also + // leaves untouched any column between the configured ones. + const firstFreeRow = await this.findFirstFreeRow( + service, + spreadsheetId, + sheetName, + writeConfig.keyColumn, + writeConfig.sheetStartIndex + ); - this.drive.files.list({}); + const updates: { range: string; values: string[][] }[] = []; + lines.forEach((line, index) => { + updates.push( + ...this.buildLineUpdates( + sheetName, + firstFreeRow + index, + line, + writeConfig + ) + ); + }); + + const result = await service.spreadsheets.values.batchUpdate({ + spreadsheetId, + requestBody: { + valueInputOption: "RAW", + data: updates, + }, + }); + + console.log( + `${lines.length} line(s) appended (${result.data.totalUpdatedCells} cells).` + ); + return result; + } + + /** + * First writable row: the row right after the last non-empty cell in the key + * column, starting at sheetStartIndex. values.get trims trailing blank rows, + * so its row count is exactly the number of used rows from startRow. + */ + private async findFirstFreeRow( + 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 usedRows = response.data.values?.length ?? 0; + return startRow + usedRows; } - listFiles() { - this.drive.files.list( + private buildLineUpdates( + sheetName: string, + rowIndex: number, + line: { key: string; values: string[] }, + writeConfig: SheetWriteConfig + ): { range: string; values: string[][] }[] { + return [ { - pageSize: 10, - fields: "nextPageToken, files(id, name)" + range: `${sheetName}!${writeConfig.keyColumn}${rowIndex}`, + values: [[line.key]], }, - (err, res) => { - const files = res?.data.files; - if (files?.length) { - console.log("Files:"); - files.map(file => { - console.log(`${file.name} (${file.id})`); - }); - } else { - console.log("No files found"); - } - } + ...this.buildTranslationUpdates(sheetName, rowIndex, line, writeConfig), + ]; + } + + async updateLines( + spreadsheetId: string, + sheetNames: string[] | undefined, + lines: { key: string; values: string[] }[], + writeConfig: SheetWriteConfig + ) { + const service = google.sheets({ version: "v4", auth: this.auth }); + const resolvedSheetNames = await this.resolveSheetNames( + service, + spreadsheetId, + sheetNames + ); + const keyLocations = await this.findKeyLocations( + service, + spreadsheetId, + resolvedSheetNames, + writeConfig.keyColumn, + writeConfig.sheetStartIndex ); + + const updates: { range: string; values: string[][] }[] = []; + for (const line of lines) { + const location = pickKeyLocation( + line.key, + keyLocations.get(line.key), + resolvedSheetNames + ); + + updates.push( + ...this.buildTranslationUpdates( + location.sheetName, + location.rowIndex, + line, + writeConfig + ) + ); + } + + const result = await service.spreadsheets.values.batchUpdate({ + spreadsheetId, + requestBody: { + valueInputOption: "RAW", + 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 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]]], + })); } - async addLine(spreadsheetId: string, sheetName: string, key: string, ...values: string[]) { + async removeLines( + spreadsheetId: string, + sheetNames: string[] | undefined, + keys: string[], + writeConfig: SheetWriteConfig + ) { const service = google.sheets({ version: "v4", auth: this.auth }); - try { - const result = await service.spreadsheets.values.append({ + const resolvedSheetNames = await this.resolveSheetNames( + service, + spreadsheetId, + sheetNames + ); + const keyLocations = await this.findKeyLocations( + service, + spreadsheetId, + resolvedSheetNames, + writeConfig.keyColumn, + writeConfig.sheetStartIndex + ); + + const uniqueKeys = [...new Set(keys)]; + const locations = uniqueKeys.map((key) => + pickKeyLocation(key, keyLocations.get(key), resolvedSheetNames) + ); + + // DeleteDimension requires the numeric sheet id, not its title. + const meta = await service.spreadsheets.get({ spreadsheetId }); + const sheetIdsByTitle = new Map(); + meta.data.sheets?.forEach((sheet) => { + const title = sheet.properties?.title; + const sheetId = sheet.properties?.sheetId; + if (title != null && sheetId != null) { + sheetIdsByTitle.set(title, sheetId); + } + }); + + // Delete bottom-up so earlier deletions don't shift the next row indexes. + const requests = locations + .sort((a, b) => b.rowIndex - a.rowIndex) + .map((location) => { + const sheetId = sheetIdsByTitle.get(location.sheetName); + if (sheetId === undefined) { + throw new ConfigError( + `Sheet "${location.sheetName}" not found in spreadsheet` + ); + } + return { + deleteDimension: { + range: { + sheetId, + dimension: "ROWS", + startIndex: location.rowIndex - 1, + endIndex: location.rowIndex, + }, + }, + }; + }); + + const result = await service.spreadsheets.batchUpdate({ + spreadsheetId, + requestBody: { requests }, + }); + + console.log(`${uniqueKeys.length} line(s) removed.`); + return result; + } + + private async resolveSheetNames( + service: ReturnType, + spreadsheetId: string, + sheetNames: string[] | undefined + ): Promise { + if (sheetNames && sheetNames.length > 0) { + return sheetNames; + } + + const meta = await service.spreadsheets.get({ spreadsheetId }); + const titles = meta.data.sheets + ?.map((sheet) => sheet.properties?.title) + .filter((title): title is string => Boolean(title)); + + if (!titles?.length) { + throw new Error("No sheets found in spreadsheet"); + } + + return titles; + } + + private async findKeyLocations( + service: ReturnType, + spreadsheetId: string, + sheetNames: string[], + keyColumn: string, + startRow: number + ): Promise> { + const keyLocations = new Map(); + + for (const sheetName of sheetNames) { + const response = await service.spreadsheets.values.get({ spreadsheetId, - range: sheetName, - valueInputOption: "RAW", - requestBody: { - values: [[key, ...values]], - }, + range: `${sheetName}!${keyColumn}${startRow}:${keyColumn}`, }); - console.log(`${result.data.updates?.updatedCells} cells appended.`); - return result; - } catch (err) { - throw err; + const keys = response.data.values ?? []; + keys.forEach((row, index) => { + const key = row[0]; + if (!key) { + return; + } + + const locations = keyLocations.get(key) ?? []; + locations.push({ sheetName, rowIndex: startRow + index }); + keyLocations.set(key, locations); + }); } + + return keyLocations; } async exportAsXlsx(fileId: string, output: string, mimeType: string) { - const dest = fs.createWriteStream(output); - const res = await this.drive.files.export( - { - fileId, - mimeType - }, - { responseType: "stream" } - ); - await new Promise((resolve, reject) => { - (res.data as any) - .on("error", reject) - .pipe(dest) - .on("error", reject) - .on("finish", resolve); - }); + try { + const res = await this.drive.files.export( + { + fileId, + mimeType + }, + { responseType: "stream" } + ); + const dest = fs.createWriteStream(output); + await new Promise((resolve, reject) => { + (res.data as any) + .on("error", reject) + .pipe(dest) + .on("error", reject) + .on("finish", resolve); + }); + } catch (error) { + // Don't leave a partial/empty xlsx behind: a later --update would + // silently read it as an empty workbook. + fs.rmSync(output, { force: true }); + throw error; + } } } diff --git a/src/google/GoogleAuth.ts b/src/google/GoogleAuth.ts index b94e0d4..8a10361 100644 --- a/src/google/GoogleAuth.ts +++ b/src/google/GoogleAuth.ts @@ -6,32 +6,50 @@ import http from "http"; import open from "open"; import url from "url"; +import { AuthRequiredError } from "../errors"; import { defaultCredentials } from "./DefaultCredentials"; const TOKEN_PATH = ".google_access_token.json"; +export function hasStoredToken(): boolean { + return fs.existsSync(TOKEN_PATH); +} + export class GoogleAuth { + /** + * Non-interactive authorization: requires a previously stored token. + * Fails fast with an actionable message instead of opening a browser, + * so the command stays usable from CI and AI agents. + */ async authorize( credentials_path: string, scopes: string[] ): Promise { - return new Promise(async (resolve, reject) => { - const credentials = await this.loadCredentials(credentials_path); - const { client_secret, client_id, redirect_uris } = credentials.installed; - const oAuth2Client = new OAuth2Client( - client_id, - client_secret, - redirect_uris[0] + const oAuth2Client = await this.createClient(credentials_path); + const token = await this.readToken(); + if (!token) { + throw new AuthRequiredError( + `No stored Google access token ("${TOKEN_PATH}" not found). ` + + `Run "npx sync-wording auth" once to authenticate interactively.` ); + } + oAuth2Client.setCredentials(token); + return oAuth2Client; + } - // Check if we have previously stored a token. - let token = await this.readToken(); - if (!token) { - token = await this.getAccessToken(oAuth2Client, scopes); - await this.writeToken(token); - } - oAuth2Client.setCredentials(token); - resolve(oAuth2Client); - }); + /** + * Interactive login: opens a browser, stores the token for later + * non-interactive runs. Used by the explicit "auth" command. + */ + async login(credentials_path: string, scopes: string[]): Promise { + const oAuth2Client = await this.createClient(credentials_path); + const token = await this.getAccessToken(oAuth2Client, scopes); + await this.writeToken(token); + } + + private async createClient(credentials_path: string): Promise { + const credentials = await this.loadCredentials(credentials_path); + const { client_secret, client_id, redirect_uris } = credentials.installed; + return new OAuth2Client(client_id, client_secret, redirect_uris[0]); } async loadCredentials(path: string): Promise { @@ -39,10 +57,21 @@ export class GoogleAuth { if (path && path.length > 0) { fs.readFile(path, (err, content) => { if (err) { - console.log("Error loading client secret file:", err); - reject(err); + reject( + new AuthRequiredError( + `Can't read credentials file "${path}": ${err.message}` + ) + ); } else { - resolve(JSON.parse(content.toString())); + try { + resolve(JSON.parse(content.toString())); + } catch (e: any) { + reject( + new AuthRequiredError( + `Invalid credentials file "${path}": ${e.message}` + ) + ); + } } }); } else { @@ -113,7 +142,13 @@ export class GoogleAuth { if (err) { resolve(undefined); } else { - resolve(JSON.parse(token.toString())); + try { + resolve(JSON.parse(token.toString())); + } catch { + // Corrupt/truncated token: treat as no token so authorize() + // raises AuthRequiredError (exit 3) pointing to "auth". + resolve(undefined); + } } }); }); @@ -131,41 +166,4 @@ export class GoogleAuth { }); }); } - - createServer( - oAuth2Client: OAuth2Client, - resolve: (value: Credentials | PromiseLike) => void, - reject: (reason?: any) => void - ) { - return http - .createServer(async function (req, res) { - if (req?.url?.startsWith("/oauth2callback")) { - const query = url.parse(req.url, true).query; - if (query.error) { - // An error response e.g. error=access_denied - reject("Error:" + query.error); - } else { - let code: string | undefined; - if (!Array.isArray(query.code)) { - code = query.code; - } else { - code = query.code[0] ?? ""; - } - if(!code) { - reject("No code found"); - res.end("authentification failed"); - return; - } - // Get access and refresh tokens (if access_type is offline) - let { tokens } = await oAuth2Client.getToken(code); - resolve(tokens); - res.end("successful authentification"); - } - } else { - reject("No url mathching"); - res.end("authentification failed"); - } - }) - .listen(8181); - } } diff --git a/src/index.ts b/src/index.ts index 348c4ed..b45c310 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,67 +1,200 @@ #!/usr/bin/env node -import colors from 'colors/safe'; +import colors from "colors/safe"; import { program } from "commander"; +import readline from "readline/promises"; +import { runCheck } from "./commands/check"; +import { isInteractive, runInit } from "./commands/init"; +import { TargetConfig, WordingConfig } from "./config/WordingConfig"; import { loadConfiguration } from "./config/WordingConfigLoader"; +import { AuthRequiredError, ConfigError, EXIT_CODES } from "./errors"; import { AngularJsonWordingExporter } from "./exporters/AngularJsonWordingExporter"; import { FlatJsonWordingExporter } from "./exporters/FlatJsonWordingExportter"; import { NestedJsonWordingExporter } from "./exporters/NestedJsonWordingExporter"; import { WordingExporter } from "./exporters/WordingExporter"; -import { Drive } from "./google/Drive"; -import { GoogleAuth } from "./google/GoogleAuth"; +import { Drive, SheetWriteConfig } from "./google/Drive"; +import { GoogleAuth, hasStoredToken } 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("--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("-v, --verbose", "show verbose logs") - .parse(process.argv); - -if (!process.argv.slice(2).length) { - program.outputHelp(); -} +// Least privilege: read-only Drive access (xlsx export of the sheet) +// and Sheets read/write (--add / --set / --remove). +const scopes = [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/spreadsheets", +]; -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( + target: TargetConfig, + 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 target "${target.name}", language "${language}":`, + ), + ); + result.invalidKeys.forEach((k) => console.warn(colors.yellow(`\t"${k}"`))); } -}; +} -const options = program.opts(); +/** + * Requires an unambiguous single target for sheet-mutating commands + * (--add / --set): with multiple targets, --target must be provided. + */ +function requireSingleTarget( + config: WordingConfig, + targets: TargetConfig[], + command: string, +): TargetConfig { + if (targets.length > 1) { + const available = config.targets.map((t) => t.name).join(", "); + throw new ConfigError( + `${command} needs a single target but the configuration defines several. ` + + `Use --target (available: ${available})`, + ); + } + return targets[0]; +} -const scopes = [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/spreadsheets", -]; +function sheetWriteConfig(target: TargetConfig): SheetWriteConfig { + return { + keyColumn: target.keyColumn, + languages: target.languages, + sheetStartIndex: target.sheetStartIndex, + }; +} -loadConfiguration(options.config).then(async (config) => { +/** + * A --sheet-name outside the target's sheets would write translations + * with the wrong column mapping: reject it upfront. + */ +function validateSheetNameInTarget( + sheetName: string, + target: TargetConfig, + command: string, +): void { + if ( + target.sheetNames && + target.sheetNames.length > 0 && + !target.sheetNames.includes(sheetName) + ) { + throw new ConfigError( + `Sheet "${sheetName}" is not part of target "${target.name}" ` + + `(sheets: ${target.sheetNames.join(", ")}). ` + + `${command} would write with the wrong columns β€” use the matching --target or fix --sheet-name`, + ); + } +} + +function resolveAddSheetName(target: TargetConfig, options: any): string { + if (options.sheetName) { + validateSheetNameInTarget(options.sheetName, target, "--add"); + return options.sheetName; + } + const first = target.sheetNames?.[0]; + if (!first) { + throw new ConfigError( + `--add needs a sheet to write to: pass --sheet-name or set "sheetNames" in the configuration`, + ); + } + return first; +} + +function resolveSearchSheetNames( + target: TargetConfig, + options: any, + command: string, +): string[] | undefined { + if (options.sheetName) { + validateSheetNameInTarget(options.sheetName, target, command); + return [options.sheetName]; + } + // undefined lets Drive search every tab of the document + return target.sheetNames; +} + +async function runSync(options: any): Promise { + const config = await loadConfiguration(options.config); if (options.verbose) { console.log(config); } + const targets = config.selectTargets(options.target); + + // One action per invocation: the blocks below are independent `if`s, so + // without this guard `--add … --set …` would run several sheet writes (and + // re-authenticate) in one run, and `--check --add …` would silently drop the + // mutation (check returns first). + const actions = [ + options.check && "--check", + options.add && "--add", + options.set && "--set", + options.remove && "--remove", + (options.upgrade || options.update) && + (options.upgrade ? "--upgrade" : "--update"), + ].filter(Boolean); + if (actions.length > 1) { + throw new ConfigError( + `Only one action can run at a time, got: ${actions.join(", ")}. ` + + `Run them as separate commands.`, + ); + } + + if (options.check) { + const report = runCheck(config, targets); + if (report.hasInvalidKeys && options.invalid === "error") { + process.exitCode = EXIT_CODES.INVALID_WORDINGS; + } + return; + } + if (options.add) { - const [key, ...values] = options.add; + const target = requireSingleTarget(config, targets, "--add"); + const sheetName = resolveAddSheetName(target, options); + const lines = parseWordingLines(options.add, target.languages.length); 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).addLines( + config.sheetId, + sheetName, + lines, + sheetWriteConfig(target), + ); + } + + if (options.set) { + const target = requireSingleTarget(config, targets, "--set"); + const sheetNames = resolveSearchSheetNames(target, options, "--set"); + const lines = parseWordingLines(options.set, target.languages.length, "--set"); + const auth = await new GoogleAuth().authorize(config.credentials, scopes); + await new Drive(auth).updateLines( + config.sheetId, + sheetNames, + lines, + sheetWriteConfig(target), + ); + } + + if (options.remove) { + const target = requireSingleTarget(config, targets, "--remove"); + const sheetNames = resolveSearchSheetNames(target, options, "--remove"); + const auth = await new GoogleAuth().authorize(config.credentials, scopes); + await new Drive(auth).removeLines( + config.sheetId, + sheetNames, + options.remove, + sheetWriteConfig(target), + ); } if (options.upgrade) { @@ -71,27 +204,228 @@ 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", ); } if (options.upgrade || options.update) { console.log("Updating wording files"); - const loader = new WordingLoader(config); + const loader = new WordingLoader(config.wording_file); + + // Validate every target's sheets upfront so a bad target + // fails the whole run before any file is written. + targets.forEach((target) => loader.resolveSheetNames(target)); + + let hasError = false; + let filesWritten = 0; + const summaryLines: string[] = []; - const exporter = getExporter(config.format); - 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 - }); + for (const target of targets) { + const exporter = getExporter(target.format); + for (const language of target.languages) { + const result = loader.loadWording(target, language); + await exporter.export(language.name, result.wordings, language.output); + printReport(target, language.name, result); + hasError = hasError || result.hasInvalidKeys; + filesWritten++; + const invalidNote = + result.invalidKeys.length > 0 + ? `, ${result.invalidKeys.length} invalid` + : ""; + summaryLines.push( + ` [${target.name}] ${language.name}: ${result.wordings.size} keys${invalidNote} β†’ ${language.output}`, + ); + } + } + + console.log("\nSummary:"); + summaryLines.forEach((line) => console.log(line)); + console.log(`${filesWritten} file(s) written`); if (hasError && options.invalid === "error") { - process.exitCode = 1 + process.exitCode = EXIT_CODES.INVALID_WORDINGS; } } -}); +} + +/** + * Validate the freshly scaffolded configuration: parse it and report what a + * sync would produce, without writing anything. Surfaces a broken config + * immediately rather than at the first real sync. + */ +async function runInitCheck(options: any): Promise { + console.log(`\nValidating configuration...`); + const config = await loadConfiguration(options.config); + const targets = config.selectTargets(options.target); + runCheck(config, targets); +} + +/** + * Interactive follow-up after init: walk the user through authentication + * and a first sync, so a single command goes from zero to generated files. + * Skipped entirely in non-interactive contexts (CI, agents). + */ +async function postInitGuidance(options: any): Promise { + if (!isInteractive()) { + console.log(`Next steps:`); + console.log(` - review ${options.config}`); + console.log(` - run "sync-wording auth" if not authenticated yet`); + console.log(` - run "sync-wording --check" to validate the setup`); + return; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + if (!hasStoredToken()) { + const answer = ( + await rl.question("Authenticate with Google now (opens a browser)? [Y/n] ") + ) + .trim() + .toLowerCase(); + if (["", "y", "yes", "o", "oui"].includes(answer)) { + await runAuth(options); + } else { + console.log(`Skipped β€” run "sync-wording auth" before the first sync.`); + return; + } + } + const answer = ( + await rl.question("Run a first sync now (download + generate files)? [Y/n] ") + ) + .trim() + .toLowerCase(); + if (["", "y", "yes", "o", "oui"].includes(answer)) { + await runSync({ + config: options.config, + upgrade: true, + invalid: "warning", + }); + } else { + console.log(`Skipped β€” run "sync-wording --upgrade" when ready.`); + } + } finally { + rl.close(); + } +} +async function runAuth(options: any): Promise { + let credentials = ""; + try { + const config = await loadConfiguration(options.config); + credentials = config.credentials; + } catch (e) { + // No readable configuration: fall back to embedded credentials so + // authentication can happen before the config file exists. + } + await new GoogleAuth().login(credentials, scopes); + console.log(colors.green("Authentication successful")); +} + +function fail(error: any): void { + if (error instanceof ConfigError) { + console.error(colors.red(`Configuration error: ${error.message}`)); + process.exit(EXIT_CODES.CONFIG_ERROR); + } + if (error instanceof AuthRequiredError) { + console.error(colors.red(`Authentication error: ${error.message}`)); + process.exit(EXIT_CODES.AUTH_ERROR); + } + // Google Drive answers 404 both for a wrong id and for a sheet + // the authenticated account can't access. + if (error?.code === 404 || error?.response?.status === 404) { + console.error( + colors.red( + `Configuration error: Google Sheet not found. Check that "sheetId" is correct ` + + `and that the sheet is shared with the authenticated Google account.`, + ), + ); + process.exit(EXIT_CODES.CONFIG_ERROR); + } + console.error(colors.red(`Unexpected error: ${error?.message ?? error}`)); + process.exit(EXIT_CODES.UNEXPECTED_ERROR); +} + +program + .name("sync-wording") + .description("Upgrade application wording from Google Sheet") + // Required so "auth"/"init" parse their own --config/--target options + // instead of the root command capturing them. + .enablePositionalOptions() + .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( + "--remove ", + "remove wording line(s) from remote Google Sheet by key (deletes the whole row; repeat keys for multiple lines)", + ) + .option( + "--sheet-name ", + "sheet tab for --add / --set / --remove; must belong to the selected target (--add default: first sheetNames entry, --set/--remove default: search all target sheets)", + ) + .option("--config ", "wording config path", "wording_config.json") + .option("--target ", "restrict the command to a single target") + .option("--upgrade", "upgrade wording from remote Google Sheet") + .option("--update", "update wording from local xlsx") + .option( + "--check", + "validate configuration and report what would be written, without writing", + ) + .option( + "--invalid ", + "error|warning on invalid translation", + "warning", + ) + .option("-v, --verbose", "show verbose logs") + .action((options) => runSync(options).catch(fail)); + +program + .command("auth") + .description("authenticate to Google interactively and store the access token") + .option("--config ", "wording config path", "wording_config.json") + .action((options) => runAuth(options).catch(fail)); + +program + .command("init") + .description( + "scaffold wording_config.json and wire AI agent skill references", + ) + .option("--config ", "wording config path", "wording_config.json") + .option("--sheet-id ", "Google Sheet document id") + .option( + "--languages ", + "language:column pairs, e.g. fr:D,en:C", + ) + .option("--sheet-names ", "comma-separated sheet (tab) names") + .option("--format ", "output format (json|flat-json|angular-json)") + .option("--output-dir ", "output directory for generated files") + .option("--key-column ", "column containing wording keys") + .option( + "--target ", + "create or add a named target instead of a flat configuration", + ) + .option("--skip-agents", "do not create/update AI agent entry point files") + .option( + "--write-agents", + "create AGENTS.md from scratch when absent instead of printing the snippet", + ) + .action((options) => + runInit(options) + .then(() => runInitCheck(options)) + .then(() => postInitGuidance(options)) + .catch(fail), + ); + +if (!process.argv.slice(2).length) { + program.outputHelp(); +} else { + program.parseAsync(process.argv).catch(fail); +} diff --git a/src/utils.ts b/src/utils.ts index bdc9d12..1fb58fb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,3 +8,22 @@ function concat(first: Map, other: Map): Map { }); return result; } + +export function parseWordingLines( + args: string[], + languageCount: number, + command: string = "--add" +): { key: string; values: string[] }[] { + const lineSize = 1 + languageCount; + if (args.length % lineSize !== 0) { + throw new Error( + `Invalid ${command} 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; +}