From 587d137341652a94fcea7d5cd3f69a0b2a1018dc Mon Sep 17 00:00:00 2001 From: snodel Date: Tue, 22 Sep 2026 22:31:04 -0700 Subject: [PATCH 01/20] refactor(core): own the resource folder pair and unify the staleness rule Introduce a pure Staleness module in domain (applyBaseChange, recordTranslation, needsTranslation, resolveImportStatus) and a ResourceFolder module in core that owns resource_entries.json + tracker_meta.json. Every writer (add/edit/delete/move resource, import, normalize, translate, add/remove locale) and reader now goes through it. Fixes two behaviours: an import that changes a base value now marks translations stale, and moving a resource or folder keeps verified/stale statuses instead of resetting them to translated. moveFolder now stops before deleting when a folder cannot be read. Co-Authored-By: Claude Fable 5.1 --- apps/cli/src/add-resource/add-resource.ts | 34 +- architecture-docs/core-library.md | 24 +- architecture-docs/glossary.md | 16 + architecture-docs/monorepo-structure.md | 6 +- .../add-locale-to-collection.spec.ts | 15 + .../add-locale-to-collection.ts | 65 +--- .../remove-locale-from-collection.spec.ts | 15 + .../remove-locale-from-collection.ts | 49 +-- libs/core/src/lib/folder/delete-folder.ts | 11 +- .../lib/folder/move-folder.real-fs.spec.ts | 55 +++ libs/core/src/lib/folder/move-folder.ts | 44 ++- libs/core/src/lib/import/determine-status.ts | 31 +- .../src/lib/import/load-base-locale-values.ts | 28 +- .../lib/import/process-resource-group.spec.ts | 61 +++ .../src/lib/import/process-resource-group.ts | 223 ++++------- .../src/lib/import/resource-grouping.spec.ts | 38 +- libs/core/src/lib/import/resource-grouping.ts | 45 +-- libs/core/src/lib/import/types.ts | 7 +- libs/core/src/lib/normalize/folder-utils.ts | 19 +- .../core/src/lib/normalize/normalize-entry.ts | 221 ++--------- libs/core/src/lib/normalize/normalize.spec.ts | 24 ++ libs/core/src/lib/normalize/normalize.ts | 269 ++----------- libs/core/src/lib/resource/index.ts | 1 + .../src/lib/resource/load-resource-tree.ts | 38 +- .../src/lib/resource/metadata-operations.ts | 81 +--- .../src/lib/resource/resource-folder.spec.ts | 317 ++++++++++++++++ libs/core/src/lib/resource/resource-folder.ts | 356 ++++++++++++++++++ libs/core/src/lib/resource/search.ts | 67 +--- .../translate-existing-resource.ts | 82 +--- .../lib/translation/translate-locale.spec.ts | 6 + .../src/lib/translation/translate-locale.ts | 70 +--- libs/core/src/resource/add-resource.ts | 94 ++--- libs/core/src/resource/delete-resource.ts | 34 +- libs/core/src/resource/edit-resource.ts | 156 +++----- .../resource/move-resource.real-fs.spec.ts | 101 +++++ libs/core/src/resource/move-resource.ts | 91 ++--- libs/domain/src/index.ts | 2 +- libs/domain/src/lib/staleness.spec.ts | 153 ++++++++ libs/domain/src/lib/staleness.ts | 134 +++++++ libs/domain/src/lib/status-helpers.spec.ts | 131 ------- libs/domain/src/lib/status-helpers.ts | 80 ---- 41 files changed, 1722 insertions(+), 1572 deletions(-) create mode 100644 libs/core/src/lib/folder/move-folder.real-fs.spec.ts create mode 100644 libs/core/src/lib/resource/resource-folder.spec.ts create mode 100644 libs/core/src/lib/resource/resource-folder.ts create mode 100644 libs/core/src/resource/move-resource.real-fs.spec.ts create mode 100644 libs/domain/src/lib/staleness.spec.ts create mode 100644 libs/domain/src/lib/staleness.ts delete mode 100644 libs/domain/src/lib/status-helpers.spec.ts delete mode 100644 libs/domain/src/lib/status-helpers.ts diff --git a/apps/cli/src/add-resource/add-resource.ts b/apps/cli/src/add-resource/add-resource.ts index 8c70578c..a8d2bf82 100644 --- a/apps/cli/src/add-resource/add-resource.ts +++ b/apps/cli/src/add-resource/add-resource.ts @@ -1,8 +1,6 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; import type { LingoTrackerConfig } from '@simoncodes-ca/core'; -import { addResource, createDefaultTranslations } from '@simoncodes-ca/core'; -import { resolveResourceKey, splitResolvedKey, type TranslationStatus, translocoToICU } from '@simoncodes-ca/domain'; +import { addResource, createDefaultTranslations, openResourceFolder, resolveResourcePaths } from '@simoncodes-ca/core'; +import { type TranslationStatus, translocoToICU } from '@simoncodes-ca/domain'; import prompts from 'prompts'; import { ConsoleFormatter, @@ -43,15 +41,13 @@ export async function addResourceCommand(options: AddResourceOptions): Promise; - return entryKey in data; + return openResourceFolder(folderPath).has(entryKey); } catch { return false; } diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index bea48e5a..9e4ccf8f 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -154,7 +154,7 @@ graph TD FILEIO["file-io/\nreadJsonFile · writeJsonFile\nensureDirectoryExists"] CONFIG_LIB["config/\ncreateConfigFileOperations"] ERRORS["errors/\nErrorMessages"] - RESOURCE_LIB["resource/\nresource-file-paths\nmetadata-operations\nload-resource-tree"] + RESOURCE_LIB["resource/\nresource-folder\nresource-file-paths\nload-resource-tree"] end subgraph domain["@simoncodes-ca/domain (peer)"] @@ -228,7 +228,9 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that ## Resource CRUD Flows -Resource CRUD is implemented across four functions in `libs/core/src/resource/`. Each function follows the same structural pattern: resolve the dot-delimited [resource key](glossary.md#resource-key) to a filesystem path, load the current JSON files, apply changes, recompute [checksums](glossary.md#checksum) and [translation status](glossary.md#translation-status), then write both files back atomically. +Resource CRUD is implemented across four functions in `libs/core/src/resource/`. Each function follows the same structural pattern: resolve the dot-delimited [resource key](glossary.md#resource-key) to a filesystem path, load the current JSON files, apply changes, recompute [checksums](glossary.md#checksum) and [translation status](glossary.md#translation-status), then write both files back. Both files are always written together by one call (`ResourceFolder.save()`); the writes are sequential, not atomic. + +**All writes go through `ResourceFolder`.** `openResourceFolder(folderPath, { baseLocale })` in `lib/resource/resource-folder.ts` is the only owner of a [resource folder](glossary.md#resource-folder) (`resource_entries.json` + `tracker_meta.json`). Add, edit, delete, move, import, normalize, translate-locale, translate-existing-resource, and add/remove-locale all load the pair through it, change it with `setBase` / `setTranslation` / `setStatus` / `setDetails` / `setEntry` / `seedLocale` / `dropLocale` / `remove`, and persist with `save()` (which deletes both files when the folder becomes empty). `ResourceFolder` computes the checksums and applies the domain [staleness rule](glossary.md#staleness-rule) (`applyBaseChange`, `recordTranslation` in `libs/domain/src/lib/staleness.ts`), so no caller builds `{ checksum, baseChecksum, status }` by hand. Readers (tree loading, search, folder move/delete, folder cleanup) use it too, and `resolveResourcePaths()` is the only function that maps a key to its folder. ### add-resource @@ -238,14 +240,14 @@ Steps: 1. **Resolve paths** — `validateAndResolvePaths()` calls `resolveResourceKey()` and `splitResolvedKey()` from `@simoncodes-ca/domain` to derive `folderPath`, `resourceEntriesPath`, `trackerMetaPath`, and `entryKey`. 2. **Ensure directory** — `ensureDirectoryExists()` creates the folder tree with `mkdirSync({ recursive: true })`. -3. **Load existing files** — `readResourceEntries()` and `readTrackerMetadata()` return the current JSON or empty objects if the files do not exist yet. +3. **Load existing files** — `openResourceFolder()` loads both files (missing files are empty). 4. **Normalize base value** — `translocoToICU()` converts any Transloco `{{ varName }}` syntax in the incoming base value to ICU `{varName}` before storage. 5. **Resolve translations** — three-way priority: - Explicit translations in `params.translations` are used as-is. - If no explicit translations and `translationConfig` is enabled, `autoTranslateResource()` is called (see [Auto-Translation Pipeline](#auto-translation-pipeline)). - Otherwise, the entry is stored with no translations (all locales default to `new` status). -6. **Build metadata** — `createResourceMetadata()` in `lib/resource/metadata-operations.ts` computes MD5 checksums for the base value and each translation, assigns `TranslationStatus` per locale. -7. **Write files** — `writeJsonFile()` writes both `resource_entries.json` and `tracker_meta.json`. +6. **Replace the entry** — `setEntry` / `setBase` / `setDetails` / `setTranslation` on the `ResourceFolder`. A translation equal to the base value is stored as `new`. +7. **Write files** — `folder.save()` writes both `resource_entries.json` and `tracker_meta.json`. ### edit-resource @@ -255,11 +257,11 @@ Steps: 1. **Resolve paths and load** — same as add-resource. 2. **Throws if not found** — exits immediately if either JSON file or the specific entry key is absent. -3. **Update base value** (if changed) — `translocoToICU()` normalizes the incoming value; `updateMetadataForBaseValueChange()` recomputes the base checksum and marks every non-base locale as `stale` if their stored `baseChecksum` diverges from the new base checksum. +3. **Update base value** (if changed) — `translocoToICU()` normalizes the incoming value; `folder.setBase()` recomputes the base checksum and applies the [staleness rule](glossary.md#staleness-rule) to every non-base locale. 4. **Update comment/tags** — simple field overwrites with change detection to avoid unnecessary writes. 5. **Update locale values** — for each locale in `options.locales`, normalizes with `translocoToICU()`, recomputes checksum via `calculateChecksum()`, and updates `status` (defaults to `'translated'` if not provided). -6. **Persist initial changes** — writes both files before attempting auto-translation, so the base value change is durable even if the translation API call fails. -7. **Auto-translate on base change** — if `baseValueDidChange` and `translationConfig` is enabled, `autoTranslateResource()` is called for all non-base locales; results are written in a second pass. +6. **Persist initial changes** — `folder.save()` before attempting auto-translation, so the base value change is durable even if the translation API call fails. +7. **Auto-translate on base change** — if `baseValueDidChange` and `translationConfig` is enabled, `autoTranslateResource()` is called for all non-base locales; results are written by a second `folder.save()`. ### delete-resource @@ -269,8 +271,8 @@ Steps: 1. **Validate each key** — `validateKey()` from `@simoncodes-ca/domain`. 2. **Resolve paths** — `resolveResourcePaths()`. -3. **Load and mutate** — reads `resource_entries.json`, deletes the entry key, reads `tracker_meta.json`, deletes the matching metadata key. -4. **Cleanup empty files** — if `resource_entries.json` is now empty (`Object.keys(entries).length === 0`), both JSON files are deleted with `unlinkSync()`. Otherwise, both are rewritten. +3. **Remove** — `folder.remove(entryKey)` removes the entry and its metadata. +4. **Save** — `folder.save()` rewrites both files, or deletes both when the folder has no entries left. 5. **Batch errors** — errors per key are collected and returned; the operation does not stop on first failure. ### move-resource @@ -279,7 +281,7 @@ Steps: Two modes: -- **Single key move** (`moveSingleResource`) — validates source and destination keys, checks for collision at destination (returns warning unless `override` is set), calls `addResource()` at the destination with the source entry's existing translations (bypassing auto-translation), then calls `deleteResource()` at the source. +- **Single key move** (`moveSingleResource`) — validates source and destination keys, checks for collision at destination (returns warning unless `override` is set), copies the entry and its metadata to the destination with `setEntry()` (lossless: values, comment, tags, checksums, and statuses such as `verified` and `stale` are kept; no auto-translation), then calls `deleteResource()` at the source. `moveFolder()` moves each resource this way. - **Wildcard pattern move** (`moveResourcesByPattern`) — patterns ending with `*` are expanded by `walkFolders()` to enumerate all keys under the prefix, then each key is moved individually using `moveSingleResource()`. --- diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index bab65df8..04f95e85 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -153,6 +153,14 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md) --- +### Resource Folder + +One folder of the translation hierarchy, seen as a unit: its `resource_entries.json` ([resource entries](#resource-entry)) and `tracker_meta.json` ([tracker metadata](#tracker-metadata)) are always read and written together. In code, `openResourceFolder()` returns a `ResourceFolder` (`libs/core/src/lib/resource/resource-folder.ts`), and every core operation that changes resources goes through it. It computes checksums and applies the [staleness rule](#staleness-rule). + +Explained in context: [`core-library.md`](core-library.md#resource-crud-flows) + +--- + ### Resource Key A dot-delimited string that uniquely identifies a [resource entry](#resource-entry) within a [collection](#collection). Segments may contain only alphanumeric characters, underscores, and hyphens (`[A-Za-z0-9_-]`). @@ -189,6 +197,14 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [` --- +### Staleness Rule + +The one rule for what happens to translations when the [base locale](#base-locale) value changes (`applyBaseChange` in `libs/domain/src/lib/staleness.ts`): the base checksum is updated, every other locale's `baseChecksum` is set to the new base checksum, and its status becomes `stale` — or `new` when the translation is identical to the new base value (an untranslated copy). Edit, import, and normalize all use this rule. The same module holds `recordTranslation`, `needsTranslation`, and `resolveImportStatus`. + +Explained in context: [`core-library.md`](core-library.md#resource-crud-flows) + +--- + ## T ### Target Folder diff --git a/architecture-docs/monorepo-structure.md b/architecture-docs/monorepo-structure.md index e3ff752e..3d59bbf7 100644 --- a/architecture-docs/monorepo-structure.md +++ b/architecture-docs/monorepo-structure.md @@ -47,7 +47,7 @@ lingo-tracker/ # Nx workspace root │ │ ├── translation-status.ts # TranslationStatus type │ │ ├── locale-metadata.ts # LocaleMetadata interface │ │ ├── resource-key.ts # Key validation, resolve, split -│ │ ├── status-helpers.ts # Checksum-driven status transitions +│ │ ├── staleness.ts # Staleness rule and status transitions │ │ ├── icu-to-transloco.ts # ICU → Transloco syntax conversion │ │ ├── transloco-to-icu.ts # Transloco → ICU syntax conversion │ │ ├── icu-auto-fixer.ts # ICU quote-escape repair @@ -141,7 +141,7 @@ graph TD | `translation-status.ts` | Defines the `TranslationStatus` union type (`'new' \| 'translated' \| 'stale' \| 'verified'`) | | `locale-metadata.ts` | Defines the `LocaleMetadata` interface (checksum, baseChecksum, status) | | `resource-key.ts` | Validates, resolves (`resolveResourceKey`), and splits (`splitResolvedKey`) dot-delimited keys | -| `status-helpers.ts` | Pure functions for checksum-driven status transitions (`shouldMarkStale`, `createBaseLocaleMetadata`, etc.) | +| `staleness.ts` | The staleness rule and status transitions (`applyBaseChange`, `recordTranslation`, `needsTranslation`, `resolveImportStatus`) | | `icu-to-transloco.ts` | Converts ICU `{varName}` to Transloco `{{ varName }}` at bundle time | | `transloco-to-icu.ts` | Converts Transloco `{{ varName }}` back to ICU `{varName}` at import time | | `icu-classifier.ts` | Classifies a string as `plain`, `simple-placeholders`, or `complex-icu` | @@ -162,7 +162,7 @@ See [domain-and-data-model.md](domain-and-data-model.md) for the data structures Key responsibilities: -- **Resource CRUD**: Reading and writing `resource_entries.json` and `tracker_meta.json` atomically. +- **Resource CRUD**: Reading and writing `resource_entries.json` and `tracker_meta.json` (both files are always written together by one call; the writes are not atomic). - **Checksum calculation**: `calculateChecksum(value)` uses `node:crypto` MD5. - **Bundle generation**: Aggregating resources across collections, applying tag filters, converting ICU to Transloco syntax, writing locale JSON files. - **Import/export**: Parsing external XLIFF or JSON, applying ICU auto-fixes, determining translation status on import. diff --git a/libs/core/src/collections-manager/add-locale-to-collection.spec.ts b/libs/core/src/collections-manager/add-locale-to-collection.spec.ts index 3b1d5d39..687faa19 100644 --- a/libs/core/src/collections-manager/add-locale-to-collection.spec.ts +++ b/libs/core/src/collections-manager/add-locale-to-collection.spec.ts @@ -117,6 +117,21 @@ describe('addLocaleToCollection', () => { expect(result.filesUpdated).toBe(0); }); + it('ignores a folder that has only a (malformed) tracker_meta.json', async () => { + setupMockFs({ + [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) }, + [TRANSLATIONS_FOLDER]: { type: 'directory', children: ['tracker_meta.json'] }, + [path.join(TRANSLATIONS_FOLDER, 'tracker_meta.json')]: { type: 'file', content: '{ not json' }, + }); + + const result = await addLocaleToCollection('main', 'de', { cwd: CWD }); + + expect(result.entriesBackfilled).toBe(0); + expect(result.filesUpdated).toBe(0); + // Only the config file is written + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(1); + }); + it('handles non-existent translations folder gracefully', async () => { setupMockFs({ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) }, diff --git a/libs/core/src/collections-manager/add-locale-to-collection.ts b/libs/core/src/collections-manager/add-locale-to-collection.ts index d2e6b8e4..c305b2fd 100644 --- a/libs/core/src/collections-manager/add-locale-to-collection.ts +++ b/libs/core/src/collections-manager/add-locale-to-collection.ts @@ -1,12 +1,11 @@ import * as path from 'node:path'; -import * as fs from 'node:fs'; +import { existsSync } from 'node:fs'; import { validateLocale } from '@simoncodes-ca/domain'; import { updateConfig } from '../lib/config/config-file-operations'; import { walkFolders } from '../lib/normalize/iterative-folder-walker'; -import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../lib/file-io/json-file-operations'; -import { calculateChecksum } from '../resource/checksum'; +import { openResourceFolder } from '../lib/resource/resource-folder'; import { ErrorMessages } from '../lib/errors/error-messages'; -import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../constants'; +import { RESOURCE_ENTRIES_FILENAME } from '../constants'; export interface AddLocaleToCollectionOptions { readonly cwd?: string; @@ -66,52 +65,18 @@ export async function addLocaleToCollection( let filesUpdated = 0; for (const visit of walkFolders(translationsFolderPath)) { - const resourceEntriesPath = path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME); - const trackerMetaPath = path.join(visit.absolutePath, TRACKER_META_FILENAME); - - if (!fs.existsSync(resourceEntriesPath)) continue; - - const resourceEntries = readResourceEntries(resourceEntriesPath); - - const trackerMetadata = readTrackerMetadata(trackerMetaPath, {}); - - let folderModified = false; - - for (const entryKey of Object.keys(resourceEntries)) { - const entry = resourceEntries[entryKey]; - - if (typeof entry !== 'object' || entry === null || typeof entry.source !== 'string') { - continue; - } - - if (typeof entry[locale] === 'string') { - continue; - } - - // Seed the new locale with the base (source) value and status 'new' — - // this matches the convention used by normalizeEntry/ensureLocaleEntryExists, - // which also seeds missing locales with baseValue and marks them 'new'. - const baseValue = entry.source; - const checksum = calculateChecksum(baseValue); - - entry[locale] = baseValue; - - if (!trackerMetadata[entryKey]) { - trackerMetadata[entryKey] = {}; - } - trackerMetadata[entryKey][locale] = { - checksum, - baseChecksum: checksum, - status: 'new', - }; - - entriesBackfilled++; - folderModified = true; - } - - if (folderModified) { - writeJsonFile({ filePath: resourceEntriesPath, data: resourceEntries }); - writeJsonFile({ filePath: trackerMetaPath, data: trackerMetadata }); + if (!existsSync(path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME))) continue; + + const folder = openResourceFolder(visit.absolutePath, { + baseLocale: collection.baseLocale ?? updatedConfig.baseLocale, + }); + + // Seed the new locale with the base (source) value and status 'new' — the same + // convention normalize uses for missing locales. + const seeded = folder.seedLocale(locale); + if (seeded > 0) { + folder.save(); + entriesBackfilled += seeded; filesUpdated++; } } diff --git a/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts b/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts index de75241d..8b6b04f4 100644 --- a/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts +++ b/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts @@ -123,6 +123,21 @@ describe('removeLocaleFromCollection', () => { expect(result.filesUpdated).toBe(0); }); + it('ignores a folder that has only a (malformed) tracker_meta.json', async () => { + setupMockFs({ + [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) }, + [TRANSLATIONS_FOLDER]: { type: 'directory', children: ['tracker_meta.json'] }, + [path.join(TRANSLATIONS_FOLDER, 'tracker_meta.json')]: { type: 'file', content: '{ not json' }, + }); + + const result = await removeLocaleFromCollection('main', 'fr', { cwd: CWD }); + + expect(result.entriesPurged).toBe(0); + expect(result.filesUpdated).toBe(0); + // Only the config file is written + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(1); + }); + it('handles non-existent translations folder gracefully', async () => { setupMockFs({ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) }, diff --git a/libs/core/src/collections-manager/remove-locale-from-collection.ts b/libs/core/src/collections-manager/remove-locale-from-collection.ts index 288f23fc..62c31a3c 100644 --- a/libs/core/src/collections-manager/remove-locale-from-collection.ts +++ b/libs/core/src/collections-manager/remove-locale-from-collection.ts @@ -1,11 +1,11 @@ import * as path from 'node:path'; -import * as fs from 'node:fs'; +import { existsSync } from 'node:fs'; import { validateLocale } from '@simoncodes-ca/domain'; import { updateConfig } from '../lib/config/config-file-operations'; import { walkFolders } from '../lib/normalize/iterative-folder-walker'; -import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../lib/file-io/json-file-operations'; +import { openResourceFolder } from '../lib/resource/resource-folder'; import { ErrorMessages } from '../lib/errors/error-messages'; -import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../constants'; +import { RESOURCE_ENTRIES_FILENAME } from '../constants'; export interface RemoveLocaleFromCollectionOptions { readonly cwd?: string; @@ -65,45 +65,14 @@ export async function removeLocaleFromCollection( let filesUpdated = 0; for (const visit of walkFolders(translationsFolderPath)) { - const resourceEntriesPath = path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME); - const trackerMetaPath = path.join(visit.absolutePath, TRACKER_META_FILENAME); + if (!existsSync(path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME))) continue; - if (!fs.existsSync(resourceEntriesPath)) continue; + const folder = openResourceFolder(visit.absolutePath); - const resourceEntries = readResourceEntries(resourceEntriesPath); - - const trackerMetadata = readTrackerMetadata(trackerMetaPath, {}); - - let folderModified = false; - - for (const entryKey of Object.keys(resourceEntries)) { - const entry = resourceEntries[entryKey]; - - if (typeof entry !== 'object' || entry === null) { - continue; - } - - let entryModified = false; - - if (locale in entry) { - delete entry[locale]; - entryModified = true; - } - - if (trackerMetadata[entryKey] && locale in trackerMetadata[entryKey]) { - delete trackerMetadata[entryKey][locale]; - entryModified = true; - } - - if (entryModified) { - entriesPurged++; - folderModified = true; - } - } - - if (folderModified) { - writeJsonFile({ filePath: resourceEntriesPath, data: resourceEntries }); - writeJsonFile({ filePath: trackerMetaPath, data: trackerMetadata }); + const purged = folder.dropLocale(locale); + if (purged > 0) { + folder.save(); + entriesPurged += purged; filesUpdated++; } } diff --git a/libs/core/src/lib/folder/delete-folder.ts b/libs/core/src/lib/folder/delete-folder.ts index f2068922..58f59912 100644 --- a/libs/core/src/lib/folder/delete-folder.ts +++ b/libs/core/src/lib/folder/delete-folder.ts @@ -1,8 +1,8 @@ -import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { existsSync, rmSync, statSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { walkFolders } from '../normalize/iterative-folder-walker'; import { isValidSegment } from '@simoncodes-ca/domain'; -import { RESOURCE_ENTRIES_FILENAME } from '../../constants'; +import { openResourceFolder } from '../resource/resource-folder'; export interface DeleteFolderParams { /** The folder path to delete (dot-delimited path like "apps.common.buttons") */ @@ -118,13 +118,8 @@ function countResourcesInFolder(folderPath: string): number { let totalResources = 0; for (const visit of walkFolders(folderPath, { skipHidden: false })) { - const entriesPath = join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME); - if (!existsSync(entriesPath)) continue; - try { - const entriesContent = readFileSync(entriesPath, 'utf8'); - const entries = JSON.parse(entriesContent); - totalResources += Object.keys(entries).length; + totalResources += openResourceFolder(visit.absolutePath).keys().length; } catch { // Malformed JSON or read error, skip counting } diff --git a/libs/core/src/lib/folder/move-folder.real-fs.spec.ts b/libs/core/src/lib/folder/move-folder.real-fs.spec.ts new file mode 100644 index 00000000..624572ea --- /dev/null +++ b/libs/core/src/lib/folder/move-folder.real-fs.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { moveFolder } from './move-folder'; + +/** + * Regression: a folder that could not be read (malformed tracker_meta.json) was silently skipped + * while enumerating keys, so moveFolder deleted the source tree with that folder's entries never copied. + */ +describe('moveFolder with an unreadable folder (real fs)', () => { + let root: string; + + const entries = JSON.stringify({ ok: { source: 'OK' } }); + + function writeFolder(meta: string, ...segments: string[]): void { + const folder = join(root, ...segments); + mkdirSync(folder, { recursive: true }); + writeFileSync(join(folder, 'resource_entries.json'), entries); + writeFileSync(join(folder, 'tracker_meta.json'), meta); + } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'move-folder-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('reports an error and moves or deletes nothing when one child folder has malformed metadata', async () => { + writeFolder(JSON.stringify({ ok: { en: { checksum: 'x' } } }), 'apps', 'good'); + writeFolder('{ not json', 'apps', 'bad'); + + const result = await moveFolder(root, { sourceFolderPath: 'apps', destinationFolderPath: 'shared' }); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('apps.bad'); + expect(result.movedCount).toBe(0); + expect(result.foldersDeleted).toBe(0); + expect(existsSync(join(root, 'shared'))).toBe(false); + expect(readFileSync(join(root, 'apps', 'good', 'resource_entries.json'), 'utf8')).toBe(entries); + expect(readFileSync(join(root, 'apps', 'bad', 'resource_entries.json'), 'utf8')).toBe(entries); + }); + + it('does not delete a source folder whose only resources are unreadable', async () => { + writeFolder('{ not json', 'apps', 'bad'); + + const result = await moveFolder(root, { sourceFolderPath: 'apps.bad', destinationFolderPath: 'shared' }); + + expect(result.errors).toHaveLength(1); + expect(result.foldersDeleted).toBe(0); + expect(readFileSync(join(root, 'apps', 'bad', 'resource_entries.json'), 'utf8')).toBe(entries); + }); +}); diff --git a/libs/core/src/lib/folder/move-folder.ts b/libs/core/src/lib/folder/move-folder.ts index 3b09580d..a6a35232 100644 --- a/libs/core/src/lib/folder/move-folder.ts +++ b/libs/core/src/lib/folder/move-folder.ts @@ -1,11 +1,10 @@ -import { existsSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, statSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { walkFolders } from '../normalize/iterative-folder-walker'; import { isValidSegment } from '@simoncodes-ca/domain'; import { moveResource, type MoveResourceResult } from '../../resource/move-resource'; import { deleteFolder, type DeleteFolderResult } from './delete-folder'; -import { RESOURCE_ENTRIES_FILENAME } from '../../constants'; -import type { ResourceEntries } from '../../resource/resource-entry'; +import { openResourceFolder } from '../resource/resource-folder'; export interface MoveFolderParams { /** The source folder path to move (dot-delimited like "apps.common.buttons") */ @@ -148,7 +147,16 @@ export async function moveFolder(translationsFolder: string, params: MoveFolderP } // Extract all resource keys from the source folder tree - const resourceKeys = extractAllResourceKeysFromFolder(absoluteSourcePath, sourceFolderPath); + const { keys: resourceKeys, errors: enumerationErrors } = extractAllResourceKeysFromFolder( + absoluteSourcePath, + sourceFolderPath, + ); + + // An unreadable folder would be deleted without its entries being copied; stop before any move/delete. + if (enumerationErrors.length > 0) { + result.errors.push(...enumerationErrors); + return result; + } if (resourceKeys.length === 0) { result.warnings.push('No resources found in source folder. Nothing to move.'); @@ -243,10 +251,14 @@ export async function moveFolder(translationsFolder: string, params: MoveFolderP * * @param absoluteFolderPath - Absolute filesystem path to the folder * @param folderKeyPrefix - Dot-delimited key prefix for this folder - * @returns Array of full resource keys found in the folder tree + * @returns Full resource keys found in the folder tree, and one error per folder that could not be read */ -function extractAllResourceKeysFromFolder(absoluteFolderPath: string, folderKeyPrefix: string): string[] { - const resourceKeys: string[] = []; +function extractAllResourceKeysFromFolder( + absoluteFolderPath: string, + folderKeyPrefix: string, +): { keys: string[]; errors: string[] } { + const keys: string[] = []; + const errors: string[] = []; for (const visit of walkFolders(absoluteFolderPath, { skipHidden: false })) { const currentKeyPrefix = visit.keyPrefix @@ -255,21 +267,15 @@ function extractAllResourceKeysFromFolder(absoluteFolderPath: string, folderKeyP : visit.keyPrefix : folderKeyPrefix; - const entriesPath = join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME); - if (!existsSync(entriesPath)) continue; - try { - const entriesContent = readFileSync(entriesPath, 'utf8'); - const entries: ResourceEntries = JSON.parse(entriesContent); - - for (const entryKey of Object.keys(entries)) { - const fullKey = currentKeyPrefix ? `${currentKeyPrefix}.${entryKey}` : entryKey; - resourceKeys.push(fullKey); + for (const entryKey of openResourceFolder(visit.absolutePath).keys()) { + keys.push(currentKeyPrefix ? `${currentKeyPrefix}.${entryKey}` : entryKey); } - } catch { - // Malformed JSON or read error, skip this folder + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + errors.push(`Failed to read resources in "${currentKeyPrefix || '.'}": ${reason}`); } } - return resourceKeys; + return { keys, errors }; } diff --git a/libs/core/src/lib/import/determine-status.ts b/libs/core/src/lib/import/determine-status.ts index 853cce49..137dbb22 100644 --- a/libs/core/src/lib/import/determine-status.ts +++ b/libs/core/src/lib/import/determine-status.ts @@ -44,33 +44,12 @@ export function determineNewResourceStatus(options: ImportOptions, resource: Imp } /** - * Determines the translation status when updating an existing resource. - * - * Covers both the case where the value changed and the case where the value - * is unchanged (the caller distinguishes by the `valueChanged` flag for - * strategy-specific unchanged-value handling). - * - * @param options - Import options including strategy and preserveStatus flag - * @param resource - The imported resource providing the new value - * @param oldStatus - The existing translation status before this import - * @returns The translation status to assign after the update + * Returns the imported resource's status when it should be honoured (see {@link shouldUseSourceStatus}), + * otherwise `undefined` so the strategy decides (see `resolveImportStatus` in domain). */ -export function determineUpdatedResourceStatus( +export function honouredSourceStatus( options: ImportOptions, resource: ImportedResource, - oldStatus: TranslationStatus | undefined, -): TranslationStatus { - if (shouldUseSourceStatus(options, resource)) { - return resource.status; - } - - switch (options.strategy) { - case 'verification': - return 'verified'; - case 'update': - return oldStatus ?? 'translated'; - default: - // translation-service and migration (no source status): set to translated - return 'translated'; - } +): TranslationStatus | undefined { + return shouldUseSourceStatus(options, resource) ? resource.status : undefined; } diff --git a/libs/core/src/lib/import/load-base-locale-values.ts b/libs/core/src/lib/import/load-base-locale-values.ts index 3fe4126b..9875b641 100644 --- a/libs/core/src/lib/import/load-base-locale-values.ts +++ b/libs/core/src/lib/import/load-base-locale-values.ts @@ -1,9 +1,6 @@ -import { resolve, join } from 'node:path'; import type { ImportedResource } from './types'; -import type { ResourceEntries } from '../../resource/resource-entry'; -import { splitResolvedKey } from '@simoncodes-ca/domain'; -import { RESOURCE_ENTRIES_FILENAME } from '../../constants'; -import { readJsonFile } from '../file-io/json-file-operations'; +import { resolveResourcePaths } from '../resource/resource-file-paths'; +import { openResourceFolder } from '../resource/resource-folder'; /** * Loads base locale values for all imported resources from existing resource files. @@ -27,14 +24,12 @@ export function loadBaseLocaleValues( const folderToKeys = new Map>(); for (const resource of resources) { - const { folderPath: pathSegments, entryKey } = splitResolvedKey(resource.key); + const { folderPath, entryKey } = resolveResourcePaths({ key: resource.key, translationsFolder, cwd }); - const fullFolderPath = pathSegments.length ? join(translationsFolder, ...pathSegments) : translationsFolder; - - let folderKeys = folderToKeys.get(fullFolderPath); + let folderKeys = folderToKeys.get(folderPath); if (!folderKeys) { folderKeys = []; - folderToKeys.set(fullFolderPath, folderKeys); + folderToKeys.set(folderPath, folderKeys); } folderKeys.push({ key: resource.key, entryKey }); @@ -42,18 +37,13 @@ export function loadBaseLocaleValues( // Load base values from each folder for (const [folderPath, keys] of folderToKeys.entries()) { - const entryResourcePath = resolve(cwd, folderPath, RESOURCE_ENTRIES_FILENAME); - try { - const resourceEntries = readJsonFile({ - filePath: entryResourcePath, - defaultValue: {} as ResourceEntries, - }); + const folder = openResourceFolder(folderPath); for (const { key, entryKey } of keys) { - const entry = resourceEntries[entryKey]; - if (entry?.source) { - baseValues.set(key, entry.source); + const source = folder.get(entryKey)?.entry.source; + if (source) { + baseValues.set(key, source); } } } catch { diff --git a/libs/core/src/lib/import/process-resource-group.spec.ts b/libs/core/src/lib/import/process-resource-group.spec.ts index 5ae6dcdb..c24b5b7f 100644 --- a/libs/core/src/lib/import/process-resource-group.spec.ts +++ b/libs/core/src/lib/import/process-resource-group.spec.ts @@ -1330,6 +1330,67 @@ describe('process-resource-group', () => { expect(meta.ok.en.status).toBeUndefined(); }); + describe('staleness when the base value changes (regression)', () => { + const md5 = calculateChecksum; + + function writeExisting(): void { + mkdirSync(folderPath, { recursive: true }); + writeFileSync(entryResourcePath, JSON.stringify({ ok: { source: 'OK', es: 'Bien', fr: 'Okay', de: 'OK' } })); + writeFileSync( + entryMetaPath, + JSON.stringify({ + ok: { + en: { checksum: md5('OK') }, + es: { checksum: md5('Bien'), baseChecksum: md5('OK'), status: 'verified' }, + fr: { checksum: md5('Okay'), baseChecksum: md5('OK'), status: 'translated' }, + de: { checksum: md5('OK'), baseChecksum: md5('OK'), status: 'new' }, + }, + }), + ); + } + + function importBase(value: string): void { + const group: ResourceGroup = { + folderPath, + entryResourcePath, + entryMetaPath, + resources: [{ resource: { key: 'common.buttons.ok', value }, entryKey: 'ok' }], + }; + processResourceGroup( + group, + 'en', + 'en', + { source: 'test.json', locale: 'en', strategy: 'migration' }, + false, + true, // isBaseLocaleImport + new Set(), + [], + ); + } + + it('marks translations stale and points them at the new base checksum', () => { + writeExisting(); + + importBase('Okay'); + + const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); + expect(meta.ok.en).toEqual({ checksum: md5('Okay') }); + expect(meta.ok.es).toEqual({ checksum: md5('Bien'), baseChecksum: md5('Okay'), status: 'stale' }); + // A translation equal to the new base value is an untranslated copy: 'new', not 'stale' + expect(meta.ok.fr).toEqual({ checksum: md5('Okay'), baseChecksum: md5('Okay'), status: 'new' }); + expect(meta.ok.de).toEqual({ checksum: md5('OK'), baseChecksum: md5('Okay'), status: 'stale' }); + }); + + it('leaves translations alone when the base value is unchanged', () => { + writeExisting(); + const before = readFileSync(entryMetaPath, 'utf8'); + + importBase('OK'); + + expect(readFileSync(entryMetaPath, 'utf8')).toBe(before); + }); + }); + it('should update comment and tags for base locale when flags are set', () => { // Create existing resource mkdirSync(folderPath, { recursive: true }); diff --git a/libs/core/src/lib/import/process-resource-group.ts b/libs/core/src/lib/import/process-resource-group.ts index 2519150f..e9f8701e 100644 --- a/libs/core/src/lib/import/process-resource-group.ts +++ b/libs/core/src/lib/import/process-resource-group.ts @@ -1,16 +1,14 @@ -import { existsSync } from 'node:fs'; +import { dirname } from 'node:path'; import { findPreferredTermFindings, findProtectedTermViolations, - type LocaleMetadata, + resolveImportStatus, type TranslationStatus, } from '@simoncodes-ca/domain'; import { calculateChecksum } from '../../resource/checksum'; -import type { ResourceEntries, ResourceEntry } from '../../resource/resource-entry'; -import type { TrackerMetadata } from '../../resource/tracker-metadata'; -import { readJsonFile, writeJsonFile } from '../file-io/json-file-operations'; +import { openResourceFolder, type ResourceFolder } from '../resource/resource-folder'; import { describePreferredTermRule } from '../validate/validate-terminology'; -import { determineNewResourceStatus, determineUpdatedResourceStatus, shouldUseSourceStatus } from './determine-status'; +import { determineNewResourceStatus, honouredSourceStatus } from './determine-status'; import type { ResourceGroup } from './resource-grouping'; import type { ImportChange, ImportedResource, ImportOptions } from './types'; @@ -22,8 +20,7 @@ interface GroupContext { readonly locale: string; readonly baseLocale: string; readonly options: ImportOptions; - resourceEntries: ResourceEntries; - trackerMeta: TrackerMetadata; + readonly folder: ResourceFolder; dataModified: boolean; } @@ -31,42 +28,34 @@ interface GroupContext { // Shared helpers // --------------------------------------------------------------------------- -function applyCommentUpdate(entry: ResourceEntry, resource: ImportedResource, options: ImportOptions): boolean { - if (!options.updateComments || resource.comment === undefined) return false; - - if (resource.comment) { - if (entry.comment !== resource.comment) { - entry.comment = resource.comment; - return true; - } - } else if (entry.comment !== undefined) { - delete entry.comment; - return true; - } - - return false; +function applyCommentUpdate(ctx: GroupContext, entryKey: string, resource: ImportedResource): boolean { + if (!ctx.options.updateComments || resource.comment === undefined) return false; + // An empty comment in the import removes the stored comment. + return ctx.folder.setDetails(entryKey, { comment: resource.comment || null }); } -function applyTagsUpdate(entry: ResourceEntry, resource: ImportedResource, options: ImportOptions): boolean { - if (!options.updateTags || resource.tags === undefined) return false; +function applyTagsUpdate(ctx: GroupContext, entryKey: string, resource: ImportedResource): boolean { + if (!ctx.options.updateTags || resource.tags === undefined) return false; + if (resource.tags.length === 0) return ctx.folder.setDetails(entryKey, { tags: null }); - if (resource.tags.length > 0) { - if (JSON.stringify([...(entry.tags ?? [])].sort()) !== JSON.stringify([...(resource.tags ?? [])].sort())) { - entry.tags = resource.tags; - return true; - } - } else if (entry.tags !== undefined) { - delete entry.tags; - return true; - } + // Tag order is not significant: an import with the same tags in another order is not a change. + const currentTags = ctx.folder.get(entryKey)?.entry.tags ?? []; + if (JSON.stringify([...currentTags].sort()) === JSON.stringify([...resource.tags].sort())) return false; - return false; + return ctx.folder.setDetails(entryKey, { tags: resource.tags }); } -function ensureEntryMeta(trackerMeta: TrackerMetadata, entryKey: string): void { - if (!trackerMeta[entryKey]) { - trackerMeta[entryKey] = {}; - } +function applyDetailUpdates(ctx: GroupContext, entryKey: string, resource: ImportedResource): void { + if (applyCommentUpdate(ctx, entryKey, resource)) ctx.dataModified = true; + if (applyTagsUpdate(ctx, entryKey, resource)) ctx.dataModified = true; +} + +/** Comment and tags for a resource created by the import (empty values are not stored). */ +function createdDetails(resource: ImportedResource): { comment?: string; tags?: string[] } { + return { + comment: resource.comment || undefined, + tags: resource.tags && resource.tags.length > 0 ? resource.tags : undefined, + }; } // --------------------------------------------------------------------------- @@ -79,17 +68,11 @@ function handleNewResource( entryKey: string, isBaseLocaleImport: boolean, ): ImportChange { - const { locale, baseLocale, options, resourceEntries, trackerMeta } = ctx; + const { locale, options, folder } = ctx; if (isBaseLocaleImport) { - const newEntry: ResourceEntry = { source: resource.value }; - if (resource.comment) newEntry.comment = resource.comment; - if (resource.tags && resource.tags.length > 0) newEntry.tags = resource.tags; - - resourceEntries[entryKey] = newEntry; - - ensureEntryMeta(trackerMeta, entryKey); - trackerMeta[entryKey][baseLocale] = { checksum: calculateChecksum(resource.value) }; + folder.setBase(entryKey, resource.value); + folder.setDetails(entryKey, createdDetails(resource)); ctx.dataModified = true; return { key: resource.key, type: 'created', oldValue: '', newValue: resource.value }; @@ -103,19 +86,11 @@ function handleNewResource( }; } - const newEntry: ResourceEntry = { source: resource.baseValue, [locale]: resource.value }; - if (resource.comment) newEntry.comment = resource.comment; - if (resource.tags && resource.tags.length > 0) newEntry.tags = resource.tags; - - resourceEntries[entryKey] = newEntry; - - const newChecksum = calculateChecksum(resource.value); - const baseChecksum = calculateChecksum(resource.baseValue); const createdStatus = determineNewResourceStatus(options, resource); - ensureEntryMeta(trackerMeta, entryKey); - trackerMeta[entryKey][baseLocale] = { checksum: baseChecksum }; - trackerMeta[entryKey][locale] = { checksum: newChecksum, baseChecksum, status: createdStatus }; + folder.setBase(entryKey, resource.baseValue); + folder.setTranslation(entryKey, locale, resource.value, createdStatus); + folder.setDetails(entryKey, createdDetails(resource)); ctx.dataModified = true; return { key: resource.key, type: 'created', oldValue: '', newValue: resource.value, newStatus: createdStatus }; @@ -126,28 +101,14 @@ function handleNewResource( // --------------------------------------------------------------------------- function handleBaseLocaleUpdate(ctx: GroupContext, resource: ImportedResource, entryKey: string): ImportChange { - const { baseLocale, options, resourceEntries, trackerMeta } = ctx; - const entry = resourceEntries[entryKey]; - - const oldValue = entry.source ?? ''; + const oldValue = ctx.folder.get(entryKey)?.entry.source ?? ''; const valueChanged = oldValue !== resource.value; - if (valueChanged) { - entry.source = resource.value; - ctx.dataModified = true; - } - - if (applyCommentUpdate(entry, resource, options)) ctx.dataModified = true; - if (applyTagsUpdate(entry, resource, options)) ctx.dataModified = true; + // setBase applies the Staleness rule: when the base value changes, every translation becomes + // 'stale' (or 'new' when it is an untranslated copy of the new base value). + if (ctx.folder.setBase(entryKey, resource.value)) ctx.dataModified = true; - const newChecksum = calculateChecksum(resource.value); - ensureEntryMeta(trackerMeta, entryKey); - - const existingBaseChecksum = trackerMeta[entryKey][baseLocale]?.checksum; - if (existingBaseChecksum !== newChecksum) { - trackerMeta[entryKey][baseLocale] = { checksum: newChecksum }; - ctx.dataModified = true; - } + applyDetailUpdates(ctx, entryKey, resource); return { key: resource.key, @@ -162,12 +123,11 @@ function handleBaseLocaleUpdate(ctx: GroupContext, resource: ImportedResource, e // --------------------------------------------------------------------------- function handleTargetLocaleUpdate(ctx: GroupContext, resource: ImportedResource, entryKey: string): ImportChange { - const { locale, baseLocale, options, resourceEntries, trackerMeta } = ctx; - const entry = resourceEntries[entryKey]; - const entryMeta = trackerMeta[entryKey]; + const { locale, options, folder } = ctx; + const entry = folder.get(entryKey)?.entry; - const oldValue = (entry[locale] as string) ?? ''; - const oldStatus = entryMeta?.[locale]?.status; + const oldValue = (entry?.[locale] as string | undefined) ?? ''; + const oldStatus = folder.get(entryKey)?.meta?.[locale]?.status; const valueChanged = oldValue !== resource.value; // Strategy-specific handling for unchanged values. @@ -175,23 +135,22 @@ function handleTargetLocaleUpdate(ctx: GroupContext, resource: ImportedResource, // locale write: when `entry[locale]` is undefined, `oldValue` resolves to `''`, which // means first-time writes correctly fall through to the value-changed path below. if (!valueChanged && oldValue !== '') { - return handleUnchangedTargetLocaleValue(ctx, resource, entryKey, entry, entryMeta, oldValue, oldStatus); + return handleUnchangedTargetLocaleValue(ctx, resource, entryKey, oldValue, oldStatus); } // Value is new or first-time write for this locale — update entry and metadata. - entry[locale] = resource.value; - ctx.dataModified = true; - - if (applyCommentUpdate(entry, resource, options)) ctx.dataModified = true; - if (applyTagsUpdate(entry, resource, options)) ctx.dataModified = true; + const newStatus = resolveImportStatus({ + strategy: options.strategy, + oldStatus, + incomingStatus: honouredSourceStatus(options, resource), + valueChanged: true, + baseChecksumChanged: false, + }); - const newChecksum = calculateChecksum(resource.value); - const baseChecksum = entryMeta?.[baseLocale]?.checksum ?? calculateChecksum(entry.source); - const newStatus = determineUpdatedResourceStatus(options, resource, oldStatus); + folder.setTranslation(entryKey, locale, resource.value, newStatus); + ctx.dataModified = true; - ensureEntryMeta(trackerMeta, entryKey); - const newMetadata: LocaleMetadata = { checksum: newChecksum, baseChecksum, status: newStatus }; - trackerMeta[entryKey][locale] = newMetadata; + applyDetailUpdates(ctx, entryKey, resource); return { key: resource.key, @@ -207,12 +166,10 @@ function handleUnchangedTargetLocaleValue( ctx: GroupContext, resource: ImportedResource, entryKey: string, - entry: ResourceEntry, - entryMeta: TrackerMetadata[string], oldValue: string, oldStatus: TranslationStatus | undefined, ): ImportChange { - const { locale, baseLocale, options, trackerMeta } = ctx; + const { locale, baseLocale, options, folder } = ctx; if (options.strategy === 'update') { return { @@ -229,31 +186,24 @@ function handleUnchangedTargetLocaleValue( // Keep the update/migration strategies' existing metadata behavior intact, // while bringing the target locale's base checksum back in sync with the // current base locale metadata during re-confirmation. + const stored = folder.get(entryKey); + const entryMeta = stored?.meta; const shouldRefreshBaseChecksum = options.strategy === 'translation-service' || options.strategy === 'verification'; - const currentBaseChecksum = entryMeta?.[baseLocale]?.checksum ?? calculateChecksum(entry.source); + const currentBaseChecksum = entryMeta?.[baseLocale]?.checksum ?? calculateChecksum(stored?.entry.source ?? ''); const baseChecksumChanged = shouldRefreshBaseChecksum && entryMeta?.[locale]?.baseChecksum !== currentBaseChecksum; - const resolvedStatus: TranslationStatus = shouldUseSourceStatus(options, resource) - ? resource.status - : options.strategy === 'verification' - ? 'verified' - : options.strategy === 'translation-service' && (oldStatus === 'stale' || baseChecksumChanged) - ? 'translated' - : (oldStatus ?? 'translated'); + const resolvedStatus = resolveImportStatus({ + strategy: options.strategy, + oldStatus, + incomingStatus: honouredSourceStatus(options, resource), + valueChanged: false, + baseChecksumChanged, + }); if (resolvedStatus !== oldStatus || baseChecksumChanged) { - ensureEntryMeta(trackerMeta, entryKey); - - if (!trackerMeta[entryKey][locale]) { - trackerMeta[entryKey][locale] = { - checksum: entryMeta?.[locale]?.checksum ?? calculateChecksum(oldValue), - baseChecksum: currentBaseChecksum, - status: resolvedStatus, - }; + if (entryMeta?.[locale]) { + folder.setStatus(entryKey, locale, resolvedStatus, { refreshBaseChecksum: shouldRefreshBaseChecksum }); } else { - trackerMeta[entryKey][locale].status = resolvedStatus; - if (shouldRefreshBaseChecksum) { - trackerMeta[entryKey][locale].baseChecksum = currentBaseChecksum; - } + folder.setTranslation(entryKey, locale, oldValue, resolvedStatus); } ctx.dataModified = true; } @@ -341,29 +291,22 @@ export function processResourceGroup( ): ImportChange[] { const changes: ImportChange[] = []; - const exists = existsSync(group.entryResourcePath) && existsSync(group.entryMetaPath); - - let resourceEntries: ResourceEntries = {}; - let trackerMeta: TrackerMetadata = {}; - - if (exists) { - try { - resourceEntries = readJsonFile({ filePath: group.entryResourcePath, defaultValue: {} }); - trackerMeta = readJsonFile({ filePath: group.entryMetaPath, defaultValue: {} }); - } catch (error) { - for (const { resource } of group.resources) { - changes.push({ key: resource.key, type: 'failed', reason: `Failed to read resource files: ${error}` }); - } - return changes; + let folder: ResourceFolder; + try { + folder = openResourceFolder(dirname(group.entryResourcePath), { baseLocale }); + } catch (error) { + for (const { resource } of group.resources) { + changes.push({ key: resource.key, type: 'failed', reason: `Failed to read resource files: ${error}` }); } + return changes; } - const ctx: GroupContext = { locale, baseLocale, options, resourceEntries, trackerMeta, dataModified: false }; + const ctx: GroupContext = { locale, baseLocale, options, folder, dataModified: false }; for (const { resource, entryKey } of group.resources) { - const resourceExists = entryKey in resourceEntries; + const stored = folder.get(entryKey); - if (!resourceExists) { + if (!stored) { if (!options.createMissing) { changes.push({ key: resource.key, @@ -387,7 +330,7 @@ export function processResourceGroup( // Validate baseValue mismatch before dispatching to target locale handler if (resource.baseValue && options.validateBase !== false) { - const existingBase = resourceEntries[entryKey].source; + const existingBase = stored.entry.source; if (existingBase !== resource.baseValue) { warnings.push( `Base value mismatch for "${resource.key}": import has "${resource.baseValue}", ` + @@ -400,7 +343,7 @@ export function processResourceGroup( // Base-locale imports never reach here (they are handled by the base-locale branch above). const terms = options.protectedTerms ?? []; if (terms.length > 0) { - const storedSource = resourceEntries[entryKey].source ?? ''; + const storedSource = stored.entry.source ?? ''; const violations = findProtectedTermViolations(storedSource, resource.value, terms); if (violations.length > 0) { const reason = `Protected term(s) altered: ${violations.join(', ')}`; @@ -417,11 +360,9 @@ export function processResourceGroup( // Logging an 'updated' change (e.g. update strategy with unchanged value) does not imply a // disk write is needed — `dataModified` is the authoritative signal for that. if (!dryRun && ctx.dataModified) { - writeJsonFile({ filePath: group.entryResourcePath, data: resourceEntries, ensureDirectory: true }); - writeJsonFile({ filePath: group.entryMetaPath, data: trackerMeta }); - - filesModified.add(group.entryResourcePath); - filesModified.add(group.entryMetaPath); + for (const filePath of folder.save().written) { + filesModified.add(filePath); + } } return changes; diff --git a/libs/core/src/lib/import/resource-grouping.spec.ts b/libs/core/src/lib/import/resource-grouping.spec.ts index ff3e7071..3c61b541 100644 --- a/libs/core/src/lib/import/resource-grouping.spec.ts +++ b/libs/core/src/lib/import/resource-grouping.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { normalize, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { groupResourcesByFolder } from './resource-grouping'; import type { ImportedResource } from './types'; @@ -14,16 +14,16 @@ describe('groupResourcesByFolder', () => { const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); expect(groups.size).toBe(2); - expect(groups.has(normalize('src/translations/common'))).toBe(true); - expect(groups.has(normalize('src/translations/errors'))).toBe(true); + expect(groups.has(resolve('/project', 'src/translations/common'))).toBe(true); + expect(groups.has(resolve('/project', 'src/translations/errors'))).toBe(true); - const commonGroup = groups.get(normalize('src/translations/common')); + const commonGroup = groups.get(resolve('/project', 'src/translations/common')); expect(commonGroup).toBeDefined(); expect(commonGroup?.resources).toHaveLength(2); expect(commonGroup?.resources[0].entryKey).toBe('ok'); expect(commonGroup?.resources[1].entryKey).toBe('cancel'); - const errorsGroup = groups.get(normalize('src/translations/errors')); + const errorsGroup = groups.get(resolve('/project', 'src/translations/errors')); expect(errorsGroup).toBeDefined(); expect(errorsGroup?.resources).toHaveLength(1); expect(errorsGroup?.resources[0].entryKey).toBe('notFound'); @@ -38,9 +38,9 @@ describe('groupResourcesByFolder', () => { const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); expect(groups.size).toBe(1); - expect(groups.has('src/translations')).toBe(true); + expect(groups.has(resolve('/project', 'src/translations'))).toBe(true); - const rootGroup = groups.get('src/translations'); + const rootGroup = groups.get(resolve('/project', 'src/translations')); expect(rootGroup).toBeDefined(); expect(rootGroup?.resources).toHaveLength(2); expect(rootGroup?.resources[0].entryKey).toBe('welcome'); @@ -57,10 +57,10 @@ describe('groupResourcesByFolder', () => { const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); expect(groups.size).toBe(2); - expect(groups.has(normalize('src/translations/apps/admin/users/list'))).toBe(true); - expect(groups.has(normalize('src/translations/apps/admin/settings/general'))).toBe(true); + expect(groups.has(resolve('/project', 'src/translations/apps/admin/users/list'))).toBe(true); + expect(groups.has(resolve('/project', 'src/translations/apps/admin/settings/general'))).toBe(true); - const usersListGroup = groups.get(normalize('src/translations/apps/admin/users/list')); + const usersListGroup = groups.get(resolve('/project', 'src/translations/apps/admin/users/list')); expect(usersListGroup).toBeDefined(); expect(usersListGroup?.resources).toHaveLength(2); expect(usersListGroup?.resources[0].entryKey).toBe('title'); @@ -72,15 +72,13 @@ describe('groupResourcesByFolder', () => { const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); - const commonGroup = groups.get(normalize('src/translations/common')); + const commonGroup = groups.get(resolve('/project', 'src/translations/common')); expect(commonGroup).toBeDefined(); - expect(commonGroup?.folderPath).toBe(normalize('src/translations/common')); + expect(commonGroup?.folderPath).toBe(resolve('/project', 'src/translations/common')); expect(commonGroup?.entryResourcePath).toBe( - resolve('/project', normalize('src/translations/common'), 'resource_entries.json'), - ); - expect(commonGroup?.entryMetaPath).toBe( - resolve('/project', normalize('src/translations/common'), 'tracker_meta.json'), + resolve('/project', 'src/translations/common', 'resource_entries.json'), ); + expect(commonGroup?.entryMetaPath).toBe(resolve('/project', 'src/translations/common', 'tracker_meta.json')); }); it('should handle mixed levels of nesting', () => { @@ -93,9 +91,9 @@ describe('groupResourcesByFolder', () => { const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); expect(groups.size).toBe(3); - expect(groups.has('src/translations')).toBe(true); - expect(groups.has(normalize('src/translations/common'))).toBe(true); - expect(groups.has(normalize('src/translations/apps/admin'))).toBe(true); + expect(groups.has(resolve('/project', 'src/translations'))).toBe(true); + expect(groups.has(resolve('/project', 'src/translations/common'))).toBe(true); + expect(groups.has(resolve('/project', 'src/translations/apps/admin'))).toBe(true); }); it('should preserve resource metadata', () => { @@ -112,7 +110,7 @@ describe('groupResourcesByFolder', () => { const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); - const commonGroup = groups.get(normalize('src/translations/common')); + const commonGroup = groups.get(resolve('/project', 'src/translations/common')); expect(commonGroup).toBeDefined(); const groupedResource = commonGroup?.resources[0].resource; diff --git a/libs/core/src/lib/import/resource-grouping.ts b/libs/core/src/lib/import/resource-grouping.ts index 587d8f73..35afc421 100644 --- a/libs/core/src/lib/import/resource-grouping.ts +++ b/libs/core/src/lib/import/resource-grouping.ts @@ -1,7 +1,5 @@ -import { resolve, join } from 'node:path'; import type { ImportedResource } from './types'; -import { splitResolvedKey } from '@simoncodes-ca/domain'; -import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; +import { resolveResourcePaths } from '../resource/resource-file-paths'; /** * Represents a group of resources that belong to the same folder path. @@ -16,7 +14,7 @@ import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constant * - Better performance for bulk imports */ export interface ResourceGroup { - /** The full folder path where these resources are stored */ + /** Absolute path of the folder where these resources are stored */ folderPath: string; /** Absolute path to the resource_entries.json file for this folder */ entryResourcePath: string; @@ -51,7 +49,7 @@ export interface ResourceGroup { * @param resources - Array of resources to group by folder path * @param translationsFolder - Base translations folder path (e.g., 'src/translations') * @param cwd - Current working directory for resolving absolute paths - * @returns Map of folder paths to ResourceGroup objects containing grouped resources + * @returns Map of absolute folder paths to ResourceGroup objects containing grouped resources * * @example * ```typescript @@ -69,8 +67,8 @@ export interface ResourceGroup { * * // Returns: * // Map { - * // 'src/translations/common' => { - * // folderPath: 'src/translations/common', + * // '/project/src/translations/common' => { + * // folderPath: '/project/src/translations/common', * // entryResourcePath: '/project/src/translations/common/resource_entries.json', * // entryMetaPath: '/project/src/translations/common/tracker_meta.json', * // resources: [ @@ -78,8 +76,8 @@ export interface ResourceGroup { * // { resource: { key: 'common.cancel', value: 'Cancel' }, entryKey: 'cancel' } * // ] * // }, - * // 'src/translations/errors' => { - * // folderPath: 'src/translations/errors', + * // '/project/src/translations/errors' => { + * // folderPath: '/project/src/translations/errors', * // entryResourcePath: '/project/src/translations/errors/resource_entries.json', * // entryMetaPath: '/project/src/translations/errors/tracker_meta.json', * // resources: [ @@ -97,29 +95,20 @@ export function groupResourcesByFolder( const groups = new Map(); for (const resource of resources) { - const { folderPath: pathSegments, entryKey } = splitResolvedKey(resource.key); + const paths = resolveResourcePaths({ key: resource.key, translationsFolder, cwd }); - const fullFolderPath = pathSegments.length ? join(translationsFolder, ...pathSegments) : translationsFolder; - - const entryResourcePath = resolve(cwd, fullFolderPath, RESOURCE_ENTRIES_FILENAME); - const entryMetaPath = resolve(cwd, fullFolderPath, TRACKER_META_FILENAME); - - if (!groups.has(fullFolderPath)) { - groups.set(fullFolderPath, { - folderPath: fullFolderPath, - entryResourcePath, - entryMetaPath, + let group = groups.get(paths.folderPath); + if (!group) { + group = { + folderPath: paths.folderPath, + entryResourcePath: paths.resourceEntriesPath, + entryMetaPath: paths.trackerMetaPath, resources: [], - }); + }; + groups.set(paths.folderPath, group); } - const group = groups.get(fullFolderPath); - if (group) { - group.resources.push({ - resource, - entryKey, - }); - } + group.resources.push({ resource, entryKey: paths.entryKey }); } return groups; diff --git a/libs/core/src/lib/import/types.ts b/libs/core/src/lib/import/types.ts index 1137acb0..dad57988 100644 --- a/libs/core/src/lib/import/types.ts +++ b/libs/core/src/lib/import/types.ts @@ -1,4 +1,4 @@ -import type { PreferredTermRule, TranslationStatus } from '@simoncodes-ca/domain'; +import type { ImportStrategy, PreferredTermRule, TranslationStatus } from '@simoncodes-ca/domain'; /** * Supported import formats @@ -6,9 +6,10 @@ import type { PreferredTermRule, TranslationStatus } from '@simoncodes-ca/domain export type ImportFormat = 'xliff' | 'json'; /** - * Import strategies determine how imported data is processed and merged + * Import strategies determine how imported data is processed and merged. + * Defined in domain next to the status rule that depends on it (`resolveImportStatus`). */ -export type ImportStrategy = 'translation-service' | 'verification' | 'migration' | 'update'; +export type { ImportStrategy }; /** * Options for importing translations diff --git a/libs/core/src/lib/normalize/folder-utils.ts b/libs/core/src/lib/normalize/folder-utils.ts index e36d23a8..e10e09fc 100644 --- a/libs/core/src/lib/normalize/folder-utils.ts +++ b/libs/core/src/lib/normalize/folder-utils.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { walkFolders } from './iterative-folder-walker'; +import { openResourceFolder } from '../resource/resource-folder'; /** * Recursively traverses a directory tree and returns all folder paths @@ -51,23 +52,9 @@ export function isFolderEmpty(folderPath: string): boolean { return false; } - // Check for resource_entries.json with actual entries - const resourceEntriesPath = path.join(folderPath, 'resource_entries.json'); - - if (!fs.existsSync(resourceEntriesPath)) { - // No resource_entries.json means empty - return true; - } - + // Empty unless resource_entries.json has entries (a missing file counts as empty) try { - const fileContent = fs.readFileSync(resourceEntriesPath, 'utf8'); - const resourceEntries = JSON.parse(fileContent); - - // Check if resource_entries.json has any keys - const hasEntries = Object.keys(resourceEntries).length > 0; - - // Empty if no entries in resource_entries.json - return !hasEntries; + return openResourceFolder(folderPath).isEmpty(); } catch { // If we can't parse the file, consider it NOT empty to prevent deletion // This preserves corrupted files so they can be manually fixed diff --git a/libs/core/src/lib/normalize/normalize-entry.ts b/libs/core/src/lib/normalize/normalize-entry.ts index 015e179d..27006382 100644 --- a/libs/core/src/lib/normalize/normalize-entry.ts +++ b/libs/core/src/lib/normalize/normalize-entry.ts @@ -2,8 +2,8 @@ import { calculateChecksum } from '../../resource/checksum'; import type { ResourceEntry } from '../../resource/resource-entry'; import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; import type { TranslationStatus } from '@simoncodes-ca/domain'; -import type { LocaleMetadata } from '@simoncodes-ca/domain'; -import { translocoToICU, normalizeTags } from '@simoncodes-ca/domain'; +import { translocoToICU, normalizeTags, applyBaseChange, recordTranslation } from '@simoncodes-ca/domain'; +import { translationLocales } from '../resource/resource-folder'; export interface NormalizeEntryParams { readonly entryKey: string; @@ -25,104 +25,6 @@ export interface NormalizeEntryResult { }; } -interface UpdateBaseLocaleMetadataParams { - readonly currentBaseChecksum: string; - readonly previousBaseMetadata: LocaleMetadata | undefined; - readonly normalizedMetadata: ResourceEntryMetadata; - readonly baseLocale: string; -} - -interface DetermineTranslationStatusParams { - readonly hadLocaleEntry: boolean; - readonly baseValueChanged: boolean; - readonly currentLocaleValue: string; - readonly baseValue: string; - readonly previousStatus: TranslationStatus | undefined; -} - -interface ProcessLocaleParams { - readonly locale: string; - readonly baseValue: string; - readonly currentBaseChecksum: string; - readonly baseValueChanged: boolean; - readonly previousLocaleMetadata: LocaleMetadata | undefined; - readonly normalizedEntry: ResourceEntry; -} - -interface ProcessLocaleResult { - readonly localeMetadata: LocaleMetadata; - readonly wasLocaleAdded: boolean; - readonly wasChecksumUpdated: boolean; - readonly wasStatusChanged: boolean; -} - -interface CreateLocaleMetadataParams { - readonly currentLocaleChecksum: string; - readonly currentBaseChecksum: string; - readonly translationStatus: TranslationStatus; -} - -interface TrackLocaleChangesParams { - readonly previousLocaleMetadata: LocaleMetadata | undefined; - readonly currentLocaleChecksum: string; - readonly newStatus: TranslationStatus; -} - -function updateBaseLocaleMetadata(params: UpdateBaseLocaleMetadataParams): number { - const { currentBaseChecksum, previousBaseMetadata, normalizedMetadata, baseLocale } = params; - - normalizedMetadata[baseLocale] = { - checksum: currentBaseChecksum, - }; - - const wasChecksumUpdated = !previousBaseMetadata || previousBaseMetadata.checksum !== currentBaseChecksum; - return wasChecksumUpdated ? 1 : 0; -} - -function determineTranslationStatus(params: DetermineTranslationStatusParams): TranslationStatus { - const { hadLocaleEntry, baseValueChanged, currentLocaleValue, baseValue, previousStatus } = params; - - if (!hadLocaleEntry) { - return 'new'; - } - - if (baseValueChanged) { - return currentLocaleValue === baseValue ? 'new' : 'stale'; - } - - return previousStatus || 'translated'; -} - -function createLocaleMetadata(params: CreateLocaleMetadataParams): LocaleMetadata { - const { currentLocaleChecksum, currentBaseChecksum, translationStatus } = params; - - return { - checksum: currentLocaleChecksum, - baseChecksum: currentBaseChecksum, - status: translationStatus, - }; -} - -function trackLocaleChanges(params: TrackLocaleChangesParams) { - const { previousLocaleMetadata, currentLocaleChecksum, newStatus } = params; - - return { - wasStatusChanged: previousLocaleMetadata?.status !== newStatus, - wasChecksumUpdated: previousLocaleMetadata?.checksum !== currentLocaleChecksum, - }; -} - -function ensureLocaleEntryExists(normalizedEntry: ResourceEntry, locale: string, baseValue: string): boolean { - const localeValueInEntry = normalizedEntry[locale]; - const hadLocaleEntry = typeof localeValueInEntry === 'string'; - - if (!hadLocaleEntry) { - normalizedEntry[locale] = baseValue; - } - - return hadLocaleEntry; -} - interface ProcessAllLocalesParams { readonly locales: string[]; readonly baseLocale: string; @@ -131,87 +33,55 @@ interface ProcessAllLocalesParams { readonly baseValueChanged: boolean; readonly metadata: ResourceEntryMetadata; readonly normalizedEntry: ResourceEntry; - readonly normalizedMetadata: ResourceEntryMetadata; } interface ProcessAllLocalesResult { + readonly metadata: ResourceEntryMetadata; readonly localesAdded: number; readonly checksumsUpdated: number; readonly statusesChanged: number; } -function processLocale(params: ProcessLocaleParams): ProcessLocaleResult { - const { locale, baseValue, currentBaseChecksum, baseValueChanged, previousLocaleMetadata, normalizedEntry } = params; - - const hadLocaleEntry = ensureLocaleEntryExists(normalizedEntry, locale, baseValue); - const currentLocaleValue = normalizedEntry[locale] as string; - const currentLocaleChecksum = calculateChecksum(currentLocaleValue); - - const translationStatus = determineTranslationStatus({ - hadLocaleEntry, - baseValueChanged, - currentLocaleValue, - baseValue, - previousStatus: previousLocaleMetadata?.status, - }); - - const localeMetadata = createLocaleMetadata({ - currentLocaleChecksum, - currentBaseChecksum, - translationStatus, - }); - const changes = trackLocaleChanges({ - previousLocaleMetadata, - currentLocaleChecksum, - newStatus: translationStatus, - }); - - return { - localeMetadata, - wasLocaleAdded: !hadLocaleEntry, - wasChecksumUpdated: changes.wasChecksumUpdated, - wasStatusChanged: changes.wasStatusChanged, - }; -} - +/** + * Adds missing locales (as copies of the base value), recomputes checksums, and applies the + * Staleness rule when the base value changed. Mutates `normalizedEntry` to add missing locales. + */ function processAllLocales(params: ProcessAllLocalesParams): ProcessAllLocalesResult { - const { - locales, - baseLocale, - baseValue, - currentBaseChecksum, - baseValueChanged, - metadata, - normalizedEntry, - normalizedMetadata, - } = params; + const { locales, baseLocale, baseValue, currentBaseChecksum, baseValueChanged, metadata, normalizedEntry } = params; + const targetLocales = locales.filter((locale) => locale !== baseLocale); + let normalizedMetadata: ResourceEntryMetadata = { ...metadata, [baseLocale]: { checksum: currentBaseChecksum } }; let localesAdded = 0; - let checksumsUpdated = 0; - let statusesChanged = 0; - for (const locale of locales) { - if (locale === baseLocale) { - continue; + for (const locale of targetLocales) { + const hadLocaleEntry = typeof normalizedEntry[locale] === 'string'; + if (!hadLocaleEntry) { + normalizedEntry[locale] = baseValue; + localesAdded++; } - const result = processLocale({ + const status: TranslationStatus = hadLocaleEntry ? (metadata[locale]?.status ?? 'translated') : 'new'; + normalizedMetadata = recordTranslation( + normalizedMetadata, locale, - baseValue, + calculateChecksum(normalizedEntry[locale] as string), currentBaseChecksum, - baseValueChanged, - previousLocaleMetadata: metadata[locale], - normalizedEntry, - }); + status, + ); + } - normalizedMetadata[locale] = result.localeMetadata; + if (baseValueChanged) { + normalizedMetadata = applyBaseChange(normalizedMetadata, baseLocale, currentBaseChecksum); + } - if (result.wasLocaleAdded) localesAdded++; - if (result.wasChecksumUpdated) checksumsUpdated++; - if (result.wasStatusChanged) statusesChanged++; + let checksumsUpdated = 0; + let statusesChanged = 0; + for (const locale of targetLocales) { + if (metadata[locale]?.checksum !== normalizedMetadata[locale].checksum) checksumsUpdated++; + if (metadata[locale]?.status !== normalizedMetadata[locale].status) statusesChanged++; } - return { localesAdded, checksumsUpdated, statusesChanged }; + return { metadata: normalizedMetadata, localesAdded, checksumsUpdated, statusesChanged }; } /** @@ -222,7 +92,8 @@ function processAllLocales(params: ProcessAllLocalesParams): ProcessAllLocalesRe * - Base locale checksum is always recomputed * - Missing locale entries are added with base value and status 'new' * - Locale checksums and baseChecksums are recomputed - * - Status is set to 'stale' if base value changed (unless locale equals new base) + * - When the base value changed, the Staleness rule (`applyBaseChange`) sets statuses: + * 'stale', or 'new' when the locale value equals the new base * - Existing statuses are preserved when base hasn't changed * - Comments and tags are preserved * @@ -237,7 +108,6 @@ export function normalizeEntry(params: NormalizeEntryParams): NormalizeEntryResu } const normalizedEntry: ResourceEntry = { ...resourceEntry }; - const normalizedMetadata: ResourceEntryMetadata = { ...metadata }; if (baseLocale in normalizedEntry && baseLocale !== 'source') { delete normalizedEntry[baseLocale]; @@ -265,15 +135,12 @@ export function normalizeEntry(params: NormalizeEntryParams): NormalizeEntryResu } } - const nonValueKeys = new Set(['source', 'comment', 'tags']); - for (const key of Object.keys(normalizedEntry)) { - if (!nonValueKeys.has(key) && typeof normalizedEntry[key] === 'string') { - const original = normalizedEntry[key] as string; - const converted = translocoToICU(original); - if (converted !== original) { - normalizedEntry[key] = converted; - valuesConverted++; - } + for (const key of translationLocales(normalizedEntry)) { + const original = normalizedEntry[key] as string; + const converted = translocoToICU(original); + if (converted !== original) { + normalizedEntry[key] = converted; + valuesConverted++; } } @@ -282,12 +149,7 @@ export function normalizeEntry(params: NormalizeEntryParams): NormalizeEntryResu const previousBaseChecksum = metadata[baseLocale]?.checksum; const baseValueChanged = !!previousBaseChecksum && previousBaseChecksum !== currentBaseChecksum; - const baseChecksumsUpdated = updateBaseLocaleMetadata({ - currentBaseChecksum, - previousBaseMetadata: metadata[baseLocale], - normalizedMetadata, - baseLocale, - }); + const baseChecksumsUpdated = previousBaseChecksum !== currentBaseChecksum ? 1 : 0; const localeChanges = processAllLocales({ locales, @@ -297,12 +159,11 @@ export function normalizeEntry(params: NormalizeEntryParams): NormalizeEntryResu baseValueChanged, metadata, normalizedEntry, - normalizedMetadata, }); return { resourceEntry: normalizedEntry, - metadata: normalizedMetadata, + metadata: localeChanges.metadata, changes: { localesAdded: localeChanges.localesAdded, checksumsUpdated: baseChecksumsUpdated + localeChanges.checksumsUpdated, diff --git a/libs/core/src/lib/normalize/normalize.spec.ts b/libs/core/src/lib/normalize/normalize.spec.ts index c2be81af..e040de94 100644 --- a/libs/core/src/lib/normalize/normalize.spec.ts +++ b/libs/core/src/lib/normalize/normalize.spec.ts @@ -180,6 +180,30 @@ describe('Normalize', () => { ); }); + it('should leave a folder with empty entries and no tracker_meta.json untouched', async () => { + const testDir = '/test-root'; + const entriesPath = path.join(testDir, 'resource_entries.json'); + + const mockFs: MockFileSystem = { + [testDir]: { type: 'directory', children: ['resource_entries.json'] }, + [entriesPath]: { type: 'file', content: '{}' }, + }; + + setupMockFileSystem(mockFs); + + vi.spyOn(cleanupModule, 'cleanupEmptyFolders').mockReturnValue({ + foldersRemoved: 0, + removedPaths: [], + }); + + const result = await normalize({ translationsFolder: testDir, baseLocale, locales }); + + expect(result.filesCreated).toBe(0); + expect(result.filesUpdated).toBe(0); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + it('should add missing locale entries across all resources', async () => { const testDir = '/test-root'; const entriesPath = path.join(testDir, 'resource_entries.json'); diff --git a/libs/core/src/lib/normalize/normalize.ts b/libs/core/src/lib/normalize/normalize.ts index 0e8d5fa6..87a9684b 100644 --- a/libs/core/src/lib/normalize/normalize.ts +++ b/libs/core/src/lib/normalize/normalize.ts @@ -1,10 +1,8 @@ import * as fs from 'node:fs'; -import * as path from 'node:path'; import { normalizeEntry } from './normalize-entry'; import { cleanupEmptyFolders } from './cleanup-empty-folders'; import { walkFolders } from './iterative-folder-walker'; -import type { ResourceEntries } from '../../resource/resource-entry'; -import type { TrackerMetadata } from '../../resource/tracker-metadata'; +import { openResourceFolder, type ResourceFolder } from '../resource/resource-folder'; export interface NormalizeParams { readonly translationsFolder: string; @@ -33,259 +31,70 @@ interface NormalizationCounters { filesUpdated: number; } -interface ResourceFiles { - readonly resourceEntries: ResourceEntries; - readonly trackerMetadata: TrackerMetadata; - readonly resourceEntriesExisted: boolean; - readonly trackerMetaExisted: boolean; -} - -interface NormalizedFolderData { - readonly resourceEntries: ResourceEntries; - readonly trackerMetadata: TrackerMetadata; - readonly folderHadChanges: boolean; - readonly entriesProcessedCount: number; - readonly localesAddedCount: number; - readonly valuesConvertedCount: number; - readonly tagsNormalizedCount: number; -} - -interface PersistResourcesParams { - readonly folderPath: string; - readonly resourceEntries: ResourceEntries; - readonly trackerMetadata: TrackerMetadata; - readonly resourceEntriesExisted: boolean; - readonly trackerMetaExisted: boolean; - readonly folderHadChanges: boolean; - readonly dryRun: boolean; -} - -interface PersistResourcesResult { - readonly filesCreated: number; - readonly filesUpdated: number; -} - -function loadResourceFiles(folderPath: string): ResourceFiles | null { - const resourceEntriesPath = path.join(folderPath, 'resource_entries.json'); - const trackerMetaPath = path.join(folderPath, 'tracker_meta.json'); - - let resourceEntries: ResourceEntries = {}; - let trackerMetadata: TrackerMetadata = {}; - let resourceEntriesExisted = false; - let trackerMetaExisted = false; - - if (fs.existsSync(resourceEntriesPath)) { - try { - const content = fs.readFileSync(resourceEntriesPath, 'utf8'); - resourceEntries = JSON.parse(content); - resourceEntriesExisted = true; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('\n⚠️ Skipping folder due to invalid JSON:', folderPath); - console.error(' Error in file: resource_entries.json'); - console.error(' Parse error:', errorMessage); - console.error(' Please fix the JSON syntax manually.\n'); - return null; - } +function openFolderOrWarn(folderPath: string, baseLocale: string): ResourceFolder | null { + try { + return openResourceFolder(folderPath, { baseLocale }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('\n⚠️ Skipping folder due to invalid JSON:', folderPath); + console.error(' Parse error:', errorMessage); + console.error(' Please fix the JSON syntax manually.\n'); + return null; } - - if (fs.existsSync(trackerMetaPath)) { - try { - const content = fs.readFileSync(trackerMetaPath, 'utf8'); - trackerMetadata = JSON.parse(content); - trackerMetaExisted = true; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('\n⚠️ Skipping folder due to invalid JSON:', folderPath); - console.error(' Error in file: tracker_meta.json'); - console.error(' Parse error:', errorMessage); - console.error(' Please fix the JSON syntax manually.\n'); - return null; - } - } - - return { - resourceEntries, - trackerMetadata, - resourceEntriesExisted, - trackerMetaExisted, - }; } -interface NormalizeAllEntriesParams { - readonly resourceEntries: ResourceEntries; - readonly trackerMetadata: TrackerMetadata; +interface NormalizeFolderParams { + readonly folderPath: string; readonly baseLocale: string; readonly locales: string[]; + readonly dryRun: boolean; + readonly counters: NormalizationCounters; } -function normalizeAllEntriesInFolder(params: NormalizeAllEntriesParams): NormalizedFolderData { - const { resourceEntries, trackerMetadata, baseLocale, locales } = params; +function normalizeFolderResources(params: NormalizeFolderParams): void { + const { folderPath, baseLocale, locales, dryRun, counters } = params; - const entryKeys = Object.keys(resourceEntries); - if (entryKeys.length === 0) { - return { - resourceEntries, - trackerMetadata, - folderHadChanges: false, - entriesProcessedCount: 0, - localesAddedCount: 0, - valuesConvertedCount: 0, - tagsNormalizedCount: 0, - }; + // Skip this folder if there was a JSON parsing error + const folder = openFolderOrWarn(folderPath, baseLocale); + if (folder === null || folder.isEmpty()) { + return; } let folderHadChanges = false; - let entriesProcessedCount = 0; - let localesAddedCount = 0; - let valuesConvertedCount = 0; - let tagsNormalizedCount = 0; - - const updatedResourceEntries = { ...resourceEntries }; - const updatedTrackerMetadata = { ...trackerMetadata }; - for (const entryKey of entryKeys) { - const resourceEntry = resourceEntries[entryKey]; - const entryMetadata = trackerMetadata[entryKey] || {}; + for (const entryKey of folder.keys()) { + const stored = folder.get(entryKey); + if (!stored) continue; const result = normalizeEntry({ entryKey, - resourceEntry, - metadata: entryMetadata, + resourceEntry: stored.entry, + metadata: stored.meta ?? {}, baseLocale, locales, }); - updatedResourceEntries[entryKey] = result.resourceEntry; - updatedTrackerMetadata[entryKey] = result.metadata; + folder.setEntry(entryKey, result.resourceEntry, result.metadata); - entriesProcessedCount++; - localesAddedCount += result.changes.localesAdded; - valuesConvertedCount += result.changes.valuesConverted; - tagsNormalizedCount += result.changes.tagsNormalized; + counters.entriesProcessed++; + counters.localesAdded += result.changes.localesAdded; + counters.valuesConverted += result.changes.valuesConverted; + counters.tagsNormalized += result.changes.tagsNormalized; - if ( - result.changes.localesAdded > 0 || - result.changes.checksumsUpdated > 0 || - result.changes.statusesChanged > 0 || - result.changes.valuesConverted > 0 || - result.changes.tagsNormalized > 0 - ) { + if (Object.values(result.changes).some((count) => count > 0)) { folderHadChanges = true; } } - return { - resourceEntries: updatedResourceEntries, - trackerMetadata: updatedTrackerMetadata, - folderHadChanges, - entriesProcessedCount, - localesAddedCount, - valuesConvertedCount, - tagsNormalizedCount, - }; -} - -function writeResourceFile(filePath: string, content: ResourceEntries | TrackerMetadata): void { - fs.writeFileSync(filePath, JSON.stringify(content, null, 2), 'utf8'); -} - -function persistFolderResources(params: PersistResourcesParams): PersistResourcesResult { - const { - folderPath, - resourceEntries, - trackerMetadata, - resourceEntriesExisted, - trackerMetaExisted, - folderHadChanges, - dryRun, - } = params; - - const resourceEntriesPath = path.join(folderPath, 'resource_entries.json'); - const trackerMetaPath = path.join(folderPath, 'tracker_meta.json'); - - let filesCreated = 0; - let filesUpdated = 0; - - if (!dryRun) { - if (!resourceEntriesExisted) { - writeResourceFile(resourceEntriesPath, resourceEntries); - filesCreated++; - } else if (folderHadChanges) { - writeResourceFile(resourceEntriesPath, resourceEntries); - filesUpdated++; - } - - if (!trackerMetaExisted) { - writeResourceFile(trackerMetaPath, trackerMetadata); - filesCreated++; - } else if (folderHadChanges) { - writeResourceFile(trackerMetaPath, trackerMetadata); - filesUpdated++; - } - } else { - if (!resourceEntriesExisted) { - filesCreated++; - } else if (folderHadChanges) { - filesUpdated++; - } - - if (!trackerMetaExisted) { - filesCreated++; - } else if (folderHadChanges) { - filesUpdated++; - } - } - - return { filesCreated, filesUpdated }; -} - -interface NormalizeFolderParams { - readonly folderPath: string; - readonly baseLocale: string; - readonly locales: string[]; - readonly dryRun: boolean; - readonly counters: NormalizationCounters; -} - -function normalizeFolderResources(params: NormalizeFolderParams): void { - const { folderPath, baseLocale, locales, dryRun, counters } = params; - - const resourceFiles = loadResourceFiles(folderPath); - - // Skip this folder if there was a JSON parsing error - if (resourceFiles === null) { - return; - } - - const normalizedData = normalizeAllEntriesInFolder({ - resourceEntries: resourceFiles.resourceEntries, - trackerMetadata: resourceFiles.trackerMetadata, - baseLocale, - locales, - }); - - if (normalizedData.entriesProcessedCount === 0) { + // Normalize guarantees both files exist, so a missing file is written even without changes. + const filesMissing = !fs.existsSync(folder.entriesPath) || !fs.existsSync(folder.metaPath); + if (!folderHadChanges && !filesMissing) { return; } - counters.entriesProcessed += normalizedData.entriesProcessedCount; - counters.localesAdded += normalizedData.localesAddedCount; - counters.valuesConverted += normalizedData.valuesConvertedCount; - counters.tagsNormalized += normalizedData.tagsNormalizedCount; - - const persistResult = persistFolderResources({ - folderPath, - resourceEntries: normalizedData.resourceEntries, - trackerMetadata: normalizedData.trackerMetadata, - resourceEntriesExisted: resourceFiles.resourceEntriesExisted, - trackerMetaExisted: resourceFiles.trackerMetaExisted, - folderHadChanges: normalizedData.folderHadChanges, - dryRun, - }); - - counters.filesCreated += persistResult.filesCreated; - counters.filesUpdated += persistResult.filesUpdated; + const { written, created } = folder.save({ dryRun }); + counters.filesCreated += created.length; + counters.filesUpdated += written.length - created.length; } interface NormalizeAllFoldersParams { @@ -309,8 +118,8 @@ async function normalizeAllFolders(params: NormalizeAllFoldersParams): Promise a - b); for (const depth of depths) { // Concurrency safety: folders at the same depth share the `counters` object. This is safe - // because normalizeFolderResources contains no await points — all I/O (fs.readFileSync, - // fs.writeFileSync) is synchronous, so mutations to `counters` are never interleaved. + // because normalizeFolderResources contains no await points — all I/O (ResourceFolder + // open/save) is synchronous, so mutations to `counters` are never interleaved. await Promise.all( (foldersByDepth.get(depth) ?? []).map((folderPath) => normalizeFolderResources({ folderPath, baseLocale, locales, dryRun, counters }), diff --git a/libs/core/src/lib/resource/index.ts b/libs/core/src/lib/resource/index.ts index 1ca96300..8eda2b88 100644 --- a/libs/core/src/lib/resource/index.ts +++ b/libs/core/src/lib/resource/index.ts @@ -5,3 +5,4 @@ export * from './load-full-resource-tree'; export * from './extract-subtree'; export * from './search'; export * from './tree-fingerprint'; +export * from './resource-folder'; diff --git a/libs/core/src/lib/resource/load-resource-tree.ts b/libs/core/src/lib/resource/load-resource-tree.ts index 98c94372..e5011630 100644 --- a/libs/core/src/lib/resource/load-resource-tree.ts +++ b/libs/core/src/lib/resource/load-resource-tree.ts @@ -1,9 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; -import type { ResourceEntries } from '../../resource/resource-entry'; -import type { TrackerMetadata } from '../../resource/tracker-metadata'; import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; +import { openResourceFolder } from './resource-folder'; export interface ResourceTreeNode { /** Folder path segments (empty array for root) */ @@ -81,38 +79,14 @@ export function loadResourceTree(options: LoadResourceTreeOptions): ResourceTree } function loadResourcesFromFolder(folderPath: string): ResourceTreeEntry[] { - const entriesPath = path.join(folderPath, RESOURCE_ENTRIES_FILENAME); - const metaPath = path.join(folderPath, TRACKER_META_FILENAME); - - if (!fs.existsSync(entriesPath) || !fs.existsSync(metaPath)) { - return []; - } - const resources: ResourceTreeEntry[] = []; try { - const entries: ResourceEntries = JSON.parse(fs.readFileSync(entriesPath, 'utf8')); - const metadata: TrackerMetadata = JSON.parse(fs.readFileSync(metaPath, 'utf8')); - - for (const [key, entry] of Object.entries(entries)) { - const meta = metadata[key]; - if (!meta) continue; - - const translations: Record = {}; - for (const [prop, value] of Object.entries(entry)) { - if (prop !== 'source' && prop !== 'tags' && prop !== 'comment' && typeof value === 'string') { - translations[prop] = value; - } - } - - resources.push({ - key, - source: entry.source, - translations, - comment: entry.comment, - tags: entry.tags, - metadata: meta, - }); + const folder = openResourceFolder(folderPath); + for (const key of folder.keys()) { + // Entries without metadata are skipped (a folder without tracker_meta.json has no resources) + const resource = folder.treeEntry(key); + if (resource) resources.push(resource); } } catch (error) { // Malformed JSON, skip this folder's resources diff --git a/libs/core/src/lib/resource/metadata-operations.ts b/libs/core/src/lib/resource/metadata-operations.ts index 570162b5..589ef721 100644 --- a/libs/core/src/lib/resource/metadata-operations.ts +++ b/libs/core/src/lib/resource/metadata-operations.ts @@ -1,7 +1,7 @@ import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; import type { TranslationStatus } from '@simoncodes-ca/domain'; import { calculateChecksum } from '../../resource/checksum'; -import { createBaseLocaleMetadata } from '@simoncodes-ca/domain'; +import { isUntranslatedCopy, recordTranslation } from '@simoncodes-ca/domain'; export interface CreateResourceMetadataParams { /** The entry key for this resource */ @@ -18,87 +18,24 @@ export interface CreateResourceMetadataParams { }>; } -export interface UpdateBaseValueParams { - /** Existing metadata for this entry */ - readonly metadata: ResourceEntryMetadata; - /** New base value */ - readonly newBaseValue: string; - /** Base locale code */ - readonly baseLocale: string; -} - /** - * Creates complete metadata for a new resource entry. - * - * Handles: - * - Creating base locale metadata with checksum - * - Creating translation metadata with proper status - * - Detecting when translation matches base (status = 'new') + * Builds the metadata `addResource` writes for a new entry, without touching disk. + * Used by the API to update its cache after an add. * - * @param params - Metadata creation parameters - * @returns Complete metadata object ready to be written + * Follows the Staleness rules: a translation that is an untranslated copy of the base is `new`, + * whatever status was provided. */ export function createResourceMetadata(params: CreateResourceMetadataParams): ResourceEntryMetadata { const { baseValue, baseLocale, translations = [] } = params; - const metadata: ResourceEntryMetadata = {}; const baseChecksum = calculateChecksum(baseValue); + let metadata: ResourceEntryMetadata = { [baseLocale]: { checksum: baseChecksum } }; - metadata[baseLocale] = createBaseLocaleMetadata(baseChecksum); - - // Create translation metadata for (const { locale, value, status } of translations) { - if (locale === baseLocale) { - continue; // Skip base locale - already handled - } - - const checksum = calculateChecksum(value); - - // If translation matches base value, mark as 'new' regardless of provided status - const finalStatus = checksum === baseChecksum ? 'new' : status; - - metadata[locale] = { - checksum, - baseChecksum, - status: finalStatus, - }; + if (locale === baseLocale) continue; + const finalStatus = isUntranslatedCopy(value, baseValue) ? 'new' : status; + metadata = recordTranslation(metadata, locale, calculateChecksum(value), baseChecksum, finalStatus); } return metadata; } - -/** - * Updates metadata when the base value changes. - * - * Handles: - * - Updating base locale checksum - * - Updating baseChecksum for all translations - * - Marking translations as 'stale' - * - * @param params - Update parameters - * @returns Updated metadata object - */ -export function updateMetadataForBaseValueChange(params: UpdateBaseValueParams): ResourceEntryMetadata { - const { metadata, newBaseValue, baseLocale } = params; - - const newBaseChecksum = calculateChecksum(newBaseValue); - const updatedMetadata = { ...metadata }; - - updatedMetadata[baseLocale] = { - ...updatedMetadata[baseLocale], - checksum: newBaseChecksum, - }; - - // Update all translations to reference new base and mark as stale - for (const locale of Object.keys(updatedMetadata)) { - if (locale !== baseLocale) { - updatedMetadata[locale] = { - ...updatedMetadata[locale], - baseChecksum: newBaseChecksum, - status: 'stale', - }; - } - } - - return updatedMetadata; -} diff --git a/libs/core/src/lib/resource/resource-folder.spec.ts b/libs/core/src/lib/resource/resource-folder.spec.ts new file mode 100644 index 00000000..7646a62b --- /dev/null +++ b/libs/core/src/lib/resource/resource-folder.spec.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { openResourceFolder, translationLocales } from './resource-folder'; +import { calculateChecksum } from '../../resource/checksum'; + +const md5 = calculateChecksum; + +describe('ResourceFolder', () => { + let dir: string; + let folderPath: string; + + const readEntries = () => JSON.parse(readFileSync(join(folderPath, 'resource_entries.json'), 'utf8')); + const readMeta = () => JSON.parse(readFileSync(join(folderPath, 'tracker_meta.json'), 'utf8')); + const writePair = (entries: unknown, meta: unknown) => { + writeFileSync(join(folderPath, 'resource_entries.json'), JSON.stringify(entries)); + writeFileSync(join(folderPath, 'tracker_meta.json'), JSON.stringify(meta)); + }; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'resource-folder-')); + folderPath = dir; + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + describe('open', () => { + it('treats missing files as an empty folder', () => { + const folder = openResourceFolder(join(dir, 'missing')); + expect(folder.isEmpty()).toBe(true); + expect(folder.keys()).toEqual([]); + expect(folder.get('ok')).toBeUndefined(); + }); + + it('loads both files', () => { + writePair({ ok: { source: 'OK', fr: 'Oui' } }, { ok: { en: { checksum: md5('OK') } } }); + const folder = openResourceFolder(folderPath); + expect(folder.keys()).toEqual(['ok']); + expect(folder.get('ok')).toEqual({ entry: { source: 'OK', fr: 'Oui' }, meta: { en: { checksum: md5('OK') } } }); + }); + + it('throws on malformed JSON', () => { + writeFileSync(join(folderPath, 'resource_entries.json'), '{ not json'); + expect(() => openResourceFolder(folderPath)).toThrow(); + }); + + it('does not treat prototype properties as keys', () => { + writePair({}, {}); + expect(openResourceFolder(folderPath).has('constructor')).toBe(false); + }); + }); + + describe('setBase', () => { + it('creates a new entry with its base checksum', () => { + const folder = openResourceFolder(join(dir, 'a', 'b')); + expect(folder.setBase('ok', 'OK')).toBe(true); + folder.save(); + + folderPath = join(dir, 'a', 'b'); + expect(readEntries()).toEqual({ ok: { source: 'OK' } }); + expect(readMeta()).toEqual({ ok: { en: { checksum: md5('OK') } } }); + }); + + it('returns false when nothing changed', () => { + writePair({ ok: { source: 'OK' } }, { ok: { en: { checksum: md5('OK') } } }); + expect(openResourceFolder(folderPath).setBase('ok', 'OK')).toBe(false); + }); + + it('applies the staleness rule when the base value changes', () => { + writePair( + { ok: { source: 'OK', fr: "D'accord", es: 'Okay' } }, + { + ok: { + en: { checksum: md5('OK') }, + fr: { checksum: md5("D'accord"), baseChecksum: md5('OK'), status: 'verified' }, + es: { checksum: md5('Okay'), baseChecksum: md5('OK'), status: 'translated' }, + }, + }, + ); + + const folder = openResourceFolder(folderPath); + folder.setBase('ok', 'Okay'); + + expect(folder.get('ok')?.meta).toEqual({ + en: { checksum: md5('Okay') }, + fr: { checksum: md5("D'accord"), baseChecksum: md5('Okay'), status: 'stale' }, + // An untranslated copy of the new base stays 'new' + es: { checksum: md5('Okay'), baseChecksum: md5('Okay'), status: 'new' }, + }); + }); + + it('treats a stored checksum that disagrees with the stored value as a base change', () => { + writePair( + { ok: { source: 'Edited by hand', fr: 'Oui' } }, + { + ok: { + en: { checksum: md5('OK') }, + fr: { checksum: md5('Oui'), baseChecksum: md5('OK'), status: 'translated' }, + }, + }, + ); + + const folder = openResourceFolder(folderPath); + expect(folder.setBase('ok', 'Edited by hand')).toBe(true); + expect(folder.get('ok')?.meta?.['fr'].status).toBe('stale'); + }); + + it('records a missing base checksum without staling translations', () => { + writePair( + { ok: { source: 'OK', fr: 'Oui' } }, + { ok: { fr: { checksum: md5('Oui'), baseChecksum: md5('OK'), status: 'verified' } } }, + ); + + const folder = openResourceFolder(folderPath); + folder.setBase('ok', 'OK'); + expect(folder.get('ok')?.meta).toEqual({ + fr: { checksum: md5('Oui'), baseChecksum: md5('OK'), status: 'verified' }, + en: { checksum: md5('OK') }, + }); + }); + + it('uses the configured base locale', () => { + const folder = openResourceFolder(folderPath, { baseLocale: 'fr' }); + folder.setBase('ok', 'Oui'); + expect(folder.get('ok')?.meta).toEqual({ fr: { checksum: md5('Oui') } }); + }); + }); + + describe('setTranslation / setStatus', () => { + it('records checksum, current base checksum, and status', () => { + const folder = openResourceFolder(folderPath); + folder.setBase('ok', 'OK'); + folder.setTranslation('ok', 'fr', 'Oui', 'verified'); + + expect(folder.get('ok')?.entry).toEqual({ source: 'OK', fr: 'Oui' }); + expect(folder.get('ok')?.meta?.['fr']).toEqual({ + checksum: md5('Oui'), + baseChecksum: md5('OK'), + status: 'verified', + }); + }); + + it('defaults status to translated', () => { + const folder = openResourceFolder(folderPath); + folder.setBase('ok', 'OK'); + folder.setTranslation('ok', 'fr', 'Oui'); + expect(folder.get('ok')?.meta?.['fr'].status).toBe('translated'); + }); + + it('falls back to the checksum of the source when base metadata is missing', () => { + writePair({ ok: { source: 'OK' } }, {}); + const folder = openResourceFolder(folderPath); + folder.setTranslation('ok', 'fr', 'Oui'); + expect(folder.get('ok')?.meta?.['fr'].baseChecksum).toBe(md5('OK')); + }); + + it('rejects unknown keys and the base locale', () => { + const folder = openResourceFolder(folderPath); + expect(() => folder.setTranslation('missing', 'fr', 'Oui')).toThrow('Resource entry not found'); + folder.setBase('ok', 'OK'); + expect(() => folder.setTranslation('ok', 'en', 'OK')).toThrow('base locale'); + }); + + it('setStatus changes only the status', () => { + const folder = openResourceFolder(folderPath); + folder.setBase('ok', 'OK'); + folder.setTranslation('ok', 'fr', 'Oui'); + folder.setStatus('ok', 'fr', 'verified'); + expect(folder.get('ok')?.meta?.['fr']).toEqual({ + checksum: md5('Oui'), + baseChecksum: md5('OK'), + status: 'verified', + }); + expect(() => folder.setStatus('ok', 'de', 'verified')).toThrow(); + }); + it('setStatus can re-confirm against the current base checksum', () => { + writePair( + { ok: { source: 'Okay', fr: 'Oui' } }, + { + ok: { + en: { checksum: md5('Okay') }, + fr: { checksum: 'kept', baseChecksum: md5('OK'), status: 'stale' }, + }, + }, + ); + const folder = openResourceFolder(folderPath); + folder.setStatus('ok', 'fr', 'translated', { refreshBaseChecksum: true }); + expect(folder.get('ok')?.meta?.['fr']).toEqual({ + checksum: 'kept', + baseChecksum: md5('Okay'), + status: 'translated', + }); + }); + }); + + describe('setDetails', () => { + it('sets, keeps, and removes comment and tags', () => { + const folder = openResourceFolder(folderPath); + folder.setBase('ok', 'OK'); + + expect(folder.setDetails('ok', { comment: 'Button', tags: ['ui'] })).toBe(true); + expect(folder.setDetails('ok', { comment: 'Button', tags: ['ui'] })).toBe(false); + expect(folder.setDetails('ok', {})).toBe(false); + expect(folder.get('ok')?.entry).toEqual({ source: 'OK', comment: 'Button', tags: ['ui'] }); + + expect(folder.setDetails('ok', { comment: null, tags: [] })).toBe(true); + expect(folder.get('ok')?.entry).toEqual({ source: 'OK' }); + }); + }); + + describe('setEntry', () => { + it('stores entry and metadata losslessly, keeping key position', () => { + writePair({ a: { source: 'A' }, b: { source: 'B' } }, {}); + const folder = openResourceFolder(folderPath); + const meta = { + en: { checksum: md5('A2') }, + fr: { checksum: md5('Un'), baseChecksum: md5('A1'), status: 'verified' as const }, + }; + + folder.setEntry('a', { source: 'A2', fr: 'Un', comment: 'c' }, meta); + + expect(folder.keys()).toEqual(['a', 'b']); + expect(folder.get('a')).toEqual({ entry: { source: 'A2', fr: 'Un', comment: 'c' }, meta }); + }); + }); + + describe('seedLocale / dropLocale', () => { + it('seeds missing locales as new copies of the base and drops them again', () => { + writePair( + { ok: { source: 'OK' }, no: { source: 'No', de: 'Nein' } }, + { ok: { en: { checksum: md5('OK') } }, no: { en: { checksum: md5('No') } } }, + ); + const folder = openResourceFolder(folderPath); + + expect(folder.seedLocale('de')).toBe(1); + expect(folder.get('ok')?.entry['de']).toBe('OK'); + expect(folder.get('ok')?.meta?.['de']).toEqual({ checksum: md5('OK'), baseChecksum: md5('OK'), status: 'new' }); + expect(folder.get('no')?.entry['de']).toBe('Nein'); + + expect(folder.dropLocale('de')).toBe(2); + expect(folder.get('ok')).toEqual({ entry: { source: 'OK' }, meta: { en: { checksum: md5('OK') } } }); + expect(folder.dropLocale('de')).toBe(0); + }); + }); + + describe('treeEntry / translationLocales', () => { + it('builds the tree entry and lists translation locales', () => { + writePair({ ok: { source: 'OK', comment: 'c', tags: [], fr: 'Oui' } }, { ok: { en: { checksum: md5('OK') } } }); + const folder = openResourceFolder(folderPath); + const stored = folder.get('ok'); + expect(stored).toBeDefined(); + expect(translationLocales(stored?.entry ?? { source: '' })).toEqual(['fr']); + expect(folder.treeEntry('ok')).toEqual({ + key: 'ok', + source: 'OK', + translations: { fr: 'Oui' }, + metadata: { en: { checksum: md5('OK') } }, + comment: 'c', + }); + }); + + it('returns undefined when metadata is missing', () => { + writePair({ ok: { source: 'OK' } }, {}); + expect(openResourceFolder(folderPath).treeEntry('ok')).toBeUndefined(); + }); + }); + + describe('save', () => { + it('writes both files and reports which were created', () => { + const target = join(dir, 'nested'); + const folder = openResourceFolder(target); + folder.setBase('ok', 'OK'); + + const first = folder.save(); + expect(first.written).toEqual([join(target, 'resource_entries.json'), join(target, 'tracker_meta.json')]); + expect(first.created).toEqual(first.written); + + const second = folder.save(); + expect(second.created).toEqual([]); + }); + + it('dryRun reports without writing', () => { + const target = join(dir, 'dry'); + const folder = openResourceFolder(target); + folder.setBase('ok', 'OK'); + + const result = folder.save({ dryRun: true }); + expect(result.written).toHaveLength(2); + expect(existsSync(target)).toBe(false); + }); + + it('removes both files when the folder becomes empty', () => { + writePair({ ok: { source: 'OK' } }, { ok: { en: { checksum: md5('OK') } } }); + const folder = openResourceFolder(folderPath); + expect(folder.remove('ok')).toBe(true); + expect(folder.remove('ok')).toBe(false); + + const result = folder.save(); + expect(result.removed).toHaveLength(2); + expect(existsSync(join(folderPath, 'resource_entries.json'))).toBe(false); + expect(existsSync(join(folderPath, 'tracker_meta.json'))).toBe(false); + }); + + it('round-trips through disk', () => { + const folder = openResourceFolder(folderPath); + folder.setBase('ok', 'OK'); + folder.setTranslation('ok', 'fr', 'Oui'); + folder.save(); + + const reopened = openResourceFolder(folderPath); + expect(reopened.get('ok')).toEqual(folder.get('ok')); + }); + }); +}); diff --git a/libs/core/src/lib/resource/resource-folder.ts b/libs/core/src/lib/resource/resource-folder.ts new file mode 100644 index 00000000..59c695d0 --- /dev/null +++ b/libs/core/src/lib/resource/resource-folder.ts @@ -0,0 +1,356 @@ +import { existsSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { applyBaseChange, recordTranslation, type TranslationStatus } from '@simoncodes-ca/domain'; +import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; +import { calculateChecksum } from '../../resource/checksum'; +import type { ResourceEntries, ResourceEntry } from '../../resource/resource-entry'; +import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; +import type { TrackerMetadata } from '../../resource/tracker-metadata'; +import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../file-io/json-file-operations'; +import type { ResourceTreeEntry } from './load-resource-tree'; + +/** + * Resource Folder — the owner of one folder's `resource_entries.json` + `tracker_meta.json` pair. + * + * Every read-modify-write of the pair goes through this module, so: + * - both files are always loaded and saved together, + * - checksums are computed here, and + * - status changes follow the domain Staleness rule (`applyBaseChange`, `recordTranslation`). + * + * Changes stay in memory until `save()`. + */ +export interface ResourceFolder { + /** Absolute or cwd-relative folder path this instance was opened with. */ + readonly folderPath: string; + readonly entriesPath: string; + readonly metaPath: string; + + has(key: string): boolean; + /** Returns the stored entry and its metadata (`meta` is `undefined` when tracker_meta has no record). */ + get(key: string): ResourceFolderEntry | undefined; + keys(): string[]; + isEmpty(): boolean; + /** The entry as the API/UI sees it. `undefined` when the entry or its metadata is missing. */ + treeEntry(key: string): ResourceTreeEntry | undefined; + + /** + * Sets the base value. Creates the entry when it does not exist. + * When an existing base value changes (or its stored checksum is out of date), the + * Staleness rule updates every translation's status. + * + * @returns true when anything changed + */ + setBase(key: string, value: string): boolean; + /** + * Updates comment and/or tags. `undefined` leaves a field alone; `null` (or an empty tag list) removes it. + * @returns true when anything changed + */ + setDetails(key: string, details: EntryDetails): boolean; + /** + * Writes a translation value and records `{ checksum, baseChecksum, status }` for it. + * `baseChecksum` is the current base checksum. + */ + setTranslation(key: string, locale: string, value: string, status?: TranslationStatus): void; + /** + * Changes the status of a locale that already has metadata. The value and its checksum are kept. + * With `refreshBaseChecksum`, the locale's `baseChecksum` is also set to the current base checksum + * (the translation is re-confirmed against the current base). + */ + setStatus( + key: string, + locale: string, + status: TranslationStatus, + options?: { readonly refreshBaseChecksum?: boolean }, + ): void; + /** Stores an entry and its metadata exactly as given (lossless copy, used by move and normalize). */ + setEntry(key: string, entry: Readonly, meta: Readonly): void; + /** + * Adds `locale` to every entry that has no value for it, as a copy of the base value with status `new`. + * @returns number of entries seeded + */ + seedLocale(locale: string): number; + /** + * Removes `locale` values and metadata from every entry. + * @returns number of entries changed + */ + dropLocale(locale: string): number; + /** @returns true when the entry existed */ + remove(key: string): boolean; + + /** + * Writes both files (creating the folder if needed). When the folder has no entries, + * both files are deleted instead. With `dryRun`, reports what would happen without touching disk. + */ + save(options?: { readonly dryRun?: boolean }): ResourceFolderSaveResult; +} + +export interface ResourceFolderEntry { + readonly entry: Readonly; + readonly meta: Readonly | undefined; +} + +export interface EntryDetails { + readonly comment?: string | null; + readonly tags?: readonly string[] | null; +} + +export interface ResourceFolderSaveResult { + /** Files written (both files, or none). */ + readonly written: string[]; + /** Subset of `written` that did not exist before. */ + readonly created: string[]; + /** Files deleted because the folder became empty. */ + readonly removed: string[]; +} + +export interface OpenResourceFolderOptions { + /** Base locale of the collection (default: `en`). Needed to find the base checksum in metadata. */ + readonly baseLocale?: string; +} + +const NON_LOCALE_PROPS: ReadonlySet = new Set(['source', 'comment', 'tags']); + +/** + * Returns the locales that have a translation value in `entry` — + * every string property except `source`, `comment`, and `tags`. + */ +export function translationLocales(entry: Readonly): string[] { + return Object.keys(entry).filter((prop) => !NON_LOCALE_PROPS.has(prop) && typeof entry[prop] === 'string'); +} + +/** + * Opens the resource folder at `folderPath`. Missing files are treated as empty. + * @throws Error when a file exists but is not valid JSON + */ +export function openResourceFolder(folderPath: string, options: OpenResourceFolderOptions = {}): ResourceFolder { + return new FileResourceFolder(folderPath, options.baseLocale ?? 'en'); +} + +/** Own-property check, so keys like "constructor" are not mistaken for entries (lib es2020 has no Object.hasOwn). */ +function hasOwn(target: object, key: string): boolean { + return Object.getOwnPropertyDescriptor(target, key) !== undefined; +} + +class FileResourceFolder implements ResourceFolder { + readonly entriesPath: string; + readonly metaPath: string; + private readonly entries: ResourceEntries; + private readonly meta: TrackerMetadata; + private entriesExist: boolean; + private metaExists: boolean; + + constructor( + readonly folderPath: string, + private readonly baseLocale: string, + ) { + this.entriesPath = join(folderPath, RESOURCE_ENTRIES_FILENAME); + this.metaPath = join(folderPath, TRACKER_META_FILENAME); + this.entriesExist = existsSync(this.entriesPath); + this.metaExists = existsSync(this.metaPath); + this.entries = this.entriesExist ? readResourceEntries(this.entriesPath) : {}; + this.meta = this.metaExists ? readTrackerMetadata(this.metaPath) : {}; + } + + has(key: string): boolean { + return hasOwn(this.entries, key); + } + + get(key: string): ResourceFolderEntry | undefined { + if (!this.has(key)) return undefined; + return { entry: this.entries[key], meta: hasOwn(this.meta, key) ? this.meta[key] : undefined }; + } + + keys(): string[] { + return Object.keys(this.entries); + } + + isEmpty(): boolean { + return this.keys().length === 0; + } + + treeEntry(key: string): ResourceTreeEntry | undefined { + const stored = this.get(key); + if (!stored?.meta) return undefined; + const { entry, meta } = stored; + + const translations: Record = {}; + for (const locale of translationLocales(entry)) { + translations[locale] = entry[locale] as string; + } + + return { + key, + source: entry.source, + translations, + metadata: meta, + ...(entry.comment !== undefined && { comment: entry.comment }), + ...(entry.tags !== undefined && entry.tags.length > 0 && { tags: entry.tags }), + }; + } + + setBase(key: string, value: string): boolean { + const checksum = calculateChecksum(value); + const entry = this.has(key) ? this.entries[key] : undefined; + const entryMeta = this.metaOf(key); + const previousChecksum = entryMeta[this.baseLocale]?.checksum; + + if (entry && entry.source === value && previousChecksum === checksum) { + return false; + } + + // The base changed when an existing entry gets a different value, or when the stored checksum + // disagrees with the (hand-edited) stored value. A missing checksum on an unchanged value is just recorded. + const baseChanged = entry !== undefined && (entry.source !== value || previousChecksum !== undefined); + + if (entry) { + entry.source = value; + } else { + this.entries[key] = { source: value }; + } + + this.meta[key] = baseChanged + ? applyBaseChange(entryMeta, this.baseLocale, checksum) + : { ...entryMeta, [this.baseLocale]: { ...entryMeta[this.baseLocale], checksum } }; + + return true; + } + + setDetails(key: string, details: EntryDetails): boolean { + const entry = this.requireEntry(key); + let changed = false; + + if (details.comment === null && entry.comment !== undefined) { + delete entry.comment; + changed = true; + } else if (typeof details.comment === 'string' && entry.comment !== details.comment) { + entry.comment = details.comment; + changed = true; + } + + const removeTags = details.tags === null || details.tags?.length === 0; + if (removeTags && entry.tags !== undefined) { + delete entry.tags; + changed = true; + } else if (details.tags && !removeTags && !sameTags(entry.tags, details.tags)) { + entry.tags = [...details.tags]; + changed = true; + } + + return changed; + } + + setTranslation(key: string, locale: string, value: string, status: TranslationStatus = 'translated'): void { + if (locale === this.baseLocale) { + throw new Error(`Cannot set a translation for the base locale "${locale}"; use setBase`); + } + const entry = this.requireEntry(key); + entry[locale] = value; + + const entryMeta = this.metaOf(key); + const baseChecksum = entryMeta[this.baseLocale]?.checksum ?? calculateChecksum(entry.source); + this.meta[key] = recordTranslation(entryMeta, locale, calculateChecksum(value), baseChecksum, status); + } + + setStatus( + key: string, + locale: string, + status: TranslationStatus, + options: { readonly refreshBaseChecksum?: boolean } = {}, + ): void { + const entryMeta = this.metaOf(key); + const localeMeta = entryMeta[locale]; + if (!localeMeta) { + throw new Error(`No metadata for locale "${locale}" of resource "${key}"`); + } + localeMeta.status = status; + if (options.refreshBaseChecksum) { + localeMeta.baseChecksum = + entryMeta[this.baseLocale]?.checksum ?? calculateChecksum(this.requireEntry(key).source); + } + } + + setEntry(key: string, entry: Readonly, meta: Readonly): void { + this.entries[key] = { ...entry }; + this.meta[key] = { ...meta }; + } + + seedLocale(locale: string): number { + let seeded = 0; + for (const key of this.keys()) { + const entry = this.entries[key]; + if (typeof entry !== 'object' || entry === null || typeof entry.source !== 'string') continue; + if (typeof entry[locale] === 'string') continue; + + this.setTranslation(key, locale, entry.source, 'new'); + seeded++; + } + return seeded; + } + + dropLocale(locale: string): number { + let changedEntries = 0; + for (const key of this.keys()) { + const entry = this.entries[key]; + if (typeof entry !== 'object' || entry === null) continue; + + let changed = false; + if (locale in entry) { + delete entry[locale]; + changed = true; + } + const entryMeta = this.meta[key]; + if (entryMeta && locale in entryMeta) { + delete entryMeta[locale]; + changed = true; + } + if (changed) changedEntries++; + } + return changedEntries; + } + + remove(key: string): boolean { + if (!this.has(key)) return false; + delete this.entries[key]; + delete this.meta[key]; + return true; + } + + save(options: { readonly dryRun?: boolean } = {}): ResourceFolderSaveResult { + const dryRun = options.dryRun ?? false; + + if (this.isEmpty()) { + const removed = [...(this.entriesExist ? [this.entriesPath] : []), ...(this.metaExists ? [this.metaPath] : [])]; + if (!dryRun) { + for (const filePath of removed) unlinkSync(filePath); + this.entriesExist = false; + this.metaExists = false; + } + return { written: [], created: [], removed }; + } + + const created = [...(this.entriesExist ? [] : [this.entriesPath]), ...(this.metaExists ? [] : [this.metaPath])]; + if (!dryRun) { + writeJsonFile({ filePath: this.entriesPath, data: this.entries, ensureDirectory: true }); + writeJsonFile({ filePath: this.metaPath, data: this.meta }); + this.entriesExist = true; + this.metaExists = true; + } + return { written: [this.entriesPath, this.metaPath], created, removed: [] }; + } + + private metaOf(key: string): ResourceEntryMetadata { + return hasOwn(this.meta, key) ? this.meta[key] : {}; + } + + private requireEntry(key: string): ResourceEntry { + if (!this.has(key)) { + throw new Error(`Resource entry not found: ${key}`); + } + return this.entries[key]; + } +} + +function sameTags(current: readonly string[] | undefined, next: readonly string[]): boolean { + if (!current) return false; + return current.length === next.length && current.every((tag, index) => tag === next[index]); +} diff --git a/libs/core/src/lib/resource/search.ts b/libs/core/src/lib/resource/search.ts index b564129c..7c1ccb3b 100644 --- a/libs/core/src/lib/resource/search.ts +++ b/libs/core/src/lib/resource/search.ts @@ -1,8 +1,5 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { walkFolders } from '../normalize/iterative-folder-walker'; -import type { ResourceEntry } from '../../resource/resource-entry'; -import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; +import { openResourceFolder, translationLocales } from './resource-folder'; import type { TranslationStatus } from '@simoncodes-ca/domain'; import type { ResourceTreeNode } from './load-resource-tree'; @@ -92,25 +89,14 @@ export function searchTranslations(params: SearchParams): SearchResult[] { const results: SearchResult[] = []; for (const visit of walkFolders(translationsFolder, { skipHidden: false })) { - const entriesFile = join(visit.absolutePath, 'resource_entries.json'); - const metaFile = join(visit.absolutePath, 'tracker_meta.json'); - - if (!existsSync(entriesFile)) { - continue; - } - try { - const entriesData = readFileSync(entriesFile, 'utf-8'); - const entries: Record = JSON.parse(entriesData); - - let metadata: Record = {}; - if (existsSync(metaFile)) { - const metaData = readFileSync(metaFile, 'utf-8'); - metadata = JSON.parse(metaData); - } + const folder = openResourceFolder(visit.absolutePath); // Search each entry - for (const [entryKey, entry] of Object.entries(entries)) { + for (const entryKey of folder.keys()) { + const stored = folder.get(entryKey); + if (!stored) continue; + const { entry } = stored; const fullKey = visit.keyPrefix ? `${visit.keyPrefix}.${entryKey}` : entryKey; const normalizedKey = fullKey.toLowerCase(); @@ -139,44 +125,29 @@ export function searchTranslations(params: SearchParams): SearchResult[] { } // Search all other locale translations - for (const [locale, value] of Object.entries(entry)) { - // Skip non-string properties (comment, tags, source) - if (locale === 'comment' || locale === 'tags' || locale === 'source') { - continue; - } - - if (typeof value === 'string') { - const normalizedValue = value.toLowerCase(); - if (normalizedValue === normalizedQuery) { - matchType = 'exact-value'; - matchedLocales.push(locale); - } else if (normalizedValue.includes(normalizedQuery)) { - if (matchType !== 'exact-value') { - matchType = 'partial-value'; - } - matchedLocales.push(locale); + for (const locale of translationLocales(entry)) { + const normalizedValue = (entry[locale] as string).toLowerCase(); + if (normalizedValue === normalizedQuery) { + matchType = 'exact-value'; + matchedLocales.push(locale); + } else if (normalizedValue.includes(normalizedQuery)) { + if (matchType !== 'exact-value') { + matchType = 'partial-value'; } + matchedLocales.push(locale); } } } // Add to results if match found if (matchType) { - const meta = metadata[entryKey] || {}; + const meta = stored.meta ?? {}; const status: Record = {}; const translations: Record = {}; - // Extract all locale translations (excluding special fields) - for (const locale in entry) { - if (locale === 'comment' || locale === 'tags' || locale === 'source') { - continue; - } - - const value = entry[locale]; - if (typeof value === 'string') { - translations[locale] = value; - status[locale] = meta[locale]?.status; - } + for (const locale of translationLocales(entry)) { + translations[locale] = entry[locale] as string; + status[locale] = meta[locale]?.status; } // Include base locale value from source field diff --git a/libs/core/src/lib/translation/translate-existing-resource.ts b/libs/core/src/lib/translation/translate-existing-resource.ts index 52bcdc3f..56443c9d 100644 --- a/libs/core/src/lib/translation/translate-existing-resource.ts +++ b/libs/core/src/lib/translation/translate-existing-resource.ts @@ -1,9 +1,8 @@ -import { existsSync } from 'node:fs'; +import { needsTranslation } from '@simoncodes-ca/domain'; import type { TranslationConfig } from '../../config/translation-config'; import type { ResourceTreeEntry } from '../resource/load-resource-tree'; import { validateAndResolvePaths } from '../resource/resource-file-paths'; -import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../file-io/json-file-operations'; -import { calculateChecksum } from '../../resource/checksum'; +import { openResourceFolder, type ResourceFolder } from '../resource/resource-folder'; import { autoTranslateResource } from './auto-translate-resources'; export interface TranslateExistingResourceOptions { @@ -44,91 +43,50 @@ export async function translateExistingResource( const paths = validateAndResolvePaths({ key, translationsFolder, cwd }); - if (!existsSync(paths.resourceEntriesPath) || !existsSync(paths.trackerMetaPath)) { - throw new Error(`Resource not found: ${paths.resolvedKey}`); - } + const folder = openResourceFolder(paths.folderPath, { baseLocale }); + const current = folder.get(paths.entryKey); - const resourceEntries = readResourceEntries(paths.resourceEntriesPath); - const trackerMeta = readTrackerMetadata(paths.trackerMetaPath); - - if (!resourceEntries[paths.entryKey] || !trackerMeta[paths.entryKey]) { + if (!current?.meta) { throw new Error(`Resource not found: ${paths.resolvedKey}`); } - const resourceEntry = resourceEntries[paths.entryKey]; - const metaEntry = trackerMeta[paths.entryKey]; - const baseValue = resourceEntry.source; - - const targetLocales = allLocales.filter((locale) => { - if (locale === baseLocale) return false; - const localeMeta = metaEntry[locale]; - return !localeMeta || localeMeta.status === 'new' || localeMeta.status === 'stale'; - }); + const { entry, meta } = current; + const targetLocales = allLocales.filter((locale) => locale !== baseLocale && needsTranslation(meta[locale])); if (targetLocales.length === 0) { - const translations: Record = {}; - for (const [prop, value] of Object.entries(resourceEntry)) { - if (prop !== 'source' && prop !== 'tags' && prop !== 'comment' && typeof value === 'string') { - translations[prop] = value; - } - } - return { translatedCount: 0, skippedLocales: [], - entry: { - key: paths.entryKey, - source: baseValue, - translations, - metadata: metaEntry, - ...(resourceEntry.comment !== undefined && { comment: resourceEntry.comment }), - ...(resourceEntry.tags !== undefined && resourceEntry.tags.length > 0 && { tags: resourceEntry.tags }), - }, + entry: requireTreeEntry(folder, paths.entryKey, paths.resolvedKey), }; } const { translations: translatedEntries, skippedLocales } = await autoTranslateResource({ - baseValue, + baseValue: entry.source, baseLocale, targetLocales, translationConfig, }); - const baseChecksum = metaEntry[baseLocale]?.checksum ?? calculateChecksum(baseValue); - for (const { locale, value } of translatedEntries) { - resourceEntry[locale] = value; - - const newChecksum = calculateChecksum(value); - metaEntry[locale] = { - checksum: newChecksum, - baseChecksum, - status: 'translated', - }; + folder.setTranslation(paths.entryKey, locale, value, 'translated'); } if (translatedEntries.length > 0) { - writeJsonFile({ filePath: paths.resourceEntriesPath, data: resourceEntries }); - writeJsonFile({ filePath: paths.trackerMetaPath, data: trackerMeta }); - } - - const finalTranslations: Record = {}; - for (const [prop, value] of Object.entries(resourceEntry)) { - if (prop !== 'source' && prop !== 'tags' && prop !== 'comment' && typeof value === 'string') { - finalTranslations[prop] = value; - } + folder.save(); } return { translatedCount: translatedEntries.length, skippedLocales, - entry: { - key: paths.entryKey, - source: baseValue, - translations: finalTranslations, - metadata: metaEntry, - ...(resourceEntry.comment !== undefined && { comment: resourceEntry.comment }), - ...(resourceEntry.tags !== undefined && resourceEntry.tags.length > 0 && { tags: resourceEntry.tags }), - }, + entry: requireTreeEntry(folder, paths.entryKey, paths.resolvedKey), }; } + +function requireTreeEntry(folder: ResourceFolder, entryKey: string, resolvedKey: string): ResourceTreeEntry { + const treeEntry = folder.treeEntry(entryKey); + if (!treeEntry) { + throw new Error(`Resource not found: ${resolvedKey}`); + } + return treeEntry; +} diff --git a/libs/core/src/lib/translation/translate-locale.spec.ts b/libs/core/src/lib/translation/translate-locale.spec.ts index 7580587e..298fb6fc 100644 --- a/libs/core/src/lib/translation/translate-locale.spec.ts +++ b/libs/core/src/lib/translation/translate-locale.spec.ts @@ -13,6 +13,12 @@ vi.mock('../resource/extract-subtree'); vi.mock('../file-io/json-file-operations'); vi.mock('./translation-provider-factory'); vi.mock('./translation-orchestrator'); +// ResourceFolder only reads files that exist. Only the resource file pair "exists"; the mocked readers above +// supply its contents. +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + existsSync: vi.fn((filePath: unknown) => /(^|[\\/])(resource_entries|tracker_meta)\.json$/.test(String(filePath))), +})); import { loadResourceTree } from '../resource/load-resource-tree'; import { extractResourcesRecursively } from '../resource/extract-subtree'; diff --git a/libs/core/src/lib/translation/translate-locale.ts b/libs/core/src/lib/translation/translate-locale.ts index 991a0804..866c98e0 100644 --- a/libs/core/src/lib/translation/translate-locale.ts +++ b/libs/core/src/lib/translation/translate-locale.ts @@ -14,14 +14,13 @@ import * as path from 'node:path'; import { loadResourceTree } from '../resource/load-resource-tree'; import { extractResourcesRecursively } from '../resource/extract-subtree'; -import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../file-io/json-file-operations'; -import { calculateChecksum } from '../../resource/checksum'; +import { resolveResourcePaths } from '../resource/resource-file-paths'; +import { openResourceFolder } from '../resource/resource-folder'; +import { needsTranslation } from '@simoncodes-ca/domain'; import { createTranslationProvider } from './translation-provider-factory'; import { TranslationOrchestrator } from './translation-orchestrator'; import { TranslationError } from './translation-provider'; -import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; import type { TranslationConfig } from '../../config/translation-config'; -import type { ResourceTreeEntry } from '../resource/load-resource-tree'; // --------------------------------------------------------------------------- // Public interfaces @@ -73,35 +72,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** - * Returns true when a resource needs translation for `targetLocale`. - * A resource needs translation when its status is `new`, `stale`, - * or when there is no metadata at all for the locale. - */ -function needsTranslation(resource: ResourceTreeEntry, targetLocale: string): boolean { - const meta = resource.metadata[targetLocale]; - if (!meta) return true; - return meta.status === 'new' || meta.status === 'stale'; -} - -/** - * Converts a dot-delimited composite key (e.g. `apps.common.buttons.ok`) - * into the filesystem folder path (e.g. `apps/common/buttons`) and the - * entry key (`ok`). - */ -function resolveResourcePath( - compositeKey: string, - absoluteTranslationsFolder: string, -): { folderPath: string; entryKey: string } { - const segments = compositeKey.split('.'); - const entryKey = segments[segments.length - 1]; - const folderSegments = segments.slice(0, -1); - const folderPath = - folderSegments.length > 0 ? path.join(absoluteTranslationsFolder, ...folderSegments) : absoluteTranslationsFolder; - - return { folderPath, entryKey }; -} - // --------------------------------------------------------------------------- // Internal types // --------------------------------------------------------------------------- @@ -109,7 +79,6 @@ function resolveResourcePath( interface FolderWriteEntry { readonly entryKey: string; readonly result: { kind: string; value: string }; - readonly source: string; readonly resourceKey: string; } @@ -133,39 +102,23 @@ function writeTranslatedResources( writtenKeys: string[]; skippedKeys: string[]; } { - const entriesFilePath = path.join(folderPath, RESOURCE_ENTRIES_FILENAME); - const metaFilePath = path.join(folderPath, TRACKER_META_FILENAME); - - const resourceEntries = readResourceEntries(entriesFilePath, {}); - const trackerMeta = readTrackerMetadata(metaFilePath, {}); + const folder = openResourceFolder(folderPath, { baseLocale }); const writtenKeys: string[] = []; const skippedKeys: string[] = []; for (const entry of entries) { - if (!resourceEntries[entry.entryKey]) { + if (!folder.has(entry.entryKey)) { skippedKeys.push(entry.resourceKey); continue; } - (resourceEntries[entry.entryKey] as Record)[targetLocale] = entry.result.value; - - const baseChecksum = trackerMeta[entry.entryKey]?.[baseLocale]?.checksum ?? calculateChecksum(entry.source); - trackerMeta[entry.entryKey] = { - ...trackerMeta[entry.entryKey], - [targetLocale]: { - checksum: calculateChecksum(entry.result.value), - baseChecksum, - status: 'translated', - }, - }; - + folder.setTranslation(entry.entryKey, targetLocale, entry.result.value, 'translated'); writtenKeys.push(entry.resourceKey); } if (writtenKeys.length > 0) { - writeJsonFile({ filePath: entriesFilePath, data: resourceEntries }); - writeJsonFile({ filePath: metaFilePath, data: trackerMeta }); + folder.save(); } return { writtenKeys, skippedKeys }; @@ -201,7 +154,7 @@ export async function translateLocale(params: TranslateLocaleParams): Promise needsTranslation(resource, targetLocale)); + const resourcesToTranslate = allResources.filter((resource) => needsTranslation(resource.metadata[targetLocale])); if (resourcesToTranslate.length === 0) { return { @@ -261,9 +214,12 @@ export async function translateLocale(params: TranslateLocaleParams): Promise 0) { - const normalized = normalizeTags(params.tags); - if (normalized.length > 0) { - resourceEntry.tags = normalized; - } - } + const normalizedTags = normalizeTags(params.tags ?? []); // Resolve translations: prefer explicit translations, fall back to auto-translation, then nothing. // Pass the ICU-normalized base value so the translation provider receives the stored form, @@ -114,39 +95,31 @@ export async function addResource( translationConfig, }); - const resolvedTranslations = resolveResult?.translations ?? null; - - // Add translations (skip base locale - it's in 'source') — normalize values to ICU format - if (resolvedTranslations) { - resolvedTranslations.forEach(({ locale, value }) => { - // Skip base locale - its value comes from 'source' property - if (locale !== baseLocale) { - resourceEntry[locale] = translocoToICU(value); - } - }); - } - - resourceEntries[paths.entryKey] = resourceEntry; - - // Create metadata — use the already-normalized base value for checksum consistency - const trackerMeta = readTrackerMetadata(paths.trackerMetaPath, {}); - - const normalizedTranslations = resolvedTranslations?.map(({ locale, value, status }) => ({ + const normalizedTranslations = resolveResult?.translations.map(({ locale, value, status }) => ({ locale, value: translocoToICU(value), status, })); - trackerMeta[paths.entryKey] = createResourceMetadata({ - entryKey: paths.entryKey, - baseValue: normalizedBaseValue, - baseLocale, - translations: normalizedTranslations ?? undefined, - }); + // add-resource replaces the whole entry (previous translations and metadata are dropped). + // setEntry clears it in place so an existing key keeps its position in the file. + folder.setEntry(paths.entryKey, { source: normalizedBaseValue }, {}); + folder.setBase(paths.entryKey, normalizedBaseValue); + folder.setDetails(paths.entryKey, { comment: params.comment || undefined, tags: normalizedTags }); + + // Skip the base locale — its value is the entry's 'source'. + for (const { locale, value, status } of normalizedTranslations ?? []) { + if (locale === baseLocale) continue; + // Staleness rule: an untranslated copy of the base is 'new', whatever status was requested. + folder.setTranslation( + paths.entryKey, + locale, + value, + isUntranslatedCopy(value, normalizedBaseValue) ? 'new' : status, + ); + } - // Write files back - writeJsonFile({ filePath: paths.resourceEntriesPath, data: resourceEntries }); - writeJsonFile({ filePath: paths.trackerMetaPath, data: trackerMeta }); + folder.save(); return { resolvedKey: paths.resolvedKey, @@ -209,20 +182,3 @@ async function resolveTranslations( skippedLocales: autoTranslateResult.skippedLocales, }; } - -/** - * Checks if a resource entry already exists in a file. - */ -function hasEntryKey(filePath: string, entryKey: string): boolean { - if (!existsSync(filePath)) { - return false; - } - - try { - const content = readFileSync(filePath, 'utf8'); - const data = JSON.parse(content) as Record; - return entryKey in data; - } catch { - return false; - } -} diff --git a/libs/core/src/resource/delete-resource.ts b/libs/core/src/resource/delete-resource.ts index dd365f96..41c3effc 100644 --- a/libs/core/src/resource/delete-resource.ts +++ b/libs/core/src/resource/delete-resource.ts @@ -1,8 +1,7 @@ -import { existsSync, unlinkSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { resolveResourcePaths } from '../lib/resource/resource-file-paths'; -import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../lib/file-io/json-file-operations'; +import { openResourceFolder } from '../lib/resource/resource-folder'; import { validateKey } from '@simoncodes-ca/domain'; -import type { TrackerMetadata } from './tracker-metadata'; export interface DeleteResourceParams { keys: string[]; @@ -56,35 +55,14 @@ function deleteSingleResource(translationsFolder: string, key: string): boolean throw new Error(`Resource file not found: ${paths.resourceEntriesPath}`); } - const resourceEntries = readResourceEntries(paths.resourceEntriesPath); + const folder = openResourceFolder(paths.folderPath); - if (!(paths.entryKey in resourceEntries)) { + if (!folder.remove(paths.entryKey)) { throw new Error(`Resource entry not found: ${key}`); } - delete resourceEntries[paths.entryKey]; - - // Load and update metadata - let trackerMeta: TrackerMetadata = {}; - if (existsSync(paths.trackerMetaPath)) { - trackerMeta = readTrackerMetadata(paths.trackerMetaPath); - delete trackerMeta[paths.entryKey]; - } - - const isEmpty = Object.keys(resourceEntries).length === 0; - - if (isEmpty) { - unlinkSync(paths.resourceEntriesPath); - if (existsSync(paths.trackerMetaPath)) { - unlinkSync(paths.trackerMetaPath); - } - } else { - writeJsonFile({ - filePath: paths.resourceEntriesPath, - data: resourceEntries, - }); - writeJsonFile({ filePath: paths.trackerMetaPath, data: trackerMeta }); - } + // Removes both files when this was the folder's last entry. + folder.save(); return true; } diff --git a/libs/core/src/resource/edit-resource.ts b/libs/core/src/resource/edit-resource.ts index 5035794a..41d63ad6 100644 --- a/libs/core/src/resource/edit-resource.ts +++ b/libs/core/src/resource/edit-resource.ts @@ -1,14 +1,9 @@ -import { existsSync } from 'node:fs'; -import { calculateChecksum } from './checksum'; import { validateAndResolvePaths } from '../lib/resource/resource-file-paths'; -import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../lib/file-io/json-file-operations'; -import { updateMetadataForBaseValueChange } from '../lib/resource/metadata-operations'; +import { openResourceFolder, type ResourceFolder } from '../lib/resource/resource-folder'; import type { ResourceTreeEntry } from '../lib/resource/load-resource-tree'; import type { TranslationConfig } from '../config/translation-config'; import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; import type { TranslationStatus } from '@simoncodes-ca/domain'; -import type { ResourceEntry } from './resource-entry'; -import type { ResourceEntryMetadata } from './resource-entry-metadata'; import { translocoToICU, normalizeTags } from '@simoncodes-ca/domain'; export interface EditResourceOptions { @@ -61,92 +56,55 @@ export async function editResource( cwd, }); - if (!existsSync(paths.resourceEntriesPath) || !existsSync(paths.trackerMetaPath)) { - throw new Error(`Resource not found: ${paths.resolvedKey}`); - } - - const resourceEntries = readResourceEntries(paths.resourceEntriesPath); - const trackerMeta = readTrackerMetadata(paths.trackerMetaPath); + const folder = openResourceFolder(paths.folderPath, { baseLocale }); + const current = folder.get(paths.entryKey); - if (!resourceEntries[paths.entryKey] || !trackerMeta[paths.entryKey]) { + if (!current?.meta) { throw new Error(`Resource not found: ${paths.resolvedKey}`); } - const resourceEntry = resourceEntries[paths.entryKey]; - let metaEntry = trackerMeta[paths.entryKey]; + const key = paths.entryKey; + // Live view of the stored entry: it reflects every change made through `folder`. + const { entry } = current; let hasChanges = false; - let baseValueDidChange = false; - // 1. Update Base Value — normalize to ICU format before comparing and storing + // 1. Update Base Value — normalize to ICU format before comparing and storing. + // setBase applies the Staleness rule to every translation. const normalizedBaseValue = options.baseValue !== undefined ? translocoToICU(options.baseValue) : undefined; + const baseValueDidChange = normalizedBaseValue !== undefined && normalizedBaseValue !== entry.source; - if (normalizedBaseValue !== undefined && normalizedBaseValue !== resourceEntry.source) { - resourceEntry.source = normalizedBaseValue; - - metaEntry = updateMetadataForBaseValueChange({ - metadata: metaEntry, - newBaseValue: normalizedBaseValue, - baseLocale, - }); - - trackerMeta[paths.entryKey] = metaEntry; + if (baseValueDidChange) { + folder.setBase(key, normalizedBaseValue); hasChanges = true; - baseValueDidChange = true; } // 2. Update Comment - if (options.comment !== undefined && options.comment !== resourceEntry.comment) { - resourceEntry.comment = options.comment; + if (options.comment !== undefined && folder.setDetails(key, { comment: options.comment })) { hasChanges = true; } // 3. Update Tags - if (options.tags !== undefined) { - const currentTags = resourceEntry.tags || []; - const newTags = normalizeTags(options.tags); - const isDifferent = - currentTags.length !== newTags.length || !currentTags.every((tag, index) => tag === newTags[index]); - - if (isDifferent) { - resourceEntry.tags = newTags.length > 0 ? newTags : undefined; - hasChanges = true; - } + if (options.tags !== undefined && folder.setDetails(key, { tags: normalizeTags(options.tags) })) { + hasChanges = true; } // 4. Update Locales if (options.locales) { - const currentBaseChecksum = metaEntry[baseLocale]?.checksum; - - Object.entries(options.locales).forEach(([locale, { value, status }]) => { - if (locale === baseLocale) return; // Base value handled separately + for (const [locale, { value, status }] of Object.entries(options.locales)) { + if (locale === baseLocale) continue; // Base value handled separately const normalizedLocaleValue = translocoToICU(value); - const currentValue = resourceEntry[locale]; const resolvedStatus = status ?? 'translated'; - const valueChanged = normalizedLocaleValue !== currentValue; - const statusChanged = metaEntry[locale]?.status !== resolvedStatus; + const localeMeta = folder.get(key)?.meta?.[locale]; - if (valueChanged) { - resourceEntry[locale] = normalizedLocaleValue; - const newChecksum = calculateChecksum(normalizedLocaleValue); - - if (!metaEntry[locale]) { - metaEntry[locale] = { - checksum: newChecksum, - baseChecksum: currentBaseChecksum, - status: resolvedStatus, - }; - } else { - metaEntry[locale].checksum = newChecksum; - metaEntry[locale].baseChecksum = currentBaseChecksum; - metaEntry[locale].status = resolvedStatus; - } + if (normalizedLocaleValue !== entry[locale]) { + folder.setTranslation(key, locale, normalizedLocaleValue, resolvedStatus); hasChanges = true; - } else if (statusChanged && metaEntry[locale]) { - metaEntry[locale].status = resolvedStatus; + } else if (localeMeta && localeMeta.status !== resolvedStatus) { + folder.setStatus(key, locale, resolvedStatus); hasChanges = true; } - }); + } } if (!hasChanges) { @@ -157,64 +115,49 @@ export async function editResource( }; } - // Persist the changes before attempting auto-translation. This ensures the + // Two-phase write: persist the edit before attempting auto-translation, so the // base value update is durable even if the translation API call fails. - writeJsonFile({ filePath: paths.resourceEntriesPath, data: resourceEntries }); - writeJsonFile({ filePath: paths.trackerMetaPath, data: trackerMeta }); + folder.save(); // 5. Auto-translate when base value changed and translation is configured let autoTranslateSkippedLocales: string[] | undefined; - if (baseValueDidChange && normalizedBaseValue !== undefined) { - const updatedEntry = await applyAutoTranslationsAfterBaseValueChange({ - resourceEntry, - metaEntry, + if (baseValueDidChange) { + const autoTranslateResult = await applyAutoTranslationsAfterBaseValueChange({ + folder, + key, baseValue: normalizedBaseValue, baseLocale, allLocales: options.allLocales, translationConfig: options.translationConfig, }); - if (updatedEntry.skippedLocales.length > 0) { - autoTranslateSkippedLocales = updatedEntry.skippedLocales; + if (autoTranslateResult.skippedLocales.length > 0) { + autoTranslateSkippedLocales = autoTranslateResult.skippedLocales; } - if (updatedEntry.didTranslate) { - // Persist translated values - resourceEntries[paths.entryKey] = resourceEntry; - trackerMeta[paths.entryKey] = metaEntry; - writeJsonFile({ filePath: paths.resourceEntriesPath, data: resourceEntries }); - writeJsonFile({ filePath: paths.trackerMetaPath, data: trackerMeta }); + if (autoTranslateResult.didTranslate) { + // Second phase: persist the translated values. + folder.save(); } } - const translations: Record = {}; - for (const [prop, value] of Object.entries(resourceEntry)) { - if (prop !== 'source' && prop !== 'tags' && prop !== 'comment' && typeof value === 'string') { - translations[prop] = value; - } + const updatedEntry = folder.treeEntry(key); + if (!updatedEntry) { + throw new Error(`Resource not found: ${paths.resolvedKey}`); } - const entry: ResourceTreeEntry = { - key: paths.entryKey, - source: resourceEntry.source, - translations, - metadata: metaEntry, - ...(resourceEntry.comment !== undefined && { comment: resourceEntry.comment }), - ...(resourceEntry.tags !== undefined && resourceEntry.tags.length > 0 && { tags: resourceEntry.tags }), - }; - return { resolvedKey: paths.resolvedKey, updated: true, - entry, + entry: updatedEntry, ...(autoTranslateSkippedLocales !== undefined && { skippedLocales: autoTranslateSkippedLocales }), }; } interface ApplyAutoTranslationsParams { - resourceEntry: ResourceEntry; - metaEntry: ResourceEntryMetadata; + readonly folder: ResourceFolder; + readonly key: string; readonly baseValue: string; readonly baseLocale: string; readonly allLocales: string[] | undefined; @@ -227,8 +170,8 @@ interface ApplyAutoTranslationsResult { } /** - * Translates the updated base value to all non-base locales and mutates - * `resourceEntry` and `metaEntry` in place with the results. + * Translates the updated base value to all non-base locales and records the + * results in `folder` (not saved). * * Returns `{ didTranslate: false, skippedLocales: [] }` when auto-translation is * not configured, disabled, or when no target locales are available. @@ -236,7 +179,7 @@ interface ApplyAutoTranslationsResult { async function applyAutoTranslationsAfterBaseValueChange( params: ApplyAutoTranslationsParams, ): Promise { - const { resourceEntry, metaEntry, baseValue, baseLocale, allLocales, translationConfig } = params; + const { folder, key, baseValue, baseLocale, allLocales, translationConfig } = params; if (!translationConfig?.enabled || !allLocales || allLocales.length === 0) { return { didTranslate: false, skippedLocales: [] }; @@ -258,19 +201,8 @@ async function applyAutoTranslationsAfterBaseValueChange( return { didTranslate: false, skippedLocales: autoTranslateResult.skippedLocales }; } - const newBaseChecksum = calculateChecksum(baseValue); - for (const { locale, value } of autoTranslateResult.translations) { - const normalizedValue = translocoToICU(value); - resourceEntry[locale] = normalizedValue; - const translationChecksum = calculateChecksum(normalizedValue); - - metaEntry[locale] = { - ...metaEntry[locale], - checksum: translationChecksum, - baseChecksum: newBaseChecksum, - status: 'translated', - }; + folder.setTranslation(key, locale, translocoToICU(value), 'translated'); } return { didTranslate: true, skippedLocales: autoTranslateResult.skippedLocales }; diff --git a/libs/core/src/resource/move-resource.real-fs.spec.ts b/libs/core/src/resource/move-resource.real-fs.spec.ts new file mode 100644 index 00000000..c5749498 --- /dev/null +++ b/libs/core/src/resource/move-resource.real-fs.spec.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { moveResource } from './move-resource'; +import { moveFolder } from '../lib/folder/move-folder'; +import { calculateChecksum } from './checksum'; + +const md5 = calculateChecksum; + +/** + * Regression: moves used to go through addResource, which rebuilt metadata and marked every + * carried translation 'translated' — losing 'verified' and 'stale'. Moves are now lossless. + */ +describe('moving resources keeps metadata (real fs)', () => { + let root: string; + + const entries = { ok: { source: 'OK', comment: 'Button', tags: ['ui'], fr: "D'accord", es: 'Vale' } }; + const meta = { + ok: { + en: { checksum: md5('OK') }, + fr: { checksum: md5("D'accord"), baseChecksum: md5('OK'), status: 'verified' }, + es: { checksum: md5('Vale'), baseChecksum: md5('Old OK'), status: 'stale' }, + }, + }; + + function writeFolder(...segments: string[]): void { + const folder = join(root, ...segments); + mkdirSync(folder, { recursive: true }); + writeFileSync(join(folder, 'resource_entries.json'), JSON.stringify(entries)); + writeFileSync(join(folder, 'tracker_meta.json'), JSON.stringify(meta)); + } + + function read(file: 'resource_entries.json' | 'tracker_meta.json', ...segments: string[]) { + return JSON.parse(readFileSync(join(root, ...segments, file), 'utf8')); + } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'move-resource-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('moveResource carries values, details, checksums, and statuses', async () => { + writeFolder('common'); + + const result = await moveResource(root, { source: 'common.ok', destination: 'shared.buttons.confirm' }); + + expect(result).toEqual({ movedCount: 1, warnings: [], errors: [] }); + expect(read('resource_entries.json', 'shared', 'buttons')).toEqual({ confirm: entries.ok }); + expect(read('tracker_meta.json', 'shared', 'buttons')).toEqual({ confirm: meta.ok }); + // Source folder had only this entry, so both files are removed + expect(existsSync(join(root, 'common', 'resource_entries.json'))).toBe(false); + }); + + it('moveResource by pattern keeps statuses', async () => { + writeFolder('common', 'buttons'); + + await moveResource(root, { source: 'common.*', destination: 'shared' }); + + expect(read('tracker_meta.json', 'shared', 'buttons').ok.fr.status).toBe('verified'); + expect(read('tracker_meta.json', 'shared', 'buttons').ok.es.status).toBe('stale'); + }); + + it('moveResource across collections keeps statuses', async () => { + writeFolder('common'); + const otherCollection = join(root, 'other'); + + await moveResource(root, { + source: 'common.ok', + destination: 'common.ok', + destinationTranslationsFolder: otherCollection, + }); + + expect(read('tracker_meta.json', 'other', 'common')).toEqual(meta); + }); + + it('moveFolder keeps statuses', async () => { + writeFolder('apps', 'buttons'); + + const result = await moveFolder(root, { sourceFolderPath: 'apps.buttons', destinationFolderPath: 'shared' }); + + expect(result.errors).toEqual([]); + expect(read('resource_entries.json', 'shared', 'buttons')).toEqual(entries); + expect(read('tracker_meta.json', 'shared', 'buttons')).toEqual(meta); + expect(existsSync(join(root, 'apps', 'buttons'))).toBe(false); + }); + + it('does not overwrite an existing destination without override', async () => { + writeFolder('common'); + writeFolder('shared'); + + const result = await moveResource(root, { source: 'common.ok', destination: 'shared.ok' }); + + expect(result.movedCount).toBe(0); + expect(result.warnings[0]).toContain('Destination key already exists'); + expect(existsSync(join(root, 'common', 'resource_entries.json'))).toBe(true); + }); +}); diff --git a/libs/core/src/resource/move-resource.ts b/libs/core/src/resource/move-resource.ts index 4a54fb44..6f3a2ff4 100644 --- a/libs/core/src/resource/move-resource.ts +++ b/libs/core/src/resource/move-resource.ts @@ -1,11 +1,10 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import { walkFolders } from '../lib/normalize/iterative-folder-walker'; -import { addResource } from './add-resource'; import { deleteResource } from './delete-resource'; -import { resolveResourceKey, splitResolvedKey, validateKey } from '@simoncodes-ca/domain'; -import type { ResourceEntries } from './resource-entry'; -import type { TranslationStatus } from '@simoncodes-ca/domain'; +import { validateKey } from '@simoncodes-ca/domain'; +import { resolveResourcePaths } from '../lib/resource/resource-file-paths'; +import { openResourceFolder, type ResourceFolder } from '../lib/resource/resource-folder'; import { RESOURCE_ENTRIES_FILENAME } from '../constants'; export interface MoveResourceParams { @@ -61,76 +60,44 @@ async function moveSingleResource( } // 1. Check if source exists - const sourceResolved = resolveResourceKey(sourceKey); - const { folderPath: srcFolder, entryKey: srcEntryKey } = splitResolvedKey(sourceResolved); - const srcFullPath = srcFolder.length ? join(sourceTranslationsFolder, ...srcFolder) : sourceTranslationsFolder; - const srcResourcePath = resolve(srcFullPath, RESOURCE_ENTRIES_FILENAME); + const sourcePaths = resolveResourcePaths({ key: sourceKey, translationsFolder: sourceTranslationsFolder }); - if (!existsSync(srcResourcePath)) { + if (!existsSync(sourcePaths.resourceEntriesPath)) { result.errors.push(`Source resource file not found for key: ${sourceKey}`); return result; } - let sourceEntries: ResourceEntries; + let sourceFolder: ResourceFolder; try { - sourceEntries = JSON.parse(readFileSync(srcResourcePath, 'utf8')); + sourceFolder = openResourceFolder(sourcePaths.folderPath); } catch { result.errors.push(`Failed to read source file for key: ${sourceKey}`); return result; } - if (!(srcEntryKey in sourceEntries)) { + const sourceData = sourceFolder.get(sourcePaths.entryKey); + if (!sourceData) { result.errors.push(`Source key not found: ${sourceKey}`); return result; } - const sourceData = sourceEntries[srcEntryKey]; - // 2. Check if destination exists (Collision Check) - const destResolved = resolveResourceKey(destinationKey); - const { folderPath: destFolder, entryKey: destEntryKey } = splitResolvedKey(destResolved); - const destFullPath = destFolder.length - ? join(destinationTranslationsFolder, ...destFolder) - : destinationTranslationsFolder; - const destResourcePath = resolve(destFullPath, RESOURCE_ENTRIES_FILENAME); - - if (existsSync(destResourcePath)) { - try { - const destEntries: ResourceEntries = JSON.parse(readFileSync(destResourcePath, 'utf8')); - if (destEntryKey in destEntries && !override) { - result.warnings.push(`Destination key already exists: ${destinationKey}. Use override option to force move.`); - return result; - } - } catch { - // Ignore read error on dest, addResource will handle or fail - } - } - - // 3. Perform Move — carry existing translations without triggering auto-translation - const translations: Array<{ - locale: string; - value: string; - status: TranslationStatus; - }> = []; - - Object.keys(sourceData).forEach((key) => { - if (key !== 'source' && key !== 'comment' && key !== 'tags') { - translations.push({ - locale: key, - value: sourceData[key] as string, - status: 'translated', - }); - } + const destinationPaths = resolveResourcePaths({ + key: destinationKey, + translationsFolder: destinationTranslationsFolder, }); + // 3. Perform Move — a lossless copy: values, comment, tags, checksums, and statuses + // (including 'verified' and 'stale') are carried as they are. No auto-translation. try { - await addResource(destinationTranslationsFolder, { - key: destinationKey, - baseValue: sourceData.source, - comment: sourceData.comment, - tags: sourceData.tags, - translations: translations, - }); + const destinationFolder = openResourceFolder(destinationPaths.folderPath); + if (destinationFolder.has(destinationPaths.entryKey) && !override) { + result.warnings.push(`Destination key already exists: ${destinationKey}. Use override option to force move.`); + return result; + } + + destinationFolder.setEntry(destinationPaths.entryKey, sourceData.entry, sourceData.meta ?? {}); + destinationFolder.save(); } catch (error) { result.errors.push(`Failed to create destination resource: ${(error as Error).message}`); return result; @@ -186,17 +153,13 @@ async function moveResourcesByPattern( for (const visit of walkFolders(rootFolderPath, { skipHidden: false })) { const currentKeyPrefix = [cleanPrefix, visit.keyPrefix].filter(Boolean).join('.'); - const resourcePath = join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME); - if (!existsSync(resourcePath)) continue; - try { - const entries: ResourceEntries = JSON.parse(readFileSync(resourcePath, 'utf8')); - for (const key of Object.keys(entries)) { + for (const key of openResourceFolder(visit.absolutePath).keys()) { const fullKey = currentKeyPrefix ? `${currentKeyPrefix}.${key}` : key; keysToMove.push(fullKey); } - } catch (_e) { - result.errors.push(`Failed to read file at ${resourcePath}`); + } catch { + result.errors.push(`Failed to read file at ${join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME)}`); } } } else { diff --git a/libs/domain/src/index.ts b/libs/domain/src/index.ts index ec63a668..96f5e9a2 100644 --- a/libs/domain/src/index.ts +++ b/libs/domain/src/index.ts @@ -2,7 +2,7 @@ export * from './lib/escape-regexp'; export * from './lib/translation-status'; export * from './lib/locale-metadata'; export * from './lib/resource-key'; -export * from './lib/status-helpers'; +export * from './lib/staleness'; export * from './lib/icu-auto-fixer'; export * from './lib/icu-to-transloco'; export * from './lib/transloco-to-icu'; diff --git a/libs/domain/src/lib/staleness.spec.ts b/libs/domain/src/lib/staleness.spec.ts new file mode 100644 index 00000000..f05ed6a6 --- /dev/null +++ b/libs/domain/src/lib/staleness.spec.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest'; +import { + applyBaseChange, + isUntranslatedCopy, + needsTranslation, + recordTranslation, + resolveImportStatus, + type EntryLocaleMetadata, + type ResolveImportStatusParams, +} from './staleness'; + +describe('isUntranslatedCopy', () => { + it('is true when the translation equals the base', () => { + expect(isUntranslatedCopy('OK', 'OK')).toBe(true); + }); + + it('is false when the translation differs from the base', () => { + expect(isUntranslatedCopy('Accepter', 'OK')).toBe(false); + }); +}); + +describe('applyBaseChange', () => { + const entryMeta: EntryLocaleMetadata = { + en: { checksum: 'base-old' }, + fr: { checksum: 'fr-sum', baseChecksum: 'base-old', status: 'verified' }, + es: { checksum: 'es-sum', baseChecksum: 'base-old', status: 'translated' }, + de: { checksum: 'base-new', baseChecksum: 'base-old', status: 'translated' }, + }; + + it('updates the base checksum', () => { + const result = applyBaseChange(entryMeta, 'en', 'base-new'); + expect(result['en']).toEqual({ checksum: 'base-new' }); + }); + + it('marks every translated locale stale and points it at the new base, including verified ones', () => { + const result = applyBaseChange(entryMeta, 'en', 'base-new'); + expect(result['fr']).toEqual({ checksum: 'fr-sum', baseChecksum: 'base-new', status: 'stale' }); + expect(result['es']).toEqual({ checksum: 'es-sum', baseChecksum: 'base-new', status: 'stale' }); + }); + + it('marks a locale "new" when its value is an untranslated copy of the new base', () => { + const result = applyBaseChange(entryMeta, 'en', 'base-new'); + expect(result['de']).toEqual({ checksum: 'base-new', baseChecksum: 'base-new', status: 'new' }); + }); + + it('does not mutate its input', () => { + const before = JSON.stringify(entryMeta); + applyBaseChange(entryMeta, 'en', 'base-new'); + expect(JSON.stringify(entryMeta)).toBe(before); + }); + + it('creates base metadata when there was none', () => { + expect(applyBaseChange({}, 'en', 'sum')).toEqual({ en: { checksum: 'sum' } }); + }); + + it('keeps locale key order so files diff cleanly', () => { + const result = applyBaseChange(entryMeta, 'en', 'base-new'); + expect(Object.keys(result)).toEqual(['en', 'fr', 'es', 'de']); + }); +}); + +describe('recordTranslation', () => { + it('sets the locale metadata and leaves other locales alone', () => { + const entryMeta: EntryLocaleMetadata = { + en: { checksum: 'base' }, + es: { checksum: 'es', baseChecksum: 'base', status: 'verified' }, + }; + + const result = recordTranslation(entryMeta, 'fr', 'fr-sum', 'base', 'translated'); + + expect(result).toEqual({ + en: { checksum: 'base' }, + es: { checksum: 'es', baseChecksum: 'base', status: 'verified' }, + fr: { checksum: 'fr-sum', baseChecksum: 'base', status: 'translated' }, + }); + expect(entryMeta).not.toHaveProperty('fr'); + }); + + it('replaces existing locale metadata', () => { + const result = recordTranslation( + { fr: { checksum: 'old', baseChecksum: 'old-base', status: 'stale' } }, + 'fr', + 'new', + 'base', + 'verified', + ); + expect(result['fr']).toEqual({ checksum: 'new', baseChecksum: 'base', status: 'verified' }); + }); +}); + +describe('needsTranslation', () => { + it('is true when there is no metadata', () => { + expect(needsTranslation(undefined)).toBe(true); + }); + + it.each(['new', 'stale'] as const)('is true for %s', (status) => { + expect(needsTranslation({ checksum: 'x', status })).toBe(true); + }); + + it.each(['translated', 'verified'] as const)('is false for %s', (status) => { + expect(needsTranslation({ checksum: 'x', status })).toBe(false); + }); +}); + +describe('resolveImportStatus', () => { + const base: ResolveImportStatusParams = { + strategy: 'translation-service', + oldStatus: undefined, + incomingStatus: undefined, + valueChanged: true, + baseChecksumChanged: false, + }; + + it('uses an honoured incoming status over every strategy', () => { + expect(resolveImportStatus({ ...base, strategy: 'verification', incomingStatus: 'stale' })).toBe('stale'); + }); + + it('verification always verifies', () => { + expect(resolveImportStatus({ ...base, strategy: 'verification', valueChanged: false, oldStatus: 'stale' })).toBe( + 'verified', + ); + }); + + it('update keeps the previous status, defaulting to translated', () => { + expect(resolveImportStatus({ ...base, strategy: 'update', oldStatus: 'verified' })).toBe('verified'); + expect(resolveImportStatus({ ...base, strategy: 'update' })).toBe('translated'); + }); + + it.each(['translation-service', 'migration'] as const)('%s marks a changed value translated', (strategy) => { + expect(resolveImportStatus({ ...base, strategy, oldStatus: 'verified' })).toBe('translated'); + }); + + describe('unchanged value', () => { + const unchanged = { ...base, valueChanged: false }; + + it('translation-service re-confirms a stale value as translated', () => { + expect(resolveImportStatus({ ...unchanged, oldStatus: 'stale' })).toBe('translated'); + }); + + it('translation-service re-confirms when the base checksum moved', () => { + expect(resolveImportStatus({ ...unchanged, oldStatus: 'new', baseChecksumChanged: true })).toBe('translated'); + }); + + it('translation-service keeps a verified status when nothing moved', () => { + expect(resolveImportStatus({ ...unchanged, oldStatus: 'verified' })).toBe('verified'); + }); + + it('migration keeps the previous status', () => { + expect(resolveImportStatus({ ...unchanged, strategy: 'migration', oldStatus: 'stale' })).toBe('stale'); + expect(resolveImportStatus({ ...unchanged, strategy: 'migration' })).toBe('translated'); + }); + }); +}); diff --git a/libs/domain/src/lib/staleness.ts b/libs/domain/src/lib/staleness.ts new file mode 100644 index 00000000..a8ce4fc4 --- /dev/null +++ b/libs/domain/src/lib/staleness.ts @@ -0,0 +1,134 @@ +import type { LocaleMetadata } from './locale-metadata'; +import type { TranslationStatus } from './translation-status'; + +/** + * Staleness — the single home of the rules that decide a translation's + * `{ checksum, baseChecksum, status }` metadata. + * + * Every writer (add, edit, import, normalize, translate, locale seeding) goes + * through these functions so that "what happens to translations when the base + * value changes" is answered in exactly one place. + * + * Pure: no Node.js dependencies. Checksums are computed by the caller. + */ + +/** Metadata for one resource entry, keyed by locale (base locale included). */ +export type EntryLocaleMetadata = Readonly>; + +/** Import strategies. Each strategy implies a different status outcome. */ +export type ImportStrategy = 'translation-service' | 'verification' | 'migration' | 'update'; + +/** + * Returns true when a translation is an untranslated copy of the base value. + * + * Pass either both values or both checksums. A translation that is identical to + * the base is treated as "not translated yet", so its status is `new`: + * - when an entry is created with translations (`add-resource`), and + * - when the base value changes (see {@link applyBaseChange}). + */ +export function isUntranslatedCopy(translation: string, base: string): boolean { + return translation === base; +} + +/** + * The staleness rule. Applies a base value change to an entry's metadata. + * + * - The base locale's `checksum` becomes `newBaseChecksum`. + * - Every other locale's `baseChecksum` becomes `newBaseChecksum`, and its status becomes: + * - `new` when its value is an untranslated copy of the new base value + * (its `checksum` equals `newBaseChecksum`), because there is nothing to re-review; + * - `stale` otherwise, whatever the previous status was (including `verified`). + * + * Decision: the "copy of the base stays `new`" clause comes from normalize, and it is the same + * rule that `add-resource` uses at creation time. It relies on each locale's `checksum` being + * current, which every writer guarantees because it recomputes the checksum when it writes a value. + * + * Callers apply this only when the base value actually changed. + * + * @returns New metadata object; the input is not mutated. + */ +export function applyBaseChange( + entryMeta: EntryLocaleMetadata, + baseLocale: string, + newBaseChecksum: string, +): Record { + const updated: Record = {}; + + for (const [locale, localeMeta] of Object.entries(entryMeta)) { + if (locale === baseLocale) continue; + updated[locale] = { + ...localeMeta, + baseChecksum: newBaseChecksum, + status: isUntranslatedCopy(localeMeta.checksum, newBaseChecksum) ? 'new' : 'stale', + }; + } + + return { ...entryMeta, ...updated, [baseLocale]: { ...entryMeta[baseLocale], checksum: newBaseChecksum } }; +} + +/** + * Records a translation for `locale`. Returns new entry metadata with that locale set to + * `{ checksum, baseChecksum, status }`. Other locales are not changed. + * + * @param checksum - Checksum of the translated value + * @param baseChecksum - Checksum of the base value that the translation was made from + */ +export function recordTranslation( + entryMeta: EntryLocaleMetadata, + locale: string, + checksum: string, + baseChecksum: string, + status: TranslationStatus, +): Record { + return { ...entryMeta, [locale]: { checksum, baseChecksum, status } }; +} + +/** + * Returns true when a locale needs (machine or human) translation: + * there is no metadata for it, or its status is `new` or `stale`. + */ +export function needsTranslation(localeMeta: LocaleMetadata | undefined): boolean { + if (!localeMeta) return true; + return localeMeta.status === 'new' || localeMeta.status === 'stale'; +} + +export interface ResolveImportStatusParams { + /** `undefined` gets no strategy-specific handling (rules 2, 3, and 5 do not apply). */ + readonly strategy: ImportStrategy | undefined; + /** Status before the import, if the locale had metadata. */ + readonly oldStatus: TranslationStatus | undefined; + /** + * Status carried by the imported file, only when the caller has decided to honour it + * (for example `preserveStatus`). `undefined` means "use the strategy". + */ + readonly incomingStatus: TranslationStatus | undefined; + /** True when the imported value differs from the stored value (or the locale had no value). */ + readonly valueChanged: boolean; + /** + * True when the locale's stored `baseChecksum` no longer matches the current base checksum. + * Only relevant when the value is unchanged. + */ + readonly baseChecksumChanged: boolean; +} + +/** + * Resolves the status of a target-locale value written (or re-confirmed) by an import. + * + * 1. An honoured incoming status always wins. + * 2. `verification` → `verified`. + * 3. `update` → keeps the previous status (`translated` if none). + * 4. A changed value (`translation-service`, `migration`) → `translated`. + * 5. An unchanged value re-confirmed by `translation-service` → `translated` when it was + * `stale` or its base checksum moved; otherwise the previous status is kept. + * 6. Otherwise the previous status is kept (`translated` if none). + */ +export function resolveImportStatus(params: ResolveImportStatusParams): TranslationStatus { + const { strategy, oldStatus, incomingStatus, valueChanged, baseChecksumChanged } = params; + + if (incomingStatus) return incomingStatus; + if (strategy === 'verification') return 'verified'; + if (strategy === 'update') return oldStatus ?? 'translated'; + if (valueChanged) return 'translated'; + if (strategy === 'translation-service' && (oldStatus === 'stale' || baseChecksumChanged)) return 'translated'; + return oldStatus ?? 'translated'; +} diff --git a/libs/domain/src/lib/status-helpers.spec.ts b/libs/domain/src/lib/status-helpers.spec.ts deleted file mode 100644 index 4d7dfecb..00000000 --- a/libs/domain/src/lib/status-helpers.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - getInitialStatus, - getTranslatedStatus, - shouldMarkStale, - createBaseLocaleMetadata, - createTranslatedMetadata, - updateMetadataForBaseChange, -} from './status-helpers'; -import type { LocaleMetadata } from './locale-metadata'; - -describe('getInitialStatus', () => { - it('returns "new"', () => { - expect(getInitialStatus()).toBe('new'); - }); -}); - -describe('getTranslatedStatus', () => { - it('returns "translated"', () => { - expect(getTranslatedStatus()).toBe('translated'); - }); -}); - -describe('shouldMarkStale', () => { - it('returns false when no baseChecksum exists (base locale entry)', () => { - const metadata: LocaleMetadata = { checksum: 'abc123' }; - expect(shouldMarkStale(metadata, 'def456')).toBe(false); - }); - - it('returns false when the baseChecksum matches the new base checksum', () => { - const metadata: LocaleMetadata = { - checksum: 'abc123', - baseChecksum: 'base123', - }; - expect(shouldMarkStale(metadata, 'base123')).toBe(false); - }); - - it('returns true when the baseChecksum differs from the new base checksum', () => { - const metadata: LocaleMetadata = { - checksum: 'abc123', - baseChecksum: 'old-base', - }; - expect(shouldMarkStale(metadata, 'new-base')).toBe(true); - }); -}); - -describe('createBaseLocaleMetadata', () => { - it('creates metadata with only checksum — no status or baseChecksum', () => { - const metadata = createBaseLocaleMetadata('base-checksum-123'); - expect(metadata).toEqual({ checksum: 'base-checksum-123' }); - expect(metadata.status).toBeUndefined(); - expect(metadata.baseChecksum).toBeUndefined(); - }); -}); - -describe('createTranslatedMetadata', () => { - it('creates metadata with checksum, baseChecksum, and translated status', () => { - const metadata = createTranslatedMetadata('trans-checksum', 'base-checksum'); - expect(metadata).toEqual({ - checksum: 'trans-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }); - }); -}); - -describe('updateMetadataForBaseChange', () => { - it('marks entry as stale when the baseChecksum has changed', () => { - const metadata = createTranslatedMetadata('trans-old', 'base-old'); - const updated = updateMetadataForBaseChange(metadata, 'base-new'); - - expect(updated).toEqual({ - checksum: 'trans-old', - baseChecksum: 'base-new', - status: 'stale', - }); - }); - - it('leaves status unchanged when the baseChecksum has not changed', () => { - const metadata = createTranslatedMetadata('trans', 'base-same'); - const updated = updateMetadataForBaseChange(metadata, 'base-same'); - - expect(updated).toEqual({ - checksum: 'trans', - baseChecksum: 'base-same', - status: 'translated', - }); - }); - - it('marks verified entries as stale when the base changes', () => { - const metadata: LocaleMetadata = { - checksum: 'trans', - baseChecksum: 'base-old', - status: 'verified', - }; - const updated = updateMetadataForBaseChange(metadata, 'base-new'); - expect(updated.status).toBe('stale'); - }); - - it('preserves verified status when the base has not changed', () => { - const metadata: LocaleMetadata = { - checksum: 'trans', - baseChecksum: 'base-same', - status: 'verified', - }; - const updated = updateMetadataForBaseChange(metadata, 'base-same'); - expect(updated.status).toBe('verified'); - }); - - it('does not alter base locale metadata (no baseChecksum)', () => { - const metadata = createBaseLocaleMetadata('base-checksum'); - const updated = updateMetadataForBaseChange(metadata, 'new-base'); - - expect(updated).toEqual({ checksum: 'base-checksum' }); - expect(updated.status).toBeUndefined(); - }); - - describe('full lifecycle', () => { - it('progresses through new → translated → stale correctly', () => { - const baseMetadata = createBaseLocaleMetadata('base-v1'); - expect(baseMetadata.status).toBeUndefined(); - - const translatedMetadata = createTranslatedMetadata('trans', 'base-v1'); - expect(translatedMetadata.status).toBe('translated'); - - const staleMetadata = updateMetadataForBaseChange(translatedMetadata, 'base-v2'); - expect(staleMetadata.status).toBe('stale'); - expect(staleMetadata.baseChecksum).toBe('base-v2'); - }); - }); -}); diff --git a/libs/domain/src/lib/status-helpers.ts b/libs/domain/src/lib/status-helpers.ts deleted file mode 100644 index 959a6d82..00000000 --- a/libs/domain/src/lib/status-helpers.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { TranslationStatus } from './translation-status'; -import type { LocaleMetadata } from './locale-metadata'; - -/** - * Gets the initial status for a newly created resource entry. - * New entries are marked as "new". - * @returns The initial status - */ -export function getInitialStatus(): TranslationStatus { - return 'new'; -} - -/** - * Determines the status when a translation value is added/updated. - * Translated entries are marked as "translated". - * @returns The translated status - */ -export function getTranslatedStatus(): TranslationStatus { - return 'translated'; -} - -/** - * Determines if a resource should be marked as stale. - * A translation becomes stale when the base locale value changes but the translation has not been updated. - * This happens when baseChecksum changes but the translation was previously translated. - * @param currentMetadata - The current locale metadata - * @param newBaseChecksum - The new checksum of the base locale value - * @returns true if the entry should be marked stale - */ -export function shouldMarkStale(currentMetadata: LocaleMetadata, newBaseChecksum: string): boolean { - // Only mark stale if: - // 1. There's a previous baseChecksum (meaning this was a translated entry) - // 2. The new base checksum differs from the stored one - return currentMetadata.baseChecksum !== undefined && currentMetadata.baseChecksum !== newBaseChecksum; -} - -/** - * Creates metadata for a base locale entry. - * Base entries only have a checksum, no status (implicitly "source"). - * @param checksum - The MD5 checksum of the base value - * @returns Metadata object for the base locale - */ -export function createBaseLocaleMetadata(checksum: string): LocaleMetadata { - return { - checksum, - }; -} - -/** - * Creates metadata for a new translated entry. - * New translations are marked as "new" until confirmed/imported. - * @param checksum - The MD5 checksum of the translated value - * @param baseChecksum - The MD5 checksum of the base value at time of translation - * @returns Metadata object for the translated locale - */ -export function createTranslatedMetadata(checksum: string, baseChecksum: string): LocaleMetadata { - return { - checksum, - baseChecksum, - status: 'translated', - }; -} - -/** - * Updates metadata when base locale value changes. - * Marks all non-base entries as stale if their baseChecksum differs from the new base. - * @param metadata - The existing locale metadata - * @param newBaseChecksum - The new checksum of the base locale value - * @returns Updated metadata (may mark as stale) - */ -export function updateMetadataForBaseChange(metadata: LocaleMetadata, newBaseChecksum: string): LocaleMetadata { - if (shouldMarkStale(metadata, newBaseChecksum)) { - return { - ...metadata, - baseChecksum: newBaseChecksum, - status: 'stale', - }; - } - return metadata; -} From a4dcfa1b13f9b6bb06531fa3d4485d49f595da59 Mon Sep 17 00:00:00 2001 From: snodel Date: Tue, 22 Sep 2026 23:02:50 -0700 Subject: [PATCH 02/20] refactor(core): resolve collections once with loadConfig and openCollection Add a single config reader (loadConfig) and a Collection resolver (openCollection) in core that applies the collection-then-global-then- default fallback for baseLocale, locales, translation config, tags and translations folder, with typed ConfigNotFoundError, ConfigParseError, CollectionNotFoundError and ReadOnlyCollectionError. The CLI, the API and core's own collection operations now use it, removing ~40 copies of the fallback chain, three config readers and the repeated decode/404 preambles. import-workflow no longer re-reads the config file. Cross-collection moves and locale mutations now refuse a read-only destination. Co-Authored-By: Claude Fable 5.1 --- .../folders/folders.controller.spec.ts | 38 +++- .../collections/folders/folders.controller.ts | 44 ++--- .../guards/writable-collection.guard.spec.ts | 3 + .../guards/writable-collection.guard.ts | 19 +- .../locales/locales.controller.spec.ts | 20 ++ .../collections/locales/locales.controller.ts | 34 ++-- .../collections/open-route-collection.spec.ts | 40 ++++ .../app/collections/open-route-collection.ts | 53 ++++++ .../resources/resources.controller.spec.ts | 65 ++++--- .../resources/resources.controller.ts | 177 +++++------------- .../api/src/app/config/config.service.spec.ts | 60 +++--- apps/api/src/app/config/config.service.ts | 30 ++- .../src/app/mappers/resource-tree.mapper.ts | 11 +- apps/api/src/app/mappers/resource.mapper.ts | 4 +- .../src/app/mappers/search-result.mapper.ts | 9 +- .../cli/src/add-resource/add-resource.test.ts | 32 ++-- apps/cli/src/add-resource/add-resource.ts | 21 +-- apps/cli/src/commands/add-locale.spec.ts | 12 +- apps/cli/src/commands/delete-resource.ts | 2 +- apps/cli/src/commands/edit-resource.ts | 11 +- apps/cli/src/commands/export-cmd.test.ts | 34 ++-- apps/cli/src/commands/find-similar.spec.ts | 16 +- apps/cli/src/commands/find-similar.ts | 13 +- apps/cli/src/commands/glossary.spec.ts | 26 ++- apps/cli/src/commands/glossary.ts | 19 +- apps/cli/src/commands/import-cmd.spec.ts | 80 ++++---- apps/cli/src/commands/import-cmd.ts | 5 +- apps/cli/src/commands/move.ts | 9 +- apps/cli/src/commands/normalize.test.ts | 28 ++- apps/cli/src/commands/normalize.ts | 6 +- apps/cli/src/commands/remove-locale.spec.ts | 12 +- apps/cli/src/commands/remove-locale.ts | 5 +- apps/cli/src/commands/translate-locale.ts | 21 +-- apps/cli/src/commands/validate.icu.test.ts | 28 ++- apps/cli/src/commands/validate.test.ts | 28 ++- apps/cli/src/commands/validate.ts | 21 +-- .../cli/src/utils/collection-resolver.spec.ts | 18 +- apps/cli/src/utils/collection-resolver.ts | 74 ++++---- apps/cli/src/utils/config-loader.spec.ts | 162 ++++++---------- apps/cli/src/utils/config-loader.ts | 44 ++--- architecture-docs/api.md | 6 +- architecture-docs/cli.md | 18 +- architecture-docs/core-library.md | 30 ++- architecture-docs/glossary.md | 4 +- architecture-docs/user-flows.md | 6 +- .../add-locale-to-collection.spec.ts | 18 ++ .../add-locale-to-collection.ts | 24 +-- .../remove-locale-from-collection.spec.ts | 18 ++ .../remove-locale-from-collection.ts | 20 +- .../collections-manager/update-collection.ts | 12 +- .../src/lib/config/config-file-operations.ts | 5 +- libs/core/src/lib/config/index.ts | 2 + libs/core/src/lib/config/load-config.spec.ts | 94 ++++++++++ libs/core/src/lib/config/load-config.ts | 53 ++++++ .../src/lib/config/open-collection.spec.ts | 157 ++++++++++++++++ libs/core/src/lib/config/open-collection.ts | 77 ++++++++ libs/core/src/lib/errors/error-messages.ts | 2 + libs/core/src/lib/errors/index.ts | 1 + .../src/lib/errors/lingo-tracker-error.ts | 55 ++++++ .../src/lib/file-io/json-file-operations.ts | 11 -- .../import-error-handling.integration.spec.ts | 27 +-- .../import-from-json.integration.spec.ts | 20 +- .../src/lib/import/import-from-json.spec.ts | 58 ++++-- libs/core/src/lib/import/import-from-json.ts | 3 + .../src/lib/import/import-from-xliff.spec.ts | 20 +- libs/core/src/lib/import/import-from-xliff.ts | 3 + .../src/lib/import/import-summary.spec.ts | 13 ++ .../src/lib/import/import-workflow.spec.ts | 81 ++++---- libs/core/src/lib/import/import-workflow.ts | 30 +-- .../lib/import/process-resource-group.spec.ts | 4 + libs/core/src/lib/import/types.spec.ts | 2 + libs/core/src/lib/import/types.ts | 7 +- .../core/src/lib/normalize/normalize-entry.ts | 4 +- libs/core/src/lib/normalize/normalize.ts | 6 +- .../translate-existing-resource.ts | 2 +- .../src/lib/translation/translate-locale.ts | 2 +- libs/core/src/resource/add-resource.ts | 2 +- libs/core/src/resource/edit-resource.ts | 4 +- libs/core/src/resource/translation-helpers.ts | 2 +- 79 files changed, 1389 insertions(+), 848 deletions(-) create mode 100644 apps/api/src/app/collections/open-route-collection.spec.ts create mode 100644 apps/api/src/app/collections/open-route-collection.ts create mode 100644 libs/core/src/lib/config/load-config.spec.ts create mode 100644 libs/core/src/lib/config/load-config.ts create mode 100644 libs/core/src/lib/config/open-collection.spec.ts create mode 100644 libs/core/src/lib/config/open-collection.ts create mode 100644 libs/core/src/lib/errors/lingo-tracker-error.ts diff --git a/apps/api/src/app/collections/folders/folders.controller.spec.ts b/apps/api/src/app/collections/folders/folders.controller.spec.ts index ebea7769..6c35daef 100644 --- a/apps/api/src/app/collections/folders/folders.controller.spec.ts +++ b/apps/api/src/app/collections/folders/folders.controller.spec.ts @@ -1,5 +1,6 @@ +import { resolve } from 'node:path'; import { Test, type TestingModule } from '@nestjs/testing'; -import { HttpException, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, HttpException, NotFoundException } from '@nestjs/common'; import { FoldersController } from './folders.controller'; import { ConfigService } from '../../config/config.service'; import { CollectionCacheService } from '../../cache/collection-cache.service'; @@ -91,7 +92,7 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith('./translations/test', { + expect(core.moveFolder).toHaveBeenCalledWith(resolve('./translations/test'), { sourceFolderPath: 'apps.common.buttons', destinationFolderPath: 'apps.shared', override: undefined, @@ -131,7 +132,7 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith('./translations/test', { + expect(core.moveFolder).toHaveBeenCalledWith(resolve('./translations/test'), { sourceFolderPath: 'apps.buttons', destinationFolderPath: 'apps.actions', override: true, @@ -161,12 +162,12 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith('./translations/test', { + expect(core.moveFolder).toHaveBeenCalledWith(resolve('./translations/test'), { sourceFolderPath: 'apps.buttons', destinationFolderPath: 'shared.buttons', override: undefined, nestUnderDestination: undefined, - destinationTranslationsFolder: './translations/another', + destinationTranslationsFolder: resolve('./translations/another'), }); expect(result.movedCount).toBe(2); @@ -195,6 +196,27 @@ describe('FoldersController', () => { expect(core.moveFolder).not.toHaveBeenCalled(); }); + it('should throw ForbiddenException when destination collection is read-only', async () => { + jest.spyOn(_configService, 'getConfig').mockReturnValue({ + ...mockConfig, + collections: { + ...mockConfig.collections, + vendor: { translationsFolder: './translations/vendor', readOnly: true }, + }, + }); + const moveFolderDto = { + sourceFolderPath: 'apps.buttons', + destinationFolderPath: 'apps.actions', + toCollection: 'vendor', + }; + + const move = foldersController.move('test-collection', moveFolderDto); + await expect(move).rejects.toThrow(ForbiddenException); + await expect(move).rejects.toThrow('Collection "vendor" is read-only. Its resources cannot be modified.'); + + expect(core.moveFolder).not.toHaveBeenCalled(); + }); + it('should throw HttpException for validation errors (missing fields)', async () => { const moveFolderDto = { sourceFolderPath: '', @@ -308,7 +330,7 @@ describe('FoldersController', () => { await foldersController.move('test%2Dcollection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith('./translations/test', expect.any(Object)); + expect(core.moveFolder).toHaveBeenCalledWith(resolve('./translations/test'), expect.any(Object)); }); }); @@ -328,7 +350,7 @@ describe('FoldersController', () => { const result = await foldersController.create('test-collection', createFolderDto); - expect(core.createFolder).toHaveBeenCalledWith('./translations/test', { + expect(core.createFolder).toHaveBeenCalledWith(resolve('./translations/test'), { folderName: 'buttons', parentPath: 'apps.common', }); @@ -356,7 +378,7 @@ describe('FoldersController', () => { const result = await foldersController.delete('test-collection', deleteFolderDto); - expect(core.deleteFolder).toHaveBeenCalledWith('./translations/test', { + expect(core.deleteFolder).toHaveBeenCalledWith(resolve('./translations/test'), { folderPath: 'apps.common.buttons', }); diff --git a/apps/api/src/app/collections/folders/folders.controller.ts b/apps/api/src/app/collections/folders/folders.controller.ts index 4820e6c0..ae6bf592 100644 --- a/apps/api/src/app/collections/folders/folders.controller.ts +++ b/apps/api/src/app/collections/folders/folders.controller.ts @@ -22,6 +22,7 @@ import type { import { ConfigService } from '../../config/config.service'; import { CollectionCacheService } from '../../cache/collection-cache.service'; import { WritableCollectionGuard } from '../guards/writable-collection.guard'; +import { openDestinationCollection, openRouteCollection } from '../open-route-collection'; @UseGuards(WritableCollectionGuard) @Controller('collections/:collectionName/folders') @@ -37,15 +38,10 @@ export class FoldersController { @Body() createFolderDto: CreateFolderDto, ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; + const { name: decodedCollectionName, translationsFolder } = openRouteCollection( + this.configService.getConfig(), + collectionName, + ); const result = createFolder(translationsFolder, { folderName: createFolderDto.folderName, @@ -108,15 +104,10 @@ export class FoldersController { @Body() deleteFolderDto: DeleteFolderDto, ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; + const { name: decodedCollectionName, translationsFolder } = openRouteCollection( + this.configService.getConfig(), + collectionName, + ); const result = deleteFolder(translationsFolder, { folderPath: deleteFolderDto.folderPath, @@ -159,15 +150,8 @@ export class FoldersController { @Body() moveFolderDto: MoveFolderDto, ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); const config = this.configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; + const { name: decodedCollectionName, translationsFolder } = openRouteCollection(config, collectionName); if ( !moveFolderDto.sourceFolderPath || @@ -184,11 +168,9 @@ export class FoldersController { let destinationTranslationsFolder: string | undefined; let destinationCollectionName: string | undefined; if (moveFolderDto.toCollection) { - destinationCollectionName = decodeURIComponent(moveFolderDto.toCollection); - if (!config.collections || !config.collections[destinationCollectionName]) { - throw new NotFoundException(`Destination collection "${destinationCollectionName}" not found`); - } - destinationTranslationsFolder = config.collections[destinationCollectionName].translationsFolder; + const destination = openDestinationCollection(config, moveFolderDto.toCollection); + destinationCollectionName = destination.name; + destinationTranslationsFolder = destination.translationsFolder; } // Perform the move diff --git a/apps/api/src/app/collections/guards/writable-collection.guard.spec.ts b/apps/api/src/app/collections/guards/writable-collection.guard.spec.ts index 878fa49d..429c739a 100644 --- a/apps/api/src/app/collections/guards/writable-collection.guard.spec.ts +++ b/apps/api/src/app/collections/guards/writable-collection.guard.spec.ts @@ -36,6 +36,9 @@ describe('WritableCollectionGuard', () => { it('blocks mutating requests against a read-only collection', () => { expect(() => guard.canActivate(createContext('DELETE', { collectionName: 'vendor' }))).toThrow(ForbiddenException); + expect(() => guard.canActivate(createContext('DELETE', { collectionName: 'vendor' }))).toThrow( + 'Collection "vendor" is read-only. Its resources cannot be modified.', + ); }); it('decodes the collection name from the route param', () => { diff --git a/apps/api/src/app/collections/guards/writable-collection.guard.ts b/apps/api/src/app/collections/guards/writable-collection.guard.ts index 95f4aeec..8fcee3e9 100644 --- a/apps/api/src/app/collections/guards/writable-collection.guard.ts +++ b/apps/api/src/app/collections/guards/writable-collection.guard.ts @@ -1,4 +1,5 @@ import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { CollectionNotFoundError, openCollection, ReadOnlyCollectionError } from '@simoncodes-ca/core'; import { ConfigService } from '../../config/config.service'; interface CollectionRequest { @@ -10,9 +11,9 @@ interface CollectionRequest { * Blocks mutating requests (anything other than GET) against a read-only collection. * * Read requests always pass. For mutating requests, the collection named by the - * `:collectionName` route param is looked up in config; if it is flagged `readOnly`, - * a 403 is thrown. Unknown collections are allowed through so the controller can - * return its own 404. + * `:collectionName` route param is opened with core `openCollection(..., { writable: true })`; + * a `ReadOnlyCollectionError` becomes a 403. Unknown collections are allowed through so + * the controller can return its own 404. * * Apply at controller level to the resources/locales/folders controllers. Do NOT * apply to the collections controller — editing or unregistering a collection's @@ -44,10 +45,16 @@ export class WritableCollectionGuard implements CanActivate { // file read+parse on mutating requests only — negligible at this app's scale, and // not worth threading request-scoped state through every controller call site. const config = this.#configService.getConfig(); - const collection = config.collections?.[collectionName]; - if (collection?.readOnly) { - throw new ForbiddenException(`Collection "${collectionName}" is read-only. Its resources cannot be modified.`); + try { + openCollection(config, collectionName, { writable: true }); + } catch (error: unknown) { + if (error instanceof ReadOnlyCollectionError) { + throw new ForbiddenException(error.message); + } + if (!(error instanceof CollectionNotFoundError)) { + throw error; + } } return true; diff --git a/apps/api/src/app/collections/locales/locales.controller.spec.ts b/apps/api/src/app/collections/locales/locales.controller.spec.ts index 3200dbc3..ed51c9ae 100644 --- a/apps/api/src/app/collections/locales/locales.controller.spec.ts +++ b/apps/api/src/app/collections/locales/locales.controller.spec.ts @@ -126,6 +126,16 @@ describe('LocalesController', () => { expect((error as HttpException).getStatus()).toBe(500); }); + it('returns 403 when core refuses a read-only collection', async () => { + (core.addLocaleToCollection as jest.Mock).mockRejectedValue(new core.ReadOnlyCollectionError('test-collection')); + + const error = await localesController + .addLocale('test-collection', { locale: 'de' }) + .catch((e: HttpException) => e); + + expect((error as HttpException).getStatus()).toBe(403); + }); + it('does not clear cache when collection lookup fails before core is called', async () => { await localesController.addLocale('nonexistent-collection', { locale: 'de' }).catch(() => undefined); @@ -189,6 +199,16 @@ describe('LocalesController', () => { expect((error as HttpException).getStatus()).toBe(500); }); + it('returns 403 when core refuses a read-only collection', async () => { + (core.removeLocaleFromCollection as jest.Mock).mockRejectedValue( + new core.ReadOnlyCollectionError('test-collection'), + ); + + const error = await localesController.removeLocale('test-collection', 'fr').catch((e: HttpException) => e); + + expect((error as HttpException).getStatus()).toBe(403); + }); + it('does not clear cache when collection lookup fails before core is called', async () => { await localesController.removeLocale('nonexistent-collection', 'fr').catch(() => undefined); diff --git a/apps/api/src/app/collections/locales/locales.controller.ts b/apps/api/src/app/collections/locales/locales.controller.ts index 3e013046..3b44994f 100644 --- a/apps/api/src/app/collections/locales/locales.controller.ts +++ b/apps/api/src/app/collections/locales/locales.controller.ts @@ -6,14 +6,16 @@ import { Body, HttpException, HttpStatus, + ForbiddenException, NotFoundException, UseGuards, } from '@nestjs/common'; -import { addLocaleToCollection, removeLocaleFromCollection } from '@simoncodes-ca/core'; +import { addLocaleToCollection, ReadOnlyCollectionError, removeLocaleFromCollection } from '@simoncodes-ca/core'; import type { AddLocaleDto, AddLocaleResponseDto, RemoveLocaleResponseDto } from '@simoncodes-ca/data-transfer'; import { ConfigService } from '../../config/config.service'; import { CollectionCacheService } from '../../cache/collection-cache.service'; import { WritableCollectionGuard } from '../guards/writable-collection.guard'; +import { openRouteCollection } from '../open-route-collection'; @UseGuards(WritableCollectionGuard) @Controller('collections/:collectionName/locales') @@ -32,15 +34,11 @@ export class LocalesController { @Body() body: AddLocaleDto, ): Promise { try { - const config = this.#configService.getConfig(); + const { name } = openRouteCollection(this.#configService.getConfig(), collectionName); - if (!config.collections || !config.collections[collectionName]) { - throw new NotFoundException(`Collection "${collectionName}" not found`); - } - - const result = await addLocaleToCollection(collectionName, body.locale); + const result = await addLocaleToCollection(name, body.locale); - this.#cacheService.clearCache(collectionName); + this.#cacheService.clearCache(name); return result; } catch (error: unknown) { @@ -48,6 +46,11 @@ export class LocalesController { throw error; } + // WritableCollectionGuard normally refuses these first; core enforces it too. + if (error instanceof ReadOnlyCollectionError) { + throw new ForbiddenException(error.message); + } + const errorMessage = error instanceof Error ? error.message : 'Error adding locale'; if (errorMessage.includes('not found') || errorMessage.includes('not found in collection')) { @@ -72,15 +75,11 @@ export class LocalesController { @Param('locale') locale: string, ): Promise { try { - const config = this.#configService.getConfig(); + const { name } = openRouteCollection(this.#configService.getConfig(), collectionName); - if (!config.collections || !config.collections[collectionName]) { - throw new NotFoundException(`Collection "${collectionName}" not found`); - } - - const result = await removeLocaleFromCollection(collectionName, locale); + const result = await removeLocaleFromCollection(name, locale); - this.#cacheService.clearCache(collectionName); + this.#cacheService.clearCache(name); return result; } catch (error: unknown) { @@ -88,6 +87,11 @@ export class LocalesController { throw error; } + // WritableCollectionGuard normally refuses these first; core enforces it too. + if (error instanceof ReadOnlyCollectionError) { + throw new ForbiddenException(error.message); + } + const errorMessage = error instanceof Error ? error.message : 'Error removing locale'; if (errorMessage.includes('Collection') && errorMessage.includes('not found')) { diff --git a/apps/api/src/app/collections/open-route-collection.spec.ts b/apps/api/src/app/collections/open-route-collection.spec.ts new file mode 100644 index 00000000..9d3ac436 --- /dev/null +++ b/apps/api/src/app/collections/open-route-collection.spec.ts @@ -0,0 +1,40 @@ +import { NotFoundException } from '@nestjs/common'; +import { resolve } from 'node:path'; +import type { LingoTrackerConfig } from '@simoncodes-ca/core'; +import { openDestinationCollection, openRouteCollection } from './open-route-collection'; + +describe('openRouteCollection', () => { + const config: LingoTrackerConfig = { + exportFolder: 'dist/export', + importFolder: 'dist/import', + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { + 'my collection': { translationsFolder: 'src/i18n', baseLocale: 'fr', locales: ['fr', 'de'] }, + vendor: { translationsFolder: 'node_modules/x', readOnly: true }, + }, + }; + + it('decodes the route param and returns the effective collection', () => { + const collection = openRouteCollection(config, 'my%20collection'); + + expect(collection.name).toBe('my collection'); + expect(collection.translationsFolder).toBe(resolve(process.cwd(), 'src/i18n')); + expect(collection.baseLocale).toBe('fr'); + expect(collection.targetLocales).toEqual(['de']); + }); + + it('returns read-only collections (the guard refuses writes)', () => { + expect(openRouteCollection(config, 'vendor').readOnly).toBe(true); + }); + + it('throws a 404 naming the decoded collection when it is missing', () => { + expect(() => openRouteCollection(config, 'no%20such')).toThrow(NotFoundException); + expect(() => openRouteCollection(config, 'no%20such')).toThrow('Collection "no such" not found'); + }); + + it('names a missing move destination as the destination', () => { + expect(() => openDestinationCollection(config, 'gone')).toThrow('Destination collection "gone" not found'); + expect(openDestinationCollection(config, 'my%20collection').name).toBe('my collection'); + }); +}); diff --git a/apps/api/src/app/collections/open-route-collection.ts b/apps/api/src/app/collections/open-route-collection.ts new file mode 100644 index 00000000..84dcb319 --- /dev/null +++ b/apps/api/src/app/collections/open-route-collection.ts @@ -0,0 +1,53 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + type Collection, + CollectionNotFoundError, + type LingoTrackerConfig, + openCollection, + ReadOnlyCollectionError, +} from '@simoncodes-ca/core'; + +/** + * Resolves a `:collectionName` route param (URI-encoded) to its effective collection + * with core `openCollection`, or throws a 404. The only place API controllers decode a + * collection name or check that it exists. + * + * Read-only collections are returned too: `WritableCollectionGuard` refuses mutating + * requests before a controller runs. + */ +export function openRouteCollection(config: LingoTrackerConfig, routeName: string): Collection { + return openNamedCollection(config, decodeURIComponent(routeName), (name) => `Collection "${name}" not found`, false); +} + +/** + * Resolves the destination collection of a cross-collection move (a URI-encoded body + * field), or throws a 404 naming it as the destination. The destination is written to, so + * a read-only one throws a 403 (`WritableCollectionGuard` only checks the route collection). + */ +export function openDestinationCollection(config: LingoTrackerConfig, encodedName: string): Collection { + return openNamedCollection( + config, + decodeURIComponent(encodedName), + (name) => `Destination collection "${name}" not found`, + true, + ); +} + +function openNamedCollection( + config: LingoTrackerConfig, + name: string, + notFoundMessage: (name: string) => string, + writable: boolean, +): Collection { + try { + return openCollection(config, name, { writable }); + } catch (error: unknown) { + if (error instanceof CollectionNotFoundError) { + throw new NotFoundException(notFoundMessage(name)); + } + if (error instanceof ReadOnlyCollectionError) { + throw new ForbiddenException(error.message); + } + throw error; + } +} diff --git a/apps/api/src/app/collections/resources/resources.controller.spec.ts b/apps/api/src/app/collections/resources/resources.controller.spec.ts index 953bb0fd..68b2497f 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path'; import { Test, type TestingModule } from '@nestjs/testing'; import { HttpException, NotFoundException } from '@nestjs/common'; import { TranslationError } from '@simoncodes-ca/core'; @@ -174,7 +175,7 @@ describe('ResourcesController', () => { created: true, }); expect(addResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ key: 'app.button.ok', baseValue: 'OK', @@ -275,7 +276,7 @@ describe('ResourcesController', () => { await resourcesController.createResources('test-collection', dto); expect(addResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ baseLocale: 'fr-ca', }), @@ -298,7 +299,7 @@ describe('ResourcesController', () => { await resourcesController.createResources('test-collection', dto); expect(addResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ baseLocale: 'es', }), @@ -329,7 +330,7 @@ describe('ResourcesController', () => { await resourcesController.createResources('My%20Collection', dto); - expect(addResource).toHaveBeenCalledWith('./translations/my-collection', expect.any(Object)); + expect(addResource).toHaveBeenCalledWith(resolve('./translations/my-collection'), expect.any(Object)); }); it('should throw NotFoundException when collection does not exist', async () => { @@ -434,7 +435,7 @@ describe('ResourcesController', () => { created: true, }); expect(addResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ key: 'cancel', baseValue: 'Cancel', @@ -472,7 +473,7 @@ describe('ResourcesController', () => { await resourcesController.createResources('test-collection', dto); expect(addResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ translations: [ { locale: 'fr-ca', value: "D'accord", status: 'translated' }, @@ -504,7 +505,7 @@ describe('ResourcesController', () => { await resourcesController.createResources('test-collection', dto); expect(addResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ key: 'app.button.ok', baseValue: 'OK', @@ -551,7 +552,7 @@ describe('ResourcesController', () => { await resourcesController.createResources('test-collection', dto); expect(addResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ translations: [ { locale: 'fr-ca', value: 'OK', status: 'new' }, @@ -593,7 +594,7 @@ describe('ResourcesController', () => { await resourcesController.createResources('test-collection', dto); expect(addResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ translations: undefined, }), @@ -619,7 +620,7 @@ describe('ResourcesController', () => { entriesDeleted: 1, errors: undefined, }); - expect(deleteResource).toHaveBeenCalledWith('./translations/test', { + expect(deleteResource).toHaveBeenCalledWith(resolve('./translations/test'), { keys: ['app.button.ok'], }); }); @@ -641,7 +642,7 @@ describe('ResourcesController', () => { entriesDeleted: 3, errors: undefined, }); - expect(deleteResource).toHaveBeenCalledWith('./translations/test', { + expect(deleteResource).toHaveBeenCalledWith(resolve('./translations/test'), { keys: ['app.button.ok', 'app.button.cancel', 'app.button.save'], }); }); @@ -696,7 +697,7 @@ describe('ResourcesController', () => { await resourcesController.delete('My%20Collection', dto); - expect(deleteResource).toHaveBeenCalledWith('./translations/my-collection', { keys: ['app.button.ok'] }); + expect(deleteResource).toHaveBeenCalledWith(resolve('./translations/my-collection'), { keys: ['app.button.ok'] }); }); it('should throw NotFoundException when collection does not exist', async () => { @@ -776,7 +777,7 @@ describe('ResourcesController', () => { entriesDeleted: 1, errors: undefined, }); - expect(deleteResource).toHaveBeenCalledWith('./translations/test', { + expect(deleteResource).toHaveBeenCalledWith(resolve('./translations/test'), { keys: ['apps.common.buttons.ok'], }); }); @@ -803,7 +804,7 @@ describe('ResourcesController', () => { errors: [], }); expect(moveResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ source: 'app.button.ok', destination: 'app.actions.ok', @@ -832,7 +833,7 @@ describe('ResourcesController', () => { await resourcesController.move('test-collection', dto); expect(moveResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ source: 'app.button.ok', destination: 'app.actions.ok', @@ -907,11 +908,11 @@ describe('ResourcesController', () => { expect(result.movedCount).toBe(1); expect(moveResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ source: 'app.button.ok', destination: 'app.actions.ok', - destinationTranslationsFolder: './translations/other', + destinationTranslationsFolder: resolve('./translations/other'), }), ); }); @@ -934,6 +935,26 @@ describe('ResourcesController', () => { expect(result.errors).toContain('Destination collection "non-existent" not found'); expect(moveResource).not.toHaveBeenCalled(); }); + + it('should report error if destination collection is read-only', async () => { + const moveResource = core.moveResource as jest.Mock; + jest.spyOn(configService, 'getConfig').mockReturnValue({ + ...mockConfig, + collections: { + ...mockConfig.collections, + vendor: { translationsFolder: './translations/vendor', readOnly: true }, + }, + }); + + const dto = { + moves: [{ source: 'app.button.ok', destination: 'app.actions.ok', toCollection: 'vendor' }], + }; + const result = await resourcesController.move('test-collection', dto); + + expect(result.movedCount).toBe(0); + expect(result.errors).toContain('Collection "vendor" is read-only. Its resources cannot be modified.'); + expect(moveResource).not.toHaveBeenCalled(); + }); }); describe('update', () => { @@ -957,7 +978,7 @@ describe('ResourcesController', () => { message: undefined, }); expect(editResource).toHaveBeenCalledWith( - './translations/test', + resolve('./translations/test'), expect.objectContaining({ key: 'app.button.ok', baseValue: 'OK Updated', @@ -1134,7 +1155,7 @@ describe('ResourcesController', () => { await resourcesController.getTree('test-collection', '', mockResponse as any); - expect(indexCollection).toHaveBeenCalledWith('test-collection', './translations/test', 3); + expect(indexCollection).toHaveBeenCalledWith('test-collection', resolve('./translations/test'), 3); expect(mockResponse.status).toHaveBeenCalledWith(202); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -1179,7 +1200,7 @@ describe('ResourcesController', () => { await resourcesController.getTree('test-collection', '', mockResponse as any); - expect(indexCollection).toHaveBeenCalledWith('test-collection', './translations/test', 3); + expect(indexCollection).toHaveBeenCalledWith('test-collection', resolve('./translations/test'), 3); expect(mockResponse.status).toHaveBeenCalledWith(202); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -1300,7 +1321,7 @@ describe('ResourcesController', () => { status: 'not-started', collectionName: 'test-collection', }); - expect(indexCollection).toHaveBeenCalledWith('test-collection', './translations/test', 3); + expect(indexCollection).toHaveBeenCalledWith('test-collection', resolve('./translations/test'), 3); }); it('should return 404 for non-existent collection', async () => { @@ -1373,7 +1394,7 @@ describe('ResourcesController', () => { expect(searchTranslations).toHaveBeenCalledWith( expect.objectContaining({ - translationsFolder: './translations/test', + translationsFolder: resolve('./translations/test'), query: 'lingo', }), ); diff --git a/apps/api/src/app/collections/resources/resources.controller.ts b/apps/api/src/app/collections/resources/resources.controller.ts index 84034cf6..2a7664fd 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -9,6 +9,7 @@ import { Body, HttpException, HttpStatus, + ForbiddenException, NotFoundException, Res, Logger, @@ -28,6 +29,7 @@ import { extractSubtree, extractResourcesRecursively, createResourceMetadata, + type Collection, type SearchResult, type ResourceTreeEntry, } from '@simoncodes-ca/core'; @@ -58,6 +60,7 @@ import { mapSearchResultsToDto } from '../../mappers/search-result.mapper'; import { CollectionCacheService, CacheStatus } from '../../cache/collection-cache.service'; import { TranslationJobService } from '../../translation-job/translation-job.service'; import { WritableCollectionGuard } from '../guards/writable-collection.guard'; +import { openDestinationCollection, openRouteCollection } from '../open-route-collection'; @UseGuards(WritableCollectionGuard) @Controller('collections/:collectionName/resources') @@ -83,38 +86,23 @@ export class ResourcesController { @Body() dto: TranslateResourceDto, ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationConfig = collection.translation ?? config.translation; + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const { translationConfig } = collection; if (!translationConfig?.enabled) { throw new HttpException('Auto-translation is not enabled for this collection', HttpStatus.UNPROCESSABLE_ENTITY); } - const translationsFolder = collection.translationsFolder; - const baseLocale = collection.baseLocale || config.baseLocale || 'en'; - const allLocales = collection.locales ?? config.locales ?? []; - const result = await translateExistingResource({ key: dto.key, - translationsFolder, + translationsFolder: collection.translationsFolder, translationConfig, - allLocales, - baseLocale, + allLocales: collection.locales, + baseLocale: collection.baseLocale, cwd: process.cwd(), }); - this.#cacheService.addResourceToCache( - decodedCollectionName, - result.entry, - dto.key.split('.').slice(0, -1).join('.'), - ); + this.#cacheService.addResourceToCache(collection.name, result.entry, dto.key.split('.').slice(0, -1).join('.')); const resource = mapResourceEntryToSummary(result.entry, collection.tags); @@ -148,18 +136,8 @@ export class ResourcesController { @Body() body: CreateResourceDto | CreateResourceDto[], ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; - const baseLocale = collection.baseLocale || config.baseLocale || 'en'; - const locales = collection.locales ?? config.locales ?? []; - const translationConfig = collection.translation ?? config.translation; + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const { name: decodedCollectionName, translationsFolder, baseLocale, locales, translationConfig } = collection; // Normalize to array const resources = Array.isArray(body) ? body : [body]; @@ -278,12 +256,10 @@ export class ResourcesController { @Body() dto: DeleteResourceDto, ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } + const { name: decodedCollectionName, translationsFolder } = openRouteCollection( + this.#configService.getConfig(), + collectionName, + ); if (!dto.keys || !Array.isArray(dto.keys) || dto.keys.length === 0) { throw new HttpException( @@ -292,9 +268,6 @@ export class ResourcesController { ); } - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; - const result = deleteResource(translationsFolder, { keys: dto.keys }); // Clear cache after successful resource deletion @@ -326,15 +299,8 @@ export class ResourcesController { @Body() dto: MoveResourceDto, ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; + const { name: decodedCollectionName, translationsFolder } = openRouteCollection(config, collectionName); const result: MoveResourceResponseDto = { movedCount: 0, @@ -357,16 +323,19 @@ export class ResourcesController { let destinationTranslationsFolder: string | undefined; if (moveOp.toCollection) { - const destCollectionName = decodeURIComponent(moveOp.toCollection); - if (!config.collections || !config.collections[destCollectionName]) { - // We could throw here, or treat it as an error for this specific move op - // For consistency with other bulk ops, let's add it to errors and continue + let destination: Collection; + try { + destination = openDestinationCollection(config, moveOp.toCollection); + } catch (error: unknown) { + if (!(error instanceof NotFoundException || error instanceof ForbiddenException)) throw error; + // Missing or read-only destination: for consistency with other bulk ops, report it + // for this move op and continue. result.errors = result.errors || []; - result.errors.push(`Destination collection "${destCollectionName}" not found`); + result.errors.push(error.message); continue; } - destinationTranslationsFolder = config.collections[destCollectionName].translationsFolder; - affectedCollections.add(destCollectionName); + destinationTranslationsFolder = destination.translationsFolder; + affectedCollections.add(destination.name); } const moveResult = await moveResource(translationsFolder, { @@ -409,24 +378,14 @@ export class ResourcesController { @Body() dto: UpdateResourceDto, ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const decodedCollectionName = collection.name; - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; - const baseLocale = collection.baseLocale || config.baseLocale || 'en'; - const translationConfig = collection.translation ?? config.translation; - const allLocales = collection.locales ?? config.locales ?? []; - - const result = await editResource(translationsFolder, { + const result = await editResource(collection.translationsFolder, { ...dto, - baseLocale, - translationConfig, - allLocales, + baseLocale: collection.baseLocale, + translationConfig: collection.translationConfig, + allLocales: collection.locales, }); let resourceDto: ResourceSummaryDto | undefined; @@ -482,8 +441,6 @@ export class ResourcesController { @Res() response?: Response, ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); - // Support two calling styles for tests and consumers: // 1) (collectionName, path, includeNested, response) // 2) (collectionName, path, response) - tests pass response as third arg @@ -502,14 +459,8 @@ export class ResourcesController { isIncludeNested = false; } - const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const { name: decodedCollectionName, translationsFolder } = collection; // Pick up changes made outside this process (CLI commands, git checkouts, hand edits) // before trusting the cache. @@ -521,10 +472,11 @@ export class ResourcesController { // Handle cache states if (cacheStatus === CacheStatus.NOT_STARTED || cacheStatus === CacheStatus.ERROR) { // Trigger indexing asynchronously (don't await) - const locales = collection.locales ?? config.locales ?? []; - this.#cacheService.indexCollection(decodedCollectionName, translationsFolder, locales.length).catch((error) => { - this.#logger.warn(`Async indexing failed for ${decodedCollectionName}`, error); - }); + this.#cacheService + .indexCollection(decodedCollectionName, translationsFolder, collection.locales.length) + .catch((error) => { + this.#logger.warn(`Async indexing failed for ${decodedCollectionName}`, error); + }); const statusResponse: TreeStatusResponseDto = { status: 'not-ready', @@ -600,26 +552,19 @@ export class ResourcesController { @Get('cache/status') async getCacheStatus(@Param('collectionName') collectionName: string): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - this.#cacheService.revalidate(decodedCollectionName, collection.translationsFolder); + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const { name: decodedCollectionName, translationsFolder } = collection; + this.#cacheService.revalidate(decodedCollectionName, translationsFolder); const cacheStatus = this.#cacheService.getCacheStatus(decodedCollectionName); // If cache is not started, trigger indexing asynchronously if (cacheStatus === CacheStatus.NOT_STARTED) { - const translationsFolder = collection.translationsFolder; - const locales = collection.locales ?? config.locales ?? []; - - this.#cacheService.indexCollection(decodedCollectionName, translationsFolder, locales.length).catch((error) => { - this.#logger.warn(`Async indexing failed for ${decodedCollectionName}`, error); - }); + this.#cacheService + .indexCollection(decodedCollectionName, translationsFolder, collection.locales.length) + .catch((error) => { + this.#logger.warn(`Async indexing failed for ${decodedCollectionName}`, error); + }); } // Get additional cache metadata @@ -670,16 +615,8 @@ export class ResourcesController { @Query() dto: SearchTranslationsDto, ): Promise { try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; - const baseLocale = collection.baseLocale || config.baseLocale || 'en'; + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const { name: decodedCollectionName, translationsFolder, baseLocale } = collection; // Validate query if (!dto.query || dto.query.trim().length === 0) { @@ -748,23 +685,13 @@ export class ResourcesController { @Body() dto: TranslateLocaleRequestDto, @Res() response: Response, ): Promise { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationConfig = collection.translation ?? config.translation; + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const { translationConfig, baseLocale, locales: allLocales } = collection; if (!translationConfig?.enabled) { throw new HttpException('Auto-translation is not enabled for this collection', HttpStatus.UNPROCESSABLE_ENTITY); } - const baseLocale = collection.baseLocale || config.baseLocale || 'en'; - const allLocales = collection.locales ?? config.locales ?? []; - if (dto.locale === baseLocale || !allLocales.includes(dto.locale)) { throw new HttpException( `Invalid locale "${dto.locale}": must be a non-base locale defined in the collection's locales`, @@ -773,7 +700,7 @@ export class ResourcesController { } const jobId = this.#translationJobService.startJob({ - collectionName: decodedCollectionName, + collectionName: collection.name, translationsFolder: collection.translationsFolder, translationConfig, targetLocale: dto.locale, diff --git a/apps/api/src/app/config/config.service.spec.ts b/apps/api/src/app/config/config.service.spec.ts index efa3aec7..292da297 100644 --- a/apps/api/src/app/config/config.service.spec.ts +++ b/apps/api/src/app/config/config.service.spec.ts @@ -1,20 +1,17 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { NotFoundException, InternalServerErrorException } from '@nestjs/common'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { ConfigService } from './config.service'; -import * as fs from 'fs'; -import * as path from 'path'; - -jest.mock('fs'); -jest.mock('path'); -jest.mock('@simoncodes-ca/core', () => ({ - CONFIG_FILENAME: '.lingo-tracker.json', -})); - -const mockFs = fs as jest.Mocked; -const mockPath = path as jest.Mocked; describe('ConfigService', () => { let service: ConfigService; + let projectDir: string; + + function writeConfig(content: string): void { + writeFileSync(join(projectDir, '.lingo-tracker.json'), content, 'utf8'); + } beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -23,9 +20,13 @@ describe('ConfigService', () => { service = module.get(ConfigService); - // Reset mocks - jest.clearAllMocks(); - mockPath.join.mockReturnValue('/mock/path/.lingo-tracker.json'); + projectDir = mkdtempSync(join(tmpdir(), 'lingo-api-config-')); + jest.spyOn(process, 'cwd').mockReturnValue(projectDir); + }); + + afterEach(() => { + jest.restoreAllMocks(); + rmSync(projectDir, { recursive: true, force: true }); }); it('should be defined', () => { @@ -50,51 +51,42 @@ describe('ConfigService', () => { }, }; - mockFs.readFileSync.mockReturnValue(JSON.stringify(mockConfig)); + writeConfig(JSON.stringify(mockConfig)); const result = service.getConfig(); - expect(mockFs.readFileSync).toHaveBeenCalledWith('/mock/path/.lingo-tracker.json', 'utf8'); expect(result).toEqual(mockConfig); }); it('should throw NotFoundException when file does not exist', () => { - const notFoundError = new Error('ENOENT: no such file or directory'); - (notFoundError as any).code = 'ENOENT'; - mockFs.readFileSync.mockImplementation(() => { - throw notFoundError; - }); - expect(() => service.getConfig()).toThrow(NotFoundException); + expect(() => service.getConfig()).toThrow('Configuration file not found'); }); - it('should throw InternalServerErrorException when file cannot be read due to permissions', () => { - const permissionError = new Error('EACCES: permission denied'); - (permissionError as any).code = 'EACCES'; - mockFs.readFileSync.mockImplementation(() => { - throw permissionError; - }); + it('should throw InternalServerErrorException when the file cannot be read', () => { + // A directory in place of the file: it exists, but reading it fails (EISDIR). + mkdirSync(join(projectDir, '.lingo-tracker.json')); expect(() => service.getConfig()).toThrow(InternalServerErrorException); expect(() => service.getConfig()).toThrow('Failed to read configuration file'); }); it('should throw InternalServerErrorException when file contains invalid JSON', () => { - mockFs.readFileSync.mockReturnValue('invalid json content {'); + writeConfig('invalid json content {'); expect(() => service.getConfig()).toThrow(InternalServerErrorException); expect(() => service.getConfig()).toThrow('Invalid configuration file format'); }); it('should throw InternalServerErrorException when file is empty', () => { - mockFs.readFileSync.mockReturnValue(''); + writeConfig(''); expect(() => service.getConfig()).toThrow(InternalServerErrorException); expect(() => service.getConfig()).toThrow('Invalid configuration file format'); }); it('should throw InternalServerErrorException when file contains non-JSON content', () => { - mockFs.readFileSync.mockReturnValue('This is not JSON at all'); + writeConfig('This is not JSON at all'); expect(() => service.getConfig()).toThrow(InternalServerErrorException); expect(() => service.getConfig()).toThrow('Invalid configuration file format'); @@ -113,7 +105,7 @@ describe('ConfigService', () => { }, }; - mockFs.readFileSync.mockReturnValue(JSON.stringify(minimalConfig)); + writeConfig(JSON.stringify(minimalConfig)); const result = service.getConfig(); @@ -142,7 +134,7 @@ describe('ConfigService', () => { }, }; - mockFs.readFileSync.mockReturnValue(JSON.stringify(configWithOverrides)); + writeConfig(JSON.stringify(configWithOverrides)); const result = service.getConfig(); @@ -158,7 +150,7 @@ describe('ConfigService', () => { collections: {}, }; - mockFs.readFileSync.mockReturnValue(JSON.stringify(configWithEmptyCollections)); + writeConfig(JSON.stringify(configWithEmptyCollections)); const result = service.getConfig(); diff --git a/apps/api/src/app/config/config.service.ts b/apps/api/src/app/config/config.service.ts index dcaf618f..19d74a6a 100644 --- a/apps/api/src/app/config/config.service.ts +++ b/apps/api/src/app/config/config.service.ts @@ -1,32 +1,24 @@ import { Injectable, NotFoundException, InternalServerErrorException } from '@nestjs/common'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import type { LingoTrackerConfig } from '@simoncodes-ca/core'; -import { CONFIG_FILENAME } from '@simoncodes-ca/core'; +import { ConfigNotFoundError, ConfigParseError, type LingoTrackerConfig, loadConfig } from '@simoncodes-ca/core'; @Injectable() export class ConfigService { - private isNodeError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && 'code' in error; - } - + /** + * Reads `.lingo-tracker.json` from the server's working directory on every call (the + * file can change between requests), via core `loadConfig`, and maps its failures to + * HTTP errors. + */ getConfig(): LingoTrackerConfig { - const configPath = join(process.cwd(), CONFIG_FILENAME); - let configContent: string; - try { - configContent = readFileSync(configPath, 'utf8'); + return loadConfig({ cwd: process.cwd() }); } catch (error: unknown) { - if (this.isNodeError(error) && error.code === 'ENOENT') { + if (error instanceof ConfigNotFoundError) { throw new NotFoundException('Configuration file not found'); } + if (error instanceof ConfigParseError) { + throw new InternalServerErrorException('Invalid configuration file format'); + } throw new InternalServerErrorException('Failed to read configuration file'); } - - try { - return JSON.parse(configContent) as LingoTrackerConfig; - } catch { - throw new InternalServerErrorException('Invalid configuration file format'); - } } } diff --git a/apps/api/src/app/mappers/resource-tree.mapper.ts b/apps/api/src/app/mappers/resource-tree.mapper.ts index 1387a1e9..70da9a96 100644 --- a/apps/api/src/app/mappers/resource-tree.mapper.ts +++ b/apps/api/src/app/mappers/resource-tree.mapper.ts @@ -6,7 +6,7 @@ import type { } from '@simoncodes-ca/data-transfer'; import type { ResourceTreeNode, ResourceTreeEntry } from '@simoncodes-ca/core'; -export function mapResourceTreeToDto(node: ResourceTreeNode, collectionTags?: string[]): ResourceTreeDto { +export function mapResourceTreeToDto(node: ResourceTreeNode, collectionTags?: readonly string[]): ResourceTreeDto { return { path: node.folderPathSegments.join('.'), resources: node.resources.map((e) => mapResourceEntryToSummary(e, collectionTags)), @@ -14,7 +14,10 @@ export function mapResourceTreeToDto(node: ResourceTreeNode, collectionTags?: st }; } -export function mapResourceEntryToSummary(entry: ResourceTreeEntry, collectionTags?: string[]): ResourceSummaryDto { +export function mapResourceEntryToSummary( + entry: ResourceTreeEntry, + collectionTags?: readonly string[], +): ResourceSummaryDto { // Find base locale (the one without status/baseChecksum in metadata) let baseLocale: string | undefined; for (const [locale, meta] of Object.entries(entry.metadata)) { @@ -42,7 +45,7 @@ export function mapResourceEntryToSummary(entry: ResourceTreeEntry, collectionTa status, comment: entry.comment, tags: entry.tags, - inheritedTags: collectionTags && collectionTags.length > 0 ? collectionTags : undefined, + inheritedTags: collectionTags && collectionTags.length > 0 ? [...collectionTags] : undefined, }; } @@ -53,7 +56,7 @@ function mapFolderChildToDto( loaded: boolean; tree?: ResourceTreeNode; }, - collectionTags?: string[], + collectionTags?: readonly string[], ): FolderNodeDto { return { name: child.name, diff --git a/apps/api/src/app/mappers/resource.mapper.ts b/apps/api/src/app/mappers/resource.mapper.ts index ad51ef3a..70cadf36 100644 --- a/apps/api/src/app/mappers/resource.mapper.ts +++ b/apps/api/src/app/mappers/resource.mapper.ts @@ -1,7 +1,9 @@ import type { AddResourceParams } from '@simoncodes-ca/core'; import type { CreateResourceDto } from '@simoncodes-ca/data-transfer'; -export function mapDtoToAddResourceParams(dto: CreateResourceDto & { allLocales?: string[] }): AddResourceParams { +export function mapDtoToAddResourceParams( + dto: CreateResourceDto & { allLocales?: readonly string[] }, +): AddResourceParams { return { key: dto.key, baseValue: dto.baseValue, diff --git a/apps/api/src/app/mappers/search-result.mapper.ts b/apps/api/src/app/mappers/search-result.mapper.ts index 7bfc3f5e..ec81448e 100644 --- a/apps/api/src/app/mappers/search-result.mapper.ts +++ b/apps/api/src/app/mappers/search-result.mapper.ts @@ -5,7 +5,7 @@ import type { SearchResultDto } from '@simoncodes-ca/data-transfer'; * Maps a SearchResult from the core domain model to SearchResultDto for API responses. * The types are structurally identical, but we create explicit DTOs for API boundary clarity. */ -export function mapSearchResultToDto(searchResult: SearchResult, collectionTags?: string[]): SearchResultDto { +export function mapSearchResultToDto(searchResult: SearchResult, collectionTags?: readonly string[]): SearchResultDto { return { key: searchResult.key, translations: searchResult.translations, @@ -14,13 +14,16 @@ export function mapSearchResultToDto(searchResult: SearchResult, collectionTags? matchedLocales: searchResult.matchedLocales, comment: searchResult.comment, tags: searchResult.tags, - inheritedTags: collectionTags && collectionTags.length > 0 ? collectionTags : undefined, + inheritedTags: collectionTags && collectionTags.length > 0 ? [...collectionTags] : undefined, }; } /** * Maps an array of SearchResults to SearchResultDto array. */ -export function mapSearchResultsToDto(searchResults: SearchResult[], collectionTags?: string[]): SearchResultDto[] { +export function mapSearchResultsToDto( + searchResults: SearchResult[], + collectionTags?: readonly string[], +): SearchResultDto[] { return searchResults.map((r) => mapSearchResultToDto(r, collectionTags)); } diff --git a/apps/cli/src/add-resource/add-resource.test.ts b/apps/cli/src/add-resource/add-resource.test.ts index 9c278a5b..2672114a 100644 --- a/apps/cli/src/add-resource/add-resource.test.ts +++ b/apps/cli/src/add-resource/add-resource.test.ts @@ -186,11 +186,9 @@ describe('addResourceCommand', () => { vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); // Mock resolveWritableCollection to return collection data - vi.mocked(utils.resolveWritableCollection).mockReturnValue({ - name: 'TestCollection', - config: config.collections.TestCollection, - translationsFolderPath: '/test/translations', - }); + vi.mocked(utils.resolveWritableCollection).mockReturnValue( + core.openCollection(config, 'TestCollection', { cwd: '/test' }), + ); vi.mocked(fs.existsSync).mockReturnValue(false); @@ -247,11 +245,9 @@ describe('addResourceCommand', () => { vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); // Mock resolveWritableCollection to return collection data - vi.mocked(utils.resolveWritableCollection).mockReturnValue({ - name: 'TestCollection', - config: config.collections.TestCollection, - translationsFolderPath: '/test/translations', - }); + vi.mocked(utils.resolveWritableCollection).mockReturnValue( + core.openCollection(config, 'TestCollection', { cwd: '/test' }), + ); vi.mocked(fs.readFileSync).mockImplementation((path: string) => { if (path.includes('resource_entries.json')) { @@ -319,11 +315,9 @@ describe('addResourceCommand', () => { vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); // Mock resolveWritableCollection to return collection data - vi.mocked(utils.resolveWritableCollection).mockReturnValue({ - name: 'TestCollection', - config: config.collections.TestCollection, - translationsFolderPath: '/test/translations', - }); + vi.mocked(utils.resolveWritableCollection).mockReturnValue( + core.openCollection(config, 'TestCollection', { cwd: '/test' }), + ); vi.mocked(fs.existsSync).mockReturnValue(false); @@ -381,11 +375,9 @@ describe('addResourceCommand', () => { cwd: '/test', }); vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); - vi.mocked(utils.resolveWritableCollection).mockReturnValue({ - name: 'TestCollection', - config: config.collections.TestCollection, - translationsFolderPath: '/test/translations', - }); + vi.mocked(utils.resolveWritableCollection).mockReturnValue( + core.openCollection(config, 'TestCollection', { cwd: '/test' }), + ); originalIsTTY = process.stdout.isTTY; Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true }); logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/apps/cli/src/add-resource/add-resource.ts b/apps/cli/src/add-resource/add-resource.ts index a8d2bf82..97a0e532 100644 --- a/apps/cli/src/add-resource/add-resource.ts +++ b/apps/cli/src/add-resource/add-resource.ts @@ -1,4 +1,4 @@ -import type { LingoTrackerConfig } from '@simoncodes-ca/core'; +import type { Collection } from '@simoncodes-ca/core'; import { addResource, createDefaultTranslations, openResourceFolder, resolveResourcePaths } from '@simoncodes-ca/core'; import { type TranslationStatus, translocoToICU } from '@simoncodes-ca/domain'; import prompts from 'prompts'; @@ -37,13 +37,13 @@ export async function addResourceCommand(options: AddResourceOptions): Promise | undefined; if (!options.translations && process.stdout.isTTY) { - const collectionConfig = config.collections?.[collectionName]; - const baseLocale = collectionConfig?.baseLocale || config.baseLocale; - const locales = collectionConfig?.locales || config.locales || []; - - const nonBaseLocales = locales.filter((locale) => locale !== baseLocale); + const nonBaseLocales = collection.targetLocales; if (nonBaseLocales.length > 0) { const shouldAddTranslations = await prompts({ diff --git a/apps/cli/src/commands/add-locale.spec.ts b/apps/cli/src/commands/add-locale.spec.ts index 3ad6d9ab..32bca17f 100644 --- a/apps/cli/src/commands/add-locale.spec.ts +++ b/apps/cli/src/commands/add-locale.spec.ts @@ -20,7 +20,7 @@ vi.mock('prompts', () => ({ default: vi.fn(), })); -import { addLocaleToCollection } from '@simoncodes-ca/core'; +import { type Collection, addLocaleToCollection } from '@simoncodes-ca/core'; import { loadConfiguration, promptForCollection, resolveWritableCollection, ConsoleFormatter } from '../utils'; const BASE_CONFIG = { @@ -37,10 +37,16 @@ const LOADED_CONFIG = { cwd: '/project', }; -const RESOLVED_COLLECTION = { +const RESOLVED_COLLECTION: Collection = { name: 'main', + translationsFolder: '/project/src/i18n', + baseLocale: 'en', + locales: ['en', 'fr'], + targetLocales: ['fr'], + translationConfig: undefined, + tags: [], + readOnly: false, config: { translationsFolder: 'src/i18n' }, - translationsFolderPath: '/project/src/i18n', }; describe('addLocaleCommand', () => { diff --git a/apps/cli/src/commands/delete-resource.ts b/apps/cli/src/commands/delete-resource.ts index f48e2a52..d182285e 100644 --- a/apps/cli/src/commands/delete-resource.ts +++ b/apps/cli/src/commands/delete-resource.ts @@ -51,7 +51,7 @@ export async function deleteResourceCommand(options: DeleteResourceOptions): Pro } try { - const result = deleteResource(collection.translationsFolderPath, { keys }); + const result = deleteResource(collection.translationsFolder, { keys }); if (result.entriesDeleted === 0) { ConsoleFormatter.warning('No resources were deleted.'); diff --git a/apps/cli/src/commands/edit-resource.ts b/apps/cli/src/commands/edit-resource.ts index 055874e8..1aca827a 100644 --- a/apps/cli/src/commands/edit-resource.ts +++ b/apps/cli/src/commands/edit-resource.ts @@ -50,7 +50,7 @@ export async function editResourceCommand(options: EditResourceOptions): Promise } = { key: answers.key, cwd: resolve(cwd), - baseLocale: collection.config.baseLocale, + baseLocale: collection.baseLocale, }; if (options.targetFolder) { @@ -77,14 +77,11 @@ export async function editResourceCommand(options: EditResourceOptions): Promise ConsoleFormatter.warning('Both --locale and --localeValue must be provided to update a translation.'); } - const translationConfig = collection.config.translation ?? config.translation; - const allLocales = collection.config.locales ?? config.locales ?? []; - try { - const result = await editResource(collection.translationsFolderPath, { + const result = await editResource(collection.translationsFolder, { ...editOptions, - translationConfig, - allLocales, + translationConfig: collection.translationConfig, + allLocales: collection.locales, }); if (result.updated) { diff --git a/apps/cli/src/commands/export-cmd.test.ts b/apps/cli/src/commands/export-cmd.test.ts index 1d804934..11cc33ee 100644 --- a/apps/cli/src/commands/export-cmd.test.ts +++ b/apps/cli/src/commands/export-cmd.test.ts @@ -28,18 +28,28 @@ vi.mock('fs', async (importOriginal) => { }); vi.mock('prompts'); -vi.mock('@simoncodes-ca/core', () => ({ - CONFIG_FILENAME: '.lingo-tracker.json', - loadResourcesFromCollections: vi.fn(), - filterResources: vi.fn(), - validateOutputDirectory: vi.fn(), - validateBasePropertyName: vi.fn(), - exportToJson: vi.fn(), - exportToXliff: vi.fn(), - generateExportSummary: vi.fn(), - readGlobalProtectedTerms: vi.fn(() => []), - readCollectionProtectedTerms: vi.fn(() => []), -})); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + // Config loading and collection resolution run for real against the mocked config. + loadConfig: actual.loadConfig, + openCollection: actual.openCollection, + ConfigNotFoundError: actual.ConfigNotFoundError, + ConfigParseError: actual.ConfigParseError, + CollectionNotFoundError: actual.CollectionNotFoundError, + ReadOnlyCollectionError: actual.ReadOnlyCollectionError, + CONFIG_FILENAME: '.lingo-tracker.json', + loadResourcesFromCollections: vi.fn(), + filterResources: vi.fn(), + validateOutputDirectory: vi.fn(), + validateBasePropertyName: vi.fn(), + exportToJson: vi.fn(), + exportToXliff: vi.fn(), + generateExportSummary: vi.fn(), + readGlobalProtectedTerms: vi.fn(() => []), + readCollectionProtectedTerms: vi.fn(() => []), + }; +}); import * as core from '@simoncodes-ca/core'; const mockLoadResourcesFromCollections = vi.mocked(core.loadResourcesFromCollections); diff --git a/apps/cli/src/commands/find-similar.spec.ts b/apps/cli/src/commands/find-similar.spec.ts index ef1eba80..392c0235 100644 --- a/apps/cli/src/commands/find-similar.spec.ts +++ b/apps/cli/src/commands/find-similar.spec.ts @@ -1,9 +1,19 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { findSimilarCommand } from './find-similar'; -vi.mock('@simoncodes-ca/core', () => ({ - searchTranslations: vi.fn(), -})); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + // Config loading and collection resolution run for real against the mocked config. + loadConfig: actual.loadConfig, + openCollection: actual.openCollection, + ConfigNotFoundError: actual.ConfigNotFoundError, + ConfigParseError: actual.ConfigParseError, + CollectionNotFoundError: actual.CollectionNotFoundError, + ReadOnlyCollectionError: actual.ReadOnlyCollectionError, + searchTranslations: vi.fn(), + }; +}); vi.mock('../utils', () => ({ loadConfiguration: vi.fn(), diff --git a/apps/cli/src/commands/find-similar.ts b/apps/cli/src/commands/find-similar.ts index 2371b09b..46ced4c2 100644 --- a/apps/cli/src/commands/find-similar.ts +++ b/apps/cli/src/commands/find-similar.ts @@ -1,6 +1,5 @@ -import path from 'path'; import { loadConfiguration } from '../utils'; -import { searchTranslations } from '@simoncodes-ca/core'; +import { type Collection, CollectionNotFoundError, openCollection, searchTranslations } from '@simoncodes-ca/core'; import { normalizedLevenshtein } from '@simoncodes-ca/domain'; /** @@ -35,14 +34,16 @@ export async function findSimilarCommand(options: FindSimilarOptions): Promise ({ - loadResourcesFromCollections: vi.fn(), -})); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + // Config loading and collection resolution run for real against the mocked config. + loadConfig: actual.loadConfig, + openCollection: actual.openCollection, + ConfigNotFoundError: actual.ConfigNotFoundError, + ConfigParseError: actual.ConfigParseError, + CollectionNotFoundError: actual.CollectionNotFoundError, + ReadOnlyCollectionError: actual.ReadOnlyCollectionError, + loadResourcesFromCollections: vi.fn(), + }; +}); vi.mock('../utils', async (importOriginal) => { const actual = await importOriginal(); @@ -30,7 +40,7 @@ vi.mock('fs', async (importOriginal) => { }); import * as fs from 'fs'; -import { loadResourcesFromCollections } from '@simoncodes-ca/core'; +import { type LingoTrackerConfig, loadResourcesFromCollections, openCollection } from '@simoncodes-ca/core'; import { loadConfiguration, resolveCollection } from '../utils'; import { glossaryCommand } from './glossary'; @@ -149,11 +159,9 @@ describe('glossaryCommand', () => { }); it('resolves a single collection with --collection', async () => { - vi.mocked(resolveCollection).mockReturnValue({ - name: 'app', - config: { translationsFolder: 'i18n' }, - translationsFolderPath: '/project/i18n', - } as never); + vi.mocked(resolveCollection).mockReturnValue( + openCollection(LOADED_CONFIG.config as LingoTrackerConfig, 'app', { cwd: '/project' }), + ); await glossaryCommand({ text: 'Save', collection: 'app' }); expect(resolveCollection).toHaveBeenCalledWith('app', expect.anything(), '/project'); }); diff --git a/apps/cli/src/commands/glossary.ts b/apps/cli/src/commands/glossary.ts index 94923026..6bc73be2 100644 --- a/apps/cli/src/commands/glossary.ts +++ b/apps/cli/src/commands/glossary.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { loadResourcesFromCollections } from '@simoncodes-ca/core'; -import type { LingoTrackerCollection, LingoTrackerConfig } from '@simoncodes-ca/core'; +import { loadResourcesFromCollections, openCollection } from '@simoncodes-ca/core'; +import type { Collection, LingoTrackerConfig } from '@simoncodes-ca/core'; import { ConsoleFormatter, loadConfiguration, parseCommaSeparatedList, resolveCollection } from '../utils'; import { resolveExtractor, type CandidateExtractor, type ExtractorMode } from './glossary-extractor'; import { matchGlossary, type FlatEntry } from './glossary-matcher'; @@ -64,24 +64,21 @@ function resolveInputText(options: GlossaryCommandOptions, cwd: string): string * Returns null if a named collection cannot be resolved. */ function loadEntries(options: GlossaryCommandOptions, config: LingoTrackerConfig, cwd: string): FlatEntry[] | null { - const globalBase = config.baseLocale; - - let targets: { name: string; collection: LingoTrackerCollection }[]; + let targets: Collection[]; if (options.collection) { const resolved = resolveCollection(options.collection, config, cwd); if (!resolved) return null; - targets = [{ name: resolved.name, collection: resolved.config }]; + targets = [resolved]; } else { - targets = Object.entries(config.collections ?? {}).map(([name, collection]) => ({ name, collection })); + targets = Object.keys(config.collections ?? {}).map((name) => openCollection(config, name, { cwd })); } const entries: FlatEntry[] = []; - for (const { name, collection } of targets) { - const base = collection.baseLocale ?? globalBase; - const loaded = loadResourcesFromCollections([{ name, path: path.resolve(cwd, collection.translationsFolder) }]); + for (const { name, translationsFolder, baseLocale } of targets) { + const loaded = loadResourcesFromCollections([{ name, path: translationsFolder }]); for (const resource of loaded) { const translations = { ...resource.translations }; - if (base) delete translations[base]; + delete translations[baseLocale]; entries.push({ key: resource.fullKey, collection: resource.collection, diff --git a/apps/cli/src/commands/import-cmd.spec.ts b/apps/cli/src/commands/import-cmd.spec.ts index a20580a5..382d16d6 100644 --- a/apps/cli/src/commands/import-cmd.spec.ts +++ b/apps/cli/src/commands/import-cmd.spec.ts @@ -35,18 +35,28 @@ vi.mock('prompts', () => ({ })); // Mock the core library imports -vi.mock('@simoncodes-ca/core', () => ({ - CONFIG_FILENAME: '.lingo-tracker.json', - importFromJson: vi.fn(), - importFromXliff: vi.fn(), - detectImportFormat: vi.fn(), - generateImportSummary: vi.fn(() => '# Import Summary\n\nTest summary'), - readEffectiveProtectedTerms: vi.fn(() => []), - loadPreferredTerminology: vi.fn(() => ({ - rules: [], - filePath: '/test/project/.lingo-tracker-preferred-terminology.json', - })), -})); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + // Config loading and collection resolution run for real against the mocked config. + loadConfig: actual.loadConfig, + openCollection: actual.openCollection, + ConfigNotFoundError: actual.ConfigNotFoundError, + ConfigParseError: actual.ConfigParseError, + CollectionNotFoundError: actual.CollectionNotFoundError, + ReadOnlyCollectionError: actual.ReadOnlyCollectionError, + CONFIG_FILENAME: '.lingo-tracker.json', + importFromJson: vi.fn(), + importFromXliff: vi.fn(), + detectImportFormat: vi.fn(), + generateImportSummary: vi.fn(() => '# Import Summary\n\nTest summary'), + readEffectiveProtectedTerms: vi.fn(() => []), + loadPreferredTerminology: vi.fn(() => ({ + rules: [], + filePath: '/test/project/.lingo-tracker-preferred-terminology.json', + })), + }; +}); // Mock utilities vi.mock('../utils', () => ({ @@ -81,7 +91,15 @@ vi.mock('../utils', () => ({ })); // Import the mocked functions -import { detectImportFormat, importFromJson, importFromXliff, loadPreferredTerminology } from '@simoncodes-ca/core'; +import { + type Collection, + detectImportFormat, + importFromJson, + importFromXliff, + type LingoTrackerCollection, + loadPreferredTerminology, + openCollection, +} from '@simoncodes-ca/core'; import { ConsoleFormatter, isInteractiveTerminal, @@ -103,6 +121,10 @@ describe('import-cmd', () => { }, }; + /** What `resolveWritableCollection` resolves for this collection entry under `baseConfig`. */ + const collectionOf = (name: string, entry: LingoTrackerCollection): Collection => + openCollection({ ...baseConfig, collections: { [name]: entry } }, name, { cwd: '/test/project' }); + const baseImportResult = { resourcesImported: 10, resourcesCreated: 0, @@ -143,11 +165,9 @@ describe('import-cmd', () => { // Default collection mocks — most tests use a single 'default' collection vi.mocked(promptForCollection).mockResolvedValue('default'); - vi.mocked(resolveWritableCollection).mockReturnValue({ - name: 'default', - config: { translationsFolder: 'src/translations' }, - translationsFolderPath: '/test/project/src/translations', - }); + vi.mocked(resolveWritableCollection).mockReturnValue( + collectionOf('default', { translationsFolder: 'src/translations' }), + ); }); describe('Configuration Loading', () => { @@ -416,11 +436,9 @@ describe('import-cmd', () => { cwd: '/test/project', }); vi.mocked(promptForCollection).mockResolvedValue('admin'); - vi.mocked(resolveWritableCollection).mockReturnValue({ - name: 'admin', - config: { translationsFolder: 'src/admin-translations' }, - translationsFolderPath: '/test/project/src/admin-translations', - }); + vi.mocked(resolveWritableCollection).mockReturnValue( + collectionOf('admin', { translationsFolder: 'src/admin-translations' }), + ); vi.mocked(importFromJson).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -520,11 +538,9 @@ describe('import-cmd', () => { it("offers the collection's own locales, minus its base locale", async () => { vi.mocked(promptForCollection).mockResolvedValue('docs'); - vi.mocked(resolveWritableCollection).mockReturnValue({ - name: 'docs', - config: { translationsFolder: 'src/docs-translations', baseLocale: 'fr', locales: ['fr', 'de'] }, - translationsFolderPath: '/test/project/src/docs-translations', - }); + vi.mocked(resolveWritableCollection).mockReturnValue( + collectionOf('docs', { translationsFolder: 'src/docs-translations', baseLocale: 'fr', locales: ['fr', 'de'] }), + ); await importCommand({ source: '/test/import.json', format: 'json', strategy: 'translation-service' }); @@ -599,11 +615,9 @@ describe('import-cmd', () => { describe('collection with its own base locale', () => { beforeEach(() => { vi.mocked(promptForCollection).mockResolvedValue('docs'); - vi.mocked(resolveWritableCollection).mockReturnValue({ - name: 'docs', - config: { translationsFolder: 'src/docs-translations', baseLocale: 'fr' }, - translationsFolderPath: '/test/project/src/docs-translations', - }); + vi.mocked(resolveWritableCollection).mockReturnValue( + collectionOf('docs', { translationsFolder: 'src/docs-translations', baseLocale: 'fr' }), + ); vi.spyOn(console, 'log').mockImplementation(() => undefined); }); diff --git a/apps/cli/src/commands/import-cmd.ts b/apps/cli/src/commands/import-cmd.ts index a94b7c3f..44a90ae2 100644 --- a/apps/cli/src/commands/import-cmd.ts +++ b/apps/cli/src/commands/import-cmd.ts @@ -52,8 +52,7 @@ export async function importCommand(options: ImportCommandOptions): Promise; try { @@ -130,7 +129,7 @@ export async function importCommand(options: ImportCommandOptions): Promise 0) { diff --git a/apps/cli/src/commands/normalize.test.ts b/apps/cli/src/commands/normalize.test.ts index 029dd931..a32a7c7a 100644 --- a/apps/cli/src/commands/normalize.test.ts +++ b/apps/cli/src/commands/normalize.test.ts @@ -1,15 +1,25 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { normalizeCommand } from './normalize'; -import { normalize } from '@simoncodes-ca/core'; +import { type Collection, normalize } from '@simoncodes-ca/core'; import { loadConfiguration, resolveCollection, ConsoleFormatter } from '../utils'; vi.mock('prompts', () => ({ default: vi.fn(), })); -vi.mock('@simoncodes-ca/core', () => ({ - normalize: vi.fn(), -})); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + // Config loading and collection resolution run for real against the mocked config. + loadConfig: actual.loadConfig, + openCollection: actual.openCollection, + ConfigNotFoundError: actual.ConfigNotFoundError, + ConfigParseError: actual.ConfigParseError, + CollectionNotFoundError: actual.CollectionNotFoundError, + ReadOnlyCollectionError: actual.ReadOnlyCollectionError, + normalize: vi.fn(), + }; +}); vi.mock('../utils', () => ({ loadConfiguration: vi.fn(), @@ -37,11 +47,17 @@ const LOADED_CONFIG = { cwd: '/p', }; -function resolved(name: string, readOnly: boolean) { +function resolved(name: string, readOnly: boolean): Collection { return { name, + translationsFolder: `/p/path/${name}`, + baseLocale: 'en', + locales: ['en', 'fr'], + targetLocales: ['fr'], + translationConfig: undefined, + tags: [], + readOnly, config: { translationsFolder: `path/${name}`, ...(readOnly ? { readOnly: true } : {}) }, - translationsFolderPath: `/p/path/${name}`, }; } diff --git a/apps/cli/src/commands/normalize.ts b/apps/cli/src/commands/normalize.ts index cb3fac99..c07007de 100644 --- a/apps/cli/src/commands/normalize.ts +++ b/apps/cli/src/commands/normalize.ts @@ -74,7 +74,7 @@ export async function normalizeCommand(options: NormalizeOptions): Promise // Read-only collections cannot be normalized (it rewrites resource files). // In a bulk `--all` run, skip them without failing; when one is explicitly targeted, fail. - if (collection.config.readOnly) { + if (collection.readOnly) { if (answers.all) { if (!options.json) { console.log(''); @@ -89,9 +89,7 @@ export async function normalizeCommand(options: NormalizeOptions): Promise continue; } - const translationsFolder = collection.translationsFolderPath; - const baseLocale = collection.config.baseLocale ?? config.baseLocale; - const locales = collection.config.locales ?? config.locales; + const { translationsFolder, baseLocale, locales } = collection; if (!options.json) { console.log(''); diff --git a/apps/cli/src/commands/remove-locale.spec.ts b/apps/cli/src/commands/remove-locale.spec.ts index 051e5611..206f9bb8 100644 --- a/apps/cli/src/commands/remove-locale.spec.ts +++ b/apps/cli/src/commands/remove-locale.spec.ts @@ -20,7 +20,7 @@ vi.mock('prompts', () => ({ default: vi.fn(), })); -import { removeLocaleFromCollection } from '@simoncodes-ca/core'; +import { type Collection, removeLocaleFromCollection } from '@simoncodes-ca/core'; import { loadConfiguration, promptForCollection, resolveWritableCollection, ConsoleFormatter } from '../utils'; const BASE_CONFIG = { @@ -37,10 +37,16 @@ const LOADED_CONFIG = { cwd: '/project', }; -const RESOLVED_COLLECTION = { +const RESOLVED_COLLECTION: Collection = { name: 'main', + translationsFolder: '/project/src/i18n', + baseLocale: 'en', + locales: ['en', 'fr', 'de'], + targetLocales: ['fr', 'de'], + translationConfig: undefined, + tags: [], + readOnly: false, config: { translationsFolder: 'src/i18n', locales: ['en', 'fr', 'de'] }, - translationsFolderPath: '/project/src/i18n', }; describe('removeLocaleCommand', () => { diff --git a/apps/cli/src/commands/remove-locale.ts b/apps/cli/src/commands/remove-locale.ts index 9afbe8bd..d3e6ab69 100644 --- a/apps/cli/src/commands/remove-locale.ts +++ b/apps/cli/src/commands/remove-locale.ts @@ -18,10 +18,7 @@ export async function removeLocaleCommand(options: RemoveLocaleOptions): Promise const collection = resolveWritableCollection(collectionName, config, cwd); if (!collection) return; - const baseLocale = collection.config.baseLocale ?? config.baseLocale; - const effectiveLocales = (collection.config.locales ?? config.locales ?? []).filter( - (l) => baseLocale === undefined || l !== baseLocale, - ); + const effectiveLocales = collection.targetLocales; let locale = options.locale; diff --git a/apps/cli/src/commands/translate-locale.ts b/apps/cli/src/commands/translate-locale.ts index 820d5b96..66041734 100644 --- a/apps/cli/src/commands/translate-locale.ts +++ b/apps/cli/src/commands/translate-locale.ts @@ -59,8 +59,8 @@ export async function translateLocaleCommand(options: TranslateLocaleOptions): P // Validate translation is enabled // ------------------------------------------------------------------------- - const translationEnabled = collection.config.translation?.enabled ?? config.translation?.enabled ?? false; - if (!translationEnabled) { + const { translationConfig, baseLocale, locales: allLocales, targetLocales: nonBaseLocales } = collection; + if (!translationConfig?.enabled) { ConsoleFormatter.error( `Auto-translation is not enabled for collection "${collectionName}". ` + `Set translation.enabled = true in your configuration.`, @@ -68,11 +68,6 @@ export async function translateLocaleCommand(options: TranslateLocaleOptions): P return; } - const baseLocale = collection.config.baseLocale ?? config.baseLocale ?? 'en'; - const allLocales = collection.config.locales ?? config.locales ?? []; - - const nonBaseLocales = allLocales.filter((locale) => locale !== baseLocale); - if (nonBaseLocales.length === 0) { ConsoleFormatter.error(`No target locales configured. Add locales other than the base locale "${baseLocale}".`); return; @@ -113,21 +108,11 @@ export async function translateLocaleCommand(options: TranslateLocaleOptions): P return; } - // ------------------------------------------------------------------------- - // Resolve translation config - // ------------------------------------------------------------------------- - - const translationConfig = collection.config.translation ?? config.translation; - if (!translationConfig) { - ConsoleFormatter.error('Translation configuration is missing. Check your .lingo-tracker.json.'); - return; - } - // ------------------------------------------------------------------------- // Run translation // ------------------------------------------------------------------------- - const translationsFolder = collection.config.translationsFolder; + const { translationsFolder } = collection; console.log(''); ConsoleFormatter.progress(`Translating locale '${targetLocale}' in collection '${collectionName}'...`); diff --git a/apps/cli/src/commands/validate.icu.test.ts b/apps/cli/src/commands/validate.icu.test.ts index b7bd21d7..5d4b4d3f 100644 --- a/apps/cli/src/commands/validate.icu.test.ts +++ b/apps/cli/src/commands/validate.icu.test.ts @@ -16,15 +16,25 @@ vi.mock('fs', async (importOriginal) => { return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; }); -vi.mock('@simoncodes-ca/core', () => ({ - CONFIG_FILENAME: '.lingo-tracker.json', - validateResources: vi.fn(), - generateValidationSummary: vi.fn(), - loadPreferredTerminology: vi.fn(() => ({ - rules: [], - filePath: '/project/.lingo-tracker-preferred-terminology.json', - })), -})); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + // Config loading and collection resolution run for real against the mocked config. + loadConfig: actual.loadConfig, + openCollection: actual.openCollection, + ConfigNotFoundError: actual.ConfigNotFoundError, + ConfigParseError: actual.ConfigParseError, + CollectionNotFoundError: actual.CollectionNotFoundError, + ReadOnlyCollectionError: actual.ReadOnlyCollectionError, + CONFIG_FILENAME: '.lingo-tracker.json', + validateResources: vi.fn(), + generateValidationSummary: vi.fn(), + loadPreferredTerminology: vi.fn(() => ({ + rules: [], + filePath: '/project/.lingo-tracker-preferred-terminology.json', + })), + }; +}); import * as core from '@simoncodes-ca/core'; diff --git a/apps/cli/src/commands/validate.test.ts b/apps/cli/src/commands/validate.test.ts index ac4b4c66..790cf3fe 100644 --- a/apps/cli/src/commands/validate.test.ts +++ b/apps/cli/src/commands/validate.test.ts @@ -17,15 +17,25 @@ vi.mock('fs', async (importOriginal) => { return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; }); -vi.mock('@simoncodes-ca/core', () => ({ - CONFIG_FILENAME: '.lingo-tracker.json', - validateResources: vi.fn(), - generateValidationSummary: vi.fn(), - loadPreferredTerminology: vi.fn(() => ({ - rules: [], - filePath: '/project/.lingo-tracker-preferred-terminology.json', - })), -})); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + // Config loading and collection resolution run for real against the mocked config. + loadConfig: actual.loadConfig, + openCollection: actual.openCollection, + ConfigNotFoundError: actual.ConfigNotFoundError, + ConfigParseError: actual.ConfigParseError, + CollectionNotFoundError: actual.CollectionNotFoundError, + ReadOnlyCollectionError: actual.ReadOnlyCollectionError, + CONFIG_FILENAME: '.lingo-tracker.json', + validateResources: vi.fn(), + generateValidationSummary: vi.fn(), + loadPreferredTerminology: vi.fn(() => ({ + rules: [], + filePath: '/project/.lingo-tracker-preferred-terminology.json', + })), + }; +}); import * as core from '@simoncodes-ca/core'; diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index ec1b7634..564ff1e8 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -1,5 +1,9 @@ -import { generateValidationSummary, loadPreferredTerminology, validateResources } from '@simoncodes-ca/core'; -import * as path from 'path'; +import { + generateValidationSummary, + loadPreferredTerminology, + openCollection, + validateResources, +} from '@simoncodes-ca/core'; import { loadConfiguration } from '../utils'; /** @@ -146,10 +150,8 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< if (!loaded) return; const { config, cwd } = loaded; - const allCollections = Object.entries(config.collections || {}).map(([name, collectionConfig]) => ({ - name, - path: path.resolve(cwd, collectionConfig.translationsFolder), - })); + const collections = Object.keys(config.collections || {}).map((name) => openCollection(config, name, { cwd })); + const allCollections = collections.map(({ name, translationsFolder }) => ({ name, path: translationsFolder })); if (allCollections.length === 0) { console.error('❌ No collections found in configuration.'); @@ -193,12 +195,7 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< if (preferredTerminology.warning) { console.warn(`⚠️ ${preferredTerminology.warning}`); } - const baseLocaleByCollection = Object.fromEntries( - Object.entries(config.collections || {}).map(([name, collectionConfig]) => [ - name, - collectionConfig.baseLocale ?? config.baseLocale, - ]), - ); + const baseLocaleByCollection = Object.fromEntries(collections.map(({ name, baseLocale }) => [name, baseLocale])); const compileValues = !options.skipIcu; const requirePortablePlurals = options.requirePortablePlurals ?? false; diff --git a/apps/cli/src/utils/collection-resolver.spec.ts b/apps/cli/src/utils/collection-resolver.spec.ts index 4fe69a3b..b8428676 100644 --- a/apps/cli/src/utils/collection-resolver.spec.ts +++ b/apps/cli/src/utils/collection-resolver.spec.ts @@ -45,7 +45,7 @@ describe('collection-resolver', () => { it('should resolve translations folder path correctly', () => { const result = resolveCollection('main', mockConfig, '/project'); - expect(result?.translationsFolderPath).toBe(path.resolve('/project', 'src/i18n')); + expect(result?.translationsFolder).toBe(path.resolve('/project', 'src/i18n')); }); it('should handle different collection names', () => { @@ -63,13 +63,13 @@ describe('collection-resolver', () => { const baseDir = '/Users/developer/projects/myapp'; const result = resolveCollection('main', mockConfig, baseDir); - expect(result?.translationsFolderPath).toBe(path.resolve(baseDir, 'src/i18n')); + expect(result?.translationsFolder).toBe(path.resolve(baseDir, 'src/i18n')); }); it('should resolve relative paths correctly', () => { const result = resolveCollection('shared', mockConfig, '/project'); - expect(result?.translationsFolderPath).toBe(path.resolve('/project', '../shared/i18n')); + expect(result?.translationsFolder).toBe(path.resolve('/project', '../shared/i18n')); }); it('should handle Windows-style paths', () => { @@ -84,7 +84,7 @@ describe('collection-resolver', () => { const result = resolveCollection('windows', windowsConfig, 'C:\\Projects\\App'); - expect(result?.translationsFolderPath).toBe(path.resolve('C:\\Projects\\App', 'src\\translations\\i18n')); + expect(result?.translationsFolder).toBe(path.resolve('C:\\Projects\\App', 'src\\translations\\i18n')); }); }); @@ -135,20 +135,20 @@ describe('collection-resolver', () => { baseDirs.forEach((baseDir) => { const result = resolveCollection('main', mockConfig, baseDir); - expect(result?.translationsFolderPath).toBe(path.resolve(baseDir, 'src/i18n')); + expect(result?.translationsFolder).toBe(path.resolve(baseDir, 'src/i18n')); }); }); it('should handle base directory with trailing slash', () => { const result = resolveCollection('main', mockConfig, '/project/'); - expect(result?.translationsFolderPath).toBe(path.resolve('/project/', 'src/i18n')); + expect(result?.translationsFolder).toBe(path.resolve('/project/', 'src/i18n')); }); it('should handle empty base directory', () => { const result = resolveCollection('main', mockConfig, ''); - expect(result?.translationsFolderPath).toBe(path.resolve('', 'src/i18n')); + expect(result?.translationsFolder).toBe(path.resolve('', 'src/i18n')); }); }); @@ -238,7 +238,7 @@ describe('collection-resolver', () => { const result = resolveCollection('dotted', dotConfig, '/project'); - expect(result?.translationsFolderPath).toBe(path.resolve('/project', './src/./i18n')); + expect(result?.translationsFolder).toBe(path.resolve('/project', './src/./i18n')); }); it('should handle translation folder paths starting with slash', () => { @@ -253,7 +253,7 @@ describe('collection-resolver', () => { const result = resolveCollection('absolute', slashConfig, '/project'); - expect(result?.translationsFolderPath).toBe(path.resolve('/project', '/absolute/path/i18n')); + expect(result?.translationsFolder).toBe(path.resolve('/project', '/absolute/path/i18n')); }); it('should not modify the original config object', () => { diff --git a/apps/cli/src/utils/collection-resolver.ts b/apps/cli/src/utils/collection-resolver.ts index dca8ac5d..7f3e0b85 100644 --- a/apps/cli/src/utils/collection-resolver.ts +++ b/apps/cli/src/utils/collection-resolver.ts @@ -1,46 +1,33 @@ -import { resolve } from 'node:path'; -import type { LingoTrackerConfig, LingoTrackerCollection } from '@simoncodes-ca/core'; +import { + type Collection, + CollectionNotFoundError, + type LingoTrackerConfig, + openCollection, + ReadOnlyCollectionError, +} from '@simoncodes-ca/core'; import { ErrorMessages } from './error-messages'; /** - * Resolved collection data with computed paths - */ -export interface ResolvedCollection { - name: string; - config: LingoTrackerCollection; - translationsFolderPath: string; -} - -/** - * Validates and resolves a collection from configuration. + * Opens a collection for a CLI command: core `openCollection` resolves it (effective base + * locale, locales, translation config, absolute translations folder); this wrapper turns + * a missing collection into CLI output. * * @param collectionName - Name of collection to resolve * @param config - LingoTracker configuration - * @param baseDirectory - Base directory for resolving paths - * @returns Resolved collection data, or null if not found + * @param baseDirectory - Directory the translations folder resolves against + * @returns The resolved collection, or null (after printing an error) if not found * * @example * const collection = resolveCollection('main', config, cwd); * if (!collection) return; - * // Use: collection.translationsFolderPath + * // Use: collection.translationsFolder, collection.baseLocale, collection.locales */ export function resolveCollection( collectionName: string, config: LingoTrackerConfig, baseDirectory: string, -): ResolvedCollection | null { - const collectionConfig = config.collections?.[collectionName]; - - if (!collectionConfig) { - console.log(`❌ Collection "${collectionName}" not found.`); - return null; - } - - return { - name: collectionName, - config: collectionConfig, - translationsFolderPath: resolve(baseDirectory, collectionConfig.translationsFolder), - }; +): Collection | null { + return open(collectionName, config, baseDirectory, false); } /** @@ -60,15 +47,28 @@ export function resolveWritableCollection( collectionName: string, config: LingoTrackerConfig, baseDirectory: string, -): ResolvedCollection | null { - const resolved = resolveCollection(collectionName, config, baseDirectory); - if (!resolved) return null; +): Collection | null { + return open(collectionName, config, baseDirectory, true); +} - if (resolved.config.readOnly) { - console.log(ErrorMessages.COLLECTION_READ_ONLY(resolved.name)); - process.exitCode = 1; - return null; +function open( + collectionName: string, + config: LingoTrackerConfig, + baseDirectory: string, + writable: boolean, +): Collection | null { + try { + return openCollection(config, collectionName, { cwd: baseDirectory, writable }); + } catch (error) { + if (error instanceof CollectionNotFoundError) { + console.log(ErrorMessages.COLLECTION_NOT_FOUND(collectionName)); + return null; + } + if (error instanceof ReadOnlyCollectionError) { + console.log(ErrorMessages.COLLECTION_READ_ONLY(collectionName)); + process.exitCode = 1; + return null; + } + throw error; } - - return resolved; } diff --git a/apps/cli/src/utils/config-loader.spec.ts b/apps/cli/src/utils/config-loader.spec.ts index bac5b606..1be0a983 100644 --- a/apps/cli/src/utils/config-loader.spec.ts +++ b/apps/cli/src/utils/config-loader.spec.ts @@ -1,37 +1,8 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { loadConfiguration } from './config-loader'; -import * as fs from 'fs'; -import * as path from 'path'; - -const fsMocks = vi.hoisted(() => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), -})); -const pathMocks = vi.hoisted(() => ({ - join: vi.fn(), -})); - -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); -vi.mock('path', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...pathMocks, default: { ...actual.default, ...pathMocks } }; -}); -vi.mock('node:path', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...pathMocks, default: { ...actual.default, ...pathMocks } }; -}); - -// Mock the core library imports -vi.mock('@simoncodes-ca/core', () => ({ - CONFIG_FILENAME: '.lingo-tracker.json', -})); describe('config-loader', () => { const mockConfig = { @@ -46,13 +17,16 @@ describe('config-loader', () => { }, }; + let projectDir: string; + + function writeConfig(content: string, dir = projectDir): void { + writeFileSync(join(dir, '.lingo-tracker.json'), content, 'utf8'); + } + beforeEach(() => { vi.clearAllMocks(); - - // Mock path.join to simply concatenate with '/' - vi.mocked(path.join) - .mockReset() - .mockImplementation((...segments) => segments.join('/')); + projectDir = mkdtempSync(join(tmpdir(), 'lingo-config-loader-')); + vi.spyOn(process, 'cwd').mockReturnValue(projectDir); // Mock console methods vi.spyOn(console, 'error').mockImplementation(() => undefined); @@ -64,22 +38,25 @@ describe('config-loader', () => { afterEach(() => { vi.restoreAllMocks(); + rmSync(projectDir, { recursive: true, force: true }); }); + function mockExit(): void { + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`Process exit: ${code}`); + }); + } + describe('Happy Path', () => { it('should successfully load valid configuration', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); + writeConfig(JSON.stringify(mockConfig)); const result = loadConfiguration(); expect(result).not.toBeNull(); expect(result?.config).toEqual(mockConfig); - expect(result?.configPath).toBe('/test/project/.lingo-tracker.json'); - expect(result?.cwd).toBe('/test/project'); - expect(fs.existsSync).toHaveBeenCalledWith('/test/project/.lingo-tracker.json'); - expect(fs.readFileSync).toHaveBeenCalledWith('/test/project/.lingo-tracker.json', 'utf8'); + expect(result?.configPath).toBe(join(projectDir, '.lingo-tracker.json')); + expect(result?.cwd).toBe(projectDir); }); it('should parse complex configuration with bundles', () => { @@ -92,10 +69,7 @@ describe('config-loader', () => { }, }, }; - - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(complexConfig)); + writeConfig(JSON.stringify(complexConfig)); const result = loadConfiguration(); @@ -106,11 +80,7 @@ describe('config-loader', () => { describe('File Not Found', () => { it('should exit with code 1 when config file not found (exitOnError: true)', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`Process exit: ${code}`); - }); + mockExit(); expect(() => loadConfiguration()).toThrow('Process exit: 1'); expect(console.error).toHaveBeenCalledWith('❌ Configuration file .lingo-tracker.json not found.'); @@ -119,11 +89,7 @@ describe('config-loader', () => { }); it('should return null when config file not found (exitOnError: false)', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`Process exit: ${code}`); - }); + mockExit(); const result = loadConfiguration({ exitOnError: false }); @@ -136,12 +102,8 @@ describe('config-loader', () => { describe('Invalid JSON', () => { it('should exit with code 1 when config file has invalid JSON (exitOnError: true)', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{ invalid json'); - vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`Process exit: ${code}`); - }); + writeConfig('{ invalid json'); + mockExit(); expect(() => loadConfiguration()).toThrow('Process exit: 1'); expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/^❌ Failed to parse configuration file:/)); @@ -149,12 +111,8 @@ describe('config-loader', () => { }); it('should return null when config file has invalid JSON (exitOnError: false)', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{ invalid json'); - vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`Process exit: ${code}`); - }); + writeConfig('{ invalid json'); + mockExit(); const result = loadConfiguration({ exitOnError: false }); @@ -164,9 +122,7 @@ describe('config-loader', () => { }); it('should include specific parse error message', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{ invalid json'); + writeConfig('{ invalid json'); const result = loadConfiguration({ exitOnError: false }); @@ -180,38 +136,34 @@ describe('config-loader', () => { describe('INIT_CWD Handling', () => { it('should use INIT_CWD environment variable when set (pnpm compatibility)', () => { - process.env.INIT_CWD = '/pnpm/workspace/project'; - vi.spyOn(process, 'cwd').mockReturnValue('/different/directory'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); - - const result = loadConfiguration(); - - expect(result).not.toBeNull(); - expect(result?.cwd).toBe('/pnpm/workspace/project'); - expect(result?.configPath).toBe('/pnpm/workspace/project/.lingo-tracker.json'); - expect(fs.existsSync).toHaveBeenCalledWith('/pnpm/workspace/project/.lingo-tracker.json'); + const pnpmDir = mkdtempSync(join(tmpdir(), 'lingo-config-loader-init-cwd-')); + try { + writeConfig(JSON.stringify(mockConfig), pnpmDir); + process.env.INIT_CWD = pnpmDir; + + const result = loadConfiguration(); + + expect(result).not.toBeNull(); + expect(result?.cwd).toBe(pnpmDir); + expect(result?.configPath).toBe(join(pnpmDir, '.lingo-tracker.json')); + } finally { + rmSync(pnpmDir, { recursive: true, force: true }); + } }); it('should fall back to process.cwd() when INIT_CWD not set', () => { - delete process.env.INIT_CWD; - vi.spyOn(process, 'cwd').mockReturnValue('/standard/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); + writeConfig(JSON.stringify(mockConfig)); const result = loadConfiguration(); expect(result).not.toBeNull(); - expect(result?.cwd).toBe('/standard/project'); - expect(result?.configPath).toBe('/standard/project/.lingo-tracker.json'); + expect(result?.cwd).toBe(projectDir); + expect(result?.configPath).toBe(join(projectDir, '.lingo-tracker.json')); }); }); describe('Error Messages', () => { it('should display exact error message for file not found', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - loadConfiguration({ exitOnError: false }); expect(console.error).toHaveBeenCalledWith('❌ Configuration file .lingo-tracker.json not found.'); @@ -220,9 +172,7 @@ describe('config-loader', () => { }); it('should display exact error message format for parse failure', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{ invalid json'); + writeConfig('{ invalid json'); loadConfiguration({ exitOnError: false }); @@ -234,9 +184,7 @@ describe('config-loader', () => { describe('Edge Cases', () => { it('should handle empty configuration file', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{}'); + writeConfig('{}'); const result = loadConfiguration(); @@ -250,10 +198,7 @@ describe('config-loader', () => { locales: ['en'], collections: {}, }; - - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(minimalConfig)); + writeConfig(JSON.stringify(minimalConfig)); const result = loadConfiguration(); @@ -262,16 +207,15 @@ describe('config-loader', () => { }); it('should handle file read errors other than not found', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation(() => { - throw new Error('Permission denied'); - }); + // A directory in place of the file: it exists, but reading it fails (EISDIR). + mkdirSync(join(projectDir, '.lingo-tracker.json')); const result = loadConfiguration({ exitOnError: false }); expect(result).toBeNull(); - expect(console.error).toHaveBeenCalledWith('❌ Failed to parse configuration file: Permission denied'); + expect(console.error).toHaveBeenCalledWith( + expect.stringMatching(/^❌ Failed to parse configuration file: .*EISDIR/), + ); }); }); }); diff --git a/apps/cli/src/utils/config-loader.ts b/apps/cli/src/utils/config-loader.ts index 08a3c182..196be402 100644 --- a/apps/cli/src/utils/config-loader.ts +++ b/apps/cli/src/utils/config-loader.ts @@ -1,6 +1,11 @@ -import * as fs from 'fs'; import * as path from 'path'; -import { CONFIG_FILENAME, type LingoTrackerConfig } from '@simoncodes-ca/core'; +import { + CONFIG_FILENAME, + ConfigNotFoundError, + ConfigParseError, + type LingoTrackerConfig, + loadConfig, +} from '@simoncodes-ca/core'; /** * Gets the current working directory, respecting INIT_CWD for pnpm compatibility. @@ -54,10 +59,10 @@ export interface ConfigLoadOptions { } /** - * Loads and parses the LingoTracker configuration file (.lingo-tracker.json). + * Loads the LingoTracker configuration file (.lingo-tracker.json) for a CLI command. * - * This utility centralizes configuration loading logic used across CLI commands, - * eliminating duplication and ensuring consistent error handling and messaging. + * Reading and parsing is done by core `loadConfig`, the single config reader. This + * wrapper only picks the directory and turns failures into CLI output. * * **Directory Resolution:** * - Respects INIT_CWD environment variable (pnpm compatibility) @@ -65,7 +70,7 @@ export interface ConfigLoadOptions { * * **Error Handling:** * - File not found: Displays helpful message suggesting to run `lingo-tracker init` - * - Parse errors: Shows specific JSON parsing error message + * - Parse or read errors: Shows the specific error message * - Behavior controlled by `exitOnError` option (default: exit process) * * @param options - Configuration loading options @@ -92,36 +97,23 @@ export interface ConfigLoadOptions { */ export function loadConfiguration(options?: ConfigLoadOptions): ConfigLoadResult | null { const exitOnError = options?.exitOnError ?? true; - const cwd = getCwd(); - const configPath = path.join(cwd, CONFIG_FILENAME); - let config: LingoTrackerConfig; try { - if (fs.existsSync(configPath)) { - const fileContent = fs.readFileSync(configPath, 'utf8'); - config = JSON.parse(fileContent); - } else { + return { config: loadConfig({ cwd }), configPath: path.join(cwd, CONFIG_FILENAME), cwd }; + } catch (error) { + if (error instanceof ConfigNotFoundError) { console.error(`❌ Configuration file ${CONFIG_FILENAME} not found.`); console.error('Run "lingo-tracker init" to initialize a project.'); - - if (exitOnError) { - process.exit(1); - } - return null; + } else { + const reason = + error instanceof ConfigParseError ? error.reason : error instanceof Error ? error.message : String(error); + console.error(`❌ Failed to parse configuration file: ${reason}`); } - } catch (error) { - console.error(`❌ Failed to parse configuration file: ${(error as Error).message}`); if (exitOnError) { process.exit(1); } return null; } - - return { - config, - configPath, - cwd, - }; } diff --git a/architecture-docs/api.md b/architecture-docs/api.md index c8fb35cc..33538108 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -111,7 +111,7 @@ graph TD end subgraph services["Services / Infrastructure"] - CONFIGS["ConfigService\nReads .lingo-tracker.json\non every request"] + CONFIGS["ConfigService\ncore loadConfig() on every request\n(errors → 404 / 500)"] CACHE["CollectionCacheService\nSingle-collection in-memory\nResourceTreeNode cache"] JOBS["TranslationJobService\nIn-memory job map\nUUID → TranslationJob"] end @@ -166,9 +166,9 @@ graph TD style core fill:#d4edda,stroke:#28a745,color:#000 ``` -Controllers are the only layer that knows HTTP. They resolve collection config from `ConfigService`, delegate business operations to `@simoncodes-ca/core` (see [core-library.md](core-library.md)), apply mappers at the boundary, and update `CollectionCacheService` incrementally after successful writes. +Controllers are the only layer that knows HTTP. They read the config from `ConfigService` (a thin wrapper over core `loadConfig()` that maps `ConfigNotFoundError` to 404 and parse/read failures to 500), turn the `:collectionName` route param into the effective `Collection` with `openRouteCollection()` (`collections/open-route-collection.ts`: decodes the name, calls core `openCollection()`, maps `CollectionNotFoundError` to 404), delegate business operations to `@simoncodes-ca/core` (see [core-library.md](core-library.md)), apply mappers at the boundary, and update `CollectionCacheService` incrementally after successful writes. -**Read-only enforcement.** `WritableCollectionGuard` (`collections/guards/writable-collection.guard.ts`) is applied at the class level to the `Resources`, `Locales`, and `Folders` controllers. For any non-`GET` request it reads the `:collectionName` route param, looks up the collection in `ConfigService`, and throws `403 Forbidden` when the collection is `readOnly`. This is the single API choke-point for read-only enforcement. The `Collections` controller is intentionally **not** guarded: updating a collection's config entry or unregistering it (`PUT`/`DELETE /collections/:name`) is permitted even for read-only collections, since the lock protects resources, not the registration. On create, the controller defaults `readOnly` to `true` for `node_modules` paths (via the `isUnderNodeModules` domain helper) when the DTO omits it. +**Read-only enforcement.** `WritableCollectionGuard` (`collections/guards/writable-collection.guard.ts`) is applied at the class level to the `Resources`, `Locales`, and `Folders` controllers. For any non-`GET` request it reads the `:collectionName` route param, opens the collection with core `openCollection(config, name, { writable: true })`, and maps `ReadOnlyCollectionError` to `403 Forbidden` (unknown collections pass through so the controller returns its 404). This is the single API choke-point for read-only enforcement. The `Collections` controller is intentionally **not** guarded: updating a collection's config entry or unregistering it (`PUT`/`DELETE /collections/:name`) is permitted even for read-only collections, since the lock protects resources, not the registration. On create, the controller defaults `readOnly` to `true` for `node_modules` paths (via the `isUnderNodeModules` domain helper) when the DTO omits it. --- diff --git a/architecture-docs/cli.md b/architecture-docs/cli.md index a4e0d5af..6737d63a 100644 --- a/architecture-docs/cli.md +++ b/architecture-docs/cli.md @@ -126,7 +126,7 @@ flowchart TD PROMPT_COLLECTION --> VALIDATE_COLLECTION AUTO_SELECT --> VALIDATE_COLLECTION - VALIDATE_COLLECTION["resolveCollection()\nVerify collection exists in config\nCompute translationsFolderPath"] + VALIDATE_COLLECTION["resolveCollection()\ncore openCollection():\neffective locales + absolute folder"] VALIDATE_COLLECTION --> COLLECTION_OK{"Collection\nfound?"} COLLECTION_OK -- No --> EXIT_RESOLVE(["Exit\n❌ Collection not found"]) COLLECTION_OK -- Yes --> CHECK_FLAGS @@ -167,13 +167,13 @@ flowchart TD ### Config Loading -`loadConfiguration()` in `apps/cli/src/utils/config-loader.ts` is called at the top of nearly every command. It centralizes `.lingo-tracker.json` discovery and parse error handling so no command duplicates that logic. +`loadConfiguration()` in `apps/cli/src/utils/config-loader.ts` is called at the top of nearly every command. It picks the directory and turns failures into CLI output; reading and parsing is done by core `loadConfig({ cwd })`, the single config reader shared with the API (see [core-library.md — Config and Collection Resolution](core-library.md#config-and-collection-resolution)). Key behaviors: - **Directory resolution** — reads `process.env.INIT_CWD` first, falling back to `process.cwd()`. `INIT_CWD` is set by pnpm and points to the user's project root even when pnpm changes directory to the package location during script execution. The `getCwd()` helper centralizes this logic and is used wherever an absolute path is needed. - **File not found** — logs `❌ Configuration file .lingo-tracker.json not found. Run "lingo-tracker init" to initialize a project.` then exits with code 1 (or returns `null` when `exitOnError: false`). -- **Parse error** — logs the raw JSON parse error message and exits or returns `null`. +- **Parse or read error** — logs `❌ Failed to parse configuration file: ` (the JSON parser's message, or the I/O error) and exits or returns `null`. - **Return type** — `ConfigLoadResult` carries `{ config, configPath, cwd }` so callers never repeat path resolution. The `exitOnError` option (default `true`) allows commands like `add-resource` to do their own error handling without the process terminating mid-operation. @@ -189,9 +189,9 @@ After loading config, most commands call `promptForCollection()` followed by `re 3. If multiple collections exist and `process.stdout.isTTY` is false, throw `Missing required option: --collection`. 4. If multiple collections exist and TTY is true, show an interactive `select` prompt. -`resolveCollection()` in `collection-resolver.ts` then validates the selected name against `config.collections`, logs `❌ Collection "name" not found.` if absent, and computes the absolute `translationsFolderPath` by joining `baseDirectory` with `collectionConfig.translationsFolder`. +`resolveCollection()` in `collection-resolver.ts` then opens the selected name with core `openCollection(config, name, { cwd })`, and logs `❌ Collection "name" not found.` (returning `null`) if it is absent. The result is the core `Collection`: the absolute `translationsFolder` and the effective `baseLocale`, `locales`, `targetLocales`, and `translationConfig`. Commands read those fields; none of them applies the collection-then-global fallback itself. -**Read-only enforcement.** Commands that mutate resources call `resolveWritableCollection()` instead of `resolveCollection()`. It wraps `resolveCollection()` and, if the collection's config has `readOnly: true`, prints `❌ Collection "name" is read-only...`, sets `process.exitCode = 1` (so CI fails), and returns `null`. This is the single CLI choke-point for read-only enforcement — no per-command checks. Read-only commands (`bundle`, `export`, `validate`, `find-similar`, `glossary`) and `delete-collection` keep using plain `resolveCollection()`, since they either don't mutate resources or operate on the collection's registration rather than its contents. +**Read-only enforcement.** Commands that mutate resources call `resolveWritableCollection()` instead of `resolveCollection()`. It opens the collection with `{ writable: true }` and, when core throws `ReadOnlyCollectionError`, prints `❌ Collection "name" is read-only...`, sets `process.exitCode = 1` (so CI fails), and returns `null`. This is the single CLI choke-point for read-only enforcement — no per-command checks. Read-only commands (`bundle`, `export`, `validate`, `find-similar`, `glossary`) and `delete-collection` keep using plain `resolveCollection()`, since they either don't mutate resources or operate on the collection's registration rather than its contents. ### Resolution Flowchart @@ -202,11 +202,11 @@ flowchart LR GETCONFIG --> PROMPT["promptForCollection(config, options.collection)"] PROMPT --> NAME["collectionName: string"] NAME --> RESOLVE["resolveCollection(collectionName, config, cwd)"] - RESOLVE --> RESOLVED["ResolvedCollection\n{ name, config, translationsFolderPath }"] - RESOLVED --> CORE["@simoncodes-ca/core function\ne.g. addResource(translationsFolderPath, params)"] + RESOLVE --> RESOLVED["Collection (core)\n{ name, translationsFolder, baseLocale,\nlocales, targetLocales, translationConfig, ... }"] + RESOLVED --> CORE["@simoncodes-ca/core function\ne.g. addResource(collection.translationsFolder, params)"] ``` -The `translationsFolderPath` from `ResolvedCollection` is the first argument passed to every core resource operation. This means commands never construct filesystem paths themselves — path construction is fully delegated to `config-loader.ts` and `collection-resolver.ts`. +The `translationsFolder` from the `Collection` is the first argument passed to every core resource operation, and its `baseLocale` / `locales` / `translationConfig` fill the remaining parameters. Commands never construct filesystem paths or effective settings themselves. --- @@ -270,7 +270,7 @@ ErrorMessages.RESOURCE_NOT_FOUND(key) // factory → "❌ Resource key "key" ### Collection Resolver (`collection-resolver.ts`) -`resolveCollection(collectionName, config, baseDirectory)` — validates existence of the named [collection](glossary.md#collection) in `config.collections` and returns a `ResolvedCollection` with the computed absolute `translationsFolderPath`. Returns `null` (after logging an error) if the collection is not found, so callers use a `if (!collection) return;` guard pattern. +`resolveCollection(collectionName, config, baseDirectory)` — thin wrapper over core `openCollection()`. Returns the resolved [collection](glossary.md#collection) (`Collection` from `@simoncodes-ca/core`), or `null` (after logging an error) if the collection is not found, so callers use a `if (!collection) return;` guard pattern. `resolveWritableCollection()` is the same with `{ writable: true }`. ### String Parsers (`string-parsers.ts`) diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index 9e4ccf8f..21309eb2 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -9,6 +9,7 @@ Return to [architecture README](README.md). ## Table of Contents - [Module Map](#module-map) +- [Config and Collection Resolution](#config-and-collection-resolution) - [Resource CRUD Flows](#resource-crud-flows) - [add-resource](#add-resource) - [edit-resource](#edit-resource) @@ -64,8 +65,10 @@ libs/core/src/ │ ├── tag-filter.ts # matchesTags(): AND/OR tag filter logic │ └── type-generation/ # TypeScript type file generation from bundle keys │ - ├── config/ # Config file I/O - │ ├── config-file-operations.ts # read/write/update .lingo-tracker.json + ├── config/ # Config file I/O and collection resolution + │ ├── load-config.ts # loadConfig(): the only reader of .lingo-tracker.json + │ ├── open-collection.ts # openCollection(): a collection's effective settings (Collection) + │ ├── config-file-operations.ts # read/write/update .lingo-tracker.json (reads via loadConfig) │ └── protected-terms-file.ts # Resolve, read, and write protected-terms JSON files (cached per path) │ ├── export/ # Export pipelines (JSON and XLIFF) @@ -121,11 +124,9 @@ libs/core/src/ │ ├── json-file-operations.ts # readJsonFile(), writeJsonFile(), typed helpers │ └── directory-operations.ts # ensureDirectoryExists() │ - ├── config/ # Config file operations (reads/writes .lingo-tracker.json) - │ └── config-file-operations.ts # createConfigFileOperations() - │ - └── errors/ # Typed error messages - └── error-messages.ts # ErrorMessages: static error string builders + └── errors/ # Error messages and typed errors + ├── error-messages.ts # ErrorMessages: static error string builders + └── lingo-tracker-error.ts # LingoTrackerError and its subclasses (config / collection errors) ``` @@ -152,7 +153,7 @@ graph TD TRANSLATION["translation/\nautoTranslateResource\ntranslateExistingResource"] FOLDER["folder/\ncreateFolder · deleteFolder\nmoveFolder"] FILEIO["file-io/\nreadJsonFile · writeJsonFile\nensureDirectoryExists"] - CONFIG_LIB["config/\ncreateConfigFileOperations"] + CONFIG_LIB["config/\nloadConfig · openCollection\ncreateConfigFileOperations"] ERRORS["errors/\nErrorMessages"] RESOURCE_LIB["resource/\nresource-folder\nresource-file-paths\nload-resource-tree"] end @@ -226,6 +227,17 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that --- +## Config and Collection Resolution + +Core owns the config file and the rule that turns a collection's config entry into its effective settings. The adapters (CLI, API) call two functions in `lib/config/` once per command or request, then pass the results to the per-resource operations. + +- **`loadConfig({ cwd? })`** is the only reader of `.lingo-tracker.json`. It returns the file as written, with no validation and no fallbacks. It throws `ConfigNotFoundError` when the file does not exist and `ConfigParseError` when the file is not a JSON object; other I/O errors pass through. The CLI passes its `INIT_CWD`-aware directory, the API passes `process.cwd()`, and `createConfigFileOperations().read()` (used by the config writers) reads through it too. +- **`openCollection(config, name, { cwd?, writable? })`** returns a `Collection`: `name`, the absolute `translationsFolder` (resolved against `cwd`), `baseLocale` (collection, else global, else `en`; an empty string counts as unset), `locales` (collection, else global, else `[]`), `targetLocales` (`locales` without `baseLocale`), `translationConfig` (collection, else global; not merged), normalized `tags`, `readOnly`, and the raw entry as `config`. It throws `CollectionNotFoundError` for an unknown name and, when `writable` is set, `ReadOnlyCollectionError` for a read-only collection. + +The fallback rule lives only in `openCollection`. The collection operations in `collections-manager/` (`addLocaleToCollection`, `removeLocaleFromCollection`, `updateCollection`) use it for their locale checks. Import takes the base locale from its caller (`ImportOptions.baseLocale` is required) and never reads the config file. Per-resource operations keep their `(translationsFolder, …, baseLocale, allLocales, translationConfig)` parameters; callers fill them from the `Collection`. The typed errors extend `LingoTrackerError` (`lib/errors/lingo-tracker-error.ts`), so an adapter maps them with `instanceof` instead of matching message text. + +--- + ## Resource CRUD Flows Resource CRUD is implemented across four functions in `libs/core/src/resource/`. Each function follows the same structural pattern: resolve the dot-delimited [resource key](glossary.md#resource-key) to a filesystem path, load the current JSON files, apply changes, recompute [checksums](glossary.md#checksum) and [translation status](glossary.md#translation-status), then write both files back. Both files are always written together by one call (`ResourceFolder.save()`); the writes are sequential, not atomic. @@ -408,7 +420,7 @@ The [ICU format](glossary.md#icu-format) classification determines safety: `plai The import pipeline ingests an external translation file for a single locale and reconciles it with the existing resource tree. Steps common to both formats: -1. **Setup workflow** — `setupImportWorkflow(options)` resolves the base locale from `.lingo-tracker.json`, applies strategy-specific defaults for `createMissing`, `updateComments`, and `updateTags`, and guards against importing into the base locale with a non-`migration` strategy. +1. **Setup workflow** — `setupImportWorkflow(options)` takes the base locale from `options.baseLocale` (the caller passes the collection's effective base locale; the config file is not read), applies strategy-specific defaults for `createMissing`, `updateComments`, and `updateTags`, and guards against importing into the base locale with a non-`migration` strategy. 2. **Parse source file** — format-specific logic extracts a flat list of `ImportedResource` objects (`key`, `value`, optional `baseValue`, `comment`, `tags`, `status`). JSON import additionally detects whether the source is flat (`{"common.ok": "OK"}`) or hierarchical (`{common: {ok: "OK"}}`) via `detectJsonStructure()`, then flattens hierarchical structures. 3. **Normalize syntax** — `normalizeTranslocoSyntaxInResources()` converts any Transloco `{{ varName }}` in imported values to ICU `{varName}` before further processing. 4. **ICU auto-fix** — `applyICUAutoFixToResources()` repairs malformed ICU placeholder syntax (e.g. wrong brace styles from translation services) using `icuAutoFixer` from `@simoncodes-ca/domain`. Fixes and errors are recorded separately in the result. diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 04f95e85..a90c953d 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -51,7 +51,9 @@ Collections may declare a `tags?: string[]` array. These are **collection-level Example collections from the project's own config: `trackerResources` (the Tracker UI's own strings), `TestDataPlayground`, and `mockDesignSystem`. -Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [`cli.md`](cli.md) +**Collection (resolved).** Code outside the config module never reads a collection's raw entry to get its settings. `openCollection(config, name)` in `@simoncodes-ca/core` returns a `Collection` with the effective values: `baseLocale` (collection, else global, else `en`), `locales` (collection, else global, else none), `targetLocales` (the locales without the base locale), `translationConfig` (collection, else global; the two are not merged), the absolute `translationsFolder`, normalized `tags`, and `readOnly`. It throws `CollectionNotFoundError` for an unknown name, and `ReadOnlyCollectionError` when `{ writable: true }` is set on a read-only collection. The CLI and the API both open collections this way. + +Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [`cli.md`](cli.md), [`core-library.md`](core-library.md#config-and-collection-resolution) --- diff --git a/architecture-docs/user-flows.md b/architecture-docs/user-flows.md index 58f2ba2a..892a0d86 100644 --- a/architecture-docs/user-flows.md +++ b/architecture-docs/user-flows.md @@ -146,10 +146,10 @@ sequenceDiagram participant FS as Filesystem Translator->>CLI: import --locale fr --file ./exports/fr.json --strategy translation-service - CLI->>Core: importFromJson(options) + CLI->>FS: read .lingo-tracker.json → openCollection() → baseLocale = "en" + CLI->>Core: importFromJson(options with baseLocale) - Note over Core: setupImportWorkflow(options) - Core->>FS: read .lingo-tracker.json → baseLocale = "en" + Note over Core: setupImportWorkflow(options)
uses options.baseLocale, reads no config Core->>Core: getStrategyDefaults("translation-service")
createMissing=false, updateComments=false Note over Core: Parse source file diff --git a/libs/core/src/collections-manager/add-locale-to-collection.spec.ts b/libs/core/src/collections-manager/add-locale-to-collection.spec.ts index 687faa19..d36906bd 100644 --- a/libs/core/src/collections-manager/add-locale-to-collection.spec.ts +++ b/libs/core/src/collections-manager/add-locale-to-collection.spec.ts @@ -7,6 +7,7 @@ import type { ResourceEntries } from '../resource/resource-entry'; import type { TrackerMetadata } from '../resource/tracker-metadata'; import type { SafeAny } from '../constants'; import { setupMockFs, makeBaseConfig } from './locale-spec-helpers'; +import { ReadOnlyCollectionError } from '../lib/errors/lingo-tracker-error'; vi.mock('fs'); @@ -174,6 +175,23 @@ describe('addLocaleToCollection', () => { ); }); + it('throws ReadOnlyCollectionError and writes nothing for a read-only collection', async () => { + const config = makeConfig({ + collections: { main: { translationsFolder: 'src/i18n', readOnly: true } }, + }); + setupMockFs({ + [CONFIG_PATH]: { type: 'file', content: JSON.stringify(config) }, + [TRANSLATIONS_FOLDER]: { type: 'directory', children: ['resource_entries.json', 'tracker_meta.json'] }, + [path.join(TRANSLATIONS_FOLDER, 'resource_entries.json')]: { type: 'file', content: '{}' }, + [path.join(TRANSLATIONS_FOLDER, 'tracker_meta.json')]: { type: 'file', content: '{}' }, + }); + + await expect(addLocaleToCollection('main', 'de', { cwd: CWD })).rejects.toThrow(ReadOnlyCollectionError); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); + it('throws when locale format is invalid', async () => { setupMockFs({ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) }, diff --git a/libs/core/src/collections-manager/add-locale-to-collection.ts b/libs/core/src/collections-manager/add-locale-to-collection.ts index c305b2fd..6e7e6ad8 100644 --- a/libs/core/src/collections-manager/add-locale-to-collection.ts +++ b/libs/core/src/collections-manager/add-locale-to-collection.ts @@ -2,6 +2,7 @@ import * as path from 'node:path'; import { existsSync } from 'node:fs'; import { validateLocale } from '@simoncodes-ca/domain'; import { updateConfig } from '../lib/config/config-file-operations'; +import { openCollection } from '../lib/config/open-collection'; import { walkFolders } from '../lib/normalize/iterative-folder-walker'; import { openResourceFolder } from '../lib/resource/resource-folder'; import { ErrorMessages } from '../lib/errors/error-messages'; @@ -27,19 +28,17 @@ export async function addLocaleToCollection( validateLocale(locale); const updatedConfig = updateConfig((config) => { - if (!config.collections?.[collectionName]) { - throw new Error(ErrorMessages.collectionNotFound(collectionName)); - } - - const collection = config.collections[collectionName]; - const baseLocale = collection.baseLocale ?? config.baseLocale; + // `writable` throws inside the updater, so nothing is written for a read-only collection. + const { + baseLocale, + locales: effectiveLocales, + config: collection, + } = openCollection(config, collectionName, { cwd, writable: true }); if (locale === baseLocale) { throw new Error(ErrorMessages.cannotModifyBaseLocale(locale)); } - const effectiveLocales = collection.locales ?? config.locales ?? []; - if (effectiveLocales.includes(locale)) { throw new Error(ErrorMessages.localeAlreadyExists(locale, collectionName)); } @@ -58,18 +57,15 @@ export async function addLocaleToCollection( }; }, cwd); - const collection = updatedConfig.collections[collectionName]; - const translationsFolderPath = path.resolve(cwd, collection.translationsFolder); + const collection = openCollection(updatedConfig, collectionName, { cwd }); let entriesBackfilled = 0; let filesUpdated = 0; - for (const visit of walkFolders(translationsFolderPath)) { + for (const visit of walkFolders(collection.translationsFolder)) { if (!existsSync(path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME))) continue; - const folder = openResourceFolder(visit.absolutePath, { - baseLocale: collection.baseLocale ?? updatedConfig.baseLocale, - }); + const folder = openResourceFolder(visit.absolutePath, { baseLocale: collection.baseLocale }); // Seed the new locale with the base (source) value and status 'new' — the same // convention normalize uses for missing locales. diff --git a/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts b/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts index 8b6b04f4..3f29da92 100644 --- a/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts +++ b/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts @@ -7,6 +7,7 @@ import type { ResourceEntries } from '../resource/resource-entry'; import type { TrackerMetadata } from '../resource/tracker-metadata'; import type { SafeAny } from '../constants'; import { setupMockFs, makeBaseConfig } from './locale-spec-helpers'; +import { ReadOnlyCollectionError } from '../lib/errors/lingo-tracker-error'; vi.mock('fs'); @@ -180,6 +181,23 @@ describe('removeLocaleFromCollection', () => { ); }); + it('throws ReadOnlyCollectionError and writes nothing for a read-only collection', async () => { + const config = makeConfig({ + collections: { main: { translationsFolder: 'src/i18n', readOnly: true } }, + }); + setupMockFs({ + [CONFIG_PATH]: { type: 'file', content: JSON.stringify(config) }, + [TRANSLATIONS_FOLDER]: { type: 'directory', children: ['resource_entries.json', 'tracker_meta.json'] }, + [path.join(TRANSLATIONS_FOLDER, 'resource_entries.json')]: { type: 'file', content: '{}' }, + [path.join(TRANSLATIONS_FOLDER, 'tracker_meta.json')]: { type: 'file', content: '{}' }, + }); + + await expect(removeLocaleFromCollection('main', 'fr', { cwd: CWD })).rejects.toThrow(ReadOnlyCollectionError); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); + it('throws when locale format is invalid', async () => { setupMockFs({ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) }, diff --git a/libs/core/src/collections-manager/remove-locale-from-collection.ts b/libs/core/src/collections-manager/remove-locale-from-collection.ts index 62c31a3c..9179ca8a 100644 --- a/libs/core/src/collections-manager/remove-locale-from-collection.ts +++ b/libs/core/src/collections-manager/remove-locale-from-collection.ts @@ -2,6 +2,7 @@ import * as path from 'node:path'; import { existsSync } from 'node:fs'; import { validateLocale } from '@simoncodes-ca/domain'; import { updateConfig } from '../lib/config/config-file-operations'; +import { openCollection } from '../lib/config/open-collection'; import { walkFolders } from '../lib/normalize/iterative-folder-walker'; import { openResourceFolder } from '../lib/resource/resource-folder'; import { ErrorMessages } from '../lib/errors/error-messages'; @@ -27,19 +28,17 @@ export async function removeLocaleFromCollection( validateLocale(locale); const updatedConfig = updateConfig((config) => { - if (!config.collections?.[collectionName]) { - throw new Error(ErrorMessages.collectionNotFound(collectionName)); - } - - const collection = config.collections[collectionName]; - const baseLocale = collection.baseLocale ?? config.baseLocale; + // `writable` throws inside the updater, so nothing is written for a read-only collection. + const { + baseLocale, + locales: effectiveLocales, + config: collection, + } = openCollection(config, collectionName, { cwd, writable: true }); if (locale === baseLocale) { throw new Error(ErrorMessages.cannotModifyBaseLocale(locale)); } - const effectiveLocales = collection.locales ?? config.locales ?? []; - if (!effectiveLocales.includes(locale)) { throw new Error(ErrorMessages.localeNotFound(locale, collectionName)); } @@ -58,13 +57,12 @@ export async function removeLocaleFromCollection( }; }, cwd); - const collection = updatedConfig.collections[collectionName]; - const translationsFolderPath = path.resolve(cwd, collection.translationsFolder); + const collection = openCollection(updatedConfig, collectionName, { cwd }); let entriesPurged = 0; let filesUpdated = 0; - for (const visit of walkFolders(translationsFolderPath)) { + for (const visit of walkFolders(collection.translationsFolder)) { if (!existsSync(path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME))) continue; const folder = openResourceFolder(visit.absolutePath); diff --git a/libs/core/src/collections-manager/update-collection.ts b/libs/core/src/collections-manager/update-collection.ts index 787d3265..f77a422e 100644 --- a/libs/core/src/collections-manager/update-collection.ts +++ b/libs/core/src/collections-manager/update-collection.ts @@ -1,6 +1,7 @@ import { normalizeTags } from '@simoncodes-ca/domain'; import type { LingoTrackerCollection } from '../config/lingo-tracker-collection'; import { createConfigFileOperations, updateConfig } from '../lib/config/config-file-operations'; +import { openCollection } from '../lib/config/open-collection'; import { ErrorMessages } from '../lib/errors/error-messages'; import { addLocaleToCollection } from './add-locale-to-collection'; import { removeLocaleFromCollection } from './remove-locale-from-collection'; @@ -36,15 +37,8 @@ export async function updateCollection( // An empty/undefined list means "inherit from global" — no translation files are touched. if (newLocales !== undefined && newLocales.length > 0) { // Read config here only to diff existing vs new locales; updateConfig below will re-read the already-mutated file. - const config = createConfigFileOperations({ cwd }).read(); - const existingCollection = config.collections?.[collectionName]; - - if (!existingCollection) { - throw new Error(ErrorMessages.collectionNotFound(collectionName)); - } - - const existingLocales = existingCollection.locales ?? config.locales ?? []; - const baseLocale = existingCollection.baseLocale ?? config.baseLocale; + const existing = openCollection(createConfigFileOperations({ cwd }).read(), collectionName, { cwd }); + const { locales: existingLocales, baseLocale } = existing; const addedLocales = newLocales.filter((l) => !existingLocales.includes(l)); // Never try to remove the base locale — it can only be set at create time. diff --git a/libs/core/src/lib/config/config-file-operations.ts b/libs/core/src/lib/config/config-file-operations.ts index 34d8d2ae..1aa1f117 100644 --- a/libs/core/src/lib/config/config-file-operations.ts +++ b/libs/core/src/lib/config/config-file-operations.ts @@ -2,7 +2,8 @@ import { resolve } from 'node:path'; import { normalizeTags } from '@simoncodes-ca/domain'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; import { CONFIG_FILENAME } from '../../constants'; -import { readLingoConfig, writeJsonFile } from '../file-io/json-file-operations'; +import { writeJsonFile } from '../file-io/json-file-operations'; +import { loadConfig } from './load-config'; export interface ConfigFileOperations { /** Read the configuration file */ @@ -34,7 +35,7 @@ export function createConfigFileOperations(params: ConfigFileParams = {}): Confi let config: LingoTrackerConfig; try { - config = readLingoConfig(configPath); + config = loadConfig({ cwd }); } catch (_error) { throw new Error('Failed to read or parse configuration file'); } diff --git a/libs/core/src/lib/config/index.ts b/libs/core/src/lib/config/index.ts index d28df192..00847e32 100644 --- a/libs/core/src/lib/config/index.ts +++ b/libs/core/src/lib/config/index.ts @@ -1,3 +1,5 @@ export * from './config-file-operations'; export * from './preferred-terminology-file'; export * from './protected-terms-file'; +export * from './load-config'; +export * from './open-collection'; diff --git a/libs/core/src/lib/config/load-config.spec.ts b/libs/core/src/lib/config/load-config.spec.ts new file mode 100644 index 00000000..cfd0d65c --- /dev/null +++ b/libs/core/src/lib/config/load-config.spec.ts @@ -0,0 +1,94 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CONFIG_FILENAME } from '../../constants'; +import { ConfigNotFoundError, ConfigParseError, LingoTrackerError } from '../errors/lingo-tracker-error'; +import { loadConfig } from './load-config'; + +describe('loadConfig', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'lingo-load-config-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(dir, { recursive: true, force: true }); + }); + + function writeConfig(content: string): void { + writeFileSync(join(dir, CONFIG_FILENAME), content, 'utf8'); + } + + it('returns the parsed file unchanged', () => { + const config = { + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { app: { translationsFolder: 'i18n', tags: [' Mixed Case '] } }, + }; + writeConfig(JSON.stringify(config)); + + expect(loadConfig({ cwd: dir })).toEqual(config); + }); + + it('reads from process.cwd() by default', () => { + writeConfig('{"baseLocale":"de"}'); + vi.spyOn(process, 'cwd').mockReturnValue(dir); + + expect(loadConfig().baseLocale).toBe('de'); + }); + + it('throws ConfigNotFoundError with the searched path when the file is missing', () => { + const error = captureError(() => loadConfig({ cwd: dir })); + + expect(error).toBeInstanceOf(ConfigNotFoundError); + expect(error).toBeInstanceOf(LingoTrackerError); + expect((error as ConfigNotFoundError).configPath).toBe(join(dir, CONFIG_FILENAME)); + expect((error as ConfigNotFoundError).name).toBe('ConfigNotFoundError'); + }); + + it.each([ + ['invalid JSON', '{ invalid json'], + ['an empty file', ''], + ['non-JSON text', 'This is not JSON at all'], + ])('throws ConfigParseError for %s', (_label, content) => { + writeConfig(content); + + const error = captureError(() => loadConfig({ cwd: dir })); + + expect(error).toBeInstanceOf(ConfigParseError); + expect((error as ConfigParseError).configPath).toBe(join(dir, CONFIG_FILENAME)); + expect((error as ConfigParseError).reason).toMatch(/JSON|Unexpected|Expected/i); + }); + + it.each([ + ['an array', '[]'], + ['null', 'null'], + ['a string', '"en"'], + ])('throws ConfigParseError when the file holds %s', (_label, content) => { + writeConfig(content); + + expect(() => loadConfig({ cwd: dir })).toThrow(ConfigParseError); + }); + + it('lets other I/O errors through untyped', () => { + // A directory where the file should be: readFileSync fails with EISDIR, not ENOENT. + mkdirSync(join(dir, CONFIG_FILENAME)); + + const error = captureError(() => loadConfig({ cwd: dir })); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(LingoTrackerError); + }); +}); + +function captureError(fn: () => unknown): unknown { + try { + fn(); + } catch (error) { + return error; + } + throw new Error('Expected the call to throw'); +} diff --git a/libs/core/src/lib/config/load-config.ts b/libs/core/src/lib/config/load-config.ts new file mode 100644 index 00000000..c664c700 --- /dev/null +++ b/libs/core/src/lib/config/load-config.ts @@ -0,0 +1,53 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { CONFIG_FILENAME } from '../../constants'; +import { ConfigNotFoundError, ConfigParseError } from '../errors/lingo-tracker-error'; + +export interface LoadConfigOptions { + /** Directory that holds `.lingo-tracker.json`. Default: `process.cwd()`. */ + readonly cwd?: string; +} + +/** + * Reads and parses `.lingo-tracker.json`. This is the only config reader: the CLI, the + * API, and core's own config writers all go through it. + * + * The result is the file as written. It is not validated and no fallbacks are applied; + * call {@link openCollection} to get a collection's effective settings. + * + * @throws {ConfigNotFoundError} The file does not exist. + * @throws {ConfigParseError} The file is not valid JSON, or not a JSON object. + * Other I/O failures (for example, permission denied) propagate unchanged. + */ +export function loadConfig(options: LoadConfigOptions = {}): LingoTrackerConfig { + const configPath = resolve(options.cwd ?? process.cwd(), CONFIG_FILENAME); + + if (!existsSync(configPath)) { + throw new ConfigNotFoundError(configPath); + } + + let content: string; + try { + content = readFileSync(configPath, 'utf8'); + } catch (error) { + // The file vanished between the two calls. + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new ConfigNotFoundError(configPath); + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (error) { + throw new ConfigParseError(configPath, error instanceof Error ? error.message : String(error)); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new ConfigParseError(configPath, 'the configuration must be a JSON object'); + } + + return parsed as LingoTrackerConfig; +} diff --git a/libs/core/src/lib/config/open-collection.spec.ts b/libs/core/src/lib/config/open-collection.spec.ts new file mode 100644 index 00000000..f2fa14a1 --- /dev/null +++ b/libs/core/src/lib/config/open-collection.spec.ts @@ -0,0 +1,157 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { CONFIG_FILENAME } from '../../constants'; +import { CollectionNotFoundError, ReadOnlyCollectionError } from '../errors/lingo-tracker-error'; +import { loadConfig } from './load-config'; +import { openCollection } from './open-collection'; + +const globalTranslation = { enabled: true, provider: 'google-translate', apiKeyEnv: 'GLOBAL_KEY' }; +const collectionTranslation = { enabled: false, provider: 'google-translate', apiKeyEnv: 'OWN_KEY' }; + +const fixture: LingoTrackerConfig = { + exportFolder: 'dist/export', + importFolder: 'dist/import', + baseLocale: 'en', + locales: ['en', 'fr', 'de'], + translation: globalTranslation, + collections: { + inherits: { translationsFolder: 'src/i18n' }, + overrides: { + translationsFolder: '/abs/i18n', + baseLocale: 'fr', + locales: ['fr', 'es'], + translation: collectionTranslation, + tags: ['Shared', ' shared ', 'UI'], + }, + vendor: { translationsFolder: 'node_modules/lib/i18n', readOnly: true }, + emptyLocales: { translationsFolder: 'x', locales: [] }, + blankBase: { translationsFolder: 'x', baseLocale: '' }, + }, +}; + +describe('openCollection', () => { + let dir: string; + let config: LingoTrackerConfig; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'lingo-open-collection-')); + writeFileSync(join(dir, CONFIG_FILENAME), JSON.stringify(fixture), 'utf8'); + config = loadConfig({ cwd: dir }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('falls back to the global settings when the collection has none', () => { + const collection = openCollection(config, 'inherits', { cwd: dir }); + + expect(collection).toEqual({ + name: 'inherits', + translationsFolder: resolve(dir, 'src/i18n'), + baseLocale: 'en', + locales: ['en', 'fr', 'de'], + targetLocales: ['fr', 'de'], + translationConfig: globalTranslation, + tags: [], + readOnly: false, + config: { translationsFolder: 'src/i18n' }, + }); + }); + + it("prefers the collection's own settings and does not merge translation config", () => { + const collection = openCollection(config, 'overrides', { cwd: dir }); + + expect(collection.baseLocale).toBe('fr'); + expect(collection.locales).toEqual(['fr', 'es']); + expect(collection.translationConfig).toEqual(collectionTranslation); + expect(collection.tags).toEqual(['shared', 'ui']); + }); + + it('excludes the base locale from target locales', () => { + expect(openCollection(config, 'overrides', { cwd: dir }).targetLocales).toEqual(['es']); + expect(openCollection(config, 'inherits', { cwd: dir }).targetLocales).not.toContain('en'); + }); + + it('keeps an explicitly empty locale list instead of inheriting', () => { + const collection = openCollection(config, 'emptyLocales', { cwd: dir }); + + expect(collection.locales).toEqual([]); + expect(collection.targetLocales).toEqual([]); + }); + + it('treats an empty baseLocale as unset', () => { + expect(openCollection(config, 'blankBase', { cwd: dir }).baseLocale).toBe('en'); + }); + + it("defaults to 'en' and no locales when neither the collection nor the config sets them", () => { + const bare = { collections: { app: { translationsFolder: 'i18n' } } } as unknown as LingoTrackerConfig; + + const collection = openCollection(bare, 'app', { cwd: dir }); + + expect(collection.baseLocale).toBe('en'); + expect(collection.locales).toEqual([]); + expect(collection.translationConfig).toBeUndefined(); + }); + + it('resolves a relative translations folder against cwd and keeps an absolute one', () => { + expect(openCollection(config, 'inherits', { cwd: dir }).translationsFolder).toBe(join(dir, 'src', 'i18n')); + expect(openCollection(config, 'overrides', { cwd: dir }).translationsFolder).toBe(resolve('/abs/i18n')); + }); + + it('resolves against process.cwd() by default', () => { + vi.spyOn(process, 'cwd').mockReturnValue(dir); + + expect(openCollection(config, 'inherits').translationsFolder).toBe(resolve(dir, 'src/i18n')); + }); + + it('opens a read-only collection for reading and reports readOnly', () => { + const collection = openCollection(config, 'vendor', { cwd: dir }); + + expect(collection.readOnly).toBe(true); + }); + + it('refuses a read-only collection when writable is requested', () => { + expect(() => openCollection(config, 'vendor', { cwd: dir, writable: true })).toThrow(ReadOnlyCollectionError); + expect(() => openCollection(config, 'vendor', { writable: true })).toThrow( + 'Collection "vendor" is read-only. Its resources cannot be modified.', + ); + }); + + it('opens a writable collection when writable is requested', () => { + expect(openCollection(config, 'inherits', { cwd: dir, writable: true }).readOnly).toBe(false); + }); + + it('throws CollectionNotFoundError for an unknown name', () => { + expect(() => openCollection(config, 'missing', { cwd: dir })).toThrow(CollectionNotFoundError); + expect(() => openCollection(config, 'missing', { cwd: dir })).toThrow('Collection "missing" not found'); + }); + + it('checks existence before read-only status', () => { + expect(() => openCollection(config, 'missing', { writable: true })).toThrow(CollectionNotFoundError); + }); + + it('does not treat inherited object properties as collections', () => { + expect(() => openCollection(config, 'constructor')).toThrow(CollectionNotFoundError); + expect(() => openCollection(config, 'toString')).toThrow(CollectionNotFoundError); + }); + + it('throws CollectionNotFoundError when the config has no collections at all', () => { + const noCollections = { baseLocale: 'en', locales: [] } as unknown as LingoTrackerConfig; + + expect(() => openCollection(noCollections, 'app')).toThrow(CollectionNotFoundError); + }); + + it('exposes the raw collection entry and does not modify the config', () => { + const before = JSON.stringify(config); + + const collection = openCollection(config, 'overrides', { cwd: dir }); + + expect(collection.config).toBe(config.collections.overrides); + expect(JSON.stringify(config)).toBe(before); + }); +}); diff --git a/libs/core/src/lib/config/open-collection.ts b/libs/core/src/lib/config/open-collection.ts new file mode 100644 index 00000000..30bada81 --- /dev/null +++ b/libs/core/src/lib/config/open-collection.ts @@ -0,0 +1,77 @@ +import { resolve } from 'node:path'; +import { normalizeTags } from '@simoncodes-ca/domain'; +import type { LingoTrackerCollection } from '../../config/lingo-tracker-collection'; +import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import type { TranslationConfig } from '../../config/translation-config'; +import { DEFAULT_CONFIG } from '../../constants'; +import { CollectionNotFoundError, ReadOnlyCollectionError } from '../errors/lingo-tracker-error'; + +/** + * A collection with every setting resolved: the collection's own value where it has one, + * otherwise the global value, otherwise the default. Get one from {@link openCollection}; + * nothing else applies the fallback rules. + */ +export interface Collection { + readonly name: string; + /** Absolute path of the collection's translations folder. */ + readonly translationsFolder: string; + /** Collection `baseLocale`, else global `baseLocale`, else `'en'`. */ + readonly baseLocale: string; + /** Collection `locales`, else global `locales`, else `[]`. May include the base locale. */ + readonly locales: readonly string[]; + /** {@link locales} without the base locale. */ + readonly targetLocales: readonly string[]; + /** Collection `translation`, else global `translation`. The two are not merged. */ + readonly translationConfig: TranslationConfig | undefined; + /** Collection-level tags (normalized), inherited by every resource in the collection. */ + readonly tags: readonly string[]; + readonly readOnly: boolean; + /** The collection's raw config entry, for settings not modelled here. */ + readonly config: LingoTrackerCollection; +} + +export interface OpenCollectionOptions { + /** Directory a relative `translationsFolder` resolves against. Default: `process.cwd()`. */ + readonly cwd?: string; + /** Refuse a read-only collection. Set this for operations that change resources. */ + readonly writable?: boolean; +} + +/** + * Resolves a collection's effective settings from the config. + * + * @throws {CollectionNotFoundError} The config has no collection with this name. + * @throws {ReadOnlyCollectionError} `writable` is set and the collection is read-only. + */ +export function openCollection( + config: LingoTrackerConfig, + name: string, + options: OpenCollectionOptions = {}, +): Collection { + const collections = config.collections ?? {}; + // Own keys only: a name like 'constructor' must not resolve to an Object.prototype member. + const raw = Object.keys(collections).includes(name) ? collections[name] : undefined; + if (!raw) { + throw new CollectionNotFoundError(name); + } + + const readOnly = raw.readOnly === true; + if (options.writable && readOnly) { + throw new ReadOnlyCollectionError(name); + } + + const baseLocale = raw.baseLocale || config.baseLocale || DEFAULT_CONFIG.baseLocale; + const locales = raw.locales ?? config.locales ?? []; + + return { + name, + translationsFolder: resolve(options.cwd ?? process.cwd(), raw.translationsFolder), + baseLocale, + locales, + targetLocales: locales.filter((locale) => locale !== baseLocale), + translationConfig: raw.translation ?? config.translation, + tags: normalizeTags(raw.tags ?? []), + readOnly, + config: raw, + }; +} diff --git a/libs/core/src/lib/errors/error-messages.ts b/libs/core/src/lib/errors/error-messages.ts index 9d57cc2f..8c9b505f 100644 --- a/libs/core/src/lib/errors/error-messages.ts +++ b/libs/core/src/lib/errors/error-messages.ts @@ -22,6 +22,8 @@ export const ErrorMessages = { collectionNotFound: (name: string) => `Collection "${name}" not found`, + collectionReadOnly: (name: string) => `Collection "${name}" is read-only. Its resources cannot be modified.`, + collectionAlreadyExists: (name: string) => `Collection "${name}" already exists`, localeAlreadyExists: (locale: string, collection: string) => diff --git a/libs/core/src/lib/errors/index.ts b/libs/core/src/lib/errors/index.ts index ffbe7939..50bdac62 100644 --- a/libs/core/src/lib/errors/index.ts +++ b/libs/core/src/lib/errors/index.ts @@ -1 +1,2 @@ export * from './error-messages'; +export * from './lingo-tracker-error'; diff --git a/libs/core/src/lib/errors/lingo-tracker-error.ts b/libs/core/src/lib/errors/lingo-tracker-error.ts new file mode 100644 index 00000000..3b490fd7 --- /dev/null +++ b/libs/core/src/lib/errors/lingo-tracker-error.ts @@ -0,0 +1,55 @@ +import { ErrorMessages } from './error-messages'; + +/** + * Base class for errors LingoTracker raises on purpose. Adapters (CLI, API) can test + * `instanceof` on a subclass to choose an exit code or HTTP status, instead of matching + * message text. + */ +export class LingoTrackerError extends Error { + constructor(message: string) { + super(message); + this.name = new.target.name; + } +} + +/** `.lingo-tracker.json` does not exist in the directory that was searched. */ +export class ConfigNotFoundError extends LingoTrackerError { + readonly configPath: string; + + constructor(configPath: string) { + super(`${ErrorMessages.configNotFound()}: ${configPath}`); + this.configPath = configPath; + } +} + +/** `.lingo-tracker.json` exists but is not a JSON object. `reason` is the parser's message. */ +export class ConfigParseError extends LingoTrackerError { + readonly configPath: string; + readonly reason: string; + + constructor(configPath: string, reason: string) { + super(ErrorMessages.jsonParseFailed(configPath, reason)); + this.configPath = configPath; + this.reason = reason; + } +} + +/** The config has no collection with this name. */ +export class CollectionNotFoundError extends LingoTrackerError { + readonly collectionName: string; + + constructor(collectionName: string) { + super(ErrorMessages.collectionNotFound(collectionName)); + this.collectionName = collectionName; + } +} + +/** A mutating operation was asked of a collection flagged `readOnly`. */ +export class ReadOnlyCollectionError extends LingoTrackerError { + readonly collectionName: string; + + constructor(collectionName: string) { + super(ErrorMessages.collectionReadOnly(collectionName)); + this.collectionName = collectionName; + } +} diff --git a/libs/core/src/lib/file-io/json-file-operations.ts b/libs/core/src/lib/file-io/json-file-operations.ts index 321b9c12..653d0420 100644 --- a/libs/core/src/lib/file-io/json-file-operations.ts +++ b/libs/core/src/lib/file-io/json-file-operations.ts @@ -2,7 +2,6 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import type { ResourceEntries } from '../../resource/resource-entry'; import type { TrackerMetadata } from '../../resource/tracker-metadata'; -import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; import { ErrorMessages } from '../errors/error-messages'; export interface JsonFileReadOptions { @@ -125,13 +124,3 @@ export function readTrackerMetadata(filePath: string, defaultValue?: TrackerMeta errorContext: 'Reading tracker metadata', }); } - -/** - * Type-safe helper for reading configuration files. - */ -export function readLingoConfig(filePath: string): LingoTrackerConfig { - return readJsonFile({ - filePath, - errorContext: 'Reading LingoTracker configuration', - }); -} diff --git a/libs/core/src/lib/import/import-error-handling.integration.spec.ts b/libs/core/src/lib/import/import-error-handling.integration.spec.ts index 1dd40d81..f09c9952 100644 --- a/libs/core/src/lib/import/import-error-handling.integration.spec.ts +++ b/libs/core/src/lib/import/import-error-handling.integration.spec.ts @@ -4,7 +4,6 @@ import { importFromXliff } from './import-from-xliff'; import type { ImportOptions } from './types'; import * as fs from 'fs'; import * as path from 'path'; -import * as configFileOperations from '../config/config-file-operations'; vi.mock('fs'); vi.mock('path'); @@ -21,18 +20,6 @@ describe('import error handling integration', () => { parts.pop(); return parts.join('/'); }); - - vi.spyOn(configFileOperations, 'createConfigFileOperations').mockReturnValue({ - read: () => ({ - exportFolder: 'dist/lingo-export', - importFolder: 'dist/lingo-import', - baseLocale: 'en', - locales: ['en', 'es'], - collections: {}, - }), - write: vi.fn(), - update: vi.fn(), - }); }); describe('Fatal errors', () => { @@ -42,6 +29,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/missing.json', locale: 'es', + baseLocale: 'en', }; expect(() => importFromJson('/translations', options)).toThrow('Source file not found'); @@ -54,6 +42,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'en', // base locale + baseLocale: 'en', }; expect(() => importFromJson('/translations', options)).toThrow('Cannot import into base locale'); @@ -66,6 +55,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/malformed.json', locale: 'es', + baseLocale: 'en', }; expect(() => importFromJson('/translations', options)).toThrow('Failed to parse JSON file'); @@ -77,6 +67,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/missing.xliff', locale: 'es', + baseLocale: 'en', }; await expect(importFromXliff('/translations', options)).rejects.toThrow('Source file not found'); @@ -89,6 +80,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/malformed.xliff', locale: 'es', + baseLocale: 'en', }; await expect(importFromXliff('/translations', options)).rejects.toThrow('Failed to parse XLIFF content'); @@ -115,6 +107,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -145,6 +138,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -173,6 +167,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -205,6 +200,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -248,6 +244,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations/common', options); @@ -273,6 +270,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', // Does not allow creation }; @@ -302,6 +300,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -332,6 +331,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -385,6 +385,7 @@ describe('import error handling integration', () => { const options: ImportOptions = { source: '/import/test.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations/common', options); diff --git a/libs/core/src/lib/import/import-from-json.integration.spec.ts b/libs/core/src/lib/import/import-from-json.integration.spec.ts index 19d043a8..6572c7e1 100644 --- a/libs/core/src/lib/import/import-from-json.integration.spec.ts +++ b/libs/core/src/lib/import/import-from-json.integration.spec.ts @@ -3,7 +3,6 @@ import { importFromJson } from './import-from-json'; import type { ImportOptions } from './types'; import * as fs from 'fs'; import * as path from 'path'; -import * as configFileOperations from '../config/config-file-operations'; vi.mock('fs'); vi.mock('path'); @@ -15,18 +14,6 @@ describe('importFromJson - integration tests', () => { // Mock path functions vi.spyOn(path, 'resolve').mockImplementation((...segments) => segments.join('/')); vi.spyOn(path, 'join').mockImplementation((...segments) => segments.join('/')); - - vi.spyOn(configFileOperations, 'createConfigFileOperations').mockReturnValue({ - read: () => ({ - exportFolder: 'dist/lingo-export', - importFolder: 'dist/lingo-import', - baseLocale: 'en', - locales: ['en', 'es'], - collections: {}, - }), - write: vi.fn(), - update: vi.fn(), - }); }); describe('flat JSON import', () => { @@ -92,6 +79,7 @@ describe('importFromJson - integration tests', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', collection: 'TestCollection', }; @@ -156,6 +144,7 @@ describe('importFromJson - integration tests', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations/common/buttons', options); @@ -204,6 +193,7 @@ describe('importFromJson - integration tests', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations/common', options); @@ -255,6 +245,7 @@ describe('importFromJson - integration tests', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations/common/buttons', options); @@ -307,6 +298,7 @@ describe('importFromJson - integration tests', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations/apps/dashboard/widgets/chart', options); @@ -351,6 +343,7 @@ describe('importFromJson - integration tests', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations/common', options); @@ -397,6 +390,7 @@ describe('importFromJson - integration tests', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations/common/buttons', options); diff --git a/libs/core/src/lib/import/import-from-json.spec.ts b/libs/core/src/lib/import/import-from-json.spec.ts index 1751a9a2..a442d604 100644 --- a/libs/core/src/lib/import/import-from-json.spec.ts +++ b/libs/core/src/lib/import/import-from-json.spec.ts @@ -3,7 +3,6 @@ import * as fs from 'fs'; import * as path from 'path'; import { detectJsonStructure, extractFromFlat, extractFromHierarchical, importFromJson } from './import-from-json'; import type { ImportOptions } from './types'; -import * as configFileOperations from '../config/config-file-operations'; // Mock fs module vi.mock('fs'); @@ -18,18 +17,6 @@ describe('import-from-json', () => { // Mock path.join to return predictable paths vi.spyOn(path, 'join').mockImplementation((...segments) => segments.join('/')); - - vi.spyOn(configFileOperations, 'createConfigFileOperations').mockReturnValue({ - read: () => ({ - exportFolder: 'dist/lingo-export', - importFolder: 'dist/lingo-import', - baseLocale: 'en', - locales: ['en', 'es'], - collections: {}, - }), - write: vi.fn(), - update: vi.fn(), - }); }); afterEach(() => { @@ -235,6 +222,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/missing.json', locale: 'es', + baseLocale: 'en', }; expect(() => importFromJson('/translations', options)).toThrow('Source file not found'); @@ -247,6 +235,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/invalid.json', locale: 'es', + baseLocale: 'en', }; expect(() => importFromJson('/translations', options)).toThrow('Failed to parse JSON file'); @@ -259,6 +248,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'en', // base locale + baseLocale: 'en', }; expect(() => importFromJson('/translations', options)).toThrow('Cannot import into base locale "en"'); @@ -284,6 +274,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations', options); @@ -304,6 +295,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations', options); @@ -334,6 +326,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations', options); @@ -353,6 +346,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations', options); @@ -373,6 +367,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations', options); @@ -412,6 +407,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', dryRun: true, }; @@ -437,6 +433,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', onProgress: (msg) => progressMessages.push(msg), }; @@ -464,6 +461,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', verbose: true, onProgress: (msg) => progressMessages.push(msg), }; @@ -641,6 +639,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', updateComments: true, }; @@ -694,6 +693,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', updateComments: false, }; @@ -749,6 +749,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', updateTags: true, }; @@ -802,6 +803,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', updateTags: false, }; @@ -858,6 +860,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', validateBase: true, }; @@ -906,6 +909,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', validateBase: false, }; @@ -953,6 +957,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', preserveStatus: true, }; @@ -1004,6 +1009,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/path/to/file.json', locale: 'es', + baseLocale: 'en', preserveStatus: false, }; @@ -1086,6 +1092,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', // Don't specify updateComments/updateTags - should use strategy defaults (false) }; @@ -1139,6 +1146,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'verification', }; @@ -1190,6 +1198,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'verification', }; @@ -1239,6 +1248,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', // Don't specify updateComments/updateTags - should use strategy defaults (true) }; @@ -1283,6 +1293,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -1332,6 +1343,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'update', }; @@ -1375,6 +1387,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'update', }; @@ -1419,6 +1432,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'update', // Don't specify updateComments/updateTags - should use strategy defaults (false) }; @@ -1475,6 +1489,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', }; @@ -1519,6 +1534,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'verification', }; @@ -1562,6 +1578,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', createMissing: true, }; @@ -1620,6 +1637,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', createMissing: true, }; @@ -1656,6 +1674,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', createMissing: false, }; @@ -1703,6 +1722,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', createMissing: true, }; @@ -1762,6 +1782,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', createMissing: true, }; @@ -1814,6 +1835,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', createMissing: true, }; @@ -1853,6 +1875,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -1894,6 +1917,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -1933,6 +1957,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -1971,6 +1996,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -2012,6 +2038,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -2053,6 +2080,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', }; @@ -2097,6 +2125,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', createMissing: true, }; @@ -2153,6 +2182,7 @@ describe('import-from-json', () => { const options: ImportOptions = { source: '/import/import.json', locale: 'es', + baseLocale: 'en', }; const result = importFromJson('/translations', options); @@ -2211,6 +2241,7 @@ describe('import-from-json', () => { const result = importFromJson('/translations', { source: '/import/import.json', locale: 'es', + baseLocale: 'en', protectedTerms: ['iPhone'], }); @@ -2237,6 +2268,7 @@ describe('import-from-json', () => { const result = importFromJson('/translations', { source: '/import/import.json', locale: 'es', + baseLocale: 'en', protectedTerms: ['iPhone'], }); diff --git a/libs/core/src/lib/import/import-from-json.ts b/libs/core/src/lib/import/import-from-json.ts index 435627ff..4ad3185f 100644 --- a/libs/core/src/lib/import/import-from-json.ts +++ b/libs/core/src/lib/import/import-from-json.ts @@ -271,6 +271,7 @@ export function extractFromHierarchical(data: Record, prefix = * const result = importFromJson('/project/src/translations', { * source: 'translated-es.json', * locale: 'es', + * baseLocale: 'en', * strategy: 'translation-service', * dryRun: false * }); @@ -280,6 +281,7 @@ export function extractFromHierarchical(data: Record, prefix = * const result = importFromJson('/project/src/translations', { * source: 'old-system-fr.json', * locale: 'fr', + * baseLocale: 'en', * strategy: 'migration', * createMissing: true, * updateComments: true, @@ -292,6 +294,7 @@ export function extractFromHierarchical(data: Record, prefix = * const preview = importFromJson('/project/src/translations', { * source: 'new-translations.json', * locale: 'de', + * baseLocale: 'en', * strategy: 'translation-service', * dryRun: true * }); diff --git a/libs/core/src/lib/import/import-from-xliff.spec.ts b/libs/core/src/lib/import/import-from-xliff.spec.ts index 2c8f0046..f3ee722a 100644 --- a/libs/core/src/lib/import/import-from-xliff.spec.ts +++ b/libs/core/src/lib/import/import-from-xliff.spec.ts @@ -3,7 +3,6 @@ import { extractFromXliff, importFromXliff } from './import-from-xliff'; import type { ImportOptions } from './types'; import * as fs from 'fs'; import * as path from 'path'; -import * as configFileOperations from '../config/config-file-operations'; vi.mock('fs'); vi.mock('path'); @@ -20,18 +19,6 @@ describe('import-from-xliff', () => { parts.pop(); return parts.join('/'); }); - - vi.spyOn(configFileOperations, 'createConfigFileOperations').mockReturnValue({ - read: () => ({ - exportFolder: 'dist/lingo-export', - importFolder: 'dist/lingo-import', - baseLocale: 'en', - locales: ['en', 'es'], - collections: {}, - }), - write: vi.fn(), - update: vi.fn(), - }); }); describe('extractFromXliff', () => { @@ -165,6 +152,7 @@ describe('import-from-xliff', () => { const options: ImportOptions = { source: '/import/test.xliff', locale: 'es', + baseLocale: 'en', }; const result = await importFromXliff('/translations/common/buttons', options); @@ -220,6 +208,7 @@ describe('import-from-xliff', () => { const options: ImportOptions = { source: '/import/test.xliff', locale: 'es', + baseLocale: 'en', }; const result = await importFromXliff('/translations/common', options); @@ -263,6 +252,7 @@ describe('import-from-xliff', () => { const options: ImportOptions = { source: '/import/test.xliff', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -319,6 +309,7 @@ describe('import-from-xliff', () => { const options: ImportOptions = { source: '/import/test.xliff', locale: 'es', + baseLocale: 'en', updateComments: true, }; @@ -375,6 +366,7 @@ describe('import-from-xliff', () => { const options: ImportOptions = { source: '/import/test.xliff', locale: 'es', + baseLocale: 'en', strategy: 'verification', }; @@ -394,6 +386,7 @@ describe('import-from-xliff', () => { const options: ImportOptions = { source: '/import/missing.xliff', locale: 'es', + baseLocale: 'en', }; await expect(importFromXliff('/translations/common', options)).rejects.toThrow('Source file not found'); @@ -403,6 +396,7 @@ describe('import-from-xliff', () => { const options: ImportOptions = { source: '/import/test.xliff', locale: 'en', + baseLocale: 'en', }; await expect(importFromXliff('/translations/common', options)).rejects.toThrow('Cannot import into base locale'); diff --git a/libs/core/src/lib/import/import-from-xliff.ts b/libs/core/src/lib/import/import-from-xliff.ts index c69020b6..9a7c16ac 100644 --- a/libs/core/src/lib/import/import-from-xliff.ts +++ b/libs/core/src/lib/import/import-from-xliff.ts @@ -149,6 +149,7 @@ export async function extractFromXliff(xliffContent: string): Promise console.log(msg) @@ -168,6 +170,7 @@ export async function extractFromXliff(xliffContent: string): Promise { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', validateBase: true, dryRun: false, @@ -79,6 +80,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.xliff', locale: 'fr', + baseLocale: 'en', strategy: 'verification', dryRun: true, }; @@ -113,6 +115,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -146,6 +149,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', }; @@ -198,6 +202,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', createMissing: true, }; @@ -250,6 +255,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', }; @@ -294,6 +300,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', }; @@ -338,6 +345,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -375,6 +383,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -419,6 +428,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -451,6 +461,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', updateComments: true, updateTags: true, @@ -493,6 +504,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'update', }; @@ -524,6 +536,7 @@ describe('import-summary', () => { const options: ImportOptions = { source: '/test/import.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', createMissing: true, }; diff --git a/libs/core/src/lib/import/import-workflow.spec.ts b/libs/core/src/lib/import/import-workflow.spec.ts index 1feed477..c1e87238 100644 --- a/libs/core/src/lib/import/import-workflow.spec.ts +++ b/libs/core/src/lib/import/import-workflow.spec.ts @@ -1,24 +1,11 @@ -import { resolve } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CONFIG_FILENAME } from '../../constants'; import { setupImportWorkflow, buildImportResult } from './import-workflow'; import type { ImportOptions } from './types'; -import * as configFileOperations from '../config/config-file-operations'; +import * as loadConfigModule from '../config/load-config'; describe('import-workflow', () => { beforeEach(() => { vi.restoreAllMocks(); - vi.spyOn(configFileOperations, 'createConfigFileOperations').mockReturnValue({ - read: () => ({ - exportFolder: 'dist/lingo-export', - importFolder: 'dist/lingo-import', - baseLocale: 'en', - locales: ['en', 'es'], - collections: {}, - }), - write: vi.fn(), - update: vi.fn(), - }); }); describe('setupImportWorkflow', () => { @@ -26,6 +13,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', }; @@ -42,6 +30,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'fr', + baseLocale: 'en', strategy: 'migration', }; @@ -56,6 +45,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'de', + baseLocale: 'en', strategy: 'verification', }; @@ -70,6 +60,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'it', + baseLocale: 'en', strategy: 'update', }; @@ -84,6 +75,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', createMissing: true, // Override default (false) updateComments: true, // Override default (false) @@ -100,6 +92,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'es', + baseLocale: 'en', }; const config = setupImportWorkflow(options); @@ -112,6 +105,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'en', // Base locale + baseLocale: 'en', strategy: 'translation-service', }; @@ -124,6 +118,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'en', // Base locale + baseLocale: 'en', strategy: 'migration', }; @@ -141,6 +136,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', }; @@ -155,6 +151,7 @@ describe('import-workflow', () => { const options: ImportOptions = { source: 'test.json', locale: 'es', + baseLocale: 'en', }; const config = setupImportWorkflow(options); @@ -162,8 +159,8 @@ describe('import-workflow', () => { expect(config.cwd).toBe(process.cwd()); }); - it('should use explicitly provided baseLocale without reading config', () => { - const createConfigSpy = vi.mocked(configFileOperations.createConfigFileOperations); + it('should use the caller-supplied baseLocale without reading the config file', () => { + const loadConfigSpy = vi.spyOn(loadConfigModule, 'loadConfig'); const options: ImportOptions = { source: 'test.json', locale: 'fr', @@ -174,45 +171,26 @@ describe('import-workflow', () => { const config = setupImportWorkflow(options); expect(config.baseLocale).toBe('fr'); - expect(createConfigSpy).not.toHaveBeenCalled(); - }); - - it('should fall back to en when config is readable but baseLocale is absent', () => { - vi.spyOn(configFileOperations, 'createConfigFileOperations').mockReturnValue({ - read: () => - ({ - exportFolder: 'dist/lingo-export', - importFolder: 'dist/lingo-import', - locales: ['en', 'es'], - collections: {}, - }) as unknown as ReturnType['read']>, - write: vi.fn(), - update: vi.fn(), - }); - - const config = setupImportWorkflow({ - source: 'test.json', - locale: 'es', - }); - - expect(config.baseLocale).toBe('en'); + expect(config.isBaseLocaleImport).toBe(true); + expect(loadConfigSpy).not.toHaveBeenCalled(); }); - it('should throw with config path when config read fails', () => { - vi.spyOn(configFileOperations, 'createConfigFileOperations').mockReturnValue({ - read: () => { - throw new Error('Failed to read or parse configuration file'); - }, - write: vi.fn(), - update: vi.fn(), - }); - + it('should treat a collection base locale other than en as the base', () => { expect(() => setupImportWorkflow({ source: 'test.json', - locale: 'es', + locale: 'de', + baseLocale: 'de', }), - ).toThrow(`Failed to read configuration file at "${resolve(process.cwd(), CONFIG_FILENAME)}"`); + ).toThrow('Cannot import into base locale "de" with strategy "translation-service"'); + }); + + it('should throw when baseLocale is missing or blank', () => { + const missing = { source: 'test.json', locale: 'es' } as ImportOptions; + expect(() => setupImportWorkflow(missing)).toThrow('ImportOptions.baseLocale is required'); + expect(() => setupImportWorkflow({ source: 'test.json', locale: 'es', baseLocale: ' ' })).toThrow( + 'ImportOptions.baseLocale is required', + ); }); }); @@ -223,6 +201,7 @@ describe('import-workflow', () => { options: { source: 'test.json', locale: 'es', + baseLocale: 'en', strategy: 'translation-service', collection: 'my-collection', dryRun: false, @@ -276,6 +255,7 @@ describe('import-workflow', () => { options: { source: 'test.xlf', locale: 'fr', + baseLocale: 'en', strategy: 'verification', }, statistics: { @@ -308,6 +288,7 @@ describe('import-workflow', () => { options: { source: 'test.json', locale: 'es', + baseLocale: 'en', dryRun: true, }, statistics: { @@ -332,6 +313,7 @@ describe('import-workflow', () => { options: { source: 'test.json', locale: 'es', + baseLocale: 'en', }, statistics: { resourcesCreated: 0, @@ -357,6 +339,7 @@ describe('import-workflow', () => { options: { source: 'test.json', locale: 'es', + baseLocale: 'en', }, statistics: { resourcesCreated: 0, diff --git a/libs/core/src/lib/import/import-workflow.ts b/libs/core/src/lib/import/import-workflow.ts index 3f2c6de7..761fdf40 100644 --- a/libs/core/src/lib/import/import-workflow.ts +++ b/libs/core/src/lib/import/import-workflow.ts @@ -1,8 +1,5 @@ import type { ImportOptions, ImportResult, StatusTransition, ImportChange, ICUAutoFix, ICUAutoFixError } from './types'; -import { resolve } from 'node:path'; import { getStrategyDefaults } from './import-common'; -import { createConfigFileOperations } from '../config/config-file-operations'; -import { CONFIG_FILENAME } from '../../constants'; /** * Configuration returned after setting up an import operation. @@ -23,27 +20,12 @@ export interface ImportWorkflowConfig { isBaseLocaleImport: boolean; } -/** - * Resolves the base locale from the project config file. - * Falls back to 'en' only when the config is readable but omits baseLocale. - */ -function resolveBaseLocaleFromConfig(cwd: string): string { - const configPath = resolve(cwd, CONFIG_FILENAME); - - try { - return createConfigFileOperations({ cwd, validate: false }).read().baseLocale ?? 'en'; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to read configuration file at "${configPath}": ${message}`); - } -} - /** * Sets up and validates the import workflow configuration. * * This function performs common setup steps required by all import formats: * 1. Applies strategy-specific defaults for flags (createMissing, updateComments, updateTags) - * 2. Determines the base locale from configuration + * 2. Takes the base locale from the options (the caller resolves it from the collection) * 3. Validates that the target locale is not the base locale (except for migration strategy) * 4. Returns merged configuration ready for use * @@ -56,6 +38,7 @@ function resolveBaseLocaleFromConfig(cwd: string): string { * @param options - Raw import options from user or CLI * @returns Validated configuration with merged options and derived values * + * @throws {Error} If `baseLocale` is missing or blank * @throws {Error} If attempting to import into the base locale with non-migration strategy * * @example @@ -63,6 +46,7 @@ function resolveBaseLocaleFromConfig(cwd: string): string { * const config = setupImportWorkflow({ * source: 'translations-es.json', * locale: 'es', + * baseLocale: 'en', * strategy: 'translation-service' * }); * @@ -95,7 +79,11 @@ export function setupImportWorkflow(options: ImportOptions): ImportWorkflowConfi }; const cwd = process.cwd(); - const baseLocale = options.baseLocale ?? resolveBaseLocaleFromConfig(cwd); + const { baseLocale } = options; + // Required by the type, but JS callers and loosely-typed adapters can still omit it. + if (typeof baseLocale !== 'string' || baseLocale.trim() === '') { + throw new Error('ImportOptions.baseLocale is required'); + } const isBaseLocaleImport = locale === baseLocale; @@ -137,7 +125,7 @@ export function setupImportWorkflow(options: ImportOptions): ImportWorkflowConfi * ```typescript * const result = buildImportResult({ * format: 'json', - * options: { source: 'file.json', locale: 'es', strategy: 'translation-service' }, + * options: { source: 'file.json', locale: 'es', baseLocale: 'en', strategy: 'translation-service' }, * statistics: { resourcesCreated: 0, resourcesUpdated: 10, resourcesSkipped: 2, resourcesFailed: 0 }, * statusTransitions: [{ from: 'new', to: 'translated', count: 10 }], * changes: [...], diff --git a/libs/core/src/lib/import/process-resource-group.spec.ts b/libs/core/src/lib/import/process-resource-group.spec.ts index c24b5b7f..3fcea13c 100644 --- a/libs/core/src/lib/import/process-resource-group.spec.ts +++ b/libs/core/src/lib/import/process-resource-group.spec.ts @@ -824,6 +824,7 @@ describe('process-resource-group', () => { { source: 'test.json', locale: 'es', + baseLocale: 'en', strategy: 'migration', createMissing: true, preserveStatus: false, @@ -1231,6 +1232,7 @@ describe('process-resource-group', () => { { source: 'test.json', locale: 'en', + baseLocale: 'en', strategy: 'migration', createMissing: true, updateComments: true, @@ -1436,6 +1438,7 @@ describe('process-resource-group', () => { { source: 'test.json', locale: 'en', + baseLocale: 'en', strategy: 'migration', updateComments: true, updateTags: true, @@ -1498,6 +1501,7 @@ describe('process-resource-group', () => { { source: 'test.json', locale: 'en', + baseLocale: 'en', strategy: 'migration', updateComments: true, updateTags: true, diff --git a/libs/core/src/lib/import/types.spec.ts b/libs/core/src/lib/import/types.spec.ts index 0ef4d532..f269aef2 100644 --- a/libs/core/src/lib/import/types.spec.ts +++ b/libs/core/src/lib/import/types.spec.ts @@ -35,6 +35,7 @@ describe('import types', () => { const options: ImportOptions = { source: '/path/to/file.xliff', locale: 'es', + baseLocale: 'en', }; expect(options.source).toBe('/path/to/file.xliff'); expect(options.locale).toBe('es'); @@ -45,6 +46,7 @@ describe('import types', () => { format: 'xliff', source: '/path/to/file.xliff', locale: 'es', + baseLocale: 'en', collection: 'TestCollection', strategy: 'translation-service', updateComments: false, diff --git a/libs/core/src/lib/import/types.ts b/libs/core/src/lib/import/types.ts index dad57988..104f00e9 100644 --- a/libs/core/src/lib/import/types.ts +++ b/libs/core/src/lib/import/types.ts @@ -41,8 +41,11 @@ export interface ImportOptions { verbose?: boolean; /** Create backup before importing (.bak files) */ backup?: boolean; - /** Base locale code (e.g., 'en'). Defaults to 'en' when not specified. */ - baseLocale?: string; + /** + * The collection's base locale (e.g. 'en'), normally `openCollection(...).baseLocale`. + * Decides whether the import writes base values and which locale stays untouched. + */ + baseLocale: string; /** * Protected terms (union of global + collection) that must survive translation * verbatim. On import, an entry whose source contains such a term but whose diff --git a/libs/core/src/lib/normalize/normalize-entry.ts b/libs/core/src/lib/normalize/normalize-entry.ts index 27006382..73465160 100644 --- a/libs/core/src/lib/normalize/normalize-entry.ts +++ b/libs/core/src/lib/normalize/normalize-entry.ts @@ -10,7 +10,7 @@ export interface NormalizeEntryParams { readonly resourceEntry: ResourceEntry; readonly metadata: ResourceEntryMetadata; readonly baseLocale: string; - readonly locales: string[]; + readonly locales: readonly string[]; } export interface NormalizeEntryResult { @@ -26,7 +26,7 @@ export interface NormalizeEntryResult { } interface ProcessAllLocalesParams { - readonly locales: string[]; + readonly locales: readonly string[]; readonly baseLocale: string; readonly baseValue: string; readonly currentBaseChecksum: string; diff --git a/libs/core/src/lib/normalize/normalize.ts b/libs/core/src/lib/normalize/normalize.ts index 87a9684b..89ae8f66 100644 --- a/libs/core/src/lib/normalize/normalize.ts +++ b/libs/core/src/lib/normalize/normalize.ts @@ -7,7 +7,7 @@ import { openResourceFolder, type ResourceFolder } from '../resource/resource-fo export interface NormalizeParams { readonly translationsFolder: string; readonly baseLocale: string; - readonly locales: string[]; + readonly locales: readonly string[]; readonly dryRun?: boolean; } @@ -46,7 +46,7 @@ function openFolderOrWarn(folderPath: string, baseLocale: string): ResourceFolde interface NormalizeFolderParams { readonly folderPath: string; readonly baseLocale: string; - readonly locales: string[]; + readonly locales: readonly string[]; readonly dryRun: boolean; readonly counters: NormalizationCounters; } @@ -100,7 +100,7 @@ function normalizeFolderResources(params: NormalizeFolderParams): void { interface NormalizeAllFoldersParams { readonly rootPath: string; readonly baseLocale: string; - readonly locales: string[]; + readonly locales: readonly string[]; readonly dryRun: boolean; readonly counters: NormalizationCounters; } diff --git a/libs/core/src/lib/translation/translate-existing-resource.ts b/libs/core/src/lib/translation/translate-existing-resource.ts index 56443c9d..0f9da95b 100644 --- a/libs/core/src/lib/translation/translate-existing-resource.ts +++ b/libs/core/src/lib/translation/translate-existing-resource.ts @@ -9,7 +9,7 @@ export interface TranslateExistingResourceOptions { readonly key: string; readonly translationsFolder: string; readonly translationConfig: TranslationConfig; - readonly allLocales: string[]; + readonly allLocales: readonly string[]; readonly baseLocale: string; readonly cwd?: string; } diff --git a/libs/core/src/lib/translation/translate-locale.ts b/libs/core/src/lib/translation/translate-locale.ts index 866c98e0..b0133a89 100644 --- a/libs/core/src/lib/translation/translate-locale.ts +++ b/libs/core/src/lib/translation/translate-locale.ts @@ -31,7 +31,7 @@ export interface TranslateLocaleParams { readonly translationConfig: TranslationConfig; readonly targetLocale: string; readonly baseLocale: string; - readonly allLocales: string[]; + readonly allLocales: readonly string[]; readonly cwd?: string; readonly onProgress?: (progress: TranslateLocaleProgress) => void; } diff --git a/libs/core/src/resource/add-resource.ts b/libs/core/src/resource/add-resource.ts index 166a2bbc..77a12250 100644 --- a/libs/core/src/resource/add-resource.ts +++ b/libs/core/src/resource/add-resource.ts @@ -34,7 +34,7 @@ export interface AddResourceParams { * All configured locales. Required when using auto-translation so the * orchestrator knows which target locales to translate into. */ - allLocales?: string[]; + allLocales?: readonly string[]; } /** diff --git a/libs/core/src/resource/edit-resource.ts b/libs/core/src/resource/edit-resource.ts index 41d63ad6..eb62f111 100644 --- a/libs/core/src/resource/edit-resource.ts +++ b/libs/core/src/resource/edit-resource.ts @@ -20,7 +20,7 @@ export interface EditResourceOptions { * All configured locales. Required when using auto-translation after a base * value change so the orchestrator knows which target locales to update. */ - allLocales?: string[]; + allLocales?: readonly string[]; } export interface EditResourceResult { @@ -160,7 +160,7 @@ interface ApplyAutoTranslationsParams { readonly key: string; readonly baseValue: string; readonly baseLocale: string; - readonly allLocales: string[] | undefined; + readonly allLocales: readonly string[] | undefined; readonly translationConfig: TranslationConfig | undefined; } diff --git a/libs/core/src/resource/translation-helpers.ts b/libs/core/src/resource/translation-helpers.ts index 4f826823..bd5df8d0 100644 --- a/libs/core/src/resource/translation-helpers.ts +++ b/libs/core/src/resource/translation-helpers.ts @@ -9,7 +9,7 @@ import type { TranslationStatus } from '@simoncodes-ca/domain'; * @returns Array of translation objects with 'new' status, or undefined if no non-base locales exist */ export function createDefaultTranslations( - locales: string[], + locales: readonly string[], baseLocale: string, baseValue: string, ): Array<{ locale: string; value: string; status: TranslationStatus }> | undefined { From 540058f333b7d08bfe64fb1baa549115c7b24304 Mon Sep 17 00:00:00 2001 From: snodel Date: Tue, 22 Sep 2026 23:43:02 -0700 Subject: [PATCH 03/20] refactor(core): collapse import into importResources and add runExport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import is now two format adapters (parseJsonImport, parseXliffImport) feeding one importResources(collection, resources, options) module that owns reference resolution, Transloco→ICU normalisation, ICU auto-fix, validation, grouping and per-folder processing over ResourceFolder, with state in an ImportSession instead of positional accumulators. The import barrel shrinks from 41 to 17 symbols; reference-resolver moves to domain. Export gains the symmetric runExport(collections, options) that absorbs the per-locale loop, filtering, exporter dispatch and aggregation from the CLI. Export now uses each collection's own target locales, refuses mixed base locales, and keeps a key shared by two collections in each collection's own locales. Co-Authored-By: Claude Fable 5.1 --- apps/cli/src/commands/export-cmd.test.ts | 552 +--- apps/cli/src/commands/export-cmd.ts | 232 +- apps/cli/src/commands/import-cmd.spec.ts | 173 +- apps/cli/src/commands/import-cmd.ts | 62 +- architecture-docs/cli.md | 4 +- architecture-docs/core-library.md | 77 +- architecture-docs/glossary.md | 18 + architecture-docs/monorepo-structure.md | 6 +- architecture-docs/user-flows.md | 76 +- docs/features/export.md | 6 +- libs/core/src/index.ts | 30 +- libs/core/src/lib/export/run-export.spec.ts | 306 +++ libs/core/src/lib/export/run-export.ts | 158 ++ libs/core/src/lib/import/determine-status.ts | 8 +- .../import-error-handling.integration.spec.ts | 397 --- .../import-from-json.integration.spec.ts | 403 --- .../src/lib/import/import-from-json.spec.ts | 2285 ----------------- libs/core/src/lib/import/import-from-json.ts | 429 ---- .../src/lib/import/import-from-xliff.spec.ts | 405 --- libs/core/src/lib/import/import-from-xliff.ts | 295 --- .../import-preferred-terminology.spec.ts | 68 +- .../lib/import/import-resources.merge.spec.ts | 369 +++ .../import/import-resources.pipeline.spec.ts | 329 +++ .../src/lib/import/import-resources.spec.ts | 337 +++ libs/core/src/lib/import/import-resources.ts | 77 + .../src/lib/import/import-session.spec.ts | 137 + libs/core/src/lib/import/import-session.ts | 95 + .../src/lib/import/import-summary.spec.ts | 82 +- libs/core/src/lib/import/import-summary.ts | 21 +- .../src/lib/import/import-workflow.spec.ts | 364 --- libs/core/src/lib/import/import-workflow.ts | 188 -- libs/core/src/lib/import/index.ts | 77 +- .../src/lib/import/load-base-locale-values.ts | 13 +- .../src/lib/import/parse-json-import.spec.ts | 158 ++ libs/core/src/lib/import/parse-json-import.ts | 252 ++ .../src/lib/import/parse-xliff-import.spec.ts | 83 + .../core/src/lib/import/parse-xliff-import.ts | 129 + .../lib/import/process-resource-group.spec.ts | 1653 ------------ .../src/lib/import/process-resource-group.ts | 82 +- .../src/lib/import/resource-grouping.spec.ts | 14 +- libs/core/src/lib/import/resource-grouping.ts | 14 +- libs/core/src/lib/import/types.spec.ts | 29 +- libs/core/src/lib/import/types.ts | 46 +- libs/domain/src/index.ts | 1 + .../src/lib}/reference-resolver.spec.ts | 27 +- .../src/lib}/reference-resolver.ts | 18 +- 46 files changed, 3120 insertions(+), 7465 deletions(-) create mode 100644 libs/core/src/lib/export/run-export.spec.ts create mode 100644 libs/core/src/lib/export/run-export.ts delete mode 100644 libs/core/src/lib/import/import-error-handling.integration.spec.ts delete mode 100644 libs/core/src/lib/import/import-from-json.integration.spec.ts delete mode 100644 libs/core/src/lib/import/import-from-json.spec.ts delete mode 100644 libs/core/src/lib/import/import-from-json.ts delete mode 100644 libs/core/src/lib/import/import-from-xliff.spec.ts delete mode 100644 libs/core/src/lib/import/import-from-xliff.ts create mode 100644 libs/core/src/lib/import/import-resources.merge.spec.ts create mode 100644 libs/core/src/lib/import/import-resources.pipeline.spec.ts create mode 100644 libs/core/src/lib/import/import-resources.spec.ts create mode 100644 libs/core/src/lib/import/import-resources.ts create mode 100644 libs/core/src/lib/import/import-session.spec.ts create mode 100644 libs/core/src/lib/import/import-session.ts delete mode 100644 libs/core/src/lib/import/import-workflow.spec.ts delete mode 100644 libs/core/src/lib/import/import-workflow.ts create mode 100644 libs/core/src/lib/import/parse-json-import.spec.ts create mode 100644 libs/core/src/lib/import/parse-json-import.ts create mode 100644 libs/core/src/lib/import/parse-xliff-import.spec.ts create mode 100644 libs/core/src/lib/import/parse-xliff-import.ts delete mode 100644 libs/core/src/lib/import/process-resource-group.spec.ts rename libs/{core/src/lib/import => domain/src/lib}/reference-resolver.spec.ts (93%) rename libs/{core/src/lib/import => domain/src/lib}/reference-resolver.ts (96%) diff --git a/apps/cli/src/commands/export-cmd.test.ts b/apps/cli/src/commands/export-cmd.test.ts index 11cc33ee..066e9338 100644 --- a/apps/cli/src/commands/export-cmd.test.ts +++ b/apps/cli/src/commands/export-cmd.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { join } from 'node:path'; -import { exportCommand } from './export-cmd'; import * as fs from 'node:fs'; +import { join } from 'node:path'; import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { exportCommand } from './export-cmd'; const fsMocks = vi.hoisted(() => ({ existsSync: vi.fn(), @@ -34,31 +34,50 @@ vi.mock('@simoncodes-ca/core', async (importOriginal) => { // Config loading and collection resolution run for real against the mocked config. loadConfig: actual.loadConfig, openCollection: actual.openCollection, + exportTargetLocales: actual.exportTargetLocales, ConfigNotFoundError: actual.ConfigNotFoundError, ConfigParseError: actual.ConfigParseError, CollectionNotFoundError: actual.CollectionNotFoundError, ReadOnlyCollectionError: actual.ReadOnlyCollectionError, CONFIG_FILENAME: '.lingo-tracker.json', - loadResourcesFromCollections: vi.fn(), - filterResources: vi.fn(), + runExport: vi.fn(), validateOutputDirectory: vi.fn(), validateBasePropertyName: vi.fn(), - exportToJson: vi.fn(), - exportToXliff: vi.fn(), - generateExportSummary: vi.fn(), - readGlobalProtectedTerms: vi.fn(() => []), + readGlobalProtectedTerms: vi.fn(() => ['Acme']), readCollectionProtectedTerms: vi.fn(() => []), }; }); +import type { ExportRunResult } from '@simoncodes-ca/core'; import * as core from '@simoncodes-ca/core'; -const mockLoadResourcesFromCollections = vi.mocked(core.loadResourcesFromCollections); -const mockFilterResources = vi.mocked(core.filterResources); + +const mockRunExport = vi.mocked(core.runExport); const mockValidateOutputDirectory = vi.mocked(core.validateOutputDirectory); const mockValidateBasePropertyName = vi.mocked(core.validateBasePropertyName); -const mockExportToJson = vi.mocked(core.exportToJson); -const mockExportToXliff = vi.mocked(core.exportToXliff); -const mockGenerateExportSummary = vi.mocked(core.generateExportSummary); + +/** A run that exported fr and es; override any field. */ +const runResult = (overrides: Partial = {}): ExportRunResult => ({ + format: 'json', + filesCreated: ['fr.json', 'es.json'], + resourcesExported: 10, + warnings: [], + errors: [], + collections: ['common', 'admin'], + locales: ['fr', 'es'], + outputDirectory: '/out', + omittedResources: [], + malformedFiles: [], + hierarchicalConflicts: [], + localeResults: [ + { locale: 'fr', outcome: 'exported', resourcesExported: 5, filesCreated: ['fr.json'] }, + { locale: 'es', outcome: 'exported', resourcesExported: 5, filesCreated: ['es.json'] }, + ], + summary: '# Export Summary', + ...overrides, +}); + +/** Names of the collections passed to runExport. */ +const exportedCollections = (): string[] | undefined => mockRunExport.mock.calls[0]?.[0].map((c) => c.name); describe('exportCommand', () => { const mockConfig = { @@ -102,27 +121,7 @@ describe('exportCommand', () => { vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); mockValidateOutputDirectory.mockReturnValue(undefined); - mockLoadResourcesFromCollections.mockReturnValue([]); - mockFilterResources.mockReturnValue([]); - mockGenerateExportSummary.mockReturnValue('# Export Summary'); - mockExportToJson.mockReturnValue({ - filesCreated: ['fr.json', 'es.json'], - resourcesExported: 10, - warnings: [], - errors: [], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }); - mockExportToXliff.mockResolvedValue({ - filesCreated: ['fr.xliff', 'es.xliff'], - resourcesExported: 10, - warnings: [], - errors: [], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }); + mockRunExport.mockResolvedValue(runResult()); }); afterEach(() => { @@ -184,18 +183,7 @@ describe('exportCommand', () => { }); describe('non-interactive mode', () => { - it('should export to JSON with all required options', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - + it('should export the chosen collection and locale to JSON', async () => { await exportCommand({ format: 'json', collection: 'common', @@ -203,31 +191,20 @@ describe('exportCommand', () => { status: 'new,stale', }); - expect(mockLoadResourcesFromCollections).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ name: 'common' })]), - ); - expect(mockFilterResources).toHaveBeenCalledWith( - [], - 'fr', - ['new', 'stale'], - undefined, - expect.objectContaining({ augmentProtectedTerms: true, baseLocale: 'en' }), + expect(exportedCollections()).toEqual(['common']); + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + format: 'json', + locales: ['fr'], + status: ['new', 'stale'], + augmentProtectedTerms: true, + protectedTerms: { global: ['Acme'], collections: { common: [] } }, + }), ); - expect(mockExportToJson).toHaveBeenCalled(); }); it('should export to XLIFF with all required options', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - await exportCommand({ format: 'xliff', collection: 'common', @@ -235,202 +212,95 @@ describe('exportCommand', () => { status: 'new,stale', }); - expect(mockLoadResourcesFromCollections).toHaveBeenCalled(); - expect(mockExportToXliff).toHaveBeenCalled(); + expect(mockRunExport).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ format: 'xliff' })); }); it('should export all collections when none specified', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - await exportCommand({ format: 'json', }); - expect(mockLoadResourcesFromCollections).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ name: 'common' }), - expect.objectContaining({ name: 'admin' }), - ]), - ); + expect(exportedCollections()).toEqual(['common', 'admin']); }); - it('should export all target locales when none specified', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - + it('should export all target locales (never the base locale) when none specified', async () => { await exportCommand({ format: 'json', }); - // Should filter for fr and es (not base locale 'en') - expect(mockFilterResources).toHaveBeenCalledWith( - [], - 'fr', - undefined, - undefined, - expect.objectContaining({ augmentProtectedTerms: true, baseLocale: 'en' }), - ); - expect(mockFilterResources).toHaveBeenCalledWith( - [], - 'es', - undefined, - undefined, - expect.objectContaining({ augmentProtectedTerms: true, baseLocale: 'en' }), - ); + expect(mockRunExport).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ locales: ['fr', 'es'] })); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Locales: fr, es')); }); - it('should use default status filter when not provided', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - + it('should not filter by status when not provided', async () => { await exportCommand({ format: 'json', }); - // When status is not provided, it defaults to undefined (not filtered) - expect(mockFilterResources).toHaveBeenCalledWith( - [], - expect.any(String), - undefined, - undefined, - expect.objectContaining({ augmentProtectedTerms: true, baseLocale: 'en' }), - ); + expect(mockRunExport).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ status: undefined })); }); it('should handle dry run mode', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - await exportCommand({ format: 'json', dryRun: true, }); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[DRY RUN]')); + expect(mockRunExport).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ dryRun: true })); // In dry run mode, summary is not written to file expect(fs.writeFileSync).not.toHaveBeenCalled(); }); it('should use custom output directory when provided', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - await exportCommand({ format: 'json', output: 'custom/output', }); expect(mockValidateOutputDirectory).toHaveBeenCalledWith(expect.stringContaining(join('custom', 'output'))); + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ outputDirectory: expect.stringContaining(join('custom', 'output')) }), + ); }); it('should filter by tags when provided', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - await exportCommand({ format: 'json', tags: 'ui,buttons', }); - expect(mockFilterResources).toHaveBeenCalledWith( - [], - expect.any(String), - undefined, - ['ui', 'buttons'], - expect.objectContaining({ augmentProtectedTerms: true, baseLocale: 'en' }), + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ tags: ['ui', 'buttons'] }), ); }); it('should disable augmentation when --no-protect-notes is used', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - await exportCommand({ format: 'json', protectNotes: false, }); - expect(mockFilterResources).toHaveBeenCalledWith( - [], - expect.any(String), - undefined, - undefined, + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), expect.objectContaining({ augmentProtectedTerms: false }), ); - expect(mockExportToJson).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ augmentProtectedTerms: false }), - 'en', - ); }); - it('should skip locales with no matching resources', async () => { - mockFilterResources.mockReturnValue([]); + it('should print progress messages indented in verbose mode', async () => { + mockRunExport.mockImplementation(async (_collections, options) => { + options.onProgress?.('Skipping fr: No matching resources.'); + return runResult(); + }); await exportCommand({ format: 'json', verbose: true, }); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Skipping fr: No matching resources.')); - expect(mockExportToJson).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith(' Skipping fr: No matching resources.'); }); it('should warn when no collections found', async () => { @@ -440,6 +310,7 @@ describe('exportCommand', () => { }); expect(console.log).toHaveBeenCalledWith('⚠️ No matching collections found.'); + expect(mockRunExport).not.toHaveBeenCalled(); }); it('should warn when no target locales selected', async () => { @@ -449,6 +320,7 @@ describe('exportCommand', () => { }); expect(console.log).toHaveBeenCalledWith('⚠️ No target locales selected.'); + expect(mockRunExport).not.toHaveBeenCalled(); }); }); @@ -459,16 +331,6 @@ describe('exportCommand', () => { writable: true, configurable: true, }); - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); }); it('should prompt for format when not provided', async () => { @@ -557,12 +419,7 @@ describe('exportCommand', () => { await exportCommand({}); - expect(mockLoadResourcesFromCollections).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ name: 'common' }), - expect.objectContaining({ name: 'admin' }), - ]), - ); + expect(exportedCollections()).toEqual(['common', 'admin']); }); it('should handle specific collection selection', async () => { @@ -586,9 +443,7 @@ describe('exportCommand', () => { await exportCommand({}); - expect(mockLoadResourcesFromCollections).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ name: 'common' })]), - ); + expect(exportedCollections()).toEqual(['common']); }); it('should prompt for JSON-specific options when JSON format is selected', async () => { @@ -653,13 +508,7 @@ describe('exportCommand', () => { await exportCommand({}); - expect(mockExportToJson).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - richJson: false, - }), - expect.anything(), - ); + expect(mockRunExport).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ richJson: false })); }); it('should not prompt for already provided options', async () => { @@ -722,85 +571,31 @@ describe('exportCommand', () => { }); }); - describe('export execution', () => { - beforeEach(() => { - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); - }); - - it('should skip base locale when exporting', async () => { + describe('rendering the run', () => { + it('should write the summary returned by the run', async () => { await exportCommand({ format: 'json', }); - // Should export for fr and es, but not en (base locale) - expect(mockFilterResources).toHaveBeenCalledWith( - [], - 'fr', - undefined, - undefined, - expect.objectContaining({ augmentProtectedTerms: true, baseLocale: 'en' }), - ); - expect(mockFilterResources).toHaveBeenCalledWith( - [], - 'es', - undefined, - undefined, - expect.objectContaining({ augmentProtectedTerms: true, baseLocale: 'en' }), - ); - expect(mockFilterResources).not.toHaveBeenCalledWith([], 'en', expect.anything(), expect.anything()); - }); - - it('should call export function for each locale with resources', async () => { - await exportCommand({ - format: 'json', - }); - - // Called twice (once for fr, once for es) - expect(mockExportToJson).toHaveBeenCalledTimes(2); - }); - - it('should generate export summary', async () => { - await exportCommand({ - format: 'json', - }); - - expect(mockGenerateExportSummary).toHaveBeenCalled(); expect(fs.writeFileSync).toHaveBeenCalledWith( expect.stringContaining('lingo-tracker-export-summary'), '# Export Summary', ); }); - it('should not write summary in dry run mode', async () => { + it('should print the summary instead of writing it in dry run mode', async () => { await exportCommand({ format: 'json', dryRun: true, }); - expect(mockGenerateExportSummary).toHaveBeenCalled(); expect(fs.writeFileSync).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Summary (Dry Run)')); + expect(console.log).toHaveBeenCalledWith('# Export Summary'); }); it('should set exit code when errors occur', async () => { - mockExportToJson.mockReturnValue({ - filesCreated: [], - resourcesExported: 0, - warnings: [], - errors: ['Export failed'], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }); + mockRunExport.mockResolvedValue(runResult({ errors: ['Export failed'] })); await exportCommand({ format: 'json', @@ -810,15 +605,7 @@ describe('exportCommand', () => { }); it('should not set exit code in dry run mode even with errors', async () => { - mockExportToJson.mockReturnValue({ - filesCreated: [], - resourcesExported: 0, - warnings: [], - errors: ['Export failed'], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }); + mockRunExport.mockResolvedValue(runResult({ errors: ['Export failed'] })); await exportCommand({ format: 'json', @@ -829,143 +616,104 @@ describe('exportCommand', () => { }); it('should display warnings when present', async () => { - mockExportToJson.mockReturnValue({ - filesCreated: ['fr.json'], - resourcesExported: 5, - warnings: ['Warning 1', 'Warning 2'], - errors: [], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }); + mockRunExport.mockResolvedValue(runResult({ warnings: ['Warning 1', 'Warning 2'] })); await exportCommand({ format: 'json', }); - // Warnings are collected from both locales (fr and es), so 2 warnings * 2 locales = 4 total - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Warnings (4)')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Warnings (2)')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Warning 1')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Warning 2')); }); it('should display errors when present', async () => { - mockExportToJson.mockReturnValue({ - filesCreated: [], - resourcesExported: 0, - warnings: [], - errors: ['Error 1', 'Error 2'], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }); + mockRunExport.mockResolvedValue(runResult({ errors: ['Error 1', 'Error 2'] })); await exportCommand({ format: 'json', }); - // Errors are collected from both locales (fr and es), so 2 errors * 2 locales = 4 total - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Errors (4)')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Errors (2)')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Error 1')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Error 2')); }); it('should handle hierarchical conflicts as errors', async () => { - mockExportToJson.mockReturnValue({ - filesCreated: [], - resourcesExported: 0, - warnings: [], - errors: [], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: ['Conflict at key.path'], - }); + mockRunExport.mockResolvedValue(runResult({ hierarchicalConflicts: ['[fr] Conflict at key.path'] })); await exportCommand({ format: 'json', }); - // Hierarchical conflicts are collected from both locales (fr and es), so 1 conflict * 2 locales = 2 total - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Errors (2)')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Errors (1)')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Conflict at key.path')); + expect(process.exitCode).toBe(1); }); - it('should handle export exceptions and continue with other locales', async () => { - mockExportToJson - .mockImplementationOnce(() => { - throw new Error('Export failed for fr'); - }) - .mockReturnValueOnce({ - filesCreated: ['es.json'], - resourcesExported: 5, - warnings: [], - errors: [], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }); + it('should display a locale whose export threw', async () => { + mockRunExport.mockResolvedValue( + runResult({ + localeResults: [ + { locale: 'fr', outcome: 'failed', resourcesExported: 0, filesCreated: [], error: 'Export failed for fr' }, + { locale: 'es', outcome: 'exported', resourcesExported: 5, filesCreated: ['es.json'] }, + ], + }), + ); await exportCommand({ format: 'json', }); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('fr: Export failed - Export failed for fr')); - expect(mockExportToJson).toHaveBeenCalledTimes(2); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('es: Exported 5 resources to es.json')); }); - it('should display verbose progress messages', async () => { + it('should pass verbose and a progress callback to the run', async () => { await exportCommand({ format: 'json', verbose: true, }); - expect(mockExportToJson).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - verbose: true, - onProgress: expect.any(Function), - }), - expect.anything(), + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ verbose: true, onProgress: expect.any(Function) }), ); }); it('should display success message for each exported locale', async () => { - mockExportToJson.mockReturnValue({ - filesCreated: ['fr.json'], - resourcesExported: 10, - warnings: [], - errors: [], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], + await exportCommand({ + format: 'json', }); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('fr: Exported 5 resources to fr.json')); + }); + + it('should display failure message when a locale created no file', async () => { + mockRunExport.mockResolvedValue( + runResult({ localeResults: [{ locale: 'fr', outcome: 'failed', resourcesExported: 0, filesCreated: [] }] }), + ); + await exportCommand({ format: 'json', }); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('fr: Exported 10 resources to fr.json')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('fr: Failed')); }); - it('should display failure message when no files created', async () => { - mockExportToJson.mockReturnValue({ - filesCreated: [], - resourcesExported: 0, - warnings: [], - errors: ['Export error'], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }); + it('should say nothing per locale for a skipped locale', async () => { + mockRunExport.mockResolvedValue( + runResult({ localeResults: [{ locale: 'fr', outcome: 'skipped', resourcesExported: 0, filesCreated: [] }] }), + ); await exportCommand({ format: 'json', }); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('fr: Failed')); + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('fr:')); }); - it('should pass correct options to exportToJson', async () => { + it('should pass the JSON options to the run', async () => { await exportCommand({ format: 'json', structure: 'flat', @@ -977,8 +725,8 @@ describe('exportCommand', () => { filename: 'custom-{locale}.json', }); - expect(mockExportToJson).toHaveBeenCalledWith( - expect.anything(), + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), expect.objectContaining({ format: 'json', jsonStructure: 'flat', @@ -989,60 +737,52 @@ describe('exportCommand', () => { includeTags: true, filenamePattern: 'custom-{locale}.json', }), - expect.any(String), ); }); - it('should pass correct options to exportToXliff', async () => { + it('should pass the XLIFF options to the run', async () => { await exportCommand({ format: 'xliff', filename: 'custom-{locale}.xliff', }); - expect(mockExportToXliff).toHaveBeenCalledWith( - expect.anything(), + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), expect.objectContaining({ format: 'xliff', filenamePattern: 'custom-{locale}.xliff', }), - expect.any(String), ); }); it('should display total files and resources in summary', async () => { - mockExportToJson - .mockReturnValueOnce({ - filesCreated: ['fr.json'], - resourcesExported: 10, - warnings: [], - errors: [], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }) - .mockReturnValueOnce({ - filesCreated: ['es.json'], - resourcesExported: 15, - warnings: [], - errors: [], - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: [], - }); + mockRunExport.mockResolvedValue(runResult({ filesCreated: ['fr.json', 'es.json'], resourcesExported: 25 })); await exportCommand({ format: 'json', }); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Export Summary')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Files Created: 2')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Resources Exported: 25')); }); + + it('should report a run that cannot start and exit with an error', async () => { + mockRunExport.mockRejectedValue(new Error('Cannot export collections with different base locales together')); + vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit called with code ${code}`); + }); + + await expect(exportCommand({ format: 'json' })).rejects.toThrow('process.exit called with code 1'); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Cannot export collections with different base locales together'), + ); + }); }); describe('--base-property-name option', () => { it('should warn when --base-property-name is set without --include-base', async () => { - mockFilterResources.mockReturnValue([]); - await exportCommand({ format: 'json', locale: 'fr', @@ -1075,18 +815,7 @@ describe('exportCommand', () => { expect(console.log).toHaveBeenCalledWith(expect.stringContaining('basePropertyName "value" is a reserved key')); }); - it('should pass basePropertyName through to exportToJson', async () => { - mockFilterResources.mockReturnValue([ - { - key: 'hello', - value: 'Bonjour', - baseValue: 'Hello', - status: 'translated', - collection: 'common', - locale: 'fr', - }, - ]); - + it('should pass basePropertyName through to the run', async () => { await exportCommand({ format: 'json', locale: 'fr', @@ -1094,10 +823,9 @@ describe('exportCommand', () => { includeBase: true, }); - expect(mockExportToJson).toHaveBeenCalledWith( - expect.anything(), + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), expect.objectContaining({ basePropertyName: 'original' }), - expect.anything(), ); }); }); diff --git a/apps/cli/src/commands/export-cmd.ts b/apps/cli/src/commands/export-cmd.ts index d5bb6f87..72046546 100644 --- a/apps/cli/src/commands/export-cmd.ts +++ b/apps/cli/src/commands/export-cmd.ts @@ -1,30 +1,28 @@ -import * as path from 'path'; -import * as fs from 'fs'; -import prompts from 'prompts'; -import type { TranslationStatus } from '@simoncodes-ca/domain'; import { - type LingoTrackerConfig, - type ExportOptions, + type Collection, type ExportFormat, - loadResourcesFromCollections, - filterResources, - validateOutputDirectory, - validateBasePropertyName, - exportToJson, - exportToXliff, - generateExportSummary, - type ExportResult, + type ExportRunResult, + exportTargetLocales, + type LingoTrackerConfig, + openCollection, readCollectionProtectedTerms, readGlobalProtectedTerms, + runExport, + validateBasePropertyName, + validateOutputDirectory, } from '@simoncodes-ca/core'; +import type { TranslationStatus } from '@simoncodes-ca/domain'; +import * as fs from 'fs'; +import * as path from 'path'; +import prompts from 'prompts'; import { + buildSummaryPath, + ConsoleFormatter, + ErrorMessages, loadConfiguration, + multiselectResultToString, parseCommaSeparatedList, processMultiselectWithAll, - multiselectResultToString, - ConsoleFormatter, - ErrorMessages, - buildSummaryPath, } from '../utils'; export interface ExportCommandOptions { @@ -53,9 +51,11 @@ export async function exportCommand(options: ExportCommandOptions): Promise openCollection(config, name, { cwd })); + let answers: Partial; try { - answers = await promptForMissing(options, config); + answers = await promptForMissing(options, config, exportTargetLocales(allCollections)); } catch (error) { if ((error as Error).message === 'Export cancelled') { ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Export')); @@ -114,174 +114,107 @@ export async function exportCommand(options: ExportCommandOptions): Promise ({ - name, - path: col.translationsFolder, - tags: col.tags, - protectedTerms: readCollectionProtectedTerms(col, cwd), - })); - - const collectionsToProcess = allCollections.filter((c) => !collectionNames || collectionNames.includes(c.name)); - - if (collectionsToProcess.length === 0) { + const collections = allCollections.filter((c) => !collectionNames || collectionNames.includes(c.name)); + if (collections.length === 0) { ConsoleFormatter.warning('No matching collections found.'); return; } - // Resolve locales - const localeNames = parseCommaSeparatedList(options.locale); - const targetLocales = (config.locales || []).filter( - (l: string) => l !== config.baseLocale && (!localeNames || localeNames.includes(l)), - ); - + const targetLocales = exportTargetLocales(collections, parseCommaSeparatedList(options.locale)); if (targetLocales.length === 0) { ConsoleFormatter.warning('No target locales selected.'); return; } - // Resolve status and tags - const statusFilter = parseCommaSeparatedList(options.status)?.map((s) => s as TranslationStatus); - const tagFilter = parseCommaSeparatedList(options.tags); - ConsoleFormatter.progress(`Exporting to ${options.format.toUpperCase()}...`); - ConsoleFormatter.indent(`Collections: ${collectionsToProcess.map((c) => c.name).join(', ')}`); + ConsoleFormatter.indent(`Collections: ${collections.map((c) => c.name).join(', ')}`); ConsoleFormatter.indent(`Locales: ${targetLocales.join(', ')}`); ConsoleFormatter.indent(`Output: ${outputDir}`); if (options.dryRun) ConsoleFormatter.indent('[DRY RUN]'); - // Load resources - const allResources = loadResourcesFromCollections( - collectionsToProcess.map((c) => ({ - name: c.name, - path: path.resolve(cwd, c.path), - tags: c.tags, - protectedTerms: c.protectedTerms, - })), - ); - - const exportOptions: ExportOptions = { - format: options.format, - outputDirectory: outputDir, - collections: collectionsToProcess.map((c) => c.name), - locales: targetLocales, - status: statusFilter, - tags: tagFilter, - filenamePattern: options.filename, - dryRun: options.dryRun, - verbose: options.verbose, - jsonStructure: options.structure, - richJson: options.rich, - includeBase: options.includeBase, - includeStatus: options.includeStatus, - includeComment: options.includeComment, - includeTags: options.includeTags, - basePropertyName: options.basePropertyName, - augmentProtectedTerms: options.protectNotes !== false, - onProgress: options.verbose ? (msg) => console.log(` ${msg}`) : undefined, - }; - - const totalFiles = 0; - let totalResources = 0; - const allWarnings: string[] = []; - const allErrors: string[] = []; - const allFilesCreated: string[] = []; - - for (const locale of targetLocales) { - const filtered = filterResources(allResources, locale, statusFilter, tagFilter, { - globalProtectedTerms, + let result: ExportRunResult; + try { + result = await runExport(collections, { + format: options.format, + outputDirectory: outputDir, + locales: targetLocales, + status: parseCommaSeparatedList(options.status)?.map((s) => s as TranslationStatus), + tags: parseCommaSeparatedList(options.tags), + filenamePattern: options.filename, + dryRun: options.dryRun, + verbose: options.verbose, + jsonStructure: options.structure, + richJson: options.rich, + includeBase: options.includeBase, + includeStatus: options.includeStatus, + includeComment: options.includeComment, + includeTags: options.includeTags, + basePropertyName: options.basePropertyName, augmentProtectedTerms: options.protectNotes !== false, - baseLocale: config.baseLocale, + protectedTerms: readProtectedTerms(config, collections, cwd), + onProgress: options.verbose ? (msg) => console.log(` ${msg}`) : undefined, }); + } catch (error) { + ConsoleFormatter.error((error as Error).message); + process.exit(1); + } - if (filtered.length === 0) { - if (options.verbose) console.log(` Skipping ${locale}: No matching resources.`); - continue; - } + displayResults(result); - try { - let result: ExportResult; - if (options.format === 'xliff') { - result = await exportToXliff(filtered, { ...exportOptions, locales: [locale] }, config.baseLocale); - } else { - result = exportToJson(filtered, { ...exportOptions, locales: [locale] }, config.baseLocale); - } - - totalResources += result.resourcesExported; - allWarnings.push(...result.warnings); - allErrors.push(...result.errors); - allErrors.push(...result.hierarchicalConflicts); - allFilesCreated.push(...result.filesCreated); - - if (result.filesCreated.length > 0) { - ConsoleFormatter.indent( - `✅ ${locale}: Exported ${result.resourcesExported} resources to ${result.filesCreated.join(', ')}`, - ); - } else if (result.errors.length > 0) { - ConsoleFormatter.indent(`❌ ${locale}: Failed`); - } - } catch (error) { - ConsoleFormatter.indent(`❌ ${locale}: Export failed - ${(error as Error).message}`); - allErrors.push(`Export for locale ${locale} failed: ${(error as Error).message}`); + const summaryPath = buildSummaryPath('export'); + if (!options.dryRun) { + fs.writeFileSync(summaryPath, result.summary); + console.log(`\n📄 Summary written to: ${summaryPath}`); + } else { + console.log('\n📄 Summary (Dry Run):'); + console.log(result.summary); + } + if (result.errors.length + result.hierarchicalConflicts.length > 0 && !options.dryRun) process.exitCode = 1; +} + +function readProtectedTerms(config: LingoTrackerConfig, collections: readonly Collection[], cwd: string) { + return { + global: readGlobalProtectedTerms(config, cwd), + collections: Object.fromEntries(collections.map((c) => [c.name, readCollectionProtectedTerms(c.config, cwd)])), + }; +} + +function displayResults(result: ExportRunResult): void { + for (const { locale, outcome, resourcesExported, filesCreated, error } of result.localeResults) { + if (outcome === 'exported') { + ConsoleFormatter.indent(`✅ ${locale}: Exported ${resourcesExported} resources to ${filesCreated.join(', ')}`); + } else if (outcome === 'failed') { + ConsoleFormatter.indent(error ? `❌ ${locale}: Export failed - ${error}` : `❌ ${locale}: Failed`); } } ConsoleFormatter.section('Export Summary'); - ConsoleFormatter.keyValue('Files Created', totalFiles); - ConsoleFormatter.keyValue('Resources Exported', totalResources); + ConsoleFormatter.keyValue('Files Created', result.filesCreated.length); + ConsoleFormatter.keyValue('Resources Exported', result.resourcesExported); - if (allWarnings.length > 0) { + if (result.warnings.length > 0) { console.log(''); - ConsoleFormatter.warning(`Warnings (${allWarnings.length}):`); - allWarnings.forEach((w) => { + ConsoleFormatter.warning(`Warnings (${result.warnings.length}):`); + result.warnings.forEach((w) => { ConsoleFormatter.indent(`- ${w}`); }); } - if (allErrors.length > 0) { + const errors = [...result.errors, ...result.hierarchicalConflicts]; + if (errors.length > 0) { console.log(''); - ConsoleFormatter.error(`Errors (${allErrors.length}):`); - allErrors.forEach((e) => { + ConsoleFormatter.error(`Errors (${errors.length}):`); + errors.forEach((e) => { ConsoleFormatter.indent(`- ${e}`); }); - if (!options.dryRun) process.exitCode = 1; - } - - // Generate and write summary - const summary = generateExportSummary( - { - format: options.format, - filesCreated: allFilesCreated, - resourcesExported: totalResources, - warnings: allWarnings, - errors: allErrors.filter((e) => !e.includes('Conflict')), - collections: collectionsToProcess.map((c) => c.name), - locales: targetLocales, - outputDirectory: outputDir, - omittedResources: [], - malformedFiles: [], - hierarchicalConflicts: allErrors.filter((e) => e.includes('Conflict')), - }, - exportOptions, - ); - - const summaryPath = buildSummaryPath('export'); - if (!options.dryRun) { - fs.writeFileSync(summaryPath, summary); - console.log(`\n📄 Summary written to: ${summaryPath}`); - } else { - console.log('\n📄 Summary (Dry Run):'); - console.log(summary); } } async function promptForMissing( options: ExportCommandOptions, config: LingoTrackerConfig, + targetLocales: string[], ): Promise<{ format?: ExportFormat; collection?: string; @@ -320,7 +253,6 @@ async function promptForMissing( }> = {}; const collectionNames = Object.keys(config.collections || {}); - const targetLocales = (config.locales || []).filter((l: string) => l !== config.baseLocale); const questions: prompts.PromptObject[] = []; diff --git a/apps/cli/src/commands/import-cmd.spec.ts b/apps/cli/src/commands/import-cmd.spec.ts index 382d16d6..8cdf9e4b 100644 --- a/apps/cli/src/commands/import-cmd.spec.ts +++ b/apps/cli/src/commands/import-cmd.spec.ts @@ -46,8 +46,9 @@ vi.mock('@simoncodes-ca/core', async (importOriginal) => { CollectionNotFoundError: actual.CollectionNotFoundError, ReadOnlyCollectionError: actual.ReadOnlyCollectionError, CONFIG_FILENAME: '.lingo-tracker.json', - importFromJson: vi.fn(), - importFromXliff: vi.fn(), + parseJsonImport: vi.fn(() => []), + parseXliffImport: vi.fn(async () => []), + importResources: vi.fn(), detectImportFormat: vi.fn(), generateImportSummary: vi.fn(() => '# Import Summary\n\nTest summary'), readEffectiveProtectedTerms: vi.fn(() => []), @@ -94,11 +95,13 @@ vi.mock('../utils', () => ({ import { type Collection, detectImportFormat, - importFromJson, - importFromXliff, + generateImportSummary, + importResources, type LingoTrackerCollection, loadPreferredTerminology, openCollection, + parseJsonImport, + parseXliffImport, } from '@simoncodes-ca/core'; import { ConsoleFormatter, @@ -172,7 +175,7 @@ describe('import-cmd', () => { describe('Configuration Loading', () => { it('should load configuration from .lingo-tracker.json', async () => { - vi.mocked(importFromJson).mockReturnValue(baseImportResult); + vi.mocked(importResources).mockReturnValue(baseImportResult); const options: ImportCommandOptions = { source: '/test/import.json', @@ -196,14 +199,14 @@ describe('import-cmd', () => { await importCommand(options); - expect(importFromJson).not.toHaveBeenCalled(); + expect(importResources).not.toHaveBeenCalled(); }); }); describe('Format Auto-Detection', () => { it('should auto-detect XLIFF format from .xliff extension', async () => { vi.mocked(detectImportFormat).mockReturnValue('xliff'); - vi.mocked(importFromXliff).mockResolvedValue({ + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, resourcesImported: 5, resourcesUpdated: 5, @@ -224,7 +227,7 @@ describe('import-cmd', () => { it('should auto-detect JSON format from .json extension', async () => { vi.mocked(detectImportFormat).mockReturnValue('json'); - vi.mocked(importFromJson).mockReturnValue({ + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, resourcesImported: 5, resourcesUpdated: 5, @@ -256,13 +259,13 @@ describe('import-cmd', () => { await importCommand(options); expect(ConsoleFormatter.error).toHaveBeenCalledWith('Cannot auto-detect format from .txt extension'); - expect(importFromJson).not.toHaveBeenCalled(); + expect(importResources).not.toHaveBeenCalled(); }); }); describe('Import Execution', () => { - it('should call importFromJson for JSON format', async () => { - vi.mocked(importFromJson).mockReturnValue(baseImportResult); + it('should parse a JSON file and import its resources', async () => { + vi.mocked(importResources).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); const options: ImportCommandOptions = { @@ -273,18 +276,34 @@ describe('import-cmd', () => { await importCommand(options); - expect(importFromJson).toHaveBeenCalledWith( - '/test/project/src/translations', - expect.objectContaining({ - source: '/test/import.json', - locale: 'es', - format: 'json', - }), + expect(parseJsonImport).toHaveBeenCalledWith('/test/import.json', expect.any(Object)); + expect(parseXliffImport).not.toHaveBeenCalled(); + expect(importResources).toHaveBeenCalledWith( + expect.objectContaining({ name: 'default', translationsFolder: '/test/project/src/translations' }), + [], + expect.objectContaining({ locale: 'es', strategy: 'translation-service', validateBase: true }), + ); + }); + + it('should write the summary with the file format and source', async () => { + vi.mocked(importResources).mockReturnValue(baseImportResult); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); + + expect(generateImportSummary).toHaveBeenCalledWith( + baseImportResult, + expect.objectContaining({ format: 'json', source: '/test/import.json', locale: 'es' }), + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/tmp/lingo-tracker-import-summary-test.md', + '# Import Summary\n\nTest summary', + 'utf8', ); }); - it('should call importFromXliff for XLIFF format', async () => { - vi.mocked(importFromXliff).mockResolvedValue(baseImportResult); + it('should parse an XLIFF file and import its resources', async () => { + vi.mocked(importResources).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); const options: ImportCommandOptions = { @@ -295,18 +314,16 @@ describe('import-cmd', () => { await importCommand(options); - expect(importFromXliff).toHaveBeenCalledWith( - '/test/project/src/translations', - expect.objectContaining({ - source: '/test/import.xliff', - locale: 'es', - format: 'xliff', - }), + expect(parseXliffImport).toHaveBeenCalledWith('/test/import.xliff', expect.any(Object)); + expect(importResources).toHaveBeenCalledWith( + expect.objectContaining({ translationsFolder: '/test/project/src/translations' }), + [], + expect.objectContaining({ locale: 'es' }), ); }); - it('should return early with error if import fails', async () => { - vi.mocked(importFromJson).mockImplementation(() => { + it('should return early with error if parsing fails', async () => { + vi.mocked(parseJsonImport).mockImplementationOnce(() => { throw new Error('Source file not found'); }); @@ -319,12 +336,26 @@ describe('import-cmd', () => { await importCommand(options); expect(ConsoleFormatter.error).toHaveBeenCalledWith('Import failed: Source file not found'); + expect(importResources).not.toHaveBeenCalled(); + }); + + it('should return early with error if the import refuses to run', async () => { + vi.mocked(importResources).mockImplementationOnce(() => { + throw new Error('Cannot import into base locale "en" with strategy "translation-service".'); + }); + + await importCommand({ source: '/test/import.json', locale: 'en', format: 'json' }); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith( + 'Import failed: Cannot import into base locale "en" with strategy "translation-service".', + ); + expect(fs.writeFileSync).not.toHaveBeenCalled(); }); }); describe('Result Display', () => { it('should display success message for successful import', async () => { - vi.mocked(importFromJson).mockReturnValue({ + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, filesModified: ['file1.json', 'file2.json'], }); @@ -342,7 +373,7 @@ describe('import-cmd', () => { }); it('should display warnings for import with warnings', async () => { - vi.mocked(importFromJson).mockReturnValue({ + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, warnings: ['Warning 1', 'Warning 2'], }); @@ -361,7 +392,7 @@ describe('import-cmd', () => { }); it('should exit with code 1 for import with errors', async () => { - vi.mocked(importFromJson).mockReturnValue({ + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, resourcesUpdated: 8, resourcesFailed: 2, @@ -383,7 +414,7 @@ describe('import-cmd', () => { }); it('should exit with code 1 when only errors array is non-empty', async () => { - vi.mocked(importFromJson).mockReturnValue({ + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, resourcesFailed: 0, errors: ['some error'], @@ -403,7 +434,7 @@ describe('import-cmd', () => { }); it('should display dry-run message', async () => { - vi.mocked(importFromJson).mockReturnValue(baseImportResult); + vi.mocked(importResources).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); const options: ImportCommandOptions = { @@ -439,7 +470,7 @@ describe('import-cmd', () => { vi.mocked(resolveWritableCollection).mockReturnValue( collectionOf('admin', { translationsFolder: 'src/admin-translations' }), ); - vi.mocked(importFromJson).mockReturnValue(baseImportResult); + vi.mocked(importResources).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); const options: ImportCommandOptions = { @@ -451,11 +482,15 @@ describe('import-cmd', () => { await importCommand(options); - expect(importFromJson).toHaveBeenCalledWith('/test/project/src/admin-translations', expect.any(Object)); + expect(importResources).toHaveBeenCalledWith( + expect.objectContaining({ translationsFolder: '/test/project/src/admin-translations' }), + [], + expect.any(Object), + ); }); it('should use the auto-selected collection translations folder when no collection option is given', async () => { - vi.mocked(importFromJson).mockReturnValue(baseImportResult); + vi.mocked(importResources).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); const options: ImportCommandOptions = { @@ -466,7 +501,11 @@ describe('import-cmd', () => { await importCommand(options); - expect(importFromJson).toHaveBeenCalledWith('/test/project/src/translations', expect.any(Object)); + expect(importResources).toHaveBeenCalledWith( + expect.objectContaining({ translationsFolder: '/test/project/src/translations' }), + [], + expect.any(Object), + ); }); it('should propagate errors thrown by promptForCollection', async () => { @@ -475,7 +514,7 @@ describe('import-cmd', () => { await expect(importCommand({ source: '/test/import.json', locale: 'es', format: 'json' })).rejects.toThrow( 'Missing required option: --collection', ); - expect(importFromJson).not.toHaveBeenCalled(); + expect(importResources).not.toHaveBeenCalled(); }); it('should return early without error when promptForCollection returns null', async () => { @@ -483,7 +522,7 @@ describe('import-cmd', () => { await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); - expect(importFromJson).not.toHaveBeenCalled(); + expect(importResources).not.toHaveBeenCalled(); }); it('should return early when resolveWritableCollection returns null', async () => { @@ -491,7 +530,7 @@ describe('import-cmd', () => { await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); - expect(importFromJson).not.toHaveBeenCalled(); + expect(importResources).not.toHaveBeenCalled(); }); }); @@ -502,7 +541,7 @@ describe('import-cmd', () => { expect(ConsoleFormatter.error).toHaveBeenCalledWith( 'Source file is required. Use --source or run in interactive mode.', ); - expect(importFromJson).not.toHaveBeenCalled(); + expect(importResources).not.toHaveBeenCalled(); }); it('should call ConsoleFormatter.error and not import when --locale is missing in non-TTY mode', async () => { @@ -511,7 +550,7 @@ describe('import-cmd', () => { expect(ConsoleFormatter.error).toHaveBeenCalledWith( 'Target locale is required. Use --locale or run in interactive mode.', ); - expect(importFromJson).not.toHaveBeenCalled(); + expect(importResources).not.toHaveBeenCalled(); }); }); @@ -519,7 +558,7 @@ describe('import-cmd', () => { beforeEach(() => { vi.mocked(isInteractiveTerminal).mockReturnValue(true); vi.mocked(prompts).mockResolvedValue({ locale: 'de' }); - vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'de', warnings: [] } as never); + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, locale: 'de', warnings: [] } as never); vi.spyOn(console, 'log').mockImplementation(() => undefined); }); @@ -545,7 +584,7 @@ describe('import-cmd', () => { await importCommand({ source: '/test/import.json', format: 'json', strategy: 'translation-service' }); expect(offeredLocales()).toEqual([{ title: 'de', value: 'de' }]); - expect(importFromJson).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ locale: 'de' })); + expect(importResources).toHaveBeenCalledWith(expect.any(Object), [], expect.objectContaining({ locale: 'de' })); }); it('offers the project locales for a collection without its own', async () => { @@ -564,27 +603,29 @@ describe('import-cmd', () => { it('passes the loaded rules to the import', async () => { vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules, filePath }); - vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'en', warnings: [] } as never); + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, locale: 'en', warnings: [] } as never); vi.spyOn(console, 'log').mockImplementation(() => undefined); await importCommand({ source: '/test/import.json', locale: 'en', format: 'json', strategy: 'migration' }); expect(loadPreferredTerminology).toHaveBeenCalledWith(baseConfig, '/test/project'); - expect(importFromJson).toHaveBeenCalledWith( - expect.any(String), + expect(importResources).toHaveBeenCalledWith( + expect.any(Object), + [], expect.objectContaining({ preferredTerminology: rules }), ); }); it('adds one config warning on a base-locale import when the rule file is broken', async () => { vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules: [], filePath, error: 'not valid JSON' }); - vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'en', warnings: [] } as never); + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, locale: 'en', warnings: [] } as never); vi.spyOn(console, 'log').mockImplementation(() => undefined); await importCommand({ source: '/test/import.json', locale: 'en', format: 'json', strategy: 'migration' }); - expect(importFromJson).toHaveBeenCalledWith( - expect.any(String), + expect(importResources).toHaveBeenCalledWith( + expect.any(Object), + [], expect.objectContaining({ preferredTerminology: [] }), ); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Warnings (1)')); @@ -595,7 +636,7 @@ describe('import-cmd', () => { it('says nothing about a broken rule file on a target-locale import', async () => { vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules: [], filePath, error: 'not valid JSON' }); - vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'es', warnings: [] } as never); + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, locale: 'es', warnings: [] } as never); vi.spyOn(console, 'log').mockImplementation(() => undefined); await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); @@ -604,12 +645,16 @@ describe('import-cmd', () => { }); it('passes the project base locale for a collection without its own', async () => { - vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'es', warnings: [] } as never); + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, locale: 'es', warnings: [] } as never); vi.spyOn(console, 'log').mockImplementation(() => undefined); await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); - expect(importFromJson).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ baseLocale: 'en' })); + expect(importResources).toHaveBeenCalledWith( + expect.objectContaining({ baseLocale: 'en' }), + [], + expect.any(Object), + ); }); describe('collection with its own base locale', () => { @@ -623,7 +668,7 @@ describe('import-cmd', () => { it("treats an import into the collection's base locale as a base-locale import", async () => { vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules, filePath }); - vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'fr', warnings: [] } as never); + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, locale: 'fr', warnings: [] } as never); await importCommand({ source: '/test/import.json', @@ -633,15 +678,16 @@ describe('import-cmd', () => { strategy: 'migration', }); - expect(importFromJson).toHaveBeenCalledWith( - '/test/project/src/docs-translations', - expect.objectContaining({ locale: 'fr', baseLocale: 'fr', preferredTerminology: rules }), + expect(importResources).toHaveBeenCalledWith( + expect.objectContaining({ translationsFolder: '/test/project/src/docs-translations', baseLocale: 'fr' }), + [], + expect.objectContaining({ locale: 'fr', preferredTerminology: rules }), ); }); it("adds the config warning on an import into the collection's base locale", async () => { vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules: [], filePath, error: 'not valid JSON' }); - vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'fr', warnings: [] } as never); + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, locale: 'fr', warnings: [] } as never); await importCommand({ source: '/test/import.json', @@ -658,13 +704,14 @@ describe('import-cmd', () => { it('treats the project base locale as a target locale and adds no config warning', async () => { vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules: [], filePath, error: 'not valid JSON' }); - vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'en', warnings: [] } as never); + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, locale: 'en', warnings: [] } as never); await importCommand({ source: '/test/import.json', locale: 'en', format: 'json', collection: 'docs' }); - expect(importFromJson).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ locale: 'en', baseLocale: 'fr' }), + expect(importResources).toHaveBeenCalledWith( + expect.objectContaining({ baseLocale: 'fr' }), + [], + expect.objectContaining({ locale: 'en' }), ); expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Preferred terminology')); }); diff --git a/apps/cli/src/commands/import-cmd.ts b/apps/cli/src/commands/import-cmd.ts index 44a90ae2..1e170365 100644 --- a/apps/cli/src/commands/import-cmd.ts +++ b/apps/cli/src/commands/import-cmd.ts @@ -2,12 +2,13 @@ import { detectImportFormat, generateImportSummary, type ImportFormat, - type ImportOptions, type ImportResult, + type ImportRunOptions, type ImportStrategy, - importFromJson, - importFromXliff, + importResources, loadPreferredTerminology, + parseJsonImport, + parseXliffImport, readEffectiveProtectedTerms, } from '@simoncodes-ca/core'; import * as fs from 'fs'; @@ -93,14 +94,9 @@ export async function importCommand(options: ImportCommandOptions): Promise` elements, plus optional `` elements for comments. `protectedTermsFound` becomes a `doNotTranslate` array in rich JSON, and a `Do not translate: …` note in XLIFF. +1. **Choose the locales** — `exportTargetLocales(collections, options.locales)` lists every collection's target locales (a `Collection`'s `targetLocales`: its locales without its base locale) in order of first appearance, narrowed to the requested ones. The CLI calls it too, to print the plan before the run. The collections must share one base locale, because an export file has one source language; otherwise `runExport` throws. +2. **Load resources** — `loadResourcesFromCollections()` in `export-common.ts` walks each translations folder via `walkFolders()` and reads every `resource_entries.json` with its `tracker_meta.json`. Each entry becomes a `LoadedResource` with `source`, `translations`, `status`, `tags`, `collectionTags`, `collectionProtectedTerms`, and `comment`. +3. **Filter per locale** — for each locale, only the collections that have that locale as a target contribute. `filterResources()` keeps the resources whose status (missing counts as `new`) matches `options.status` and whose effective tags (`effectiveTags(collectionTags, resourceTags)` from `libs/domain/src/lib/effective-tags.ts`) match `options.tags`. A locale with no match is skipped, and `onProgress` reports it. +4. **Annotate protected terms** — `filterResources()` calls `findProtectedTerms(source, effectiveProtectedTerms(global, collection))` on each row and stores the matches on `FilteredResource.protectedTermsFound`. The caller reads the term lists from disk and passes them as `options.protectedTerms` (`global`, and `collections` by name), so the run reads no config. `augmentProtectedTerms: false` (the `--no-protect-notes` flag) leaves the field `undefined`. +5. **Serialize** — the JSON exporter writes a flat or hierarchical file (hierarchical key conflicts are reported separately); the XLIFF exporter writes an XLIFF 1.2 document with `` elements and `` elements for comments. `protectedTermsFound` becomes a `doNotTranslate` array in rich JSON and a `Do not translate: …` note in XLIFF. An exporter that throws fails only its locale; the run continues. +6. **Report** — `ExportRunResult` is the totals over all locales (`ExportResult`: files, resource count, warnings, errors, hierarchical conflicts), one `localeResults` entry per locale (`exported`, `skipped`, or `failed`, with the exception message when an exporter threw), and the Markdown `summary`. For the full sequence diagram, see [user-flows.md — Import / Export Flow](user-flows.md#2-import--export-flow). diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index a90c953d..c3760176 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -57,6 +57,16 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [` --- +## E + +### Export Run + +One export of one or more [collections](#collection) to one file per target locale. In code, `runExport(collections, options)` in `libs/core/src/lib/export/run-export.ts` is the whole run: it chooses the locales (every collection's target locales, narrowed to the requested ones), filters each collection's resources by status and tags for the locales it has, annotates [protected terms](#protected-term), writes the JSON or XLIFF files, and returns the totals, an outcome per locale, and the Markdown summary. The collections must share one [base locale](#base-locale). + +Explained in context: [`core-library.md`](core-library.md#export-pipeline) + +--- + ## I ### ICU Format @@ -69,6 +79,14 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [` --- +### Import Run + +One import of a set of resources into one locale of one [collection](#collection). A format adapter (`parseJsonImport`, `parseXliffImport`) turns a file into resources; `importResources(collection, resources, options)` in `libs/core/src/lib/import/import-resources.ts` does the rest: strategy defaults, Transloco reference resolution (migration only), placeholder normalization and auto-fix, validation, and the per-[folder](#resource-folder) merge. The state of one run (settings, changes, warnings, errors, written files) lives in an `ImportSession`. + +Explained in context: [`core-library.md`](core-library.md#import-pipeline) + +--- + ## L ### Locale Metadata diff --git a/architecture-docs/monorepo-structure.md b/architecture-docs/monorepo-structure.md index 3d59bbf7..172ea27b 100644 --- a/architecture-docs/monorepo-structure.md +++ b/architecture-docs/monorepo-structure.md @@ -55,6 +55,7 @@ lingo-tracker/ # Nx workspace root │ │ ├── icu-locale-validation.ts # compiles a value under its own locale │ │ ├── portable-plural-categories.ts # locale-dependent plural cases │ │ ├── normalize-transloco-syntax.ts # {{ x }} → {x} normalizer +│ │ ├── reference-resolver.ts # Inlines Transloco key references ({{t('key')}}, {{key}}) │ │ └── validation-utils.ts # Locale code, key length, conflict checks │ ├── core/ # Node.js business logic (file I/O, crypto) │ │ └── src/ @@ -63,8 +64,8 @@ lingo-tracker/ # Nx workspace root │ │ ├── resource/ # add, edit, delete, move resource; checksums │ │ └── lib/ │ │ ├── bundle/ # Bundle generation, tag filter, hierarchy -│ │ ├── export/ # JSON and XLIFF export pipelines -│ │ ├── import/ # Import pipeline, ICU auto-fix, status determination +│ │ ├── export/ # Export run (runExport) and the JSON / XLIFF exporters +│ │ ├── import/ # Import run (importResources), JSON / XLIFF parse adapters │ │ ├── folder/ # create-folder, delete-folder, move-folder │ │ ├── normalize/ # Cleanup empty folders, normalize entries │ │ ├── translate/ # Auto-translation, Google Translate provider @@ -148,6 +149,7 @@ graph TD | `icu-locale-validation.ts` | Compiles a value under the locale it is stored under; reports why it failed | | `portable-plural-categories.ts` | Finds plural branches selected by locale-dependent category rather than `=N` | | `icu-auto-fixer.ts` | Repairs malformed ICU quote escaping | +| `reference-resolver.ts` | Inlines Transloco key references (`{{t('key')}}`, `{{key}}`) between imported values; warns on missing and circular references | | `validation-utils.ts` | Locale code format checks, key length limits, hierarchical conflict detection | **Why zero Node.js dependencies?** The Tracker UI (Angular SPA) imports `@simoncodes-ca/domain` directly in the browser. Any Node.js built-in (`fs`, `path`, `crypto`, `node:*`) would break the Angular build. The zero-dependency constraint is enforced by the Nx project configuration: `domain` declares no Node.js peer dependencies and the dependency graph rules prohibit it from importing `core`. diff --git a/architecture-docs/user-flows.md b/architecture-docs/user-flows.md index 892a0d86..0d6fea15 100644 --- a/architecture-docs/user-flows.md +++ b/architecture-docs/user-flows.md @@ -102,9 +102,9 @@ sequenceDiagram ### Export -Export serializes the current resource tree for one locale to a JSON or XLIFF file for offline translator work. Core functions are documented in [core-library.md — Export Pipeline](core-library.md#export-pipeline). +Export writes the resources of the chosen collections to one JSON or XLIFF file per target locale, for offline translator work. The CLI opens the collections and calls one core function, `runExport`. Core functions are documented in [core-library.md — Export Pipeline](core-library.md#export-pipeline). - + ```mermaid sequenceDiagram @@ -113,29 +113,30 @@ sequenceDiagram participant Core as @simoncodes-ca/core participant FS as Filesystem - Dev->>CLI: export --locale fr --format json --output ./exports/fr.json - CLI->>Core: exportToJson(options) + Dev->>CLI: export --locale fr --format json --output ./exports + CLI->>FS: read .lingo-tracker.json → openCollection() per collection + CLI->>Core: validateOutputDirectory(outputDir) + CLI->>Core: exportTargetLocales(collections, ["fr"]) → print the plan + CLI->>Core: runExport(collections, options + protected terms) Core->>Core: loadResourcesFromCollections() - Note right of Core: walkFolders() traverses translationsFolder
reads resource_entries.json + tracker_meta.json per folder - Core->>FS: read resource_entries.json (per folder) - Core->>FS: read tracker_meta.json (per folder) - FS-->>Core: LoadedResource[] — key, source, translations, status, tags, comment - Core->>Core: filter by tag / key pattern [if options.filter] - Core->>Core: serialize: flat {key: value} map for locale "fr" - Note right of Core: Falls back to base locale value
when translation is absent - Core->>FS: validateOutputDirectory(outputDir) - Core->>FS: write fr.json - Core-->>CLI: ExportResult { resourcesExported, outputPath } - CLI-->>Dev: Export summary + Note right of Core: walkFolders() traverses each translationsFolder
reads resource_entries.json + tracker_meta.json per folder + Core->>FS: read resource_entries.json + tracker_meta.json (per folder) + loop For each target locale + Core->>Core: filterResources() — collections with this target locale,
status and tag filters, protected-term annotation + Core->>FS: write fr.json (JSON or XLIFF exporter; skipped in a dry run) + end + Core-->>CLI: ExportRunResult { totals, localeResults, summary } + CLI-->>Dev: Per-locale lines + export summary + CLI->>FS: write the summary file (printed instead in a dry run) ``` --- ### Import -Import ingests a translated file for one locale and reconciles it with the existing resource tree using the chosen [import strategy](glossary.md#import-strategy). Core functions are documented in [core-library.md — Import Pipeline](core-library.md#import-pipeline). +Import ingests a translated file for one locale and reconciles it with the existing resource tree using the chosen [import strategy](glossary.md#import-strategy). A format adapter parses the file; `importResources` does the rest. Core functions are documented in [core-library.md — Import Pipeline](core-library.md#import-pipeline). - + ```mermaid sequenceDiagram @@ -145,40 +146,37 @@ sequenceDiagram participant Domain as @simoncodes-ca/domain participant FS as Filesystem - Translator->>CLI: import --locale fr --file ./exports/fr.json --strategy translation-service - CLI->>FS: read .lingo-tracker.json → openCollection() → baseLocale = "en" - CLI->>Core: importFromJson(options with baseLocale) - - Note over Core: setupImportWorkflow(options)
uses options.baseLocale, reads no config - Core->>Core: getStrategyDefaults("translation-service")
createMissing=false, updateComments=false + Translator->>CLI: import --locale fr --source ./exports/fr.json --strategy translation-service + CLI->>FS: read .lingo-tracker.json → openCollection() → Collection (baseLocale "en") + CLI->>Core: detectImportFormat("./exports/fr.json") → json - Note over Core: Parse source file + Note over Core: Format adapter + CLI->>Core: parseJsonImport(path) Core->>FS: read fr.json - Core->>Core: detectJsonStructure() — flat vs hierarchical - Core->>Core: flatten hierarchical keys if needed + Core->>Core: detectJsonStructure() — flat vs hierarchical, flatten + Core-->>CLI: ImportedResource[] - Note over Core: Normalize and auto-fix + CLI->>Core: importResources(collection, resources, options) + Note over Core: openImportSession() — strategy defaults,
base-locale guard, reads no config + Core->>Domain: resolveAllReferences() [migration only] Core->>Core: normalizeTranslocoSyntaxInResources()
{{ x }} → {x} in imported values - Core->>Domain: applyICUAutoFixToResources()
repairs malformed placeholder syntax + Core->>Domain: applyICUAutoFixToResources()
repairs placeholders against the stored base value Domain-->>Core: fixed resources + ICUAutoFix[] records - - Note over Core: Validate - Core->>Core: validateImportResources() — duplicate key check + Core->>Core: validateImportResources() — keys, conflicts, empty values, duplicates Note over Core: Group and write per folder - Core->>Core: groupResourcesByFolder() — batch by resource_entries.json path - loop For each folder batch - Core->>FS: readResourceEntries() + readTrackerMetadata() - Core->>Core: determineUpdatedResourceStatus(strategy, resource, oldStatus) + Core->>Core: groupResourcesByFolder() — batch by resource folder + loop For each folder batch: processResourceGroup(session, group) + Core->>FS: openResourceFolder() — read both files + Core->>Domain: resolveImportStatus(strategy, oldStatus, …) Note right of Core: translation-service → "translated"
verification → "verified"
migration → preserves source status
update → preserves old status - Core->>Core: recompute checksums (MD5) - Core->>FS: writeJsonFile(resource_entries.json) - Core->>FS: writeJsonFile(tracker_meta.json) + Core->>FS: folder.save() — both files, once, if changed (not in a dry run) end - Core->>Core: buildImportResult() — consolidate counts, transitions, warnings + Core->>Core: sessionResult() — counts, transitions, warnings, errors Core-->>CLI: ImportResult CLI-->>Translator: Import summary (created / updated / skipped / failed, ICU fixes applied) + CLI->>FS: write generateImportSummary(result, { format, source, … }) ``` --- diff --git a/docs/features/export.md b/docs/features/export.md index 911f9ee7..e46601c7 100644 --- a/docs/features/export.md +++ b/docs/features/export.md @@ -24,7 +24,7 @@ lingo-tracker export --format [options] |--------|-------------|---------| | `-f, --format ` | Export format (`xliff` or `json`). | Required (or interactive) | | `-c, --collection ` | Comma-separated list of collections to export. | All collections | -| `-l, --locale ` | Comma-separated list of target locales. The base locale is always excluded. | All target locales | +| `-l, --locale ` | Comma-separated list of target locales. The base locale is always excluded. | All target locales of the exported collections | | `-s, --status ` | Filter by status (`new`, `translated`, `stale`, `verified`). | `new,stale` | | `-t, --tags ` | Filter by tags (comma-separated). Matches against the resource's *effective tags* — the union of per-resource tags and the collection's inherited tags. | None | | `-o, --output ` | Output directory. Defaults to `exportFolder` from config if set. | `dist/lingo-export` | @@ -59,6 +59,10 @@ lingo-tracker export --format json --filename "translations-{source}-to-{target} # Generates: translations-en-to-es-2025-12-13.json ``` +### Target locales + +A collection's target locales are its `locales` (or the global `locales` when it has none) without its base locale. The export writes one file per target locale of the exported collections, and each file holds only the resources of the collections that have that locale. The collections in one export must share a base locale, because an export file has one source language. To export collections with different base locales, run one export per collection with `--collection`. + ## Examples Export all untranslated and stale strings to XLIFF for all target locales: diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 991706a4..3a94d7bb 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,24 +1,24 @@ -export * from './constants'; -export * from './config/translation-config'; -export * from './config/lingo-tracker-config'; -export * from './config/lingo-tracker-collection'; -export * from './config/bundle-definition'; export * from './collections-manager'; -export * from './resource'; -export * from './lib/normalize'; +export * from './config/bundle-definition'; +export * from './config/lingo-tracker-collection'; +export * from './config/lingo-tracker-config'; +export * from './config/translation-config'; +export * from './constants'; export * from './lib/bundle'; -export * from './lib/export/types'; +export * from './lib/config'; +export * from './lib/errors'; export * from './lib/export/export-common'; +export * from './lib/export/export-summary'; export * from './lib/export/export-to-json'; export * from './lib/export/export-to-xliff'; -export * from './lib/export/export-summary'; -export * from './lib/import'; -export * from './lib/validate'; - +export * from './lib/export/run-export'; +export * from './lib/export/types'; // Export new utilities export * from './lib/file-io'; -export * from './lib/resource'; -export * from './lib/config'; -export * from './lib/errors'; export * from './lib/folder'; +export * from './lib/import'; +export * from './lib/normalize'; +export * from './lib/resource'; export * from './lib/translation'; +export * from './lib/validate'; +export * from './resource'; diff --git a/libs/core/src/lib/export/run-export.spec.ts b/libs/core/src/lib/export/run-export.spec.ts new file mode 100644 index 00000000..9e86babd --- /dev/null +++ b/libs/core/src/lib/export/run-export.spec.ts @@ -0,0 +1,306 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { type Collection, openCollection } from '../config/open-collection'; +import { openResourceFolder } from '../resource/resource-folder'; +import * as jsonExporter from './export-to-json'; +import { exportTargetLocales, runExport } from './run-export'; + +vi.mock('./export-to-json', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, exportToJson: vi.fn(actual.exportToJson) }; +}); + +describe('runExport', () => { + let projectDir: string; + let outputDirectory: string; + + const config: LingoTrackerConfig = { + exportFolder: 'dist/export', + importFolder: 'dist/import', + baseLocale: 'en', + locales: ['en', 'fr', 'es'], + collections: { + common: { translationsFolder: 'translations/common', tags: ['shared'] }, + admin: { translationsFolder: 'translations/admin', locales: ['en', 'fr', 'de'] }, + french: { translationsFolder: 'translations/french', baseLocale: 'fr', locales: ['fr', 'en'] }, + frOnly: { translationsFolder: 'translations/fr-only', locales: ['en', 'fr'] }, + deOnly: { translationsFolder: 'translations/de-only', locales: ['en', 'de'] }, + }, + }; + + const open = (name: string): Collection => openCollection(config, name, { cwd: projectDir }); + + /** Seeds one folder of a collection; `status` applies to every translation given. */ + const seed = ( + collection: Collection, + folder: string, + entries: Record; tags?: string[] }>, + status: 'translated' | 'verified' = 'translated', + ): void => { + const resourceFolder = openResourceFolder(join(collection.translationsFolder, folder), { + baseLocale: collection.baseLocale, + }); + for (const [key, { source, translations = {}, tags }] of Object.entries(entries)) { + resourceFolder.setBase(key, source); + if (tags) resourceFolder.setDetails(key, { tags }); + for (const [locale, value] of Object.entries(translations)) { + resourceFolder.setTranslation(key, locale, value, status); + } + } + resourceFolder.save(); + }; + + const readJson = (file: string): unknown => JSON.parse(readFileSync(join(outputDirectory, file), 'utf8')); + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'lingo-run-export-')); + outputDirectory = join(projectDir, 'out'); + mkdirSync(outputDirectory); + vi.mocked(jsonExporter.exportToJson).mockClear(); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + describe('exportTargetLocales', () => { + it("lists every collection's target locales once, in order, never a base locale", () => { + expect(exportTargetLocales([open('common'), open('admin')])).toEqual(['fr', 'es', 'de']); + expect(exportTargetLocales([open('french')])).toEqual(['en']); + }); + + it('narrows to the requested locales and ignores unknown or base ones', () => { + expect(exportTargetLocales([open('common')], ['es', 'en', 'xx'])).toEqual(['es']); + }); + }); + + it('writes one file per target locale and totals the run', async () => { + const common = open('common'); + seed(common, 'buttons', { ok: { source: 'OK', translations: { fr: "D'accord" } }, cancel: { source: 'Cancel' } }); + + const result = await runExport([common], { format: 'json', outputDirectory, jsonStructure: 'flat' }); + + expect(result.locales).toEqual(['fr', 'es']); + expect(result.collections).toEqual(['common']); + expect(result.filesCreated).toEqual(['fr.json', 'es.json']); + expect(result.resourcesExported).toBe(4); + expect(result.errors).toEqual([]); + expect(result.localeResults).toEqual([ + { locale: 'fr', outcome: 'exported', resourcesExported: 2, filesCreated: ['fr.json'] }, + { locale: 'es', outcome: 'exported', resourcesExported: 2, filesCreated: ['es.json'] }, + ]); + expect(readJson('fr.json')).toEqual({ 'buttons.ok': "D'accord", 'buttons.cancel': '' }); + expect(result.summary).toContain('# Export Summary'); + expect(result.summary).toContain('**Resources Exported**: 4'); + expect(result.summary).toContain('**Target Locales**: fr, es'); + }); + + it('filters by status and by effective tags (collection tags are inherited)', async () => { + const common = open('common'); + const admin = open('admin'); + seed(common, 'a', { done: { source: 'Done', translations: { fr: 'Fait' } } }, 'verified'); + seed(common, 'a', { todo: { source: 'To do' } }); + seed(admin, 'b', { other: { source: 'Other' }, tagged: { source: 'Tagged', tags: ['shared'] } }); + + const result = await runExport([common, admin], { + format: 'json', + outputDirectory, + jsonStructure: 'flat', + locales: ['fr'], + status: ['new', 'stale'], + tags: ['shared'], + }); + + expect(result.resourcesExported).toBe(2); + expect(readJson('fr.json')).toEqual({ 'a.todo': '', 'b.tagged': '' }); + }); + + it('exports a collection only for its own target locales', async () => { + const common = open('common'); + const admin = open('admin'); + seed(common, 'c', { hello: { source: 'Hello' } }); + seed(admin, 'd', { bye: { source: 'Bye' } }); + + const result = await runExport([common, admin], { format: 'json', outputDirectory, jsonStructure: 'flat' }); + + expect(result.locales).toEqual(['fr', 'es', 'de']); + expect(readJson('fr.json')).toEqual({ 'c.hello': '', 'd.bye': '' }); + expect(readJson('es.json')).toEqual({ 'c.hello': '' }); + expect(readJson('de.json')).toEqual({ 'd.bye': '' }); + }); + + it("keeps a key shared by two collections in each collection's own locales", async () => { + const frOnly = open('frOnly'); + const deOnly = open('deOnly'); + seed(frOnly, 'common', { ok: { source: 'OK', translations: { fr: "D'accord" } } }); + seed(deOnly, 'common', { ok: { source: 'Okay', translations: { de: 'In Ordnung' } } }); + + const result = await runExport([frOnly, deOnly], { format: 'json', outputDirectory, jsonStructure: 'flat' }); + + expect(result.locales).toEqual(['fr', 'de']); + expect(readJson('fr.json')).toEqual({ 'common.ok': "D'accord" }); + expect(readJson('de.json')).toEqual({ 'common.ok': 'In Ordnung' }); + }); + + it('skips a locale with no matching resource and says so through onProgress', async () => { + const common = open('common'); + seed(common, 'e', { ok: { source: 'OK', translations: { fr: 'OK' } } }, 'verified'); + const messages: string[] = []; + + const result = await runExport([common], { + format: 'json', + outputDirectory, + status: ['verified'], + onProgress: (message) => messages.push(message), + }); + + expect(result.localeResults).toEqual([ + { locale: 'fr', outcome: 'exported', resourcesExported: 1, filesCreated: ['fr.json'] }, + { locale: 'es', outcome: 'skipped', resourcesExported: 0, filesCreated: [] }, + ]); + expect(messages).toContain('Skipping es: No matching resources.'); + expect(existsSync(join(outputDirectory, 'es.json'))).toBe(false); + }); + + it('writes nothing in a dry run but reports the files it would create', async () => { + const common = open('common'); + seed(common, 'f', { ok: { source: 'OK' } }); + + const result = await runExport([common], { format: 'json', outputDirectory, dryRun: true }); + + expect(result.filesCreated).toEqual(['fr.json', 'es.json']); + expect(existsSync(join(outputDirectory, 'fr.json'))).toBe(false); + expect(result.summary).toContain('# Export Summary (DRY RUN)'); + }); + + it('writes XLIFF with the base locale as source language and do-not-translate notes', async () => { + const common = open('common'); + seed(common, 'g', { brand: { source: 'Open Acme' } }); + + const result = await runExport([common], { + format: 'xliff', + outputDirectory, + locales: ['fr'], + protectedTerms: { global: ['Acme'] }, + }); + + expect(result.filesCreated).toEqual(['fr.xliff']); + const xliff = readFileSync(join(outputDirectory, 'fr.xliff'), 'utf8'); + expect(xliff).toContain('source-language="en"'); + expect(xliff).toContain('target-language="fr"'); + expect(xliff).toContain('Do not translate: Acme'); + }); + + it("uses each collection's own protected terms, and none when augmentation is off", async () => { + const common = open('common'); + seed(common, 'h', { brand: { source: 'Try Widget' } }); + const options = { + format: 'json' as const, + outputDirectory, + locales: ['fr'], + jsonStructure: 'flat' as const, + richJson: true, + protectedTerms: { collections: { common: ['Widget'] } }, + }; + + await runExport([common], options); + expect(readJson('fr.json')).toEqual({ 'h.brand': { value: '', doNotTranslate: ['Widget'] } }); + + await runExport([common], { ...options, augmentProtectedTerms: false }); + expect(readJson('fr.json')).toEqual({ 'h.brand': { value: '' } }); + }); + + it('reports hierarchical key conflicts separately from errors', async () => { + const common = open('common'); + seed(common, '', { parent: { source: 'Parent' } }); + seed(common, 'parent', { child: { source: 'Child' } }); + + const result = await runExport([common], { format: 'json', outputDirectory, locales: ['fr'] }); + + expect(result.errors).toEqual([]); + expect(result.hierarchicalConflicts).toHaveLength(1); + expect(result.hierarchicalConflicts[0]).toContain('[fr]'); + expect(result.summary).toContain('### Hierarchical Key Conflicts'); + }); + + it('records a locale whose exporter throws and continues with the next locale', async () => { + const common = open('common'); + seed(common, 'i', { ok: { source: 'OK' } }); + vi.mocked(jsonExporter.exportToJson).mockImplementationOnce(() => { + throw new Error('disk full'); + }); + + const result = await runExport([common], { format: 'json', outputDirectory }); + + expect(result.localeResults[0]).toEqual({ + locale: 'fr', + outcome: 'failed', + resourcesExported: 0, + filesCreated: [], + error: 'disk full', + }); + expect(result.localeResults[1]).toMatchObject({ locale: 'es', outcome: 'exported' }); + expect(result.errors).toEqual(['Export for locale fr failed: disk full']); + expect(jsonExporter.exportToJson).toHaveBeenCalledTimes(2); + }); + + it("totals each exporter's omitted resources and malformed files", async () => { + const common = open('common'); + seed(common, 'k', { ok: { source: 'OK' } }); + vi.mocked(jsonExporter.exportToJson).mockImplementationOnce((_resources, options) => ({ + format: 'json', + filesCreated: ['fr.json'], + resourcesExported: 1, + warnings: [], + errors: [], + collections: ['common'], + locales: options.locales ?? [], + outputDirectory, + omittedResources: ['k.missing'], + malformedFiles: ['k/resource_entries.json'], + hierarchicalConflicts: [], + })); + + const result = await runExport([common], { format: 'json', outputDirectory, locales: ['fr'] }); + + expect(result.omittedResources).toEqual(['k.missing']); + expect(result.malformedFiles).toEqual(['k/resource_entries.json']); + }); + + it('passes each locale and the shared base locale to the exporter', async () => { + const common = open('common'); + seed(common, 'j', { ok: { source: 'OK' } }); + + await runExport([common], { format: 'json', outputDirectory, filenamePattern: 'strings-{locale}' }); + + expect(jsonExporter.exportToJson).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ locales: ['fr'], collections: ['common'], filenamePattern: 'strings-{locale}' }), + 'en', + ); + expect(existsSync(join(outputDirectory, 'strings-fr.json'))).toBe(true); + }); + + it('refuses collections with different base locales', async () => { + await expect(runExport([open('common'), open('french')], { format: 'json', outputDirectory })).rejects.toThrow( + 'Cannot export collections with different base locales together (common: en, french: fr)', + ); + }); + + it('refuses collections with different base locales even when no target locale is left', async () => { + await expect( + runExport([open('common'), open('french')], { format: 'json', outputDirectory, locales: ['unknown'] }), + ).rejects.toThrow('Cannot export collections with different base locales together'); + }); + + it('does nothing when no target locale is left', async () => { + const result = await runExport([open('common')], { format: 'json', outputDirectory, locales: ['en'] }); + + expect(result.locales).toEqual([]); + expect(result.localeResults).toEqual([]); + expect(jsonExporter.exportToJson).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/core/src/lib/export/run-export.ts b/libs/core/src/lib/export/run-export.ts new file mode 100644 index 00000000..6a49fca2 --- /dev/null +++ b/libs/core/src/lib/export/run-export.ts @@ -0,0 +1,158 @@ +import type { Collection } from '../config/open-collection'; +import { filterResources, loadResourcesFromCollections } from './export-common'; +import { generateExportSummary } from './export-summary'; +import { exportToJson } from './export-to-json'; +import { exportToXliff } from './export-to-xliff'; +import type { ExportOptions, ExportResult } from './types'; + +/** Options for {@link runExport}. `locales` narrows the export; unknown and base locales are ignored. */ +export interface ExportRunOptions extends Omit { + /** Protected terms, read by the caller: the global list, and each collection's own list by collection name. */ + protectedTerms?: { + global?: string[]; + collections?: Readonly>; + }; +} + +export interface ExportLocaleResult { + locale: string; + /** `skipped`: no resource matched the filters. `failed`: the exporter reported errors, or threw. */ + outcome: 'exported' | 'skipped' | 'failed'; + resourcesExported: number; + filesCreated: string[]; + /** The exception message, when the exporter threw. */ + error?: string; +} + +/** The totals over every locale, the outcome per locale, and the Markdown summary of the run. */ +export interface ExportRunResult extends ExportResult { + /** One entry per target locale, in export order. */ + localeResults: ExportLocaleResult[]; + summary: string; +} + +/** + * The locales an export writes: every target locale of the collections, in order of first + * appearance, narrowed to `requested` when given. A collection's base locale is never a target. + */ +export function exportTargetLocales(collections: readonly Collection[], requested?: readonly string[]): string[] { + const locales = new Set(collections.flatMap((collection) => collection.targetLocales)); + return [...locales].filter((locale) => !requested || requested.includes(locale)); +} + +/** + * Exports the collections' resources, one file per target locale (see {@link exportTargetLocales}). + * + * For each locale, the resources of the collections that have that locale as a target are + * filtered by status and tags (and annotated with the protected terms their source contains), + * then written by the JSON or XLIFF exporter. A locale with no matching resource is skipped; + * a locale whose exporter throws is reported and the run continues with the next locale. + * + * @throws {Error} The collections do not share one base locale (an export file has one source language). + */ +export async function runExport( + collections: readonly Collection[], + options: ExportRunOptions, +): Promise { + const { protectedTerms, ...exportOptions } = options; + const baseLocale = sharedBaseLocale(collections); + const targetLocales = exportTargetLocales(collections, options.locales); + const runOptions: ExportOptions = { + ...exportOptions, + collections: collections.map((collection) => collection.name), + locales: targetLocales, + }; + + const totals: ExportResult = { + format: options.format, + filesCreated: [], + resourcesExported: 0, + warnings: [], + errors: [], + collections: runOptions.collections ?? [], + locales: targetLocales, + outputDirectory: options.outputDirectory, + omittedResources: [], + malformedFiles: [], + hierarchicalConflicts: [], + }; + const localeResults: ExportLocaleResult[] = []; + + if (targetLocales.length > 0) { + // Loaded per collection so a key shared by two collections survives in each one's own locales. + const resourcesByCollection = new Map( + collections.map((collection) => [ + collection.name, + loadResourcesFromCollections([ + { + name: collection.name, + path: collection.translationsFolder, + tags: [...collection.tags], + protectedTerms: protectedTerms?.collections?.[collection.name], + }, + ]), + ]), + ); + + for (const locale of targetLocales) { + // Among the collections that target this locale, the last one wins a shared key. + const eligible = new Map( + collections + .filter((collection) => collection.targetLocales.includes(locale)) + .flatMap((collection) => resourcesByCollection.get(collection.name) ?? []) + .map((resource) => [resource.fullKey, resource]), + ); + const filtered = filterResources([...eligible.values()], locale, options.status, options.tags, { + globalProtectedTerms: protectedTerms?.global, + augmentProtectedTerms: options.augmentProtectedTerms !== false, + baseLocale, + }); + + if (filtered.length === 0) { + options.onProgress?.(`Skipping ${locale}: No matching resources.`); + localeResults.push({ locale, outcome: 'skipped', resourcesExported: 0, filesCreated: [] }); + continue; + } + + const localeOptions: ExportOptions = { ...runOptions, locales: [locale] }; + try { + const result = + options.format === 'xliff' + ? await exportToXliff(filtered, localeOptions, baseLocale) + : exportToJson(filtered, localeOptions, baseLocale); + + totals.resourcesExported += result.resourcesExported; + totals.filesCreated.push(...result.filesCreated); + totals.warnings.push(...result.warnings); + totals.errors.push(...result.errors); + totals.hierarchicalConflicts.push(...result.hierarchicalConflicts); + totals.omittedResources.push(...result.omittedResources); + totals.malformedFiles.push(...result.malformedFiles); + localeResults.push({ + locale, + outcome: result.filesCreated.length > 0 ? 'exported' : 'failed', + resourcesExported: result.resourcesExported, + filesCreated: result.filesCreated, + }); + } catch (error) { + const message = (error as Error).message; + totals.errors.push(`Export for locale ${locale} failed: ${message}`); + localeResults.push({ locale, outcome: 'failed', resourcesExported: 0, filesCreated: [], error: message }); + } + } + } + + return { ...totals, localeResults, summary: generateExportSummary(totals, runOptions) }; +} + +function sharedBaseLocale(collections: readonly Collection[]): string { + const baseLocales = new Set(collections.map((collection) => collection.baseLocale)); + const [baseLocale] = baseLocales; + if (baseLocales.size !== 1 || baseLocale === undefined) { + const listed = collections.map((collection) => `${collection.name}: ${collection.baseLocale}`).join(', '); + throw new Error( + `Cannot export collections with different base locales together (${listed}). Export them separately.`, + ); + } + return baseLocale; +} diff --git a/libs/core/src/lib/import/determine-status.ts b/libs/core/src/lib/import/determine-status.ts index 137dbb22..d85db7b4 100644 --- a/libs/core/src/lib/import/determine-status.ts +++ b/libs/core/src/lib/import/determine-status.ts @@ -1,5 +1,5 @@ -import type { ImportOptions, ImportedResource } from './types'; import type { TranslationStatus } from '@simoncodes-ca/domain'; +import type { ImportedResource, ImportRunOptions } from './types'; /** * Returns true when the imported resource's status field should be used as the resulting @@ -14,7 +14,7 @@ import type { TranslationStatus } from '@simoncodes-ca/domain'; * carries a status value — missing status fields fall through to strategy defaults. */ export function shouldUseSourceStatus( - options: ImportOptions, + options: ImportRunOptions, resource: ImportedResource, ): resource is ImportedResource & { status: TranslationStatus } { if (!resource.status) { @@ -39,7 +39,7 @@ export function shouldUseSourceStatus( * @param resource - The imported resource being created * @returns The translation status to assign, or `undefined` for base locale entries */ -export function determineNewResourceStatus(options: ImportOptions, resource: ImportedResource): TranslationStatus { +export function determineNewResourceStatus(options: ImportRunOptions, resource: ImportedResource): TranslationStatus { return shouldUseSourceStatus(options, resource) ? resource.status : 'translated'; } @@ -48,7 +48,7 @@ export function determineNewResourceStatus(options: ImportOptions, resource: Imp * otherwise `undefined` so the strategy decides (see `resolveImportStatus` in domain). */ export function honouredSourceStatus( - options: ImportOptions, + options: ImportRunOptions, resource: ImportedResource, ): TranslationStatus | undefined { return shouldUseSourceStatus(options, resource) ? resource.status : undefined; diff --git a/libs/core/src/lib/import/import-error-handling.integration.spec.ts b/libs/core/src/lib/import/import-error-handling.integration.spec.ts deleted file mode 100644 index f09c9952..00000000 --- a/libs/core/src/lib/import/import-error-handling.integration.spec.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { importFromJson } from './import-from-json'; -import { importFromXliff } from './import-from-xliff'; -import type { ImportOptions } from './types'; -import * as fs from 'fs'; -import * as path from 'path'; - -vi.mock('fs'); -vi.mock('path'); - -describe('import error handling integration', () => { - beforeEach(() => { - vi.clearAllMocks(); - - // Mock path functions - vi.spyOn(path, 'resolve').mockImplementation((...segments) => segments.join('/')); - vi.spyOn(path, 'join').mockImplementation((...segments) => segments.join('/')); - vi.spyOn(path, 'dirname').mockImplementation((p) => { - const parts = String(p).split('/'); - parts.pop(); - return parts.join('/'); - }); - }); - - describe('Fatal errors', () => { - it('should throw error when source file not found', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - - const options: ImportOptions = { - source: '/import/missing.json', - locale: 'es', - baseLocale: 'en', - }; - - expect(() => importFromJson('/translations', options)).toThrow('Source file not found'); - }); - - it('should throw error when importing into base locale', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{}'); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'en', // base locale - baseLocale: 'en', - }; - - expect(() => importFromJson('/translations', options)).toThrow('Cannot import into base locale'); - }); - - it('should throw error when JSON file is malformed', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{ invalid json'); - - const options: ImportOptions = { - source: '/import/malformed.json', - locale: 'es', - baseLocale: 'en', - }; - - expect(() => importFromJson('/translations', options)).toThrow('Failed to parse JSON file'); - }); - - it('should throw error when XLIFF source file not found', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - - const options: ImportOptions = { - source: '/import/missing.xliff', - locale: 'es', - baseLocale: 'en', - }; - - await expect(importFromXliff('/translations', options)).rejects.toThrow('Source file not found'); - }); - - it('should throw error when XLIFF file is malformed', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('invalid xliff content'); - - const options: ImportOptions = { - source: '/import/malformed.xliff', - locale: 'es', - baseLocale: 'en', - }; - - await expect(importFromXliff('/translations', options)).rejects.toThrow('Failed to parse XLIFF content'); - }); - }); - - describe('Non-fatal errors - invalid keys', () => { - it('should skip resources with invalid key format', () => { - const importData = { - 'common.buttons.ok': 'OK', - 'common..invalid': 'Invalid', // consecutive dots - 'dashboard.title': 'Dashboard', - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(importData)); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = importFromJson('/translations', options); - - // All 3 resources fail because migration strategy requires baseValue for creation - expect(result.resourcesFailed).toBe(3); - expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors.some((e) => e.includes('common..invalid'))).toBe(true); - expect(result.changes.find((c) => c.key === 'common..invalid' && c.type === 'failed')).toBeDefined(); - }); - - it('should skip resources with hierarchical conflicts', () => { - const importData = { - common: 'Common', // Has value - 'common.buttons': 'Buttons', // Child exists, creating conflict - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(importData)); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = importFromJson('/translations', options); - - expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors.some((e) => e.includes('Hierarchical conflict'))).toBe(true); - // Both resources fail - both have conflicts, and both need baseValue for migration - expect(result.resourcesFailed).toBe(2); - }); - - it('should fail when creating resource without baseValue', () => { - const importData = { - 'new.resource': 'Nuevo Recurso', // Simple string, no baseValue - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(importData)); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = importFromJson('/translations', options); - - expect(result.resourcesFailed).toBe(1); - const failedChange = result.changes.find((c) => c.key === 'new.resource'); - expect(failedChange?.type).toBe('failed'); - expect(failedChange?.reason).toContain('base value not provided'); - }); - }); - - describe('Warnings', () => { - it('should warn on duplicate keys in import file', () => { - const importData = { - 'common.title': { value: 'Title 1', baseValue: 'Title' }, - 'dashboard.title': { value: 'Dashboard', baseValue: 'Dashboard' }, - // Note: JSON parsing will use last occurrence, so we need to test with hierarchical - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(importData)); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = importFromJson('/translations', options); - - // No duplicates in this case due to JSON structure limitation - // Real duplicate detection happens at JSON parse level - expect(result.warnings).toBeDefined(); - }); - - it('should warn on base value mismatch', () => { - const existingEntries = { - title: { source: 'Original Title', es: 'Título Original' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'checksum-en' }, - es: { - checksum: 'checksum-es', - baseChecksum: 'checksum-en', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title': { value: 'Título Nuevo', baseValue: 'Different Title' }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations/common', options); - - expect(result.warnings.length).toBeGreaterThan(0); - const mismatchWarning = result.warnings.find((w) => w.includes('Base value mismatch')); - expect(mismatchWarning).toBeDefined(); - expect(mismatchWarning).toContain('preserving LingoTracker value'); - }); - - it('should warn on missing resource when strategy does not allow creation', () => { - const importData = { - 'new.resource': { value: 'New Value', baseValue: 'New Value' }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(importData)); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'translation-service', // Does not allow creation - }; - - const result = importFromJson('/translations', options); - - expect(result.resourcesSkipped).toBe(1); - const skippedChange = result.changes.find((c) => c.key === 'new.resource'); - expect(skippedChange?.type).toBe('skipped'); - expect(skippedChange?.reason).toContain('strategy does not allow creation'); - }); - - it('should warn on very long keys', () => { - const longKey = 'a'.repeat(201); - const importData = { - [longKey]: { value: 'Value', baseValue: 'Value' }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(importData)); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = importFromJson('/translations', options); - - expect(result.warnings.length).toBeGreaterThan(0); - expect(result.warnings.some((w) => w.includes('Very long key'))).toBe(true); - }); - }); - - describe('Graceful degradation', () => { - it('should continue processing after skipping failed resources', () => { - const importData = { - 'common.valid1': { value: 'Valid 1', baseValue: 'Valid 1' }, - 'common..invalid': { value: 'Invalid', baseValue: 'Invalid' }, // Invalid key - 'common.valid2': { value: 'Valid 2', baseValue: 'Valid 2' }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(importData)); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = importFromJson('/translations', options); - - expect(result.resourcesCreated).toBe(2); // Valid resources created - expect(result.resourcesFailed).toBe(1); // Invalid resource failed - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should accumulate warnings without stopping', () => { - const existingEntries = { - title1: { source: 'Original 1', es: 'Título 1' }, - title2: { source: 'Original 2', es: 'Título 2' }, - }; - - const existingMeta = { - title1: { - en: { checksum: 'check1' }, - es: { - checksum: 'check-es1', - baseChecksum: 'check1', - status: 'translated', - }, - }, - title2: { - en: { checksum: 'check2' }, - es: { - checksum: 'check-es2', - baseChecksum: 'check2', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title1': { value: 'Título Nuevo 1', baseValue: 'Different 1' }, // Mismatch - 'common.title2': { value: 'Título Nuevo 2', baseValue: 'Different 2' }, // Mismatch - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations/common', options); - - expect(result.warnings.length).toBeGreaterThanOrEqual(2); // At least 2 mismatch warnings - expect(result.resourcesUpdated).toBe(2); // Both resources still updated despite warnings - }); - }); -}); diff --git a/libs/core/src/lib/import/import-from-json.integration.spec.ts b/libs/core/src/lib/import/import-from-json.integration.spec.ts deleted file mode 100644 index 6572c7e1..00000000 --- a/libs/core/src/lib/import/import-from-json.integration.spec.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { importFromJson } from './import-from-json'; -import type { ImportOptions } from './types'; -import * as fs from 'fs'; -import * as path from 'path'; - -vi.mock('fs'); -vi.mock('path'); - -describe('importFromJson - integration tests', () => { - beforeEach(() => { - vi.clearAllMocks(); - - // Mock path functions - vi.spyOn(path, 'resolve').mockImplementation((...segments) => segments.join('/')); - vi.spyOn(path, 'join').mockImplementation((...segments) => segments.join('/')); - }); - - describe('flat JSON import', () => { - it('should import flat JSON and update existing resources', () => { - // Setup: existing resources - const existingEntries = { - ok: { - source: 'OK', - es: 'Aceptar', // Old translation - }, - cancel: { - source: 'Cancel', - // No Spanish translation yet - }, - }; - - const existingMeta = { - ok: { - en: { checksum: 'checksum-ok-en' }, - es: { - checksum: 'checksum-old-es', - baseChecksum: 'checksum-ok-en', - status: 'translated', - }, - }, - cancel: { - en: { checksum: 'checksum-cancel-en' }, - }, - }; - - // Import data (flat structure) - const importData = { - 'common.buttons.ok': 'OK', // Updated translation - 'common.buttons.cancel': 'Cancelar', // New translation - }; - - // Mock file system - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - if (pathStr.includes('resource_entries.json')) return true; - if (pathStr.includes('tracker_meta.json')) return true; - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) { - return JSON.stringify(importData); - } - if (pathStr.includes('resource_entries.json')) { - return JSON.stringify(existingEntries); - } - if (pathStr.includes('tracker_meta.json')) { - return JSON.stringify(existingMeta); - } - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - // Execute import - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - collection: 'TestCollection', - }; - - const result = importFromJson('/translations/common/buttons', options); - - // Verify results - expect(result.format).toBe('json'); - expect(result.locale).toBe('es'); - expect(result.resourcesUpdated).toBe(2); - expect(result.resourcesCreated).toBe(0); - expect(result.resourcesSkipped).toBe(0); - expect(result.resourcesFailed).toBe(0); - expect(result.errors).toHaveLength(0); - - // Verify files were written - expect(writeFileSyncSpy).toHaveBeenCalled(); - const writeCalls = writeFileSyncSpy.mock.calls; - - // Check that resource_entries.json and tracker_meta.json were written - expect(writeCalls.some((call) => String(call[0]).includes('resource_entries.json'))).toBe(true); - expect(writeCalls.some((call) => String(call[0]).includes('tracker_meta.json'))).toBe(true); - - // Verify updated resource entries - const resourceEntriesCall = writeCalls.find((call) => String(call[0]).includes('resource_entries.json')); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.ok.es).toBe('OK'); - expect(updatedEntries.cancel.es).toBe('Cancelar'); - } - - // Verify updated metadata - const metaCall = writeCalls.find((call) => String(call[0]).includes('tracker_meta.json')); - if (metaCall) { - const updatedMeta = JSON.parse(String(metaCall[1])); - expect(updatedMeta.ok.es.status).toBe('translated'); - expect(updatedMeta.cancel.es.status).toBe('translated'); - expect(updatedMeta.ok.es.checksum).toBeDefined(); - expect(updatedMeta.cancel.es.checksum).toBeDefined(); - } - }); - - it('should skip resources that do not exist', () => { - const importData = { - 'common.buttons.new': 'Nuevo', // Does not exist - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - // No existing resource files - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) { - return JSON.stringify(importData); - } - return '{}'; - }); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations/common/buttons', options); - - expect(result.resourcesUpdated).toBe(0); - expect(result.resourcesSkipped).toBe(1); - expect(result.changes[0].type).toBe('skipped'); - expect(result.changes[0].reason).toContain('Resource not found'); - }); - - it('should generate status transitions correctly', () => { - const existingEntries = { - title: { source: 'Title' }, - description: { source: 'Description', es: 'Descripción' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'checksum-title-en' }, - }, - description: { - en: { checksum: 'checksum-desc-en' }, - es: { - checksum: 'checksum-old', - baseChecksum: 'checksum-desc-en', - status: 'new', - }, - }, - }; - - const importData = { - 'common.title': 'Título', // New translation (no previous status) - 'common.description': 'Nueva Descripción', // Updated translation (was 'new') - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations/common', options); - - // We expect 2 status transitions: - // - undefined → translated (title - new translation) - // - new → translated (description - updated translation) - expect(result.statusTransitions.length).toBeGreaterThanOrEqual(1); - expect(result.statusTransitions.every((t) => t.to === 'translated')).toBe(true); - - // Verify total count matches resources updated - const totalCount = result.statusTransitions.reduce((sum, t) => sum + t.count, 0); - expect(totalCount).toBe(result.resourcesUpdated); - }); - }); - - describe('hierarchical JSON import', () => { - it('should import hierarchical JSON and update existing resources', () => { - const existingEntries = { - ok: { source: 'OK' }, - cancel: { source: 'Cancel' }, - }; - - const existingMeta = { - ok: { en: { checksum: 'checksum-ok-en' } }, - cancel: { en: { checksum: 'checksum-cancel-en' } }, - }; - - const importData = { - common: { - buttons: { - ok: 'OK', - cancel: 'Cancelar', - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations/common/buttons', options); - - expect(result.resourcesUpdated).toBe(2); - expect(writeFileSyncSpy).toHaveBeenCalled(); - - // Verify hierarchical structure was correctly extracted - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.ok.es).toBe('OK'); - expect(updatedEntries.cancel.es).toBe('Cancelar'); - } - }); - - it('should handle deeply nested hierarchical structures', () => { - const existingEntries = { - title: { source: 'Title' }, - }; - - const existingMeta = { - title: { en: { checksum: 'checksum-title-en' } }, - }; - - const importData = { - apps: { - dashboard: { - widgets: { - chart: { - title: 'Título del Gráfico', - }, - }, - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations/apps/dashboard/widgets/chart', options); - - expect(result.resourcesUpdated).toBe(1); - expect(result.changes[0].key).toBe('apps.dashboard.widgets.chart.title'); - }); - }); - - describe('checksum calculation', () => { - it('should recalculate checksums for updated values', () => { - const existingEntries = { - title: { source: 'Title', es: 'Título Antiguo' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title': 'Título Nuevo', // Changed value - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations/common', options); - - expect(result.resourcesUpdated).toBe(1); - - // Verify checksum was recalculated - const metaCall = writeFileSyncSpy.mock.calls.find((call) => String(call[0]).includes('tracker_meta.json')); - if (metaCall) { - const updatedMeta = JSON.parse(String(metaCall[1])); - expect(updatedMeta.title.es.checksum).not.toBe('old-checksum'); - expect(updatedMeta.title.es.baseChecksum).toBe('base-checksum'); - } - }); - }); - - describe('files modified tracking', () => { - it('should track all modified files', () => { - const existingEntries = { - ok: { source: 'OK' }, - cancel: { source: 'Cancel' }, - }; - - const existingMeta = { - ok: { en: { checksum: 'checksum-ok' } }, - cancel: { en: { checksum: 'checksum-cancel' } }, - }; - - const importData = { - 'common.buttons.ok': 'OK', - 'common.buttons.cancel': 'Cancelar', - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations/common/buttons', options); - - expect(result.filesModified.length).toBeGreaterThan(0); - expect(result.filesModified.some((f) => f.includes('resource_entries.json'))).toBe(true); - expect(result.filesModified.some((f) => f.includes('tracker_meta.json'))).toBe(true); - }); - }); -}); diff --git a/libs/core/src/lib/import/import-from-json.spec.ts b/libs/core/src/lib/import/import-from-json.spec.ts deleted file mode 100644 index a442d604..00000000 --- a/libs/core/src/lib/import/import-from-json.spec.ts +++ /dev/null @@ -1,2285 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import { detectJsonStructure, extractFromFlat, extractFromHierarchical, importFromJson } from './import-from-json'; -import type { ImportOptions } from './types'; - -// Mock fs module -vi.mock('fs'); -vi.mock('path'); - -describe('import-from-json', () => { - beforeEach(() => { - vi.clearAllMocks(); - - // Mock path.resolve to return predictable paths - vi.spyOn(path, 'resolve').mockImplementation((...segments) => segments.join('/')); - - // Mock path.join to return predictable paths - vi.spyOn(path, 'join').mockImplementation((...segments) => segments.join('/')); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('detectJsonStructure', () => { - it('should detect flat structure when all keys contain dots', () => { - const data = { - 'common.buttons.ok': 'OK', - 'common.buttons.cancel': 'Cancel', - 'dashboard.title': 'Dashboard', - }; - - expect(detectJsonStructure(data)).toBe('flat'); - }); - - it('should detect hierarchical structure when keys do not contain dots', () => { - const data = { - common: { buttons: { ok: 'OK' } }, - dashboard: { title: 'Dashboard' }, - }; - - expect(detectJsonStructure(data)).toBe('hierarchical'); - }); - - it('should detect hierarchical structure for mixed keys', () => { - const data = { - 'common.buttons': 'Invalid', // Has dot - dashboard: { title: 'Dashboard' }, // No dot - }; - - expect(detectJsonStructure(data)).toBe('hierarchical'); - }); - - it('should handle empty object as hierarchical', () => { - expect(detectJsonStructure({})).toBe('hierarchical'); - }); - - it('should detect flat structure with single dotted key', () => { - const data = { - 'common.title': 'Title', - }; - - expect(detectJsonStructure(data)).toBe('flat'); - }); - }); - - describe('extractFromFlat', () => { - it('should extract resources from flat structure', () => { - const data = { - 'common.buttons.ok': 'OK', - 'common.buttons.cancel': 'Cancel', - 'dashboard.title': 'Dashboard', - }; - - const resources = extractFromFlat(data); - - expect(resources).toHaveLength(3); - expect(resources[0]).toEqual({ - key: 'common.buttons.ok', - value: 'OK', - }); - expect(resources[1]).toEqual({ - key: 'common.buttons.cancel', - value: 'Cancel', - }); - expect(resources[2]).toEqual({ - key: 'dashboard.title', - value: 'Dashboard', - }); - }); - - it('should skip non-string values in flat structure', () => { - const data = { - 'common.title': 'Title', - 'common.count': 42, // number - 'common.config': { nested: 'value' }, // object - 'common.items': ['a', 'b'], // array - }; - - const resources = extractFromFlat(data); - - expect(resources).toHaveLength(1); - expect(resources[0]).toEqual({ - key: 'common.title', - value: 'Title', - }); - }); - - it('should handle empty object', () => { - const resources = extractFromFlat({}); - expect(resources).toHaveLength(0); - }); - }); - - describe('extractFromHierarchical', () => { - it('should extract resources from hierarchical structure', () => { - const data = { - common: { - buttons: { - ok: 'OK', - cancel: 'Cancel', - }, - title: 'Common', - }, - dashboard: { - title: 'Dashboard', - }, - }; - - const resources = extractFromHierarchical(data); - - expect(resources).toHaveLength(4); - expect(resources).toContainEqual({ - key: 'common.buttons.ok', - value: 'OK', - }); - expect(resources).toContainEqual({ - key: 'common.buttons.cancel', - value: 'Cancel', - }); - expect(resources).toContainEqual({ - key: 'common.title', - value: 'Common', - }); - expect(resources).toContainEqual({ - key: 'dashboard.title', - value: 'Dashboard', - }); - }); - - it('should handle deeply nested structures', () => { - const data = { - level1: { - level2: { - level3: { - level4: { - deepValue: 'Deep', - }, - }, - }, - }, - }; - - const resources = extractFromHierarchical(data); - - expect(resources).toHaveLength(1); - expect(resources[0]).toEqual({ - key: 'level1.level2.level3.level4.deepValue', - value: 'Deep', - }); - }); - - it('should skip non-string leaf values', () => { - const data = { - common: { - title: 'Title', - count: 42, - items: ['a', 'b'], - nested: null, - }, - }; - - const resources = extractFromHierarchical(data); - - expect(resources).toHaveLength(1); - expect(resources[0]).toEqual({ - key: 'common.title', - value: 'Title', - }); - }); - - it('should handle empty object', () => { - const resources = extractFromHierarchical({}); - expect(resources).toHaveLength(0); - }); - - it('should handle single level structure', () => { - const data = { - title: 'Title', - description: 'Description', - }; - - const resources = extractFromHierarchical(data); - - expect(resources).toHaveLength(2); - expect(resources).toContainEqual({ - key: 'title', - value: 'Title', - }); - expect(resources).toContainEqual({ - key: 'description', - value: 'Description', - }); - }); - }); - - describe('importFromJson - validation and error handling', () => { - it('should throw error if source file does not exist', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - - const options: ImportOptions = { - source: '/path/to/missing.json', - locale: 'es', - baseLocale: 'en', - }; - - expect(() => importFromJson('/translations', options)).toThrow('Source file not found'); - }); - - it('should throw error if JSON is malformed', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{ invalid json }'); - - const options: ImportOptions = { - source: '/path/to/invalid.json', - locale: 'es', - baseLocale: 'en', - }; - - expect(() => importFromJson('/translations', options)).toThrow('Failed to parse JSON file'); - }); - - it('should throw error if importing into base locale', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ 'common.title': 'Title' })); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'en', // base locale - baseLocale: 'en', - }; - - expect(() => importFromJson('/translations', options)).toThrow('Cannot import into base locale "en"'); - }); - - it('should detect and warn about duplicate keys', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - 'common.title': 'Title', - 'dashboard.title': 'Dashboard', - }), - ); - - // Mock empty resources (will skip all) - vi.spyOn(fs, 'existsSync').mockImplementation((path) => { - if (typeof path === 'string' && path.includes('.json')) { - return true; - } - return false; - }); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations', options); - - // Should not have duplicate warnings if keys are different - expect(result.warnings.filter((w) => w.includes('Duplicate'))).toHaveLength(0); - }); - - it('should detect hierarchical conflicts', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - common: 'Common Value', // This key... - 'common.buttons': 'Buttons', // ...conflicts with this - }), - ); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations', options); - - expect(result.errors.filter((e) => e.includes('Hierarchical conflict'))).toHaveLength(1); - expect(result.errors[0]).toContain('common'); - }); - - it('should warn about very long keys', () => { - const longKey = 'a'.repeat(201); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - if (typeof filePath === 'string' && filePath.includes('file.json')) { - return JSON.stringify({ - [longKey]: 'Value', - }); - } - // Mock empty resources (will skip the import) - if (typeof filePath === 'string' && filePath.includes('resource_entries.json')) { - return JSON.stringify({}); - } - if (typeof filePath === 'string' && filePath.includes('tracker_meta.json')) { - return JSON.stringify({}); - } - return '{}'; - }); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations', options); - - expect(result.warnings.filter((w) => w.includes('Very long key'))).toHaveLength(1); - }); - - it('should skip empty values', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - 'common.title': '', - 'common.description': ' ', - }), - ); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations', options); - - expect(result.resourcesSkipped).toBe(2); - expect(result.warnings.filter((w) => w.includes('Empty value'))).toHaveLength(2); - }); - - it('should validate key format', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - 'common..buttons': 'Invalid', // consecutive dots - 'common.buttons!': 'Invalid', // invalid character - }), - ); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations', options); - - expect(result.errors.filter((e) => e.includes('Invalid key format'))).toHaveLength(2); - expect(result.resourcesFailed).toBe(2); - }); - }); - - describe('importFromJson - dry run mode', () => { - it('should not write files in dry run mode', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - if (typeof filePath === 'string' && filePath.includes('file.json')) { - return JSON.stringify({ 'common.title': 'Título' }); - } - if (typeof filePath === 'string' && filePath.includes('resource_entries.json')) { - return JSON.stringify({ title: { source: 'Title', es: 'Old' } }); - } - if (typeof filePath === 'string' && filePath.includes('tracker_meta.json')) { - return JSON.stringify({ - title: { - en: { checksum: 'abc123' }, - es: { - checksum: 'old123', - baseChecksum: 'abc123', - status: 'translated', - }, - }, - }); - } - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - dryRun: true, - }; - - const result = importFromJson('/translations/common', options); - - expect(writeFileSyncSpy).not.toHaveBeenCalled(); - expect(result.dryRun).toBe(true); - expect(result.resourcesUpdated).toBe(1); - }); - }); - - describe('importFromJson - progress callback', () => { - it('should call progress callback with status updates', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - if (typeof filePath === 'string' && filePath.includes('file.json')) { - return JSON.stringify({ 'common.title': 'Title' }); - } - return '{}'; - }); - - const progressMessages: string[] = []; - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - onProgress: (msg) => progressMessages.push(msg), - }; - - importFromJson('/translations', options); - - expect(progressMessages.length).toBeGreaterThan(0); - expect(progressMessages.some((m) => m.includes('Reading JSON file'))).toBe(true); - expect(progressMessages.some((m) => m.includes('Detected'))).toBe(true); - expect(progressMessages.some((m) => m.includes('Extracted'))).toBe(true); - }); - - it('should show verbose progress when enabled', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - if (typeof filePath === 'string' && filePath.includes('file.json')) { - return JSON.stringify({ - 'common.title': 'Título', - 'common.description': 'Descripción', - }); - } - return '{}'; - }); - - const progressMessages: string[] = []; - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - verbose: true, - onProgress: (msg) => progressMessages.push(msg), - }; - - importFromJson('/translations', options); - - expect(progressMessages.some((m) => m.includes('Processing: common.title'))).toBe(true); - expect(progressMessages.some((m) => m.includes('Processing: common.description'))).toBe(true); - }); - }); - - describe('Rich format support', () => { - describe('extractFromFlat - rich format', () => { - it('should extract rich format objects from flat structure', () => { - const data = { - 'common.title': { - value: 'Título', - comment: 'Page title', - baseValue: 'Title', - status: 'verified', - tags: ['ui', 'common'], - }, - 'common.description': { - value: 'Descripción', - }, - }; - - const resources = extractFromFlat(data); - - expect(resources).toHaveLength(2); - expect(resources[0]).toEqual({ - key: 'common.title', - value: 'Título', - comment: 'Page title', - baseValue: 'Title', - status: 'verified', - tags: ['ui', 'common'], - }); - expect(resources[1]).toEqual({ - key: 'common.description', - value: 'Descripción', - }); - }); - - it('should filter out non-string tags', () => { - const data = { - 'common.title': { - value: 'Título', - tags: ['ui', 123, 'common', null], - }, - }; - - const resources = extractFromFlat(data); - - expect(resources[0].tags).toEqual(['ui', 'common']); - }); - - it('should handle mix of simple and rich formats', () => { - const data = { - 'common.title': 'Título', - 'common.description': { - value: 'Descripción', - comment: 'Description text', - }, - }; - - const resources = extractFromFlat(data); - - expect(resources).toHaveLength(2); - expect(resources[0]).toEqual({ - key: 'common.title', - value: 'Título', - }); - expect(resources[1]).toEqual({ - key: 'common.description', - value: 'Descripción', - comment: 'Description text', - }); - }); - }); - - describe('extractFromHierarchical - rich format', () => { - it('should extract rich format objects from hierarchical structure', () => { - const data = { - common: { - title: { - value: 'Título', - comment: 'Page title', - tags: ['ui'], - }, - description: { - value: 'Descripción', - }, - }, - }; - - const resources = extractFromHierarchical(data); - - expect(resources).toHaveLength(2); - expect(resources).toContainEqual({ - key: 'common.title', - value: 'Título', - comment: 'Page title', - tags: ['ui'], - }); - expect(resources).toContainEqual({ - key: 'common.description', - value: 'Descripción', - }); - }); - - it('should handle mix of simple and rich formats in hierarchy', () => { - const data = { - common: { - title: 'Título', - buttons: { - ok: { - value: 'Aceptar', - comment: 'OK button', - }, - }, - }, - }; - - const resources = extractFromHierarchical(data); - - expect(resources).toHaveLength(2); - expect(resources).toContainEqual({ - key: 'common.title', - value: 'Título', - }); - expect(resources).toContainEqual({ - key: 'common.buttons.ok', - value: 'Aceptar', - comment: 'OK button', - }); - }); - }); - - describe('Comment updates', () => { - it('should update comment when updateComments flag is true', () => { - const existingEntries = { - title: { source: 'Title', comment: 'Old comment', es: 'Old' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title': { - value: 'Título', - comment: 'New comment', - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('file.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - updateComments: true, - }; - - importFromJson('/translations/common', options); - - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - expect(resourceEntriesCall).toBeDefined(); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.title.comment).toBe('New comment'); - } - }); - - it('should NOT update comment when updateComments flag is false', () => { - const existingEntries = { - title: { source: 'Title', comment: 'Old comment', es: 'Old' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title': { - value: 'Título', - comment: 'New comment', - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('file.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - updateComments: false, - }; - - importFromJson('/translations/common', options); - - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - expect(resourceEntriesCall).toBeDefined(); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.title.comment).toBe('Old comment'); - } - }); - }); - - describe('Tags updates', () => { - it('should update tags when updateTags flag is true', () => { - const existingEntries = { - title: { source: 'Title', tags: ['old'], es: 'Old' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title': { - value: 'Título', - tags: ['new', 'updated'], - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('file.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - updateTags: true, - }; - - importFromJson('/translations/common', options); - - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - expect(resourceEntriesCall).toBeDefined(); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.title.tags).toEqual(['new', 'updated']); - } - }); - - it('should NOT update tags when updateTags flag is false', () => { - const existingEntries = { - title: { source: 'Title', tags: ['old'], es: 'Old' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title': { - value: 'Título', - tags: ['new', 'updated'], - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('file.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - updateTags: false, - }; - - importFromJson('/translations/common', options); - - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - expect(resourceEntriesCall).toBeDefined(); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.title.tags).toEqual(['old']); - } - }); - }); - - describe('Base value validation', () => { - it('should warn when baseValue differs from existing base', () => { - const existingEntries = { - title: { source: 'Original Title', es: 'Título' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title': { - value: 'Nuevo Título', - baseValue: 'Different Title', - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('file.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - validateBase: true, - }; - - const result = importFromJson('/translations/common', options); - - expect(result.warnings.some((w) => w.includes('Base value mismatch'))).toBe(true); - expect(result.warnings.some((w) => w.includes('Different Title'))).toBe(true); - expect(result.warnings.some((w) => w.includes('Original Title'))).toBe(true); - }); - - it('should skip validation when validateBase is false', () => { - const existingEntries = { - title: { source: 'Original Title', es: 'Título' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title': { - value: 'Nuevo Título', - baseValue: 'Different Title', - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('file.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - validateBase: false, - }; - - const result = importFromJson('/translations/common', options); - - expect(result.warnings.filter((w) => w.includes('Base value mismatch'))).toHaveLength(0); - }); - }); - - describe('Status preservation', () => { - it('should use status from import when preserveStatus is true', () => { - const existingEntries = { - title: { source: 'Title', es: 'Título' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.title': { - value: 'Nuevo Título', - status: 'verified', - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('file.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - preserveStatus: true, - }; - - importFromJson('/translations/common', options); - - const metaCall = writeFileSyncSpy.mock.calls.find((call) => String(call[0]).includes('tracker_meta.json')); - - expect(metaCall).toBeDefined(); - if (metaCall) { - const updatedMeta = JSON.parse(String(metaCall[1])); - expect(updatedMeta.title.es.status).toBe('verified'); - } - }); - - it('should use default status when preserveStatus is false', () => { - const existingEntries = { - title: { source: 'Title', es: 'Título' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'new', - }, - }, - }; - - const importData = { - 'common.title': { - value: 'Nuevo Título', - status: 'verified', - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('file.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/path/to/file.json', - locale: 'es', - baseLocale: 'en', - preserveStatus: false, - }; - - importFromJson('/translations/common', options); - - const metaCall = writeFileSyncSpy.mock.calls.find((call) => String(call[0]).includes('tracker_meta.json')); - - expect(metaCall).toBeDefined(); - if (metaCall) { - const updatedMeta = JSON.parse(String(metaCall[1])); - expect(updatedMeta.title.es.status).toBe('translated'); - } - }); - }); - }); - - describe('import strategies', () => { - describe('translation-service strategy (default)', () => { - it('should set status to translated for new translations', () => { - const data = { - 'common.title': 'Título', - }; - - const existingEntries = { - title: { source: 'Title' }, - }; - - const existingMeta = { - title: { en: { checksum: 'checksum-en' } }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const result = extractFromFlat(data); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - key: 'common.title', - value: 'Título', - }); - }); - - it('should apply default flags for translation-service strategy', () => { - const data = { - 'common.title': { - value: 'Título', - comment: 'New comment', - tags: ['new-tag'], - }, - }; - - const existingEntries = { - title: { source: 'Title', comment: 'Old comment', tags: ['old-tag'] }, - }; - - const existingMeta = { - title: { en: { checksum: 'checksum-en' } }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'translation-service', - // Don't specify updateComments/updateTags - should use strategy defaults (false) - }; - - const _result = importFromJson('/translations/common', options); - - // Verify comment and tags were NOT updated (strategy defaults) - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.title.comment).toBe('Old comment'); - expect(updatedEntries.title.tags).toEqual(['old-tag']); - } - }); - }); - - describe('verification strategy', () => { - it('should set status to verified when value matches existing', () => { - const data = { - 'common.title': 'Título Existente', // Matches existing - }; - - const existingEntries = { - title: { source: 'Title', es: 'Título Existente' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'checksum-en' }, - es: { - checksum: 'checksum-old', - baseChecksum: 'checksum-en', - status: 'translated', - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'verification', - }; - - const result = importFromJson('/translations/common', options); - - expect(result.resourcesUpdated).toBe(1); - - // Verify status changed to verified - const metaCall = writeFileSyncSpy.mock.calls.find((call) => String(call[0]).includes('tracker_meta.json')); - if (metaCall) { - const updatedMeta = JSON.parse(String(metaCall[1])); - expect(updatedMeta.title.es.status).toBe('verified'); - // Checksum should NOT be updated (value unchanged) - expect(updatedMeta.title.es.checksum).toBe('checksum-old'); - } - }); - - it('should set status to verified when value differs from existing', () => { - const data = { - 'common.title': 'Título Corregido', // Different from existing - }; - - const existingEntries = { - title: { source: 'Title', es: 'Título Antiguo' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'checksum-en' }, - es: { - checksum: 'checksum-old', - baseChecksum: 'checksum-en', - status: 'translated', - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'verification', - }; - - const result = importFromJson('/translations/common', options); - - expect(result.resourcesUpdated).toBe(1); - - // Verify status changed to verified and checksum updated - const metaCall = writeFileSyncSpy.mock.calls.find((call) => String(call[0]).includes('tracker_meta.json')); - if (metaCall) { - const updatedMeta = JSON.parse(String(metaCall[1])); - expect(updatedMeta.title.es.status).toBe('verified'); - expect(updatedMeta.title.es.checksum).not.toBe('checksum-old'); - } - }); - }); - - describe('migration strategy', () => { - it('should apply default flags for migration strategy', () => { - const data = { - 'common.title': { - value: 'Título', - comment: 'New comment', - tags: ['new-tag'], - }, - }; - - const existingEntries = { - title: { source: 'Title', comment: 'Old comment', tags: ['old-tag'] }, - }; - - const existingMeta = { - title: { en: { checksum: 'checksum-en' } }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - // Don't specify updateComments/updateTags - should use strategy defaults (true) - }; - - const _result = importFromJson('/translations/common', options); - - // Verify comment and tags WERE updated (strategy defaults) - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.title.comment).toBe('New comment'); - expect(updatedEntries.title.tags).toEqual(['new-tag']); - } - }); - - it('should set status to translated', () => { - const data = { - 'common.title': 'Título', - }; - - const existingEntries = { - title: { source: 'Title' }, - }; - - const existingMeta = { - title: { en: { checksum: 'checksum-en' } }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const _result = importFromJson('/translations/common', options); - - const metaCall = writeFileSyncSpy.mock.calls.find((call) => String(call[0]).includes('tracker_meta.json')); - if (metaCall) { - const updatedMeta = JSON.parse(String(metaCall[1])); - expect(updatedMeta.title.es.status).toBe('translated'); - } - }); - }); - - describe('update strategy', () => { - it('should preserve existing status when value unchanged', () => { - const data = { - 'common.title': 'Título Existente', - }; - - const existingEntries = { - title: { source: 'Title', es: 'Título Existente' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'checksum-en' }, - es: { - checksum: 'checksum-old', - baseChecksum: 'checksum-en', - status: 'verified', - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'update', - }; - - const result = importFromJson('/translations/common', options); - - // Status should remain verified - expect(result.changes[0].newStatus).toBe('verified'); - }); - - it('should preserve existing status when value changed', () => { - const data = { - 'common.title': 'Título Actualizado', - }; - - const existingEntries = { - title: { source: 'Title', es: 'Título Antiguo' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'checksum-en' }, - es: { - checksum: 'checksum-old', - baseChecksum: 'checksum-en', - status: 'verified', - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'update', - }; - - const _result = importFromJson('/translations/common', options); - - const metaCall = writeFileSyncSpy.mock.calls.find((call) => String(call[0]).includes('tracker_meta.json')); - if (metaCall) { - const updatedMeta = JSON.parse(String(metaCall[1])); - // Status should remain verified despite value change - expect(updatedMeta.title.es.status).toBe('verified'); - } - }); - - it('should apply default flags for update strategy', () => { - const data = { - 'common.title': { - value: 'Título', - comment: 'New comment', - tags: ['new-tag'], - }, - }; - - const existingEntries = { - title: { source: 'Title', comment: 'Old comment', tags: ['old-tag'] }, - }; - - const existingMeta = { - title: { en: { checksum: 'checksum-en' } }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'update', - // Don't specify updateComments/updateTags - should use strategy defaults (false) - }; - - const _result = importFromJson('/translations/common', options); - - // Verify comment and tags were NOT updated (strategy defaults) - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.title.comment).toBe('Old comment'); - expect(updatedEntries.title.tags).toEqual(['old-tag']); - } - }); - }); - - describe('status transitions', () => { - it('should track status transitions correctly for translation-service', () => { - const data = { - 'common.new': 'Nuevo', - 'common.stale': 'Obsoleto Actualizado', - }; - - const existingEntries = { - new: { source: 'New' }, - stale: { source: 'Stale', es: 'Obsoleto' }, - }; - - const existingMeta = { - new: { en: { checksum: 'checksum-new' } }, - stale: { - en: { checksum: 'checksum-stale' }, - es: { - checksum: 'checksum-old', - baseChecksum: 'checksum-stale', - status: 'stale', - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'translation-service', - }; - - const result = importFromJson('/translations/common', options); - - // Should have status transition from stale → translated - const staleTransition = result.statusTransitions.find((t) => t.from === 'stale' && t.to === 'translated'); - expect(staleTransition).toBeDefined(); - }); - - it('should track status transitions correctly for verification', () => { - const data = { - 'common.translated': 'Traducido', - }; - - const existingEntries = { - translated: { source: 'Translated', es: 'Traducido' }, - }; - - const existingMeta = { - translated: { - en: { checksum: 'checksum-en' }, - es: { - checksum: 'checksum-old', - baseChecksum: 'checksum-en', - status: 'translated', - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'verification', - }; - - const result = importFromJson('/translations/common', options); - - // Should have status transition from translated → verified - const verifiedTransition = result.statusTransitions.find((t) => t.from === 'translated' && t.to === 'verified'); - expect(verifiedTransition).toBeDefined(); - }); - }); - }); - - describe('resource creation', () => { - describe('with createMissing flag', () => { - it('should create new resource with baseValue from rich JSON', () => { - const data = { - 'common.newkey': { - value: 'Nuevo Valor', - baseValue: 'New Value', - comment: 'A new resource', - tags: ['new'], - }, - }; - - // No existing resources - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - // No existing resource files - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - createMissing: true, - }; - - const result = importFromJson('/translations/common', options); - - expect(result.resourcesCreated).toBe(1); - expect(result.resourcesUpdated).toBe(0); - expect(result.changes[0].type).toBe('created'); - expect(result.changes[0].newValue).toBe('Nuevo Valor'); - - // Verify new resource was written - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const newEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(newEntries.newkey.source).toBe('New Value'); - expect(newEntries.newkey.es).toBe('Nuevo Valor'); - expect(newEntries.newkey.comment).toBe('A new resource'); - expect(newEntries.newkey.tags).toEqual(['new']); - } - - // Verify metadata was created - const metaCall = writeFileSyncSpy.mock.calls.find((call) => String(call[0]).includes('tracker_meta.json')); - if (metaCall) { - const newMeta = JSON.parse(String(metaCall[1])); - expect(newMeta.newkey.en).toBeDefined(); - expect(newMeta.newkey.en.checksum).toBeDefined(); - expect(newMeta.newkey.es).toBeDefined(); - expect(newMeta.newkey.es.status).toBe('translated'); - expect(newMeta.newkey.es.checksum).toBeDefined(); - expect(newMeta.newkey.es.baseChecksum).toBeDefined(); - } - }); - - it('should fail to create resource without baseValue', () => { - const data = { - 'common.newkey': 'Nuevo Valor', // Simple format, no baseValue - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - return '{}'; - }); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - createMissing: true, - }; - - const result = importFromJson('/translations/common', options); - - expect(result.resourcesCreated).toBe(0); - expect(result.resourcesFailed).toBe(1); - expect(result.changes[0].type).toBe('failed'); - expect(result.changes[0].reason).toContain('base value not provided'); - }); - - it('should skip resource creation when createMissing is false', () => { - const data = { - 'common.newkey': { - value: 'Nuevo Valor', - baseValue: 'New Value', - }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - createMissing: false, - }; - - const result = importFromJson('/translations/common', options); - - expect(result.resourcesCreated).toBe(0); - expect(result.resourcesSkipped).toBe(1); - expect(result.changes[0].type).toBe('skipped'); - expect(result.changes[0].reason).toContain('strategy does not allow creation'); - - // No files should be written - expect(writeFileSyncSpy).not.toHaveBeenCalled(); - }); - - it('should create multiple new resources in one import', () => { - const data = { - 'common.key1': { - value: 'Valor 1', - baseValue: 'Value 1', - }, - 'common.key2': { - value: 'Valor 2', - baseValue: 'Value 2', - }, - 'common.key3': { - value: 'Valor 3', - baseValue: 'Value 3', - }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - createMissing: true, - }; - - const result = importFromJson('/translations/common', options); - - expect(result.resourcesCreated).toBe(3); - expect(result.changes.filter((c) => c.type === 'created')).toHaveLength(3); - - // Verify all resources were written - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const newEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(Object.keys(newEntries)).toHaveLength(3); - expect(newEntries.key1.source).toBe('Value 1'); - expect(newEntries.key2.source).toBe('Value 2'); - expect(newEntries.key3.source).toBe('Value 3'); - } - }); - - it('should create resource and update existing in same import', () => { - const data = { - 'common.existing': 'Existente Actualizado', - 'common.newkey': { - value: 'Nuevo Valor', - baseValue: 'New Value', - }, - }; - - const existingEntries = { - existing: { source: 'Existing', es: 'Existente' }, - }; - - const existingMeta = { - existing: { - en: { checksum: 'checksum-en' }, - es: { - checksum: 'checksum-old', - baseChecksum: 'checksum-en', - status: 'translated', - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - createMissing: true, - }; - - const result = importFromJson('/translations/common', options); - - expect(result.resourcesCreated).toBe(1); - expect(result.resourcesUpdated).toBe(1); - - // Verify both operations - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - // Existing resource updated - expect(updatedEntries.existing.es).toBe('Existente Actualizado'); - // New resource created - expect(updatedEntries.newkey.source).toBe('New Value'); - expect(updatedEntries.newkey.es).toBe('Nuevo Valor'); - } - }); - }); - - describe('folder creation', () => { - it('should create folders when needed for new resources', () => { - const data = { - 'apps.dashboard.title': { - value: 'Título', - baseValue: 'Title', - }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - // Folder doesn't exist yet - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(data); - return '{}'; - }); - - const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - createMissing: true, - }; - - const result = importFromJson('/translations', options); - - expect(result.resourcesCreated).toBe(1); - - // Verify folder creation was attempted - expect(mkdirSyncSpy).toHaveBeenCalled(); - }); - }); - - describe('Migration strategy with reference resolution', () => { - it('should resolve simple Transloco references', () => { - const importData = { - greeting: { value: 'Hello', baseValue: 'Hi' }, - message: { - value: '{{greeting}} World', - baseValue: '{{greeting}} World', - }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = importFromJson('/translations', options); - - expect(result.resourcesCreated).toBe(2); - - const entriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - if (entriesCall) { - const entries = JSON.parse(String(entriesCall[1])); - expect(entries.greeting.es).toBe('Hello'); - expect(entries.message.es).toBe('Hello World'); // Reference resolved - } - }); - - it('should resolve {{t()}} pattern references', () => { - const importData = { - greeting: 'Hola', - message: "{{t('greeting')}} Mundo", - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const _result = importFromJson('/translations', options); - - const entriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - if (entriesCall) { - const entries = JSON.parse(String(entriesCall[1])); - expect(entries.message.es).toBe('Hola Mundo'); // Reference resolved - } - }); - - it('should resolve nested references', () => { - const importData = { - name: 'Mundo', - target: '{{name}}', - greeting: 'Hola {{target}}', - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const _result = importFromJson('/translations', options); - - const entriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - if (entriesCall) { - const entries = JSON.parse(String(entriesCall[1])); - expect(entries.greeting.es).toBe('Hola Mundo'); // Nested references resolved - } - }); - - it('should warn on circular references and preserve literals', () => { - const importData = { - a: '{{b}}', - b: '{{a}}', - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = importFromJson('/translations', options); - - expect(result.warnings.length).toBeGreaterThan(0); - expect(result.warnings.some((w) => w.includes('Circular reference'))).toBe(true); - - const entriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - if (entriesCall) { - const entries = JSON.parse(String(entriesCall[1])); - expect(entries.a.es).toBe('{{b}}'); // Preserved literal - expect(entries.b.es).toBe('{{a}}'); // Preserved literal - } - }); - - it('should warn on missing references and preserve literals', () => { - const importData = { - message: 'Hello {{missing}} World', - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = importFromJson('/translations', options); - - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain('Missing reference target: "missing"'); - - const entriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - if (entriesCall) { - const entries = JSON.parse(String(entriesCall[1])); - expect(entries.message.es).toBe('Hello {{missing}} World'); // Preserved literal - } - }); - - it('should not resolve references for non-migration strategies', () => { - const importData = { - greeting: 'Hello', - message: '{{greeting}} World', - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - strategy: 'translation-service', - }; - - const _result = importFromJson('/translations', options); - - const entriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - if (entriesCall) { - const entries = JSON.parse(String(entriesCall[1])); - expect(entries.message.es).toBe('{{greeting}} World'); // Not resolved - } - }); - }); - }); - - describe('Transloco syntax normalization', () => { - it('should normalize {{ variable }} syntax to ICU format when creating a resource', () => { - const importData = { - 'common.greeting': { - value: 'Hello {{ name }}', - baseValue: 'Hello {{ name }}', - }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - createMissing: true, - }; - - const result = importFromJson('/translations', options); - - expect(result.resourcesCreated).toBe(1); - - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - expect(resourceEntriesCall).toBeDefined(); - if (resourceEntriesCall) { - const newEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(newEntries.greeting.es).toBe('Hello {name}'); - } - }); - - it('should normalize {{ variable }} syntax to ICU format when updating an existing resource', () => { - const existingEntries = { - greeting: { source: 'Hello {name}', es: 'Old greeting' }, - }; - - const existingMeta = { - greeting: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }; - - const importData = { - 'common.greeting': { - value: 'Hola {{ name }}', - baseValue: 'Hello {{ name }}', - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - }; - - const result = importFromJson('/translations', options); - - expect(result.resourcesUpdated).toBe(1); - - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - - expect(resourceEntriesCall).toBeDefined(); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.greeting.es).toBe('Hola {name}'); - } - }); - }); - - describe('protected terms verification', () => { - const existingEntries = { - welcome: { source: 'Welcome to iPhone' }, - cancel: { source: 'Cancel' }, - }; - - const existingMeta = { - welcome: { en: { checksum: 'c1' } }, - cancel: { en: { checksum: 'c2' } }, - }; - - beforeEach(() => { - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return true; - return true; - }); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('import.json')) return JSON.stringify(importData); - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - }); - - let importData: Record; - - it('flags an altered protected term as failed and leaves the entry unwritten', () => { - importData = { - 'common.welcome': { value: 'Bienvenido a iphone', baseValue: 'Welcome to iPhone' }, - 'common.cancel': 'Cancelar', - }; - - const writeSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const result = importFromJson('/translations', { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - protectedTerms: ['iPhone'], - }); - - expect(result.resourcesFailed).toBe(1); - expect(result.changes.find((c) => c.key === 'common.welcome')?.type).toBe('failed'); - expect(result.changes.find((c) => c.key === 'common.welcome')?.reason).toContain('Protected term(s) altered'); - expect(result.errors.some((e) => e.includes('common.welcome') && e.includes('iPhone'))).toBe(true); - - // Valid sibling still imported — resource_entries gets write with cancel only - const resourceEntriesCall = writeSpy.mock.calls.find((call) => String(call[0]).includes('resource_entries.json')); - const updatedEntries = JSON.parse(String(resourceEntriesCall?.[1])); - expect(updatedEntries.cancel.es).toBe('Cancelar'); - expect(updatedEntries.welcome.es).toBeUndefined(); - }); - - it('passes when the protected term is preserved verbatim', () => { - importData = { - 'common.welcome': 'Bienvenido a iPhone', - }; - - const writeSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const result = importFromJson('/translations', { - source: '/import/import.json', - locale: 'es', - baseLocale: 'en', - protectedTerms: ['iPhone'], - }); - - expect(result.resourcesFailed).toBe(0); - expect(result.resourcesUpdated).toBe(1); - expect(result.errors).toHaveLength(0); - expect(result.changes.some((c) => c.type === 'failed')).toBe(false); - - const resourceEntriesCall = writeSpy.mock.calls.find((call) => String(call[0]).includes('resource_entries.json')); - const updatedEntries = JSON.parse(String(resourceEntriesCall?.[1])); - expect(updatedEntries.welcome.es).toBe('Bienvenido a iPhone'); - }); - }); -}); diff --git a/libs/core/src/lib/import/import-from-json.ts b/libs/core/src/lib/import/import-from-json.ts deleted file mode 100644 index 4ad3185f..00000000 --- a/libs/core/src/lib/import/import-from-json.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { readFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import type { ImportOptions, ImportedResource, ImportResult, ICUAutoFix, ICUAutoFixError } from './types'; -import type { TranslationStatus } from '@simoncodes-ca/domain'; -import { resolveAllReferences } from './reference-resolver'; -import { groupResourcesByFolder } from './resource-grouping'; -import { processResourceGroup } from './process-resource-group'; -import { calculateImportStatistics, calculateStatusTransitions } from './import-statistics'; -import { validateImportResources } from './import-validation'; -import { setupImportWorkflow, buildImportResult } from './import-workflow'; -import { applyICUAutoFixToResources } from './apply-icu-auto-fix'; -import { normalizeTranslocoSyntaxInResources } from './normalize-transloco-syntax'; -import { loadBaseLocaleValues } from './load-base-locale-values'; - -/** - * Detects whether the JSON structure is flat or hierarchical. - * - * A flat structure uses dot-delimited keys at the root level (e.g., `{"common.ok": "OK"}`). - * A hierarchical structure uses nested objects (e.g., `{common: {ok: "OK"}}`). - * - * Detection logic: If all root-level keys contain dots, the structure is considered flat. - * Otherwise, it's hierarchical. - * - * @param data - The parsed JSON object to analyze - * @returns 'flat' if all root keys contain dots, 'hierarchical' otherwise - * - * @example - * ```typescript - * // Flat structure - * detectJsonStructure({"common.ok": "OK", "common.cancel": "Cancel"}); // 'flat' - * - * // Hierarchical structure - * detectJsonStructure({common: {ok: "OK", cancel: "Cancel"}}); // 'hierarchical' - * - * // Mixed (treated as hierarchical) - * detectJsonStructure({common: {ok: "OK"}, "other.key": "Value"}); // 'hierarchical' - * ``` - */ -export function detectJsonStructure(data: Record): 'flat' | 'hierarchical' { - const keys = Object.keys(data); - - // If all keys at root level contain dots, it's flat - const allKeysHaveDots = keys.every((key) => key.includes('.')); - - if (allKeysHaveDots && keys.length > 0) { - return 'flat'; - } - - return 'hierarchical'; -} - -/** - * Checks if a value is a rich format object (has a 'value' property) - */ -function isRichObject(value: unknown): value is Record { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - 'value' in value && - typeof (value as Record)['value'] === 'string' - ); -} - -/** - * Extracts resource from a rich format object - */ -function extractRichResource(key: string, obj: Record): ImportedResource { - const resource: ImportedResource = { - key, - value: obj['value'] as string, - }; - - if (obj['comment'] && typeof obj['comment'] === 'string') { - resource.comment = obj['comment']; - } - - if (obj['baseValue'] && typeof obj['baseValue'] === 'string') { - resource.baseValue = obj['baseValue']; - } - - if (obj['status'] && typeof obj['status'] === 'string') { - resource.status = obj['status'] as TranslationStatus; - } - - if (Array.isArray(obj['tags'])) { - resource.tags = obj['tags'].filter((tag) => typeof tag === 'string') as string[]; - } - - return resource; -} - -/** - * Extracts translation resources from a flat JSON structure. - * - * Flat structures use dot-delimited keys at the root level. Each key maps to either: - * - A simple string value (e.g., `"common.ok": "OK"`) - * - A rich object with additional metadata (e.g., `"common.ok": {value: "OK", comment: "Button text"}`) - * - * Rich format objects must have a `value` property and can optionally include: - * - `comment` - Developer notes or context - * - `baseValue` - Source locale reference value - * - `status` - Translation status (new, translated, verified, stale) - * - `tags` - Array of categorization tags - * - * Non-string and non-rich-object values are silently skipped. - * - * @param data - The flat JSON object to extract resources from - * @returns Array of imported resources with keys and values - * - * @example - * ```typescript - * // Simple flat format - * const simple = { - * "common.ok": "OK", - * "common.cancel": "Cancel" - * }; - * extractFromFlat(simple); - * // Returns: [{key: "common.ok", value: "OK"}, {key: "common.cancel", value: "Cancel"}] - * - * // Rich format with metadata - * const rich = { - * "common.submit": { - * value: "Submit", - * comment: "Form submission button", - * baseValue: "Submit", - * status: "translated", - * tags: ["forms", "buttons"] - * } - * }; - * extractFromFlat(rich); - * // Returns: [{key: "common.submit", value: "Submit", comment: "Form...", ...}] - * ``` - */ -export function extractFromFlat(data: Record): ImportedResource[] { - const resources: ImportedResource[] = []; - - for (const [key, value] of Object.entries(data)) { - if (typeof value === 'string') { - // Simple string value - resources.push({ - key, - value, - }); - } else if (isRichObject(value)) { - // Rich format object - resources.push(extractRichResource(key, value)); - } - // Skip other types - } - - return resources; -} - -/** - * Recursively extracts translation resources from a hierarchical JSON structure. - * - * Hierarchical structures use nested objects to organize translations by namespace. - * The function traverses the object tree and constructs dot-delimited keys from the path. - * - * Leaf nodes can be either: - * - Simple string values (e.g., `{common: {ok: "OK"}}` → key: "common.ok") - * - Rich objects with metadata (e.g., `{common: {ok: {value: "OK", comment: "..."}}}`) - * - * Non-leaf objects are recursed into. Arrays, null values, and other types are skipped. - * - * @param data - The hierarchical JSON object to extract resources from - * @param prefix - Internal parameter for recursion; the current key path (default: '') - * @returns Array of imported resources with fully-qualified dot-delimited keys - * - * @example - * ```typescript - * // Simple hierarchical format - * const simple = { - * common: { - * buttons: { - * ok: "OK", - * cancel: "Cancel" - * } - * } - * }; - * extractFromHierarchical(simple); - * // Returns: [ - * // {key: "common.buttons.ok", value: "OK"}, - * // {key: "common.buttons.cancel", value: "Cancel"} - * // ] - * - * // Mixed with rich format - * const mixed = { - * common: { - * ok: "OK", - * submit: { - * value: "Submit", - * comment: "Form submission", - * tags: ["forms"] - * } - * } - * }; - * extractFromHierarchical(mixed); - * // Returns: [ - * // {key: "common.ok", value: "OK"}, - * // {key: "common.submit", value: "Submit", comment: "Form submission", tags: ["forms"]} - * // ] - * ``` - */ -export function extractFromHierarchical(data: Record, prefix = ''): ImportedResource[] { - const resources: ImportedResource[] = []; - - for (const [key, value] of Object.entries(data)) { - const fullKey = prefix ? `${prefix}.${key}` : key; - - if (typeof value === 'string') { - // Simple string value - leaf node - resources.push({ - key: fullKey, - value, - }); - } else if (isRichObject(value)) { - // Rich format object - leaf node - resources.push(extractRichResource(fullKey, value)); - } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - // Nested object - recurse - resources.push(...extractFromHierarchical(value as Record, fullKey)); - } - // Skip other types (arrays, null, etc.) - } - - return resources; -} - -/** - * Imports translations from a JSON file into LingoTracker's translation storage. - * - * This is the main entry point for JSON imports. It handles both flat and hierarchical - * JSON structures, validates resources, applies import strategy rules, and updates - * translation files with checksums and metadata tracking. - * - * The import process: - * 1. Reads and parses the JSON source file - * 2. Auto-detects flat vs hierarchical structure - * 3. Extracts resources (supports simple strings and rich format with metadata) - * 4. Resolves Transloco-style references (migration strategy only) - * 5. Normalizes Transloco double-brace syntax `{{ variable }}` to ICU single-brace `{variable}` - * 6. Applies ICU placeholder auto-fixing to align translation placeholders with base locale - * 7. Validates keys, detects conflicts, and filters invalid resources - * 8. Groups resources by folder for efficient batch processing - * 9. For each resource: - * - Creates new resource if missing (when createMissing=true) - * - Updates existing resource values and metadata - * - Calculates checksums for change detection - * - Determines translation status based on strategy - * 10. Writes updated resource_entries.json and tracker_meta.json files - * 11. Returns comprehensive import result with statistics and changes - * - * Import strategies control behavior: - * - `translation-service`: Professional translation import (default, no creation) - * - `verification`: Language expert review workflow (sets verified status) - * - `migration`: Migrate from another system (allows creation, resolves references) - * - `update`: Bulk update existing translations (preserves status) - * - * @param translationsFolder - Path to the translations directory (e.g., 'src/translations') - * @param options - Import configuration including source file, locale, strategy, and flags - * @returns Detailed import result with statistics, changes, warnings, and errors - * - * @throws {Error} If source file not found or cannot be parsed - * @throws {Error} If attempting to import into base locale - * - * @example - * ```typescript - * // Basic translation service import - * const result = importFromJson('/project/src/translations', { - * source: 'translated-es.json', - * locale: 'es', - * baseLocale: 'en', - * strategy: 'translation-service', - * dryRun: false - * }); - * console.log(`Imported ${result.resourcesUpdated} translations`); - * - * // Migration from another system with creation - * const result = importFromJson('/project/src/translations', { - * source: 'old-system-fr.json', - * locale: 'fr', - * baseLocale: 'en', - * strategy: 'migration', - * createMissing: true, - * updateComments: true, - * updateTags: true, - * verbose: true, - * onProgress: (msg) => console.log(msg) - * }); - * - * // Dry-run to preview changes - * const preview = importFromJson('/project/src/translations', { - * source: 'new-translations.json', - * locale: 'de', - * baseLocale: 'en', - * strategy: 'translation-service', - * dryRun: true - * }); - * console.log(`Would update ${preview.resourcesUpdated} resources`); - * ``` - */ -export function importFromJson(translationsFolder: string, options: ImportOptions): ImportResult { - const { source, dryRun = false, verbose = false, onProgress } = options; - - // Setup and validate workflow configuration - const { cwd, baseLocale, locale, mergedOptions, isBaseLocaleImport } = setupImportWorkflow(options); - - // Read and parse JSON file - const sourceFilePath = resolve(cwd, source); - if (!existsSync(sourceFilePath)) { - throw new Error(`Source file not found: ${source}`); - } - - onProgress?.(`Reading JSON file: ${source}`); - - let jsonData: Record; - try { - const content = readFileSync(sourceFilePath, 'utf8'); - jsonData = JSON.parse(content); - } catch (error) { - throw new Error(`Failed to parse JSON file: ${error}`); - } - - // Detect structure and extract resources - const structure = detectJsonStructure(jsonData); - onProgress?.(`Detected ${structure} JSON structure`); - - let resources = structure === 'flat' ? extractFromFlat(jsonData) : extractFromHierarchical(jsonData); - - onProgress?.(`Extracted ${resources.length} resources from JSON`); - - // Apply reference resolution for migration strategy - const warnings: string[] = []; - if (mergedOptions.strategy === 'migration') { - onProgress?.(`Resolving Transloco-style references...`); - resources = resolveAllReferences(resources, true, warnings); - } - - // Normalize Transloco double-brace syntax {{ variable }} to ICU single-brace {variable} - // before any ICU parsing or auto-fixing so downstream steps see a consistent format. - resources = normalizeTranslocoSyntaxInResources(resources); - - // Apply ICU auto-fixing before validation - let icuAutoFixes: ICUAutoFix[] = []; - let icuAutoFixErrors: ICUAutoFixError[] = []; - - if (verbose) { - onProgress?.(`Checking for ICU placeholder issues...`); - } - - // Load base locale values for ICU auto-fix - const baseLocaleValues = loadBaseLocaleValues(resources, translationsFolder, cwd); - - // Apply ICU auto-fix to all resources - const icuFixResult = applyICUAutoFixToResources({ - resources, - getBaseValue: (key: string) => baseLocaleValues.get(key), - verbose, - onProgress: verbose ? onProgress : undefined, - }); - - // Update resources with auto-fixed values - resources = icuFixResult.resources; - icuAutoFixes = icuFixResult.autoFixes; - icuAutoFixErrors = icuFixResult.autoFixErrors; - - // Validate resources - const validationResult = validateImportResources(resources, { - skipEmptyValues: true, - warnOnLongKeys: true, - }); - - const validResources = validationResult.validResources; - warnings.push(...validationResult.warnings); - const errors = validationResult.errors; - const changes = [...validationResult.failedChanges]; - const filesModified = new Set(); - - // Group resources by folder for batch processing - const resourceGroups = groupResourcesByFolder(validResources, translationsFolder, cwd); - - // Process each group - for (const group of resourceGroups.values()) { - if (verbose) { - for (const { resource } of group.resources) { - onProgress?.(`Processing: ${resource.key}`); - } - } - - const groupChanges = processResourceGroup( - group, - locale, - baseLocale, - mergedOptions, - dryRun, - isBaseLocaleImport, - filesModified, - warnings, - errors, - ); - - changes.push(...groupChanges); - } - - // Calculate statistics - const statistics = calculateImportStatistics(changes); - const statusTransitions = calculateStatusTransitions(changes); - - onProgress?.( - dryRun - ? `Dry run complete: would import ${statistics.resourcesUpdated} resources` - : `Import complete: ${statistics.resourcesUpdated} resources imported`, - ); - - return buildImportResult({ - format: 'json', - options: mergedOptions, - statistics, - statusTransitions, - changes, - filesModified, - warnings, - errors, - icuAutoFixes, - icuAutoFixErrors, - }); -} diff --git a/libs/core/src/lib/import/import-from-xliff.spec.ts b/libs/core/src/lib/import/import-from-xliff.spec.ts deleted file mode 100644 index f3ee722a..00000000 --- a/libs/core/src/lib/import/import-from-xliff.spec.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { extractFromXliff, importFromXliff } from './import-from-xliff'; -import type { ImportOptions } from './types'; -import * as fs from 'fs'; -import * as path from 'path'; - -vi.mock('fs'); -vi.mock('path'); - -describe('import-from-xliff', () => { - beforeEach(() => { - vi.clearAllMocks(); - - // Mock path functions - vi.spyOn(path, 'resolve').mockImplementation((...segments) => segments.join('/')); - vi.spyOn(path, 'join').mockImplementation((...segments) => segments.join('/')); - vi.spyOn(path, 'dirname').mockImplementation((p) => { - const parts = String(p).split('/'); - parts.pop(); - return parts.join('/'); - }); - }); - - describe('extractFromXliff', () => { - it('should extract resources from valid XLIFF 1.2', async () => { - const xliffContent = ` - - - - - OK - Aceptar - - - Cancel - Cancelar - Button to cancel operation - - - -`; - - const resources = await extractFromXliff(xliffContent); - - expect(resources).toHaveLength(2); - expect(resources[0]).toEqual({ - key: 'common.buttons.ok', - value: 'Aceptar', - baseValue: 'OK', - }); - expect(resources[1]).toEqual({ - key: 'common.buttons.cancel', - value: 'Cancelar', - baseValue: 'Cancel', - comment: 'Button to cancel operation', - }); - }); - - it('should skip trans-units with empty targets', async () => { - const xliffContent = ` - - - - - Title - Título - - - Empty - - - - Missing - - - -`; - - const resources = await extractFromXliff(xliffContent); - - expect(resources).toHaveLength(1); - expect(resources[0].key).toBe('common.title'); - }); - - it('should handle XLIFF with notes', async () => { - const xliffContent = ` - - - - - Welcome - Bienvenue - Greeting message shown on home page - - - -`; - - const resources = await extractFromXliff(xliffContent); - - expect(resources).toHaveLength(1); - expect(resources[0].comment).toBe('Greeting message shown on home page'); - }); - - it('should throw error for invalid XLIFF', async () => { - const invalidXliff = 'This is not valid XML'; - - await expect(extractFromXliff(invalidXliff)).rejects.toThrow('Failed to parse XLIFF content'); - }); - }); - - describe('importFromXliff', () => { - it('should import XLIFF and update existing resources', async () => { - const xliffContent = ` - - - - - OK - Aceptar - - - -`; - - const existingEntries = { - ok: { source: 'OK' }, - }; - - const existingMeta = { - ok: { en: { checksum: 'checksum-ok-en' } }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.xliff')) return true; - if (pathStr.includes('resource_entries.json')) return true; - if (pathStr.includes('tracker_meta.json')) return true; - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.xliff')) return xliffContent; - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.xliff', - locale: 'es', - baseLocale: 'en', - }; - - const result = await importFromXliff('/translations/common/buttons', options); - - expect(result.format).toBe('xliff'); - expect(result.resourcesUpdated).toBe(1); - expect(result.resourcesCreated).toBe(0); - - // Verify file was written - expect(writeFileSyncSpy).toHaveBeenCalled(); - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.ok.es).toBe('Aceptar'); - } - }); - - it('should warn on base value mismatch', async () => { - const xliffContent = ` - - - - - Different Title - Título - - - -`; - - const existingEntries = { - title: { source: 'Original Title' }, - }; - - const existingMeta = { - title: { en: { checksum: 'checksum-en' } }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.xliff')) return xliffContent; - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.xliff', - locale: 'es', - baseLocale: 'en', - }; - - const result = await importFromXliff('/translations/common', options); - - expect(result.warnings.length).toBeGreaterThan(0); - const mismatchWarning = result.warnings.find((w) => w.includes('Base value mismatch')); - expect(mismatchWarning).toBeDefined(); - expect(mismatchWarning).toContain('common.title'); - expect(mismatchWarning).toContain('preserving LingoTracker value'); - }); - - it('should create new resources with migration strategy', async () => { - const xliffContent = ` - - - - - New Value - Nuevo Valor - A new resource - - - -`; - - vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.xliff')) return true; - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.xliff')) return xliffContent; - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.xliff', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const result = await importFromXliff('/translations/common', options); - - expect(result.resourcesCreated).toBe(1); - expect(result.resourcesUpdated).toBe(0); - - // Verify new resource was created - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const newEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(newEntries.newkey.source).toBe('New Value'); - expect(newEntries.newkey.es).toBe('Nuevo Valor'); - expect(newEntries.newkey.comment).toBe('A new resource'); - } - }); - - it('should update comments when updateComments flag is set', async () => { - const xliffContent = ` - - - - - Title - Título - New comment from XLIFF - - - -`; - - const existingEntries = { - title: { source: 'Title', comment: 'Old comment' }, - }; - - const existingMeta = { - title: { en: { checksum: 'checksum-en' } }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.xliff')) return xliffContent; - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.xliff', - locale: 'es', - baseLocale: 'en', - updateComments: true, - }; - - const _result = await importFromXliff('/translations/common', options); - - const resourceEntriesCall = writeFileSyncSpy.mock.calls.find((call) => - String(call[0]).includes('resource_entries.json'), - ); - if (resourceEntriesCall) { - const updatedEntries = JSON.parse(String(resourceEntriesCall[1])); - expect(updatedEntries.title.comment).toBe('New comment from XLIFF'); - } - }); - - it('should use verification strategy correctly', async () => { - const xliffContent = ` - - - - - Title - Título - - - -`; - - const existingEntries = { - title: { source: 'Title', es: 'Título' }, - }; - - const existingMeta = { - title: { - en: { checksum: 'checksum-en' }, - es: { - checksum: 'checksum-old', - baseChecksum: 'checksum-en', - status: 'translated', - }, - }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - const pathStr = String(filePath); - if (pathStr.includes('test.xliff')) return xliffContent; - if (pathStr.includes('resource_entries.json')) return JSON.stringify(existingEntries); - if (pathStr.includes('tracker_meta.json')) return JSON.stringify(existingMeta); - return '{}'; - }); - - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - const options: ImportOptions = { - source: '/import/test.xliff', - locale: 'es', - baseLocale: 'en', - strategy: 'verification', - }; - - const _result = await importFromXliff('/translations/common', options); - - // Verify status changed to verified - const metaCall = writeFileSyncSpy.mock.calls.find((call) => String(call[0]).includes('tracker_meta.json')); - if (metaCall) { - const updatedMeta = JSON.parse(String(metaCall[1])); - expect(updatedMeta.title.es.status).toBe('verified'); - } - }); - - it('should handle file not found error', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - - const options: ImportOptions = { - source: '/import/missing.xliff', - locale: 'es', - baseLocale: 'en', - }; - - await expect(importFromXliff('/translations/common', options)).rejects.toThrow('Source file not found'); - }); - - it('should handle invalid base locale error', async () => { - const options: ImportOptions = { - source: '/import/test.xliff', - locale: 'en', - baseLocale: 'en', - }; - - await expect(importFromXliff('/translations/common', options)).rejects.toThrow('Cannot import into base locale'); - }); - }); -}); diff --git a/libs/core/src/lib/import/import-from-xliff.ts b/libs/core/src/lib/import/import-from-xliff.ts deleted file mode 100644 index 9a7c16ac..00000000 --- a/libs/core/src/lib/import/import-from-xliff.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { readFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import * as xliff from 'xliff'; -import type { ImportOptions, ImportedResource, ImportResult, ICUAutoFix, ICUAutoFixError } from './types'; -import { groupResourcesByFolder } from './resource-grouping'; -import { processResourceGroup } from './process-resource-group'; -import { calculateImportStatistics, calculateStatusTransitions } from './import-statistics'; -import { validateImportResources } from './import-validation'; -import { setupImportWorkflow, buildImportResult } from './import-workflow'; -import { applyICUAutoFixToResources } from './apply-icu-auto-fix'; -import { loadBaseLocaleValues } from './load-base-locale-values'; - -/** - * Extracts translation resources from XLIFF 1.2 format content. - * - * XLIFF (XML Localization Interchange File Format) is an industry standard format - * used by professional translation services. This function parses XLIFF 1.2 files - * and extracts translation units (trans-units) with their source and target values. - * - * Each trans-unit is converted to an ImportedResource with: - * - `key`: The trans-unit id (translation key) - * - `value`: The target translation - * - `baseValue`: The source reference value - * - `comment`: Developer notes from elements - * - * Trans-units with empty or missing target values are automatically skipped. - * - * @param xliffContent - The raw XLIFF 1.2 XML content as a string - * @returns Array of imported resources extracted from all trans-units in the XLIFF file - * - * @throws {Error} If XLIFF content cannot be parsed or is malformed - * - * @example - * ```typescript - * const xliffXml = ` - * - * - * - * - * OK - * Aceptar - * Button text - * - * - * - * `; - * - * const resources = await extractFromXliff(xliffXml); - * // Returns: [{ - * // key: "common.ok", - * // value: "Aceptar", - * // baseValue: "OK", - * // comment: "Button text" - * // }] - * ``` - */ -export async function extractFromXliff(xliffContent: string): Promise { - const resources: ImportedResource[] = []; - - try { - // Parse XLIFF content using callback-based API - type ParsedXliff = { - resources: Record>; - }; - const parsed = await new Promise((resolve, reject) => { - xliff.xliff12ToJs(xliffContent, (err: Error | null, res: unknown) => { - if (err) reject(err); - else resolve(res as ParsedXliff); - }); - }); - - // Extract resources from each file - for (const fileData of Object.values(parsed.resources)) { - const transUnits = fileData as Record; - - for (const [key, unit] of Object.entries(transUnits)) { - // Skip if no target or target is empty - if (!unit.target || unit.target.trim() === '') { - continue; - } - - const resource: ImportedResource = { - key, - value: unit.target, - }; - - // Add base value from source - if (unit.source) { - resource.baseValue = unit.source; - } - - // Add comment from note - if (unit.note) { - resource.comment = unit.note; - } - - resources.push(resource); - } - } - } catch (error) { - throw new Error(`Failed to parse XLIFF content: ${error}`); - } - - return resources; -} - -/** - * Imports translations from an XLIFF 1.2 file into LingoTracker's translation storage. - * - * This is the main entry point for XLIFF imports, typically used when receiving - * translations back from professional translation services. XLIFF is an industry - * standard XML format that preserves source/target pairs and metadata. - * - * The import process: - * 1. Reads and parses the XLIFF 1.2 XML file - * 2. Extracts trans-units with source and target values - * 3. Validates keys and detects conflicts - * 4. Groups resources by folder for efficient batch processing - * 5. For each resource: - * - Creates new resource if missing (when createMissing=true) - * - Updates existing resource values and metadata - * - Validates base values against source elements - * - Calculates checksums for change detection - * - Determines translation status based on strategy - * 6. Writes updated resource_entries.json and tracker_meta.json files - * 7. Returns comprehensive import result with statistics and changes - * - * Import strategies control behavior: - * - `translation-service`: Professional translation import (default, no creation) - * - `verification`: Language expert review workflow (sets verified status) - * - `migration`: Migrate from another system (allows creation) - * - `update`: Bulk update existing translations (preserves status) - * - * XLIFF-specific features: - * - Automatically extracts source values as baseValue for validation - * - Preserves developer notes from elements - * - Skips trans-units with empty or missing target values - * - * @param translationsFolder - Path to the translations directory (e.g., 'src/translations') - * @param options - Import configuration including source file, locale, strategy, and flags - * @returns Detailed import result with statistics, changes, warnings, and errors - * - * @throws {Error} If source file not found or XLIFF content cannot be parsed - * @throws {Error} If attempting to import into base locale - * - * @example - * ```typescript - * // Basic translation service import from XLIFF - * const result = await importFromXliff('/project/src/translations', { - * source: 'translations-es.xlf', - * locale: 'es', - * baseLocale: 'en', - * strategy: 'translation-service', - * validateBase: true, - * dryRun: false - * }); - * console.log(`Imported ${result.resourcesUpdated} translations from XLIFF`); - * - * // Verification workflow with verbose logging - * const result = await importFromXliff('/project/src/translations', { - * source: 'verified-fr.xlf', - * locale: 'fr', - * baseLocale: 'en', - * strategy: 'verification', - * verbose: true, - * onProgress: (msg) => console.log(msg) - * }); - * - * // Dry-run to preview XLIFF import - * const preview = await importFromXliff('/project/src/translations', { - * source: 'new-de.xlf', - * locale: 'de', - * baseLocale: 'en', - * strategy: 'translation-service', - * dryRun: true - * }); - * console.log(`Would update ${preview.resourcesUpdated} resources`); - * if (preview.warnings.length > 0) { - * console.log('Warnings:', preview.warnings); - * } - * ``` - */ -export async function importFromXliff(translationsFolder: string, options: ImportOptions): Promise { - const { source, dryRun = false, verbose = false, onProgress } = options; - - // Setup and validate workflow configuration - const { cwd, baseLocale, locale, mergedOptions, isBaseLocaleImport } = setupImportWorkflow(options); - - // Read and parse XLIFF file - const sourceFilePath = resolve(cwd, source); - if (!existsSync(sourceFilePath)) { - throw new Error(`Source file not found: ${source}`); - } - - onProgress?.(`Reading XLIFF file: ${source}`); - - let xliffContent: string; - try { - xliffContent = readFileSync(sourceFilePath, 'utf8'); - } catch (error) { - throw new Error(`Failed to read XLIFF file: ${error}`); - } - - onProgress?.(`Parsing XLIFF and extracting trans-units`); - - // Extract resources from XLIFF - let resources = await extractFromXliff(xliffContent); - - onProgress?.(`Extracted ${resources.length} resources from XLIFF`); - - // Apply ICU auto-fixing before validation - let icuAutoFixes: ICUAutoFix[] = []; - let icuAutoFixErrors: ICUAutoFixError[] = []; - - if (verbose) { - onProgress?.(`Checking for ICU placeholder issues...`); - } - - // Load base locale values for ICU auto-fix - const baseLocaleValues = loadBaseLocaleValues(resources, translationsFolder, cwd); - - // Apply ICU auto-fix to all resources - const icuFixResult = applyICUAutoFixToResources({ - resources, - getBaseValue: (key: string) => baseLocaleValues.get(key), - verbose, - onProgress: verbose ? onProgress : undefined, - }); - - // Update resources with auto-fixed values - resources = icuFixResult.resources; - icuAutoFixes = icuFixResult.autoFixes; - icuAutoFixErrors = icuFixResult.autoFixErrors; - - // Validate resources - const validationResult = validateImportResources(resources, { - skipEmptyValues: false, // XLIFF already filters empty values during extraction - warnOnLongKeys: true, - }); - - const validResources = validationResult.validResources; - const warnings = validationResult.warnings; - const errors = validationResult.errors; - const changes = [...validationResult.failedChanges]; - const filesModified = new Set(); - - // Group resources by folder for batch processing - const resourceGroups = groupResourcesByFolder(validResources, translationsFolder, cwd); - - // Process each group - for (const group of resourceGroups.values()) { - if (verbose) { - for (const { resource } of group.resources) { - onProgress?.(`Processing: ${resource.key}`); - } - } - - const groupChanges = processResourceGroup( - group, - locale, - baseLocale, - mergedOptions, - dryRun, - isBaseLocaleImport, - filesModified, - warnings, - errors, - ); - - changes.push(...groupChanges); - } - - // Calculate statistics - const statistics = calculateImportStatistics(changes); - const statusTransitions = calculateStatusTransitions(changes); - - onProgress?.( - dryRun - ? `Dry run complete: would import ${statistics.resourcesUpdated} resources` - : `Import complete: ${statistics.resourcesUpdated} resources imported`, - ); - - return buildImportResult({ - format: 'xliff', - options: mergedOptions, - statistics, - statusTransitions, - changes, - filesModified, - warnings, - errors, - icuAutoFixes, - icuAutoFixErrors, - }); -} diff --git a/libs/core/src/lib/import/import-preferred-terminology.spec.ts b/libs/core/src/lib/import/import-preferred-terminology.spec.ts index 73a221e5..e8553ced 100644 --- a/libs/core/src/lib/import/import-preferred-terminology.spec.ts +++ b/libs/core/src/lib/import/import-preferred-terminology.spec.ts @@ -2,10 +2,12 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { PreferredTermRule } from '@simoncodes-ca/domain'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { importFromJson } from './import-from-json'; -import { importFromXliff } from './import-from-xliff'; -import type { ImportOptions } from './types'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { type Collection, openCollection } from '../config/open-collection'; +import { importResources } from './import-resources'; +import { parseJsonImport } from './parse-json-import'; +import { parseXliffImport } from './parse-xliff-import'; +import type { ImportRunOptions } from './types'; const rules: PreferredTermRule[] = [ { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Finance style guide' }, @@ -30,16 +32,16 @@ ${Object.entries(units) describe('preferred terminology on import', () => { let projectDir: string; let translationsFolder: string; + let collection: Collection; beforeEach(() => { projectDir = mkdtempSync(join(tmpdir(), 'lingo-import-terminology-')); translationsFolder = join(projectDir, 'translations'); mkdirSync(translationsFolder, { recursive: true }); - vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + collection = makeCollection('en'); }); afterEach(() => { - vi.restoreAllMocks(); rmSync(projectDir, { recursive: true, force: true }); }); @@ -49,10 +51,19 @@ describe('preferred terminology on import', () => { return filePath; }; - const baseOptions = (source: string, overrides: Partial = {}): ImportOptions => ({ - source, + const makeCollection = (baseLocale: string): Collection => + openCollection( + { + baseLocale: 'en', + locales: ['en', 'es', 'fr'], + collections: { main: { translationsFolder: 'translations', baseLocale } }, + }, + 'main', + { cwd: projectDir }, + ); + + const baseOptions = (overrides: Partial = {}): ImportRunOptions => ({ locale: 'en', - baseLocale: 'en', strategy: 'migration', preferredTerminology: rules, ...overrides, @@ -78,7 +89,7 @@ describe('preferred terminology on import', () => { }), ); - const result = importFromJson(translationsFolder, baseOptions(source)); + const result = importResources(collection, parseJsonImport(source), baseOptions()); expect(result.warnings).toEqual( expect.arrayContaining([ @@ -97,7 +108,7 @@ describe('preferred terminology on import', () => { seedExisting(); const source = writeSource('en.json', JSON.stringify({ 'budget.title': 'Operating expenditure' })); - const result = importFromJson(translationsFolder, baseOptions(source)); + const result = importResources(collection, parseJsonImport(source), baseOptions()); expect(result.warnings).toContain( 'Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Finance style guide', @@ -109,7 +120,7 @@ describe('preferred terminology on import', () => { it('warns during a dry run without writing anything', () => { const source = writeSource('en.json', JSON.stringify({ 'budget.title': 'Expenditure' })); - const result = importFromJson(translationsFolder, baseOptions(source, { dryRun: true })); + const result = importResources(collection, parseJsonImport(source), baseOptions({ dryRun: true })); expect(result.warnings.filter((w) => w.startsWith('Preferred terminology'))).toHaveLength(1); expect(result.filesModified).toEqual([]); @@ -119,9 +130,10 @@ describe('preferred terminology on import', () => { seedExisting(); const source = writeSource('es.json', JSON.stringify({ 'budget.title': 'Expenditure de capital' })); - const result = importFromJson( - translationsFolder, - baseOptions(source, { locale: 'es', strategy: 'translation-service' }), + const result = importResources( + collection, + parseJsonImport(source), + baseOptions({ locale: 'es', strategy: 'translation-service' }), ); expect(result.warnings.some((w) => w.startsWith('Preferred terminology'))).toBe(false); @@ -131,7 +143,11 @@ describe('preferred terminology on import', () => { it('skips the check when no rules are given', () => { const source = writeSource('en.json', JSON.stringify({ 'budget.title': 'Expenditure' })); - const result = importFromJson(translationsFolder, baseOptions(source, { preferredTerminology: undefined })); + const result = importResources( + collection, + parseJsonImport(source), + baseOptions({ preferredTerminology: undefined }), + ); expect(result.warnings.some((w) => w.startsWith('Preferred terminology'))).toBe(false); }); @@ -142,7 +158,7 @@ describe('preferred terminology on import', () => { xliff('en', { 'budget.title': { source: 'Expenditure', target: 'Capital expenditure' } }), ); - const result = await importFromXliff(translationsFolder, baseOptions(source)); + const result = importResources(collection, await parseXliffImport(source), baseOptions()); expect(result.warnings).toContain( 'Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Finance style guide', @@ -154,7 +170,8 @@ describe('preferred terminology on import', () => { it('warns on an import into that base locale', () => { const source = writeSource('fr.json', JSON.stringify({ 'budget.title': 'Expenditure du mois' })); - const result = importFromJson(translationsFolder, baseOptions(source, { locale: 'fr', baseLocale: 'fr' })); + const french = makeCollection('fr'); + const result = importResources(french, parseJsonImport(source), baseOptions({ locale: 'fr' })); expect(result.warnings).toContain( 'Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Finance style guide', @@ -167,9 +184,11 @@ describe('preferred terminology on import', () => { seedExisting(); const source = writeSource('en.json', JSON.stringify({ 'budget.title': 'Capital expenditure' })); - const result = importFromJson( - translationsFolder, - baseOptions(source, { locale: 'en', baseLocale: 'fr', strategy: 'translation-service' }), + const french = makeCollection('fr'); + const result = importResources( + french, + parseJsonImport(source), + baseOptions({ locale: 'en', strategy: 'translation-service' }), ); expect(result.warnings.some((w) => w.startsWith('Preferred terminology'))).toBe(false); @@ -185,9 +204,10 @@ describe('preferred terminology on import', () => { xliff('es', { 'budget.title': { source: 'Capital expenditure', target: 'Expenditure de capital' } }), ); - const result = await importFromXliff( - translationsFolder, - baseOptions(source, { locale: 'es', strategy: 'translation-service' }), + const result = importResources( + collection, + await parseXliffImport(source), + baseOptions({ locale: 'es', strategy: 'translation-service' }), ); expect(result.warnings.some((w) => w.startsWith('Preferred terminology'))).toBe(false); diff --git a/libs/core/src/lib/import/import-resources.merge.spec.ts b/libs/core/src/lib/import/import-resources.merge.spec.ts new file mode 100644 index 00000000..d2bcec96 --- /dev/null +++ b/libs/core/src/lib/import/import-resources.merge.spec.ts @@ -0,0 +1,369 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { calculateChecksum } from '../../resource/checksum'; +import { type Collection, openCollection } from '../config/open-collection'; +import { openResourceFolder } from '../resource/resource-folder'; +import { importResources } from './import-resources'; +import type { ImportedResource, ImportRunOptions, TranslationStatus } from './types'; + +describe('importResources merge behavior', () => { + let dir: string; + let collection: Collection; + let folderPath: string; + let entryPath: string; + let metaPath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'lingo-import-merge-')); + collection = openCollection( + { + baseLocale: 'en', + locales: ['en', 'es', 'fr', 'de'], + collections: { main: { translationsFolder: 'translations' } }, + }, + 'main', + { cwd: dir }, + ); + folderPath = join(collection.translationsFolder, 'common', 'buttons'); + entryPath = join(folderPath, 'resource_entries.json'); + metaPath = join(folderPath, 'tracker_meta.json'); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + const writeFolder = (entries: object, meta: object): void => { + mkdirSync(folderPath, { recursive: true }); + writeFileSync(entryPath, JSON.stringify(entries)); + writeFileSync(metaPath, JSON.stringify(meta)); + }; + const seed = (entries: Record): void => { + const folder = openResourceFolder(folderPath, { baseLocale: 'en' }); + for (const [key, value] of Object.entries(entries)) { + folder.setBase(key, value.source); + if (value.es !== undefined) folder.setTranslation(key, 'es', value.es, value.status ?? 'translated'); + } + folder.save(); + }; + const run = (resources: readonly ImportedResource[], options: ImportRunOptions = { locale: 'es' }) => + importResources(collection, resources, options); + const stored = (key: string) => openResourceFolder(folderPath, { baseLocale: 'en' }).get(key); + + describe('creating new resources', () => { + it('should create new resource when createMissing is true and baseValue is provided', () => { + const result = run([{ key: 'common.buttons.ok', value: 'Aceptar', baseValue: 'OK' }], { + locale: 'es', + createMissing: true, + }); + expect(result.changes[0]).toMatchObject({ type: 'created', newValue: 'Aceptar', newStatus: 'translated' }); + expect(result.filesModified.sort()).toEqual([entryPath, metaPath].sort()); + expect(stored('ok')?.entry).toMatchObject({ source: 'OK', es: 'Aceptar' }); + }); + it('should fail to create resource when createMissing is false', () => { + const result = run([{ key: 'common.buttons.ok', value: 'Aceptar' }]); + expect(result.changes[0]).toMatchObject({ + type: 'skipped', + reason: expect.stringContaining('does not allow creation'), + }); + expect(result.filesModified).toEqual([]); + }); + it('should fail to create resource when baseValue is missing', () => { + const result = run([{ key: 'common.buttons.ok', value: 'Aceptar' }], { locale: 'es', createMissing: true }); + expect(result.changes[0]).toMatchObject({ + type: 'failed', + reason: expect.stringContaining('base value not provided'), + }); + }); + }); + + describe('updating existing resources', () => { + beforeEach(() => + seed({ ok: { source: 'OK', es: 'Bien' }, cancel: { source: 'Cancel', es: 'Cancelar', status: 'verified' } }), + ); + + it('should update resource value when it changes', () => { + const result = run([{ key: 'common.buttons.ok', value: 'Aceptar' }]); + expect(result.changes[0]).toMatchObject({ + type: 'value-changed', + oldValue: 'Bien', + newValue: 'Aceptar', + newStatus: 'translated', + }); + expect(stored('ok')?.entry.es).toBe('Aceptar'); + }); + it('should preserve existing status when value does not change', () => { + expect(run([{ key: 'common.buttons.ok', value: 'Bien' }]).changes[0]).toMatchObject({ + type: 'updated', + oldStatus: 'translated', + newStatus: 'translated', + }); + }); + it('should not downgrade a current verified value for translation-service', () => { + const result = run([{ key: 'common.buttons.cancel', value: 'Cancelar' }]); + expect(result.changes[0]).toMatchObject({ oldStatus: 'verified', newStatus: 'verified' }); + expect(result.filesModified).toEqual([]); + }); + it('should preserve existing status when value does not change with update strategy', () => { + const result = run([{ key: 'common.buttons.ok', value: 'Bien' }], { locale: 'es', strategy: 'update' }); + expect(result.changes[0]).toMatchObject({ newStatus: 'translated' }); + expect(result.filesModified).toEqual([]); + }); + + it.each([ + ['translation-service', 'translated', true], + ['verification', 'verified', true], + ['update', 'stale', false], + ] as const)('reconfirms stale metadata for %s', (strategy, expectedStatus, refreshes) => { + writeFolder( + { ok: { source: 'OK', es: 'Bien' } }, + { + ok: { + en: { checksum: calculateChecksum('OK') }, + es: { checksum: calculateChecksum('Bien'), baseChecksum: calculateChecksum('Old'), status: 'stale' }, + }, + }, + ); + const result = run([{ key: 'common.buttons.ok', value: 'Bien' }], { locale: 'es', strategy }); + expect(result.changes[0]).toMatchObject({ oldStatus: 'stale', newStatus: expectedStatus }); + expect(stored('ok')?.meta?.es?.baseChecksum).toBe(calculateChecksum(refreshes ? 'OK' : 'Old')); + expect(result.filesModified.length > 0).toBe(refreshes); + }); + + it('should set status to verified for verification strategy', () => { + const result = run([{ key: 'common.buttons.ok', value: 'Bien' }], { locale: 'es', strategy: 'verification' }); + expect(result.changes[0]?.newStatus).toBe('verified'); + expect(stored('ok')?.meta?.es?.status).toBe('verified'); + }); + it('should warn on base value mismatch when validateBase is enabled', () => { + const result = run([{ key: 'common.buttons.ok', value: 'Aceptar', baseValue: 'Okay' }], { + locale: 'es', + validateBase: true, + }); + expect(result.warnings[0]).toContain('Base value mismatch'); + expect(result.warnings[0]).toContain('common.buttons.ok'); + }); + }); + + it('should not write files in dry run mode', () => { + const result = run([{ key: 'common.buttons.ok', value: 'Aceptar', baseValue: 'OK' }], { + locale: 'es', + createMissing: true, + dryRun: true, + }); + expect(result.changes[0]?.type).toBe('created'); + expect(result.filesModified).toEqual([]); + expect(existsSync(entryPath)).toBe(false); + }); + + describe('comment and tag updates', () => { + beforeEach(() => { + seed({ ok: { source: 'OK', es: 'Bien' } }); + const folder = openResourceFolder(folderPath, { baseLocale: 'en' }); + folder.setDetails('ok', { comment: 'Old comment', tags: ['old-tag'] }); + folder.save(); + }); + it('should update comment when updateComments is true', () => { + run([{ key: 'common.buttons.ok', value: 'Aceptar', comment: 'New comment' }], { + locale: 'es', + updateComments: true, + }); + expect(stored('ok')?.entry.comment).toBe('New comment'); + }); + it('should not update comment when updateComments is false', () => { + run([{ key: 'common.buttons.ok', value: 'Aceptar', comment: 'New comment' }], { + locale: 'es', + updateComments: false, + }); + expect(stored('ok')?.entry.comment).toBe('Old comment'); + }); + it('should update tags when updateTags is true', () => { + run([{ key: 'common.buttons.ok', value: 'Aceptar', tags: ['new-tag', 'another-tag'] }], { + locale: 'es', + updateTags: true, + }); + expect(stored('ok')?.entry.tags).toEqual(['new-tag', 'another-tag']); + }); + it('should not update tags when updateTags is false', () => { + run([{ key: 'common.buttons.ok', value: 'Aceptar', tags: ['new-tag'] }], { locale: 'es', updateTags: false }); + expect(stored('ok')?.entry.tags).toEqual(['old-tag']); + }); + }); + + describe('source status handling', () => { + it.each([ + ['migration', true, 'verified'], + ['migration', undefined, 'verified'], + ['migration', false, 'translated'], + ['verification', true, 'verified'], + ['verification', undefined, 'translated'], + ['verification', false, 'translated'], + ] as const)('creates with %s and preserveStatus=%s', (strategy, preserveStatus, expected) => { + const result = run([{ key: 'common.buttons.ok', value: 'Aceptar', baseValue: 'OK', status: 'verified' }], { + locale: 'es', + strategy, + preserveStatus, + createMissing: true, + }); + expect(result.changes[0]?.newStatus).toBe(expected); + }); + + it.each([ + [true, 'verified', true], + [undefined, 'verified', true], + [false, 'translated', false], + ] as const)('migration update preserveStatus=%s resolves to %s', (preserveStatus, expected, changesFile) => { + seed({ ok: { source: 'OK', es: 'Bien' } }); + const result = run([{ key: 'common.buttons.ok', value: 'Bien', status: 'verified' }], { + locale: 'es', + strategy: 'migration', + preserveStatus, + }); + expect(result.changes[0]).toMatchObject({ oldStatus: 'translated', newStatus: expected }); + expect(result.filesModified.length > 0).toBe(changesFile); + }); + + it.each([ + ['uses the incoming status when present', 'verified', undefined, 'verified'], + ['falls back to translated when the incoming status is missing', undefined, undefined, 'translated'], + ['ignores the incoming status when preserveStatus is false', 'verified', false, 'translated'], + ] as const)('migration of a changed existing value %s', (_name, status, preserveStatus, expected) => { + seed({ ok: { source: 'OK', es: 'Bien' } }); + const result = run([{ key: 'common.buttons.ok', value: 'Aceptar', status }], { + locale: 'es', + strategy: 'migration', + preserveStatus, + }); + expect(result.changes[0]).toMatchObject({ + type: 'value-changed', + oldStatus: 'translated', + newValue: 'Aceptar', + newStatus: expected, + }); + expect(stored('ok')?.entry.es).toBe('Aceptar'); + expect(stored('ok')?.meta?.es?.status).toBe(expected); + }); + + it('should not use source status for translation-service strategy when preserveStatus is undefined', () => { + seed({ ok: { source: 'OK', es: 'Bien' } }); + expect(run([{ key: 'common.buttons.ok', value: 'Aceptar', status: 'verified' }]).changes[0]?.newStatus).toBe( + 'translated', + ); + }); + it('should use source status for translation-service strategy when preserveStatus is true', () => { + seed({ ok: { source: 'OK', es: 'Bien' } }); + expect( + run([{ key: 'common.buttons.ok', value: 'Aceptar', status: 'verified' }], { + locale: 'es', + preserveStatus: true, + }).changes[0]?.newStatus, + ).toBe('verified'); + }); + it('should use source status for translation-service when preserveStatus is true and value unchanged', () => { + seed({ ok: { source: 'OK', es: 'Bien' } }); + run([{ key: 'common.buttons.ok', value: 'Bien', status: 'verified' }], { locale: 'es', preserveStatus: true }); + expect(stored('ok')?.meta?.es?.status).toBe('verified'); + }); + }); + + describe('base locale imports', () => { + const importBase = (resources: readonly ImportedResource[], options: Partial = {}) => + run(resources, { locale: 'en', strategy: 'migration', ...options }); + + it('should create new resource in base locale when isBaseLocaleImport is true', () => { + const result = importBase([ + { key: 'common.buttons.submit', value: 'Submit', comment: 'Submit', tags: ['forms'] }, + ]); + expect(result.changes[0]).toMatchObject({ type: 'created', newValue: 'Submit' }); + expect(result.changes[0]?.newStatus).toBeUndefined(); + expect(stored('submit')?.entry).toMatchObject({ source: 'Submit', comment: 'Submit', tags: ['forms'] }); + }); + it('should update existing resource source value when isBaseLocaleImport is true', () => { + seed({ ok: { source: 'OK', es: 'Bien' } }); + const result = importBase([{ key: 'common.buttons.ok', value: 'Okay' }]); + expect(result.changes[0]).toMatchObject({ type: 'value-changed', oldValue: 'OK', newValue: 'Okay' }); + expect(stored('ok')?.entry.source).toBe('Okay'); + }); + it('marks translations stale and points them at the new base checksum', () => { + const folder = openResourceFolder(folderPath, { baseLocale: 'en' }); + folder.setBase('ok', 'OK'); + folder.setTranslation('ok', 'es', 'Bien', 'verified'); + folder.setTranslation('ok', 'fr', 'Okay', 'translated'); + folder.setTranslation('ok', 'de', 'OK', 'new'); + folder.save(); + importBase([{ key: 'common.buttons.ok', value: 'Okay' }]); + expect(stored('ok')?.meta?.es).toMatchObject({ baseChecksum: calculateChecksum('Okay'), status: 'stale' }); + expect(stored('ok')?.meta?.fr).toMatchObject({ baseChecksum: calculateChecksum('Okay'), status: 'new' }); + expect(stored('ok')?.meta?.de).toMatchObject({ baseChecksum: calculateChecksum('Okay'), status: 'stale' }); + }); + it('leaves translations alone when the base value is unchanged', () => { + seed({ ok: { source: 'OK', es: 'Bien' } }); + const before = readFileSync(metaPath, 'utf8'); + expect(importBase([{ key: 'common.buttons.ok', value: 'OK' }]).filesModified).toEqual([]); + expect(readFileSync(metaPath, 'utf8')).toBe(before); + }); + it('should update comment and tags for base locale when flags are set', () => { + seed({ ok: { source: 'OK' } }); + importBase([{ key: 'common.buttons.ok', value: 'OK', comment: 'New', tags: ['new'] }]); + expect(stored('ok')?.entry).toMatchObject({ comment: 'New', tags: ['new'] }); + }); + it('should not write files when base locale comment and tags are unchanged', () => { + seed({ greeting: { source: 'Hello' } }); + const folder = openResourceFolder(folderPath, { baseLocale: 'en' }); + folder.setDetails('greeting', { comment: 'greeting', tags: ['ui'] }); + folder.save(); + expect( + importBase([{ key: 'common.buttons.greeting', value: 'Hello', comment: 'greeting', tags: ['ui'] }]) + .filesModified, + ).toEqual([]); + }); + }); + + describe('protected terms verification', () => { + it('flags an altered protected term as failed, skips writing it, and records an error', () => { + seed({ ok: { source: 'Get iPhone' } }); + const result = run([{ key: 'common.buttons.ok', value: 'Obtenez iphone' }], { + locale: 'es', + protectedTerms: ['iPhone'], + }); + expect(result.changes[0]).toEqual({ + key: 'common.buttons.ok', + type: 'failed', + reason: 'Protected term(s) altered: iPhone', + }); + expect(result.errors).toContain('"common.buttons.ok" Protected term(s) altered: iPhone'); + expect(stored('ok')?.entry.es).toBeUndefined(); + }); + it('passes when the term is preserved verbatim', () => { + seed({ ok: { source: 'Get iPhone' } }); + const result = run([{ key: 'common.buttons.ok', value: 'Obtenez iPhone' }], { + locale: 'es', + protectedTerms: ['iPhone'], + }); + expect(result.errors).toEqual([]); + expect(stored('ok')?.entry.es).toBe('Obtenez iPhone'); + }); + it('still imports a valid sibling when a term-bearing entry fails', () => { + seed({ ok: { source: 'Get iPhone' }, cancel: { source: 'Cancel' } }); + const result = run( + [ + { key: 'common.buttons.ok', value: 'Obtenez iphone' }, + { key: 'common.buttons.cancel', value: 'Cancelar' }, + ], + { locale: 'es', protectedTerms: ['iPhone'] }, + ); + expect(result.changes.find(({ key }) => key.endsWith('.ok'))?.type).toBe('failed'); + expect(result.changes.find(({ key }) => key.endsWith('.cancel'))?.type).toBe('value-changed'); + expect(stored('cancel')?.entry.es).toBe('Cancelar'); + }); + it('skips the check for base locale imports', () => { + seed({ ok: { source: 'Get iPhone' } }); + expect( + run([{ key: 'common.buttons.ok', value: 'Get iphone' }], { + locale: 'en', + strategy: 'migration', + protectedTerms: ['iPhone'], + }).changes[0]?.type, + ).toBe('value-changed'); + }); + }); +}); diff --git a/libs/core/src/lib/import/import-resources.pipeline.spec.ts b/libs/core/src/lib/import/import-resources.pipeline.spec.ts new file mode 100644 index 00000000..5b6baf60 --- /dev/null +++ b/libs/core/src/lib/import/import-resources.pipeline.spec.ts @@ -0,0 +1,329 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { calculateChecksum } from '../../resource/checksum'; +import { type Collection, openCollection } from '../config/open-collection'; +import { openResourceFolder } from '../resource/resource-folder'; +import { importResources } from './import-resources'; +import { parseJsonImport } from './parse-json-import'; +import { parseXliffImport } from './parse-xliff-import'; + +const xliff = (units: string): string => ` + +${units} +`; + +describe('importResources pipeline', () => { + let dir: string; + let collection: Collection; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'lingo-import-pipeline-')); + collection = openCollection( + { baseLocale: 'en', locales: ['en', 'es'], collections: { main: { translationsFolder: 'translations' } } }, + 'main', + { cwd: dir }, + ); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + const source = (name: string, content: string): string => { + const path = join(dir, name); + writeFileSync(path, content); + return path; + }; + const folder = (key: string) => + openResourceFolder(join(collection.translationsFolder, ...key.split('.')), { baseLocale: 'en' }); + const seed = ( + folderKey: string, + entries: Record, + ): void => { + const resourceFolder = folder(folderKey); + for (const [key, value] of Object.entries(entries)) { + resourceFolder.setBase(key, value.source); + if (value.es !== undefined) resourceFolder.setTranslation(key, 'es', value.es, value.status ?? 'translated'); + } + resourceFolder.save(); + }; + + it('imports a flat JSON file and recalculates real checksums', () => { + seed('common.buttons', { ok: { source: 'OK', es: 'Bien' }, cancel: { source: 'Cancel' } }); + const path = source( + 'es.json', + JSON.stringify({ 'common.buttons.ok': 'Aceptar', 'common.buttons.cancel': 'Cancelar' }), + ); + const result = importResources(collection, parseJsonImport(path), { locale: 'es' }); + expect(result.resourcesUpdated).toBe(2); + expect(folder('common.buttons').get('ok')?.meta?.es).toMatchObject({ + checksum: calculateChecksum('Aceptar'), + baseChecksum: calculateChecksum('OK'), + }); + }); + + it('imports hierarchical and deeply nested JSON files', () => { + seed('common.buttons', { ok: { source: 'OK' } }); + seed('level.one.two', { deep: { source: 'Deep' } }); + const path = source( + 'es.json', + JSON.stringify({ common: { buttons: { ok: 'Aceptar' } }, level: { one: { two: { deep: 'Profundo' } } } }), + ); + expect(importResources(collection, parseJsonImport(path), { locale: 'es' }).resourcesUpdated).toBe(2); + expect(folder('level.one.two').get('deep')?.entry.es).toBe('Profundo'); + }); + + it('skips missing resources with the default strategy', () => { + const result = importResources(collection, [{ key: 'common.missing', value: 'Falta' }], { locale: 'es' }); + expect(result.changes[0]).toMatchObject({ + type: 'skipped', + reason: expect.stringContaining('does not allow creation'), + }); + }); + + it('creates multiple resources and folders during migration', () => { + const result = importResources( + collection, + [ + { key: 'common.one', value: 'Uno', baseValue: 'One' }, + { key: 'new.deep.two', value: 'Dos', baseValue: 'Two' }, + ], + { locale: 'es', strategy: 'migration' }, + ); + expect(result.resourcesCreated).toBe(2); + expect(existsSync(join(collection.translationsFolder, 'new', 'deep', 'resource_entries.json'))).toBe(true); + }); + + it('fails creation without a baseValue and continues valid siblings', () => { + seed('common', { ok: { source: 'OK' } }); + const result = importResources( + collection, + [ + { key: 'new.missing', value: 'Falta' }, + { key: 'common.ok', value: 'Aceptar' }, + ], + { locale: 'es', strategy: 'migration' }, + ); + expect(result.resourcesFailed).toBe(1); + expect(result.resourcesUpdated).toBe(1); + }); + + it('detects duplicates, conflicts, invalid keys, long keys and empty values together', () => { + const longKey = `common.${'x'.repeat(260)}`; + const result = importResources( + collection, + [ + { key: 'common.ok', value: 'One', baseValue: 'OK' }, + { key: 'common.ok', value: 'Two', baseValue: 'OK' }, + { key: 'common', value: 'Conflict', baseValue: 'Conflict' }, + { key: 'bad..key', value: 'Bad', baseValue: 'Bad' }, + { key: longKey, value: 'Long', baseValue: 'Long' }, + { key: 'other.empty', value: ' ', baseValue: 'Empty' }, + ], + { locale: 'es', strategy: 'migration' }, + ); + + expect(result.warnings).toContain( + 'Duplicate key in import file: "common.ok" (used last occurrence, appeared 2 times)', + ); + expect(result.warnings).toContain(`Very long key: "${longKey}" (${longKey.length} characters)`); + expect(result.warnings).toContain('Empty value skipped: "other.empty"'); + expect(result.errors).toContain('Hierarchical conflict: "common" (has value and child keys)'); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('Invalid key format: "bad..key"')])); + expect(result.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'common', type: 'failed' }), + expect.objectContaining({ key: 'bad..key', type: 'failed' }), + expect.objectContaining({ key: 'other.empty', type: 'skipped', reason: 'Empty value' }), + ]), + ); + // Validation never stops the run: the valid resources are still written (the last duplicate wins). + expect(folder('common').get('ok')?.entry.es).toBe('Two'); + expect(folder('common').get(longKey.slice('common.'.length))?.entry.es).toBe('Long'); + }); + + it('warns for a base value mismatch only when validateBase is enabled', () => { + seed('common', { ok: { source: 'OK' } }); + const resource = [{ key: 'common.ok', value: 'Aceptar', baseValue: 'Okay' }]; + expect(importResources(collection, resource, { locale: 'es', validateBase: true }).warnings.join()).toContain( + 'Base value mismatch', + ); + expect(importResources(collection, resource, { locale: 'es', validateBase: false }).warnings).toEqual([]); + }); + + it('does not write in dry-run mode', () => { + seed('common', { ok: { source: 'OK' } }); + const path = join(collection.translationsFolder, 'common', 'resource_entries.json'); + const before = readFileSync(path, 'utf8'); + const result = importResources(collection, [{ key: 'common.ok', value: 'Aceptar' }], { + locale: 'es', + dryRun: true, + }); + expect(result.filesModified).toEqual([]); + expect(readFileSync(path, 'utf8')).toBe(before); + }); + + it('reports adapter and verbose pipeline progress', () => { + seed('common', { title: { source: 'Title' }, description: { source: 'Description' } }); + const path = source('es.json', JSON.stringify({ 'common.title': 'Título', 'common.description': 'Descripción' })); + const onProgress = vi.fn(); + const resources = parseJsonImport(path, { onProgress }); + importResources(collection, resources, { locale: 'es', verbose: true, onProgress }); + const messages = onProgress.mock.calls.map(([message]) => message); + expect(messages).toContain(`Reading JSON file: ${path}`); + expect(messages).toContain('Processing: common.title'); + expect(messages.at(-1)).toBe('Import complete: 2 resources imported'); + }); + + it.each([ + ['translation-service', 'translated'], + ['verification', 'verified'], + ['update', 'translated'], + ] as const)('applies %s status behavior', (strategy, status) => { + seed('common', { ok: { source: 'OK', es: 'Bien' } }); + const result = importResources(collection, [{ key: 'common.ok', value: 'Aceptar' }], { locale: 'es', strategy }); + expect(result.changes[0]?.newStatus).toBe(status); + }); + + it.each([ + ['changed', 'Aceptar'], + ['unchanged', 'Bien'], + ])('preserves a verified status with the update strategy when the value is %s', (_case, value) => { + seed('common', { ok: { source: 'OK', es: 'Bien', status: 'verified' } }); + const result = importResources(collection, [{ key: 'common.ok', value }], { locale: 'es', strategy: 'update' }); + expect(result.changes[0]?.newStatus).toBe('verified'); + expect(folder('common').get('ok')?.meta?.es?.status).toBe('verified'); + expect(folder('common').get('ok')?.entry.es).toBe(value); + }); + + it('tracks status transitions across updated resources', () => { + seed('common', { one: { source: 'One', es: 'Uno' }, two: { source: 'Two', es: 'Dos', status: 'verified' } }); + const result = importResources( + collection, + [ + { key: 'common.one', value: 'Primero' }, + { key: 'common.two', value: 'Segundo' }, + ], + { locale: 'es', strategy: 'verification' }, + ); + expect(result.statusTransitions).toEqual( + expect.arrayContaining([ + { from: 'translated', to: 'verified', count: 1 }, + { from: 'verified', to: 'verified', count: 1 }, + ]), + ); + }); + + it('resolves simple, t() and nested Transloco references during migration', () => { + seed('common', { one: { source: 'One' }, two: { source: 'Two' }, three: { source: 'Three' } }); + importResources( + collection, + [ + { key: 'common.one', value: 'Uno' }, + { key: 'common.two', value: '{{common.one}} dos' }, + { key: 'common.three', value: "{{t('common.two')}} tres" }, + ], + { locale: 'es', strategy: 'migration' }, + ); + expect(folder('common').get('three')?.entry.es).toBe('Uno dos tres'); + }); + + it('warns for circular and missing references and keeps them as placeholders', () => { + seed('common', { one: { source: 'One' }, two: { source: 'Two' }, missing: { source: 'Missing' } }); + const result = importResources( + collection, + [ + { key: 'common.one', value: '{{common.two}}' }, + { key: 'common.two', value: '{{common.one}}' }, + { key: 'common.missing', value: 'Hello {{unknown}} World' }, + ], + { locale: 'es', strategy: 'migration' }, + ); + + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining('Circular reference'), + 'Missing reference target: "unknown" - preserving literal', + ]), + ); + // The unresolved references survive; Transloco-to-ICU normalization then turns them into ICU placeholders. + expect(folder('common').get('one')?.entry.es).toBe('{common.two}'); + expect(folder('common').get('missing')?.entry.es).toBe('Hello {unknown} World'); + }); + + it('does not resolve references outside migration', () => { + seed('common', { one: { source: 'One' }, two: { source: 'Two' } }); + importResources( + collection, + [ + { key: 'common.one', value: 'Uno' }, + { key: 'common.two', value: '{{common.one}}' }, + ], + { locale: 'es' }, + ); + // Reference resolution is migration-only, but shared Transloco-to-ICU normalization still applies. + expect(folder('common').get('two')?.entry.es).toBe('{common.one}'); + }); + + it('normalizes Transloco interpolation when creating and updating JSON resources', () => { + seed('common', { existing: { source: 'Hello {name}' } }); + importResources( + collection, + [ + { key: 'common.existing', value: 'Hola {{ name }}' }, + { key: 'common.new', value: 'Adiós {{name}}', baseValue: 'Bye {name}' }, + ], + { locale: 'es', strategy: 'migration' }, + ); + expect(folder('common').get('existing')?.entry.es).toBe('Hola {name}'); + expect(folder('common').get('new')?.entry.es).toBe('Adiós {name}'); + }); + + it('imports XLIFF, validates its base value and updates comments', async () => { + seed('common', { ok: { source: 'OK' } }); + const path = source( + 'es.xliff', + xliff('OkayAceptarButton'), + ); + const result = importResources(collection, await parseXliffImport(path), { + locale: 'es', + validateBase: true, + updateComments: true, + }); + expect(result.warnings.join()).toContain('Base value mismatch'); + expect(folder('common').get('ok')?.entry).toMatchObject({ es: 'Aceptar', comment: 'Button' }); + }); + + it('creates resources from XLIFF in migration and applies shared normalization', async () => { + const path = source( + 'es.xliff', + xliff( + 'Hello {name}Hola {{ name }}', + ), + ); + const result = importResources(collection, await parseXliffImport(path), { locale: 'es', strategy: 'migration' }); + expect(result.resourcesCreated).toBe(1); + expect(folder('common').get('greeting')?.entry.es).toBe('Hola {name}'); + }); + + it('uses verification strategy for XLIFF updates', async () => { + seed('common', { ok: { source: 'OK', es: 'Bien' } }); + const path = source( + 'es.xliff', + xliff('OKAceptar'), + ); + const result = importResources(collection, await parseXliffImport(path), { + locale: 'es', + strategy: 'verification', + }); + expect(result.changes[0]?.newStatus).toBe('verified'); + }); + + it('refuses JSON and XLIFF resources imported into the base locale without migration', async () => { + const json = parseJsonImport(source('en.json', JSON.stringify({ 'common.ok': 'OK' }))); + const xlf = await parseXliffImport( + source('en.xliff', xliff('OKOK')), + ); + expect(() => importResources(collection, json, { locale: 'en' })).toThrow('Cannot import into base locale'); + expect(() => importResources(collection, xlf, { locale: 'en' })).toThrow('Cannot import into base locale'); + }); +}); diff --git a/libs/core/src/lib/import/import-resources.spec.ts b/libs/core/src/lib/import/import-resources.spec.ts new file mode 100644 index 00000000..e82c7275 --- /dev/null +++ b/libs/core/src/lib/import/import-resources.spec.ts @@ -0,0 +1,337 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { calculateChecksum } from '../../resource/checksum'; +import { type Collection, openCollection } from '../config/open-collection'; +import { openResourceFolder } from '../resource/resource-folder'; +import { importResources } from './import-resources'; +import type { ImportedResource } from './types'; + +describe('importResources', () => { + let projectDir: string; + let collection: Collection; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'lingo-import-resources-')); + collection = openCollection( + { baseLocale: 'en', locales: ['en', 'es', 'fr'], collections: { main: { translationsFolder: 'translations' } } }, + 'main', + { cwd: projectDir }, + ); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + /** Absolute folder path of a dot-delimited folder key, e.g. 'common.buttons'. */ + const folderOf = (folderKey: string): string => join(collection.translationsFolder, ...folderKey.split('.')); + + /** Writes a folder's two files as given (use for metadata that `ResourceFolder` would never produce). */ + const writeFolder = (folderKey: string, entries: object, meta: object): void => { + const folder = folderOf(folderKey); + mkdirSync(folder, { recursive: true }); + writeFileSync(join(folder, 'resource_entries.json'), JSON.stringify(entries)); + writeFileSync(join(folder, 'tracker_meta.json'), JSON.stringify(meta)); + }; + + /** Seeds entries through `ResourceFolder`, so checksums and statuses are consistent. */ + const seed = ( + folderKey: string, + entries: Record, + ): void => { + const folder = openResourceFolder(folderOf(folderKey), { baseLocale: 'en' }); + for (const [key, { source, es, esStatus }] of Object.entries(entries)) { + folder.setBase(key, source); + if (es !== undefined) folder.setTranslation(key, 'es', es, esStatus ?? 'translated'); + } + folder.save(); + }; + + const stored = (folderKey: string, key: string) => + openResourceFolder(folderOf(folderKey), { baseLocale: 'en' }).get(key); + + describe('target-locale import', () => { + it('writes the translation, sets its status and base checksum, and reports the change', () => { + seed('common.buttons', { ok: { source: 'OK' }, cancel: { source: 'Cancel', es: 'Cancelar' } }); + + const result = importResources( + collection, + [ + { key: 'common.buttons.ok', value: 'Aceptar' }, + { key: 'common.buttons.cancel', value: 'Cancelar ya' }, + ], + { locale: 'es' }, + ); + + expect(result).toMatchObject({ + strategy: 'translation-service', + locale: 'es', + collection: 'main', + resourcesImported: 2, + resourcesUpdated: 2, + resourcesCreated: 0, + resourcesSkipped: 0, + resourcesFailed: 0, + dryRun: false, + errors: [], + }); + expect(result.statusTransitions).toEqual( + expect.arrayContaining([ + { from: undefined, to: 'translated', count: 1 }, + { from: 'translated', to: 'translated', count: 1 }, + ]), + ); + expect(result.filesModified.sort()).toEqual( + [ + join(folderOf('common.buttons'), 'resource_entries.json'), + join(folderOf('common.buttons'), 'tracker_meta.json'), + ].sort(), + ); + + const ok = stored('common.buttons', 'ok'); + expect(ok?.entry.es).toBe('Aceptar'); + expect(ok?.meta?.es).toEqual({ + checksum: calculateChecksum('Aceptar'), + baseChecksum: calculateChecksum('OK'), + status: 'translated', + }); + expect(stored('common.buttons', 'cancel')?.entry.es).toBe('Cancelar ya'); + }); + + it('skips a resource that does not exist unless createMissing is set', () => { + seed('common', { ok: { source: 'OK' } }); + + const result = importResources(collection, [{ key: 'common.missing', value: 'Falta', baseValue: 'Missing' }], { + locale: 'es', + }); + + expect(result.resourcesSkipped).toBe(1); + expect(result.changes[0]).toMatchObject({ key: 'common.missing', type: 'skipped' }); + expect(stored('common', 'missing')).toBeUndefined(); + }); + + it('creates a missing resource from its baseValue when createMissing is set', () => { + const result = importResources(collection, [{ key: 'common.greeting', value: 'Hola', baseValue: 'Hello' }], { + locale: 'es', + createMissing: true, + }); + + expect(result.resourcesCreated).toBe(1); + expect(stored('common', 'greeting')?.entry).toMatchObject({ source: 'Hello', es: 'Hola' }); + }); + + it('refreshes a stale base checksum when a translation-service import re-confirms the value', () => { + writeFolder( + 'common', + { ok: { source: 'OK', es: 'Aceptar' } }, + { + ok: { + en: { checksum: calculateChecksum('OK') }, + es: { checksum: calculateChecksum('Aceptar'), baseChecksum: 'old', status: 'stale' }, + }, + }, + ); + + const result = importResources(collection, [{ key: 'common.ok', value: 'Aceptar' }], { locale: 'es' }); + + expect(result.changes[0]).toMatchObject({ type: 'updated', oldStatus: 'stale', newStatus: 'translated' }); + expect(stored('common', 'ok')?.meta?.es).toMatchObject({ + baseChecksum: calculateChecksum('OK'), + status: 'translated', + }); + }); + }); + + describe('dry run', () => { + it('reports the changes without writing anything', () => { + seed('common', { ok: { source: 'OK' } }); + const before = readFileSync(join(folderOf('common'), 'resource_entries.json'), 'utf8'); + + const result = importResources( + collection, + [ + { key: 'common.ok', value: 'Aceptar' }, + { key: 'fresh.key', value: 'Nuevo', baseValue: 'New' }, + ], + { locale: 'es', dryRun: true, createMissing: true }, + ); + + expect(result.dryRun).toBe(true); + expect(result.resourcesUpdated).toBe(1); + expect(result.resourcesCreated).toBe(1); + expect(result.filesModified).toEqual([]); + expect(readFileSync(join(folderOf('common'), 'resource_entries.json'), 'utf8')).toBe(before); + expect(existsSync(folderOf('fresh'))).toBe(false); + }); + }); + + describe('base-locale import', () => { + it('refuses the base locale unless the strategy is migration', () => { + expect(() => importResources(collection, [], { locale: 'en' })).toThrow( + 'Cannot import into base locale "en" with strategy "translation-service"', + ); + expect(() => importResources(collection, [], { locale: 'en', strategy: 'verification' })).toThrow( + 'Only "migration" strategy supports base locale imports.', + ); + }); + + it("uses the collection's own base locale", () => { + const french = openCollection( + { + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { fr: { translationsFolder: 'translations', baseLocale: 'fr' } }, + }, + 'fr', + { cwd: projectDir }, + ); + + expect(() => importResources(french, [], { locale: 'fr' })).toThrow('Cannot import into base locale "fr"'); + expect(importResources(french, [], { locale: 'en' }).errors).toEqual([]); + }); + + it('writes base values with migration and marks changed translations stale', () => { + seed('common', { ok: { source: 'OK', es: 'Aceptar' } }); + + const result = importResources(collection, [{ key: 'common.ok', value: 'Okay' }], { + locale: 'en', + strategy: 'migration', + }); + + expect(result.changes[0]).toMatchObject({ type: 'value-changed', oldValue: 'OK', newValue: 'Okay' }); + const ok = stored('common', 'ok'); + expect(ok?.entry.source).toBe('Okay'); + expect(ok?.meta?.es).toMatchObject({ status: 'stale', baseChecksum: calculateChecksum('Okay') }); + }); + }); + + describe('strategy defaults', () => { + it('lets migration create resources and update comments and tags by default', () => { + seed('common', { ok: { source: 'OK', es: 'Vale' } }); + + const result = importResources( + collection, + [ + { key: 'common.ok', value: 'Aceptar', comment: 'Button', tags: ['ui'] }, + { key: 'common.new', value: 'Nuevo', baseValue: 'New' }, + ], + { locale: 'es', strategy: 'migration' }, + ); + + expect(result.resourcesCreated).toBe(1); + expect(stored('common', 'ok')?.entry).toMatchObject({ comment: 'Button', tags: ['ui'] }); + }); + + it('lets explicit flags override the strategy defaults', () => { + seed('common', { ok: { source: 'OK', es: 'Vale' } }); + + const result = importResources( + collection, + [ + { key: 'common.ok', value: 'Aceptar', comment: 'Button' }, + { key: 'common.new', value: 'Nuevo', baseValue: 'New' }, + ], + { locale: 'es', strategy: 'migration', createMissing: false, updateComments: false }, + ); + + expect(result.resourcesCreated).toBe(0); + expect(result.resourcesSkipped).toBe(1); + expect(stored('common', 'ok')?.entry.comment).toBeUndefined(); + }); + }); + + describe('preparation before writing', () => { + it('resolves Transloco references between the imported resources for migration only', () => { + seed('common', { ok: { source: 'OK' }, confirm: { source: 'Press OK' } }); + const resources: ImportedResource[] = [ + { key: 'common.ok', value: 'Aceptar' }, + { key: 'common.confirm', value: "Pulsa {{t('common.ok')}}" }, + ]; + + importResources(collection, resources, { locale: 'es', strategy: 'migration' }); + expect(stored('common', 'confirm')?.entry.es).toBe('Pulsa Aceptar'); + + importResources(collection, resources, { locale: 'fr' }); + expect(stored('common', 'confirm')?.entry.fr).toBe("Pulsa {{t('common.ok')}}"); + }); + + it('converts Transloco {{ name }} placeholders to ICU before writing (JSON and XLIFF alike)', () => { + seed('common', { greeting: { source: 'Hello {name}' } }); + + importResources(collection, [{ key: 'common.greeting', value: 'Hola {{ name }}' }], { locale: 'es' }); + + expect(stored('common', 'greeting')?.entry.es).toBe('Hola {name}'); + }); + + it('auto-fixes placeholders that differ from the stored base value and reports the fix', () => { + seed('common', { greeting: { source: 'Hello {name}' } }); + + const result = importResources(collection, [{ key: 'common.greeting', value: 'Hola {nombre}' }], { + locale: 'es', + }); + + expect(result.icuAutoFixes).toEqual([ + expect.objectContaining({ key: 'common.greeting', originalValue: 'Hola {nombre}', fixedValue: 'Hola {name}' }), + ]); + expect(stored('common', 'greeting')?.entry.es).toBe('Hola {name}'); + }); + + it('fails invalid keys and skips empty values without stopping the run', () => { + seed('common', { ok: { source: 'OK' } }); + + const result = importResources( + collection, + [ + { key: 'invalid key!', value: 'x' }, + { key: 'common.empty', value: ' ' }, + { key: 'common.ok', value: 'Aceptar' }, + ], + { locale: 'es' }, + ); + + expect(result.resourcesFailed).toBe(1); + expect(result.resourcesSkipped).toBe(1); + expect(result.resourcesUpdated).toBe(1); + expect(result.errors).toEqual([expect.stringContaining('Invalid key format: "invalid key!"')]); + expect(result.warnings).toContain('Empty value skipped: "common.empty"'); + }); + }); + + describe('protected terms', () => { + it('fails an entry whose translation altered a protected term and keeps its siblings', () => { + seed('common', { brand: { source: 'Open Acme' }, ok: { source: 'OK' } }); + + const result = importResources( + collection, + [ + { key: 'common.brand', value: 'Abrir Akme' }, + { key: 'common.ok', value: 'Aceptar' }, + ], + { locale: 'es', protectedTerms: ['Acme'] }, + ); + + expect(result.errors).toEqual(['"common.brand" Protected term(s) altered: Acme']); + expect(result.changes.find((c) => c.key === 'common.brand')).toMatchObject({ type: 'failed' }); + expect(stored('common', 'brand')?.entry.es).toBeUndefined(); + expect(stored('common', 'ok')?.entry.es).toBe('Aceptar'); + }); + }); + + describe('progress', () => { + it('reports each resource and the completion when verbose', () => { + seed('common', { ok: { source: 'OK' } }); + const messages: string[] = []; + + importResources(collection, [{ key: 'common.ok', value: 'Aceptar' }], { + locale: 'es', + verbose: true, + onProgress: (message) => messages.push(message), + }); + + expect(messages).toContain('Processing: common.ok'); + expect(messages.at(-1)).toBe('Import complete: 1 resources imported'); + }); + }); +}); diff --git a/libs/core/src/lib/import/import-resources.ts b/libs/core/src/lib/import/import-resources.ts new file mode 100644 index 00000000..dbda44c8 --- /dev/null +++ b/libs/core/src/lib/import/import-resources.ts @@ -0,0 +1,77 @@ +import { resolveAllReferences } from '@simoncodes-ca/domain'; +import type { Collection } from '../config/open-collection'; +import { applyICUAutoFixToResources } from './apply-icu-auto-fix'; +import { openImportSession, sessionResult } from './import-session'; +import { validateImportResources } from './import-validation'; +import { loadBaseLocaleValues } from './load-base-locale-values'; +import { normalizeTranslocoSyntaxInResources } from './normalize-transloco-syntax'; +import { processResourceGroup } from './process-resource-group'; +import { groupResourcesByFolder } from './resource-grouping'; +import type { ImportedResource, ImportResult, ImportRunOptions } from './types'; + +/** + * Imports resources into a collection for one locale, and reports what changed. + * + * The resources come from a format adapter (`parseJsonImport`, `parseXliffImport`) or any other + * source. The run: + * 1. Applies the strategy defaults, and refuses a base-locale import unless the strategy is `migration`. + * 2. Resolves Transloco references (`{{t('key')}}`, `{{key}}`) between the resources (`migration` only). + * 3. Converts Transloco `{{ name }}` placeholders to ICU `{name}`. + * 4. Repairs placeholders that differ from the stored base value (ICU auto-fix). + * 5. Drops invalid resources: bad keys, hierarchical conflicts, empty values. Duplicate keys warn. + * 6. Applies the resources one folder at a time, with the strategy's rules for creation, + * status, comments, tags, protected terms, and preferred terminology. + * + * Nothing is written in a dry run; the result says what would change. + * + * @throws {Error} The locale is the collection's base locale and the strategy is not `migration`. + */ +export function importResources( + collection: Collection, + resources: readonly ImportedResource[], + options: ImportRunOptions, +): ImportResult { + const session = openImportSession(collection, options); + const { strategy, dryRun, verbose, onProgress } = session.options; + const { translationsFolder } = collection; + + let prepared = [...resources]; + if (strategy === 'migration') { + onProgress?.('Resolving Transloco-style references...'); + prepared = resolveAllReferences(prepared, true, session.warnings); + } + + // Before any ICU parsing or auto-fixing, so later steps see one placeholder syntax. + prepared = normalizeTranslocoSyntaxInResources(prepared); + + if (verbose) onProgress?.('Checking for ICU placeholder issues...'); + const baseValues = loadBaseLocaleValues(prepared, translationsFolder); + const autoFix = applyICUAutoFixToResources({ + resources: prepared, + getBaseValue: (key) => baseValues.get(key), + verbose, + onProgress: verbose ? onProgress : undefined, + }); + session.icuAutoFixes.push(...autoFix.autoFixes); + session.icuAutoFixErrors.push(...autoFix.autoFixErrors); + + const validation = validateImportResources(autoFix.resources, { skipEmptyValues: true, warnOnLongKeys: true }); + session.warnings.push(...validation.warnings); + session.errors.push(...validation.errors); + session.changes.push(...validation.failedChanges); + + for (const group of groupResourcesByFolder(validation.validResources, translationsFolder).values()) { + if (verbose) { + for (const { resource } of group.resources) onProgress?.(`Processing: ${resource.key}`); + } + processResourceGroup(session, group); + } + + const result = sessionResult(session); + onProgress?.( + dryRun + ? `Dry run complete: would import ${result.resourcesUpdated} resources` + : `Import complete: ${result.resourcesUpdated} resources imported`, + ); + return result; +} diff --git a/libs/core/src/lib/import/import-session.spec.ts b/libs/core/src/lib/import/import-session.spec.ts new file mode 100644 index 00000000..f2c89806 --- /dev/null +++ b/libs/core/src/lib/import/import-session.spec.ts @@ -0,0 +1,137 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { type Collection, openCollection } from '../config/open-collection'; +import { openImportSession, sessionResult } from './import-session'; + +describe('import session', () => { + let dir: string; + let collection: Collection; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'lingo-import-session-')); + collection = openCollection( + { baseLocale: 'en', locales: ['en', 'es'], collections: { main: { translationsFolder: 'translations' } } }, + 'main', + { cwd: dir }, + ); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + describe('openImportSession', () => { + it.each([ + ['translation-service', false, false, false], + ['migration', true, true, true], + ['verification', false, false, false], + ['update', false, false, false], + ] as const)('applies strategy defaults for %s strategy', (strategy, createMissing, updateComments, updateTags) => { + expect(openImportSession(collection, { locale: 'es', strategy }).options).toMatchObject({ + strategy, + createMissing, + updateComments, + updateTags, + }); + }); + + it('preserves explicitly provided flags over strategy defaults', () => { + expect( + openImportSession(collection, { + locale: 'es', + strategy: 'translation-service', + createMissing: true, + updateComments: true, + }).options, + ).toMatchObject({ createMissing: true, updateComments: true, updateTags: false }); + }); + + it('uses translation-service as the default strategy', () => { + expect(openImportSession(collection, { locale: 'es' }).options).toMatchObject({ + strategy: 'translation-service', + createMissing: false, + }); + }); + + it('throws when importing into the base locale with a non-migration strategy', () => { + expect(() => openImportSession(collection, { locale: 'en' })).toThrow( + 'Cannot import into base locale "en" with strategy "translation-service"', + ); + }); + + it('allows a migration import into the base locale', () => { + const session = openImportSession(collection, { locale: 'en', strategy: 'migration' }); + expect(session.isBaseLocaleImport).toBe(true); + expect(session.options).toMatchObject({ createMissing: true, updateComments: true, updateTags: true }); + }); + + it('sets isBaseLocaleImport false for a target locale', () => { + expect(openImportSession(collection, { locale: 'es', strategy: 'migration' }).isBaseLocaleImport).toBe(false); + }); + + it("uses the collection's own non-en base locale", () => { + const french = openCollection( + { + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { french: { translationsFolder: 'translations', baseLocale: 'fr' } }, + }, + 'french', + { cwd: dir }, + ); + expect(() => openImportSession(french, { locale: 'fr' })).toThrow('Cannot import into base locale "fr"'); + expect(openImportSession(french, { locale: 'en' }).isBaseLocaleImport).toBe(false); + }); + }); + + describe('sessionResult', () => { + it('derives counts, transitions, collection and files from session state', () => { + const session = openImportSession(collection, { locale: 'es', strategy: 'verification' }); + session.changes.push( + { key: 'common.new', type: 'created', newValue: 'Nuevo', newStatus: 'verified' }, + { key: 'common.ok', type: 'updated', oldStatus: 'translated', newStatus: 'verified' }, + { key: 'common.skip', type: 'skipped', reason: 'missing' }, + { key: 'common.fail', type: 'failed', reason: 'bad' }, + ); + session.filesModified.add('/tmp/entries.json'); + session.warnings.push('warning'); + session.errors.push('error'); + + expect(sessionResult(session)).toMatchObject({ + strategy: 'verification', + locale: 'es', + collection: 'main', + resourcesImported: 1, + resourcesCreated: 1, + resourcesUpdated: 1, + resourcesSkipped: 1, + resourcesFailed: 1, + filesModified: ['/tmp/entries.json'], + warnings: ['warning'], + errors: ['error'], + dryRun: false, + statusTransitions: [ + { from: undefined, to: 'verified', count: 1 }, + { from: 'translated', to: 'verified', count: 1 }, + ], + }); + }); + + it('preserves dryRun true', () => { + expect(sessionResult(openImportSession(collection, { locale: 'es', dryRun: true })).dryRun).toBe(true); + }); + + it('defaults dryRun to false and strategy to translation-service', () => { + expect(sessionResult(openImportSession(collection, { locale: 'es' }))).toMatchObject({ + dryRun: false, + strategy: 'translation-service', + }); + }); + + it('converts filesModified from Set to array', () => { + const session = openImportSession(collection, { locale: 'es' }); + session.filesModified.add('/one'); + session.filesModified.add('/two'); + expect(sessionResult(session).filesModified).toEqual(['/one', '/two']); + }); + }); +}); diff --git a/libs/core/src/lib/import/import-session.ts b/libs/core/src/lib/import/import-session.ts new file mode 100644 index 00000000..9d9e3e08 --- /dev/null +++ b/libs/core/src/lib/import/import-session.ts @@ -0,0 +1,95 @@ +import type { Collection } from '../config/open-collection'; +import { getStrategyDefaults } from './import-common'; +import { calculateImportStatistics, calculateStatusTransitions } from './import-statistics'; +import type { + ICUAutoFix, + ICUAutoFixError, + ImportChange, + ImportResult, + ImportRunOptions, + ImportStrategy, +} from './types'; + +/** Import options with the strategy and its defaults filled in. */ +export type ResolvedImportOptions = ImportRunOptions & { + readonly strategy: ImportStrategy; + readonly createMissing: boolean; + readonly updateComments: boolean; + readonly updateTags: boolean; +}; + +/** + * The state of one import run. Every step of {@link importResources} reads the settings from + * here and appends its findings here, so no step passes accumulators to the next. + */ +export interface ImportSession { + readonly collection: Collection; + readonly options: ResolvedImportOptions; + /** The import writes base values (`source`), not translations. Only the `migration` strategy allows it. */ + readonly isBaseLocaleImport: boolean; + readonly changes: ImportChange[]; + readonly warnings: string[]; + readonly errors: string[]; + readonly filesModified: Set; + readonly icuAutoFixes: ICUAutoFix[]; + readonly icuAutoFixErrors: ICUAutoFixError[]; +} + +/** + * Starts an import run: applies the strategy defaults (`createMissing`, `updateComments`, + * `updateTags`; explicit options win) and refuses a base-locale import unless the strategy is + * `migration`. + * + * @throws {Error} The target locale is the collection's base locale and the strategy is not `migration`. + */ +export function openImportSession(collection: Collection, options: ImportRunOptions): ImportSession { + const strategy = options.strategy ?? 'translation-service'; + const defaults = getStrategyDefaults(strategy); + const isBaseLocaleImport = options.locale === collection.baseLocale; + + if (isBaseLocaleImport && strategy !== 'migration') { + throw new Error( + `Cannot import into base locale "${collection.baseLocale}" with strategy "${strategy}". ` + + `Only "migration" strategy supports base locale imports.`, + ); + } + + return { + collection, + options: { + ...options, + strategy, + createMissing: options.createMissing ?? defaults.createMissing, + updateComments: options.updateComments ?? defaults.updateComments, + updateTags: options.updateTags ?? defaults.updateTags, + }, + isBaseLocaleImport, + changes: [], + warnings: [], + errors: [], + filesModified: new Set(), + icuAutoFixes: [], + icuAutoFixErrors: [], + }; +} + +/** The result of a finished import run: counts and status transitions derived from its changes. */ +export function sessionResult(session: ImportSession): ImportResult { + const statistics = calculateImportStatistics(session.changes); + + return { + strategy: session.options.strategy, + locale: session.options.locale, + collection: session.collection.name, + resourcesImported: statistics.resourcesUpdated, + ...statistics, + changes: session.changes, + statusTransitions: calculateStatusTransitions(session.changes), + filesModified: Array.from(session.filesModified), + warnings: session.warnings, + errors: session.errors, + icuAutoFixes: session.icuAutoFixes, + icuAutoFixErrors: session.icuAutoFixErrors, + dryRun: session.options.dryRun ?? false, + }; +} diff --git a/libs/core/src/lib/import/import-summary.spec.ts b/libs/core/src/lib/import/import-summary.spec.ts index b97bca6b..222a2386 100644 --- a/libs/core/src/lib/import/import-summary.spec.ts +++ b/libs/core/src/lib/import/import-summary.spec.ts @@ -1,14 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { generateImportSummary } from './import-summary'; -import type { ImportResult, ImportOptions } from './types'; +import type { ImportResult, ImportSummaryOptions } from './types'; describe('import-summary', () => { describe('generateImportSummary', () => { it('should generate basic summary for successful import', () => { const result: ImportResult = { - format: 'json', strategy: 'translation-service', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 10, @@ -30,10 +28,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'translation-service', validateBase: true, dryRun: false, @@ -59,9 +57,7 @@ describe('import-summary', () => { it('should generate dry-run summary', () => { const result: ImportResult = { - format: 'xliff', strategy: 'verification', - sourceFile: '/test/import.xliff', locale: 'fr', collection: 'TestCollection', resourcesImported: 5, @@ -77,10 +73,10 @@ describe('import-summary', () => { dryRun: true, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.xliff', + format: 'xliff', locale: 'fr', - baseLocale: 'en', strategy: 'verification', dryRun: true, }; @@ -94,9 +90,7 @@ describe('import-summary', () => { it('should include warnings section when warnings exist', () => { const result: ImportResult = { - format: 'json', strategy: 'migration', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 8, @@ -112,10 +106,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'migration', }; @@ -128,9 +122,7 @@ describe('import-summary', () => { it('should include errors section when errors exist', () => { const result: ImportResult = { - format: 'json', strategy: 'translation-service', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 8, @@ -146,10 +138,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'translation-service', }; @@ -162,9 +154,7 @@ describe('import-summary', () => { it('should format detailed changes for created resources', () => { const result: ImportResult = { - format: 'json', strategy: 'migration', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 3, @@ -199,10 +189,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'migration', createMissing: true, }; @@ -217,9 +207,7 @@ describe('import-summary', () => { it('should format detailed changes for updated resources', () => { const result: ImportResult = { - format: 'json', strategy: 'translation-service', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 2, @@ -252,10 +240,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'translation-service', }; @@ -268,9 +256,7 @@ describe('import-summary', () => { it('should format detailed changes for skipped resources', () => { const result: ImportResult = { - format: 'json', strategy: 'translation-service', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 0, @@ -297,10 +283,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'translation-service', }; @@ -313,9 +299,7 @@ describe('import-summary', () => { it('should format detailed changes for failed resources', () => { const result: ImportResult = { - format: 'json', strategy: 'migration', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 0, @@ -342,10 +326,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'migration', }; @@ -362,9 +346,7 @@ describe('import-summary', () => { const files = Array.from({ length: 20 }, (_, i) => `/test/file${i + 1}.json`); const result: ImportResult = { - format: 'json', strategy: 'migration', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 100, @@ -380,10 +362,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'migration', }; @@ -407,9 +389,7 @@ describe('import-summary', () => { })); const result: ImportResult = { - format: 'json', strategy: 'migration', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 50, @@ -425,10 +405,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'migration', }; @@ -440,9 +420,7 @@ describe('import-summary', () => { it('should format flags section correctly', () => { const result: ImportResult = { - format: 'json', strategy: 'migration', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 10, @@ -458,10 +436,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'migration', updateComments: true, updateTags: true, @@ -483,9 +461,7 @@ describe('import-summary', () => { it('should handle status transitions with same from/to status', () => { const result: ImportResult = { - format: 'json', strategy: 'update', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 10, @@ -501,10 +477,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'update', }; @@ -515,9 +491,7 @@ describe('import-summary', () => { it('should handle created resources in status transitions', () => { const result: ImportResult = { - format: 'json', strategy: 'migration', - sourceFile: '/test/import.json', locale: 'es', collection: 'TestCollection', resourcesImported: 5, @@ -533,10 +507,10 @@ describe('import-summary', () => { dryRun: false, }; - const options: ImportOptions = { + const options: ImportSummaryOptions = { source: '/test/import.json', + format: 'json', locale: 'es', - baseLocale: 'en', strategy: 'migration', createMissing: true, }; diff --git a/libs/core/src/lib/import/import-summary.ts b/libs/core/src/lib/import/import-summary.ts index 87c75304..ac2ff0b9 100644 --- a/libs/core/src/lib/import/import-summary.ts +++ b/libs/core/src/lib/import/import-summary.ts @@ -1,5 +1,12 @@ -import type { ImportOptions, ImportResult, ImportChange, StatusTransition, ICUAutoFix, ICUAutoFixError } from './types'; -import { formatMarkdownList, capitalize, formatISODate } from '../summary-utils'; +import { capitalize, formatISODate, formatMarkdownList } from '../summary-utils'; +import type { + ICUAutoFix, + ICUAutoFixError, + ImportChange, + ImportResult, + ImportSummaryOptions, + StatusTransition, +} from './types'; /** * Generates a comprehensive markdown summary of the import operation. @@ -13,7 +20,7 @@ import { formatMarkdownList, capitalize, formatISODate } from '../summary-utils' * - Detailed changes by category (created, updated, skipped, failed) * * @param result - The import result containing all statistics and changes - * @param options - The import options used for the operation + * @param options - The import options used for the operation, with the file's format and path * @returns A markdown-formatted string ready to be written to a file * * @example @@ -22,7 +29,7 @@ import { formatMarkdownList, capitalize, formatISODate } from '../summary-utils' * fs.writeFileSync('import-summary.md', summary); * ``` */ -export function generateImportSummary(result: ImportResult, options: ImportOptions): string { +export function generateImportSummary(result: ImportResult, options: ImportSummaryOptions): string { const isDryRun = result.dryRun; const title = isDryRun ? '# Import Summary (DRY RUN)' : '# Import Summary'; const date = formatISODate(); @@ -30,8 +37,8 @@ export function generateImportSummary(result: ImportResult, options: ImportOptio let summary = `${title} **Date**: ${date} -**Format**: ${result.format.toUpperCase()} -**Source File**: ${result.sourceFile} +**Format**: ${options.format.toUpperCase()} +**Source File**: ${options.source} **Target Locale**: ${result.locale} **Collection**: ${result.collection || '(default)'} **Strategy**: ${result.strategy} @@ -126,7 +133,7 @@ ${formatDetailedChanges(result.changes, isDryRun)} * @returns Formatted string like "--update-comments=true, --create-missing=false" or "None" * @internal */ -function formatFlags(options: ImportOptions): string { +function formatFlags(options: ImportSummaryOptions): string { const flags: string[] = []; if (options.updateComments !== undefined) { diff --git a/libs/core/src/lib/import/import-workflow.spec.ts b/libs/core/src/lib/import/import-workflow.spec.ts deleted file mode 100644 index c1e87238..00000000 --- a/libs/core/src/lib/import/import-workflow.spec.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { setupImportWorkflow, buildImportResult } from './import-workflow'; -import type { ImportOptions } from './types'; -import * as loadConfigModule from '../config/load-config'; - -describe('import-workflow', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - describe('setupImportWorkflow', () => { - it('should apply strategy defaults for translation-service strategy', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'translation-service', - }; - - const config = setupImportWorkflow(options); - - expect(config.locale).toBe('es'); - expect(config.baseLocale).toBe('en'); - expect(config.mergedOptions.createMissing).toBe(false); - expect(config.mergedOptions.updateComments).toBe(false); - expect(config.mergedOptions.updateTags).toBe(false); - }); - - it('should apply strategy defaults for migration strategy', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'fr', - baseLocale: 'en', - strategy: 'migration', - }; - - const config = setupImportWorkflow(options); - - expect(config.mergedOptions.createMissing).toBe(true); - expect(config.mergedOptions.updateComments).toBe(true); - expect(config.mergedOptions.updateTags).toBe(true); - }); - - it('should apply strategy defaults for verification strategy', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'de', - baseLocale: 'en', - strategy: 'verification', - }; - - const config = setupImportWorkflow(options); - - expect(config.mergedOptions.createMissing).toBe(false); - expect(config.mergedOptions.updateComments).toBe(false); - expect(config.mergedOptions.updateTags).toBe(false); - }); - - it('should apply strategy defaults for update strategy', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'it', - baseLocale: 'en', - strategy: 'update', - }; - - const config = setupImportWorkflow(options); - - expect(config.mergedOptions.createMissing).toBe(false); - expect(config.mergedOptions.updateComments).toBe(false); - expect(config.mergedOptions.updateTags).toBe(false); - }); - - it('should preserve explicitly provided flags over strategy defaults', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'translation-service', - createMissing: true, // Override default (false) - updateComments: true, // Override default (false) - }; - - const config = setupImportWorkflow(options); - - expect(config.mergedOptions.createMissing).toBe(true); - expect(config.mergedOptions.updateComments).toBe(true); - expect(config.mergedOptions.updateTags).toBe(false); // Still uses default - }); - - it('should use default strategy when not specified', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - }; - - const config = setupImportWorkflow(options); - - expect(config.mergedOptions.strategy).toBe('translation-service'); - expect(config.mergedOptions.createMissing).toBe(false); - }); - - it('should throw error when importing into base locale with non-migration strategy', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'en', // Base locale - baseLocale: 'en', - strategy: 'translation-service', - }; - - expect(() => setupImportWorkflow(options)).toThrow( - 'Cannot import into base locale "en" with strategy "translation-service"', - ); - }); - - it('should allow importing into base locale with migration strategy', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'en', // Base locale - baseLocale: 'en', - strategy: 'migration', - }; - - const config = setupImportWorkflow(options); - - expect(config.locale).toBe('en'); - expect(config.baseLocale).toBe('en'); - expect(config.isBaseLocaleImport).toBe(true); - expect(config.mergedOptions.createMissing).toBe(true); - expect(config.mergedOptions.updateComments).toBe(true); - expect(config.mergedOptions.updateTags).toBe(true); - }); - - it('should set isBaseLocaleImport to false for non-base locale imports', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - }; - - const config = setupImportWorkflow(options); - - expect(config.locale).toBe('es'); - expect(config.baseLocale).toBe('en'); - expect(config.isBaseLocaleImport).toBe(false); - }); - - it('should return current working directory', () => { - const options: ImportOptions = { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - }; - - const config = setupImportWorkflow(options); - - expect(config.cwd).toBe(process.cwd()); - }); - - it('should use the caller-supplied baseLocale without reading the config file', () => { - const loadConfigSpy = vi.spyOn(loadConfigModule, 'loadConfig'); - const options: ImportOptions = { - source: 'test.json', - locale: 'fr', - baseLocale: 'fr', - strategy: 'migration', - }; - - const config = setupImportWorkflow(options); - - expect(config.baseLocale).toBe('fr'); - expect(config.isBaseLocaleImport).toBe(true); - expect(loadConfigSpy).not.toHaveBeenCalled(); - }); - - it('should treat a collection base locale other than en as the base', () => { - expect(() => - setupImportWorkflow({ - source: 'test.json', - locale: 'de', - baseLocale: 'de', - }), - ).toThrow('Cannot import into base locale "de" with strategy "translation-service"'); - }); - - it('should throw when baseLocale is missing or blank', () => { - const missing = { source: 'test.json', locale: 'es' } as ImportOptions; - expect(() => setupImportWorkflow(missing)).toThrow('ImportOptions.baseLocale is required'); - expect(() => setupImportWorkflow({ source: 'test.json', locale: 'es', baseLocale: ' ' })).toThrow( - 'ImportOptions.baseLocale is required', - ); - }); - }); - - describe('buildImportResult', () => { - it('should build complete import result for json format', () => { - const result = buildImportResult({ - format: 'json', - options: { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'translation-service', - collection: 'my-collection', - dryRun: false, - }, - statistics: { - resourcesCreated: 5, - resourcesUpdated: 10, - resourcesSkipped: 2, - resourcesFailed: 1, - }, - statusTransitions: [ - { from: undefined, to: 'translated', count: 5 }, - { from: 'new', to: 'translated', count: 10 }, - ], - changes: [ - { - key: 'test.key', - type: 'created', - oldValue: '', - newValue: 'New Value', - oldStatus: undefined, - newStatus: 'translated', - }, - ], - filesModified: new Set(['/path/to/file1.json', '/path/to/file2.json']), - warnings: ['Warning 1', 'Warning 2'], - errors: ['Error 1'], - }); - - expect(result.format).toBe('json'); - expect(result.strategy).toBe('translation-service'); - expect(result.sourceFile).toBe('test.json'); - expect(result.locale).toBe('es'); - expect(result.collection).toBe('my-collection'); - expect(result.resourcesImported).toBe(10); - expect(result.resourcesCreated).toBe(5); - expect(result.resourcesUpdated).toBe(10); - expect(result.resourcesSkipped).toBe(2); - expect(result.resourcesFailed).toBe(1); - expect(result.statusTransitions).toHaveLength(2); - expect(result.changes).toHaveLength(1); - expect(result.filesModified).toEqual(['/path/to/file1.json', '/path/to/file2.json']); - expect(result.warnings).toEqual(['Warning 1', 'Warning 2']); - expect(result.errors).toEqual(['Error 1']); - expect(result.dryRun).toBe(false); - }); - - it('should build complete import result for xliff format', () => { - const result = buildImportResult({ - format: 'xliff', - options: { - source: 'test.xlf', - locale: 'fr', - baseLocale: 'en', - strategy: 'verification', - }, - statistics: { - resourcesCreated: 0, - resourcesUpdated: 20, - resourcesSkipped: 0, - resourcesFailed: 0, - }, - statusTransitions: [{ from: 'translated', to: 'verified', count: 20 }], - changes: [], - filesModified: new Set(), - warnings: [], - errors: [], - }); - - expect(result.format).toBe('xliff'); - expect(result.strategy).toBe('verification'); - expect(result.sourceFile).toBe('test.xlf'); - expect(result.locale).toBe('fr'); - expect(result.collection).toBe('default'); // Default when not specified - expect(result.resourcesImported).toBe(20); - expect(result.resourcesCreated).toBe(0); - expect(result.resourcesUpdated).toBe(20); - expect(result.dryRun).toBe(false); // Default when not specified - }); - - it('should handle dry run mode', () => { - const result = buildImportResult({ - format: 'json', - options: { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - dryRun: true, - }, - statistics: { - resourcesCreated: 0, - resourcesUpdated: 0, - resourcesSkipped: 0, - resourcesFailed: 0, - }, - statusTransitions: [], - changes: [], - filesModified: new Set(), - warnings: [], - errors: [], - }); - - expect(result.dryRun).toBe(true); - }); - - it('should use default strategy when not specified', () => { - const result = buildImportResult({ - format: 'json', - options: { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - }, - statistics: { - resourcesCreated: 0, - resourcesUpdated: 0, - resourcesSkipped: 0, - resourcesFailed: 0, - }, - statusTransitions: [], - changes: [], - filesModified: new Set(), - warnings: [], - errors: [], - }); - - expect(result.strategy).toBe('translation-service'); - }); - - it('should convert Set to Array for filesModified', () => { - const filesSet = new Set(['/path/to/file1.json', '/path/to/file2.json', '/path/to/file3.json']); - - const result = buildImportResult({ - format: 'json', - options: { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - }, - statistics: { - resourcesCreated: 0, - resourcesUpdated: 0, - resourcesSkipped: 0, - resourcesFailed: 0, - }, - statusTransitions: [], - changes: [], - filesModified: filesSet, - warnings: [], - errors: [], - }); - - expect(Array.isArray(result.filesModified)).toBe(true); - expect(result.filesModified).toHaveLength(3); - expect(result.filesModified).toContain('/path/to/file1.json'); - expect(result.filesModified).toContain('/path/to/file2.json'); - expect(result.filesModified).toContain('/path/to/file3.json'); - }); - }); -}); diff --git a/libs/core/src/lib/import/import-workflow.ts b/libs/core/src/lib/import/import-workflow.ts deleted file mode 100644 index 761fdf40..00000000 --- a/libs/core/src/lib/import/import-workflow.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { ImportOptions, ImportResult, StatusTransition, ImportChange, ICUAutoFix, ICUAutoFixError } from './types'; -import { getStrategyDefaults } from './import-common'; - -/** - * Configuration returned after setting up an import operation. - * - * This interface contains the validated and merged options ready for use - * in the import process, along with derived values like base locale. - */ -export interface ImportWorkflowConfig { - /** Current working directory */ - cwd: string; - /** Base locale (source language) */ - baseLocale: string; - /** Target locale for import */ - locale: string; - /** Merged options with strategy defaults applied */ - mergedOptions: ImportOptions; - /** Whether this is a base locale import (migration strategy only) */ - isBaseLocaleImport: boolean; -} - -/** - * Sets up and validates the import workflow configuration. - * - * This function performs common setup steps required by all import formats: - * 1. Applies strategy-specific defaults for flags (createMissing, updateComments, updateTags) - * 2. Takes the base locale from the options (the caller resolves it from the collection) - * 3. Validates that the target locale is not the base locale (except for migration strategy) - * 4. Returns merged configuration ready for use - * - * Strategy defaults applied: - * - `translation-service`: No creation, no comment/tag updates - * - `verification`: No creation, no comment/tag updates - * - `migration`: Allows creation, updates comments and tags (can import into base locale) - * - `update`: No creation by default, no comment/tag updates - * - * @param options - Raw import options from user or CLI - * @returns Validated configuration with merged options and derived values - * - * @throws {Error} If `baseLocale` is missing or blank - * @throws {Error} If attempting to import into the base locale with non-migration strategy - * - * @example - * ```typescript - * const config = setupImportWorkflow({ - * source: 'translations-es.json', - * locale: 'es', - * baseLocale: 'en', - * strategy: 'translation-service' - * }); - * - * // Returns: - * // { - * // cwd: '/project', - * // baseLocale: 'en', - * // locale: 'es', - * // mergedOptions: { - * // ...options, - * // createMissing: false, - * // updateComments: false, - * // updateTags: false - * // }, - * // isBaseLocaleImport: false - * // } - * ``` - */ -export function setupImportWorkflow(options: ImportOptions): ImportWorkflowConfig { - const { locale, strategy = 'translation-service' } = options; - - // Apply strategy defaults for flags if not explicitly provided - const strategyDefaults = getStrategyDefaults(strategy); - const mergedOptions: ImportOptions = { - ...options, - strategy, - updateComments: options.updateComments ?? strategyDefaults.updateComments, - updateTags: options.updateTags ?? strategyDefaults.updateTags, - createMissing: options.createMissing ?? strategyDefaults.createMissing, - }; - - const cwd = process.cwd(); - const { baseLocale } = options; - // Required by the type, but JS callers and loosely-typed adapters can still omit it. - if (typeof baseLocale !== 'string' || baseLocale.trim() === '') { - throw new Error('ImportOptions.baseLocale is required'); - } - - const isBaseLocaleImport = locale === baseLocale; - - if (isBaseLocaleImport && strategy !== 'migration') { - throw new Error( - `Cannot import into base locale "${baseLocale}" with strategy "${strategy}". ` + - `Only "migration" strategy supports base locale imports.`, - ); - } - - return { - cwd, - baseLocale, - locale, - mergedOptions, - isBaseLocaleImport, - }; -} - -/** - * Builds the final import result object with statistics and metadata. - * - * This function creates the standardized ImportResult object that is returned - * by all import operations. It consolidates statistics, status transitions, - * changes, warnings, and errors into a single comprehensive result. - * - * @param params - Parameters for building the result - * @param params.format - Import format used ('json' or 'xliff') - * @param params.options - Original import options - * @param params.statistics - Computed import statistics (created, updated, skipped, failed counts) - * @param params.statusTransitions - Array of status transitions that occurred - * @param params.changes - Detailed list of all changes made - * @param params.filesModified - Set of file paths that were modified - * @param params.warnings - Non-fatal warning messages - * @param params.errors - Error messages for failed resources - * @returns Complete ImportResult object ready to return to caller - * - * @example - * ```typescript - * const result = buildImportResult({ - * format: 'json', - * options: { source: 'file.json', locale: 'es', baseLocale: 'en', strategy: 'translation-service' }, - * statistics: { resourcesCreated: 0, resourcesUpdated: 10, resourcesSkipped: 2, resourcesFailed: 0 }, - * statusTransitions: [{ from: 'new', to: 'translated', count: 10 }], - * changes: [...], - * filesModified: new Set(['/path/to/resource_entries.json', '/path/to/tracker_meta.json']), - * warnings: ['Duplicate key warning'], - * errors: [] - * }); - * ``` - */ -export function buildImportResult(params: { - format: 'json' | 'xliff'; - options: ImportOptions; - statistics: { - resourcesCreated: number; - resourcesUpdated: number; - resourcesSkipped: number; - resourcesFailed: number; - }; - statusTransitions: StatusTransition[]; - changes: ImportChange[]; - filesModified: Set; - warnings: string[]; - errors: string[]; - icuAutoFixes?: ICUAutoFix[]; - icuAutoFixErrors?: ICUAutoFixError[]; -}): ImportResult { - const { - format, - options, - statistics, - statusTransitions, - changes, - filesModified, - warnings, - errors, - icuAutoFixes = [], - icuAutoFixErrors = [], - } = params; - - return { - format, - strategy: options.strategy || 'translation-service', - sourceFile: options.source, - locale: options.locale, - collection: options.collection || 'default', - resourcesImported: statistics.resourcesUpdated, - resourcesCreated: statistics.resourcesCreated, - resourcesUpdated: statistics.resourcesUpdated, - resourcesSkipped: statistics.resourcesSkipped, - resourcesFailed: statistics.resourcesFailed, - changes, - statusTransitions, - filesModified: Array.from(filesModified), - warnings, - errors, - icuAutoFixes, - icuAutoFixErrors, - dryRun: options.dryRun || false, - }; -} diff --git a/libs/core/src/lib/import/index.ts b/libs/core/src/lib/import/index.ts index 4eb02b18..b8bb901a 100644 --- a/libs/core/src/lib/import/index.ts +++ b/libs/core/src/lib/import/index.ts @@ -1,66 +1,21 @@ -// Export types +// The import module: format adapters turn a file into resources; importResources applies them. + +export { detectImportFormat } from './import-common'; +export { importResources } from './import-resources'; +export { generateImportSummary } from './import-summary'; +export { parseJsonImport } from './parse-json-import'; +export { parseXliffImport } from './parse-xliff-import'; export type { + ICUAutoFix, + ICUAutoFixError, + ImportChange, + ImportChangeType, + ImportedResource, ImportFormat, + ImportParseOptions, + ImportResult, + ImportRunOptions, ImportStrategy, - ImportOptions, - ImportedResource, - ImportChangeType, - ImportChange, + ImportSummaryOptions, StatusTransition, - ImportResult, - ICUAutoFix, - ICUAutoFixError, } from './types'; - -// Export import format detection and strategy defaults -export { detectImportFormat, getStrategyDefaults } from './import-common'; - -// Export import functions -export { - importFromJson, - detectJsonStructure, - extractFromFlat, - extractFromHierarchical, -} from './import-from-json'; - -export { importFromXliff, extractFromXliff } from './import-from-xliff'; - -// Export reference resolution utilities -export { - hasReferences, - extractReferences, - resolveReferences, - resolveAllReferences, -} from './reference-resolver'; - -// Export summary generation -export { generateImportSummary } from './import-summary'; - -// Export resource grouping utilities -export type { ResourceGroup } from './resource-grouping'; -export { groupResourcesByFolder } from './resource-grouping'; - -// Export resource processing utilities -export { processResourceGroup } from './process-resource-group'; - -// Export statistics calculation utilities -export { - calculateImportStatistics, - calculateStatusTransitions, -} from './import-statistics'; - -// Export validation utilities -export type { ValidationConfig, ValidationResult } from './import-validation'; -export { validateImportResources } from './import-validation'; - -// Export workflow utilities -export type { ImportWorkflowConfig } from './import-workflow'; -export { setupImportWorkflow, buildImportResult } from './import-workflow'; - -// Export Transloco syntax normalization -export { normalizeTranslocoSyntax } from './normalize-transloco-syntax'; - -export { - applyICUAutoFixToResource, - applyICUAutoFixToResources, -} from './apply-icu-auto-fix'; diff --git a/libs/core/src/lib/import/load-base-locale-values.ts b/libs/core/src/lib/import/load-base-locale-values.ts index 9875b641..de863a7c 100644 --- a/libs/core/src/lib/import/load-base-locale-values.ts +++ b/libs/core/src/lib/import/load-base-locale-values.ts @@ -1,6 +1,6 @@ -import type { ImportedResource } from './types'; import { resolveResourcePaths } from '../resource/resource-file-paths'; import { openResourceFolder } from '../resource/resource-folder'; +import type { ImportedResource } from './types'; /** * Loads base locale values for all imported resources from existing resource files. @@ -9,22 +9,17 @@ import { openResourceFolder } from '../resource/resource-folder'; * for ICU auto-fixing during import operations. * * @param resources - Array of imported resources to load base values for - * @param translationsFolder - Path to the translations directory - * @param cwd - Current working directory for resolving absolute paths + * @param translationsFolder - Absolute path of the translations folder * @returns Map of resource keys to their base locale values */ -export function loadBaseLocaleValues( - resources: ImportedResource[], - translationsFolder: string, - cwd: string, -): Map { +export function loadBaseLocaleValues(resources: ImportedResource[], translationsFolder: string): Map { const baseValues = new Map(); // Group by folder to minimize file reads const folderToKeys = new Map>(); for (const resource of resources) { - const { folderPath, entryKey } = resolveResourcePaths({ key: resource.key, translationsFolder, cwd }); + const { folderPath, entryKey } = resolveResourcePaths({ key: resource.key, translationsFolder }); let folderKeys = folderToKeys.get(folderPath); if (!folderKeys) { diff --git a/libs/core/src/lib/import/parse-json-import.spec.ts b/libs/core/src/lib/import/parse-json-import.spec.ts new file mode 100644 index 00000000..b2dc8afd --- /dev/null +++ b/libs/core/src/lib/import/parse-json-import.spec.ts @@ -0,0 +1,158 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { detectJsonStructure, extractFromFlat, extractFromHierarchical, parseJsonImport } from './parse-json-import'; + +describe('parse JSON import', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'lingo-parse-json-')); + }); + + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + describe('detectJsonStructure', () => { + it('should detect flat structure when all keys contain dots', () => { + expect(detectJsonStructure({ 'common.ok': 'OK', 'dashboard.title': 'Dashboard' })).toBe('flat'); + }); + it('should detect hierarchical structure when keys do not contain dots', () => { + expect(detectJsonStructure({ common: { ok: 'OK' }, dashboard: { title: 'Dashboard' } })).toBe('hierarchical'); + }); + it('should detect hierarchical structure for mixed keys', () => { + expect(detectJsonStructure({ 'common.ok': 'OK', dashboard: { title: 'Dashboard' } })).toBe('hierarchical'); + }); + it('should handle empty object as hierarchical', () => expect(detectJsonStructure({})).toBe('hierarchical')); + it('should detect flat structure with single dotted key', () => { + expect(detectJsonStructure({ 'common.title': 'Title' })).toBe('flat'); + }); + }); + + describe('extractFromFlat', () => { + it('should extract resources from flat structure', () => { + expect(extractFromFlat({ 'common.ok': 'OK', 'common.cancel': 'Cancel' })).toEqual([ + { key: 'common.ok', value: 'OK' }, + { key: 'common.cancel', value: 'Cancel' }, + ]); + }); + it('should skip non-string values in flat structure', () => { + expect(extractFromFlat({ 'common.title': 'Title', 'common.count': 42, 'common.items': ['a'] })).toEqual([ + { key: 'common.title', value: 'Title' }, + ]); + }); + it('should handle empty object', () => expect(extractFromFlat({})).toEqual([])); + it('should extract rich format objects from flat structure', () => { + expect( + extractFromFlat({ + 'common.title': { + value: 'Título', + comment: 'Page title', + baseValue: 'Title', + status: 'verified', + tags: ['ui', 'common'], + }, + }), + ).toEqual([ + { + key: 'common.title', + value: 'Título', + comment: 'Page title', + baseValue: 'Title', + status: 'verified', + tags: ['ui', 'common'], + }, + ]); + }); + it('should filter out non-string tags', () => { + expect(extractFromFlat({ 'common.title': { value: 'Título', tags: ['ui', 123, null] } })[0]?.tags).toEqual([ + 'ui', + ]); + }); + it('should handle mix of simple and rich formats', () => { + expect(extractFromFlat({ 'common.title': 'Título', 'common.description': { value: 'Descripción' } })).toEqual([ + { key: 'common.title', value: 'Título' }, + { key: 'common.description', value: 'Descripción' }, + ]); + }); + }); + + describe('extractFromHierarchical', () => { + it('should extract resources from hierarchical structure', () => { + expect(extractFromHierarchical({ common: { buttons: { ok: 'OK', cancel: 'Cancel' }, title: 'Common' } })).toEqual( + [ + { key: 'common.buttons.ok', value: 'OK' }, + { key: 'common.buttons.cancel', value: 'Cancel' }, + { key: 'common.title', value: 'Common' }, + ], + ); + }); + it('should handle deeply nested structures', () => { + expect(extractFromHierarchical({ one: { two: { three: { deepValue: 'Deep' } } } })).toEqual([ + { key: 'one.two.three.deepValue', value: 'Deep' }, + ]); + }); + it('should skip non-string leaf values', () => { + expect(extractFromHierarchical({ common: { title: 'Title', count: 42, items: ['a'], empty: null } })).toEqual([ + { key: 'common.title', value: 'Title' }, + ]); + }); + it('should handle empty object', () => expect(extractFromHierarchical({})).toEqual([])); + it('should handle single level structure', () => { + expect(extractFromHierarchical({ title: 'Title', description: 'Description' })).toEqual([ + { key: 'title', value: 'Title' }, + { key: 'description', value: 'Description' }, + ]); + }); + it('should extract rich format objects from hierarchical structure', () => { + expect( + extractFromHierarchical({ common: { title: { value: 'Título', comment: 'Page title', tags: ['ui'] } } }), + ).toEqual([{ key: 'common.title', value: 'Título', comment: 'Page title', tags: ['ui'] }]); + }); + it('should handle mix of simple and rich formats in hierarchy', () => { + expect( + extractFromHierarchical({ common: { title: 'Título', ok: { value: 'Aceptar', comment: 'OK button' } } }), + ).toEqual([ + { key: 'common.title', value: 'Título' }, + { key: 'common.ok', value: 'Aceptar', comment: 'OK button' }, + ]); + }); + }); + + describe('parseJsonImport', () => { + it('throws when the source file is missing', () => { + expect(() => parseJsonImport(join(dir, 'missing.json'))).toThrow( + `Source file not found: ${join(dir, 'missing.json')}`, + ); + }); + it('throws when JSON is malformed', () => { + const path = join(dir, 'bad.json'); + writeFileSync(path, '{bad'); + expect(() => parseJsonImport(path)).toThrow('Failed to parse JSON file:'); + }); + it('parses a flat file and reports progress', () => { + const path = join(dir, 'flat.json'); + writeFileSync(path, JSON.stringify({ 'common.ok': 'Aceptar', 'common.cancel': 'Cancelar' })); + const onProgress = vi.fn(); + expect(parseJsonImport(path, { onProgress })).toEqual([ + { key: 'common.ok', value: 'Aceptar' }, + { key: 'common.cancel', value: 'Cancelar' }, + ]); + expect(onProgress.mock.calls.map(([message]) => message)).toEqual([ + `Reading JSON file: ${path}`, + 'Detected flat JSON structure', + 'Extracted 2 resources from JSON', + ]); + }); + it('parses a hierarchical file', () => { + const path = join(dir, 'tree.json'); + writeFileSync(path, JSON.stringify({ common: { ok: 'Aceptar' } })); + expect(parseJsonImport(path)).toEqual([{ key: 'common.ok', value: 'Aceptar' }]); + }); + it('parses rich objects', () => { + const path = join(dir, 'rich.json'); + writeFileSync(path, JSON.stringify({ 'common.ok': { value: 'Aceptar', baseValue: 'OK', tags: ['ui'] } })); + expect(parseJsonImport(path)).toEqual([{ key: 'common.ok', value: 'Aceptar', baseValue: 'OK', tags: ['ui'] }]); + }); + }); +}); diff --git a/libs/core/src/lib/import/parse-json-import.ts b/libs/core/src/lib/import/parse-json-import.ts new file mode 100644 index 00000000..b2851512 --- /dev/null +++ b/libs/core/src/lib/import/parse-json-import.ts @@ -0,0 +1,252 @@ +import { existsSync, readFileSync } from 'node:fs'; +import type { TranslationStatus } from '@simoncodes-ca/domain'; +import type { ImportedResource, ImportParseOptions } from './types'; + +/** + * Detects whether the JSON structure is flat or hierarchical. + * + * A flat structure uses dot-delimited keys at the root level (e.g., `{"common.ok": "OK"}`). + * A hierarchical structure uses nested objects (e.g., `{common: {ok: "OK"}}`). + * + * Detection logic: If all root-level keys contain dots, the structure is considered flat. + * Otherwise, it's hierarchical. + * + * @param data - The parsed JSON object to analyze + * @returns 'flat' if all root keys contain dots, 'hierarchical' otherwise + * + * @example + * ```typescript + * // Flat structure + * detectJsonStructure({"common.ok": "OK", "common.cancel": "Cancel"}); // 'flat' + * + * // Hierarchical structure + * detectJsonStructure({common: {ok: "OK", cancel: "Cancel"}}); // 'hierarchical' + * + * // Mixed (treated as hierarchical) + * detectJsonStructure({common: {ok: "OK"}, "other.key": "Value"}); // 'hierarchical' + * ``` + */ +export function detectJsonStructure(data: Record): 'flat' | 'hierarchical' { + const keys = Object.keys(data); + + // If all keys at root level contain dots, it's flat + const allKeysHaveDots = keys.every((key) => key.includes('.')); + + if (allKeysHaveDots && keys.length > 0) { + return 'flat'; + } + + return 'hierarchical'; +} + +/** + * Checks if a value is a rich format object (has a 'value' property) + */ +function isRichObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + 'value' in value && + typeof (value as Record)['value'] === 'string' + ); +} + +/** + * Extracts resource from a rich format object + */ +function extractRichResource(key: string, obj: Record): ImportedResource { + const resource: ImportedResource = { + key, + value: obj['value'] as string, + }; + + if (obj['comment'] && typeof obj['comment'] === 'string') { + resource.comment = obj['comment']; + } + + if (obj['baseValue'] && typeof obj['baseValue'] === 'string') { + resource.baseValue = obj['baseValue']; + } + + if (obj['status'] && typeof obj['status'] === 'string') { + resource.status = obj['status'] as TranslationStatus; + } + + if (Array.isArray(obj['tags'])) { + resource.tags = obj['tags'].filter((tag) => typeof tag === 'string') as string[]; + } + + return resource; +} + +/** + * Extracts translation resources from a flat JSON structure. + * + * Flat structures use dot-delimited keys at the root level. Each key maps to either: + * - A simple string value (e.g., `"common.ok": "OK"`) + * - A rich object with additional metadata (e.g., `"common.ok": {value: "OK", comment: "Button text"}`) + * + * Rich format objects must have a `value` property and can optionally include: + * - `comment` - Developer notes or context + * - `baseValue` - Source locale reference value + * - `status` - Translation status (new, translated, verified, stale) + * - `tags` - Array of categorization tags + * + * Non-string and non-rich-object values are silently skipped. + * + * @param data - The flat JSON object to extract resources from + * @returns Array of imported resources with keys and values + * + * @example + * ```typescript + * // Simple flat format + * const simple = { + * "common.ok": "OK", + * "common.cancel": "Cancel" + * }; + * extractFromFlat(simple); + * // Returns: [{key: "common.ok", value: "OK"}, {key: "common.cancel", value: "Cancel"}] + * + * // Rich format with metadata + * const rich = { + * "common.submit": { + * value: "Submit", + * comment: "Form submission button", + * baseValue: "Submit", + * status: "translated", + * tags: ["forms", "buttons"] + * } + * }; + * extractFromFlat(rich); + * // Returns: [{key: "common.submit", value: "Submit", comment: "Form...", ...}] + * ``` + */ +export function extractFromFlat(data: Record): ImportedResource[] { + const resources: ImportedResource[] = []; + + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string') { + // Simple string value + resources.push({ + key, + value, + }); + } else if (isRichObject(value)) { + // Rich format object + resources.push(extractRichResource(key, value)); + } + // Skip other types + } + + return resources; +} + +/** + * Recursively extracts translation resources from a hierarchical JSON structure. + * + * Hierarchical structures use nested objects to organize translations by namespace. + * The function traverses the object tree and constructs dot-delimited keys from the path. + * + * Leaf nodes can be either: + * - Simple string values (e.g., `{common: {ok: "OK"}}` → key: "common.ok") + * - Rich objects with metadata (e.g., `{common: {ok: {value: "OK", comment: "..."}}}`) + * + * Non-leaf objects are recursed into. Arrays, null values, and other types are skipped. + * + * @param data - The hierarchical JSON object to extract resources from + * @param prefix - Internal parameter for recursion; the current key path (default: '') + * @returns Array of imported resources with fully-qualified dot-delimited keys + * + * @example + * ```typescript + * // Simple hierarchical format + * const simple = { + * common: { + * buttons: { + * ok: "OK", + * cancel: "Cancel" + * } + * } + * }; + * extractFromHierarchical(simple); + * // Returns: [ + * // {key: "common.buttons.ok", value: "OK"}, + * // {key: "common.buttons.cancel", value: "Cancel"} + * // ] + * + * // Mixed with rich format + * const mixed = { + * common: { + * ok: "OK", + * submit: { + * value: "Submit", + * comment: "Form submission", + * tags: ["forms"] + * } + * } + * }; + * extractFromHierarchical(mixed); + * // Returns: [ + * // {key: "common.ok", value: "OK"}, + * // {key: "common.submit", value: "Submit", comment: "Form submission", tags: ["forms"]} + * // ] + * ``` + */ +export function extractFromHierarchical(data: Record, prefix = ''): ImportedResource[] { + const resources: ImportedResource[] = []; + + for (const [key, value] of Object.entries(data)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (typeof value === 'string') { + // Simple string value - leaf node + resources.push({ + key: fullKey, + value, + }); + } else if (isRichObject(value)) { + // Rich format object - leaf node + resources.push(extractRichResource(fullKey, value)); + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // Nested object - recurse + resources.push(...extractFromHierarchical(value as Record, fullKey)); + } + // Skip other types (arrays, null, etc.) + } + + return resources; +} + +/** + * Reads a JSON import file and returns its resources: the JSON format adapter for {@link importResources}. + * + * Accepts a flat (`{"common.ok": "OK"}`) or hierarchical (`{common: {ok: "OK"}}`) structure, + * detected with {@link detectJsonStructure}. A leaf is a string or a rich object with `value` + * and optional `baseValue`, `comment`, `status`, and `tags`. + * + * @param filePath - Path of the JSON file (relative paths resolve against the working directory) + * @throws {Error} The file does not exist, or it is not valid JSON. + */ +export function parseJsonImport(filePath: string, options: ImportParseOptions = {}): ImportedResource[] { + const { onProgress } = options; + if (!existsSync(filePath)) { + throw new Error(`Source file not found: ${filePath}`); + } + + onProgress?.(`Reading JSON file: ${filePath}`); + + let jsonData: Record; + try { + jsonData = JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse JSON file: ${error}`); + } + + const structure = detectJsonStructure(jsonData); + onProgress?.(`Detected ${structure} JSON structure`); + + const resources = structure === 'flat' ? extractFromFlat(jsonData) : extractFromHierarchical(jsonData); + onProgress?.(`Extracted ${resources.length} resources from JSON`); + return resources; +} diff --git a/libs/core/src/lib/import/parse-xliff-import.spec.ts b/libs/core/src/lib/import/parse-xliff-import.spec.ts new file mode 100644 index 00000000..603ba743 --- /dev/null +++ b/libs/core/src/lib/import/parse-xliff-import.spec.ts @@ -0,0 +1,83 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { extractFromXliff, parseXliffImport } from './parse-xliff-import'; + +const document = (body: string): string => ` + + ${body} +`; + +describe('parse XLIFF import', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'lingo-parse-xliff-')); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + describe('extractFromXliff', () => { + it('should extract resources from valid XLIFF 1.2', async () => { + const resources = await extractFromXliff( + document( + 'OKAceptar' + + 'CancelCancelarCancel button', + ), + ); + expect(resources).toEqual([ + { key: 'common.ok', value: 'Aceptar', baseValue: 'OK' }, + { key: 'common.cancel', value: 'Cancelar', baseValue: 'Cancel', comment: 'Cancel button' }, + ]); + }); + it('should skip trans-units with empty targets', async () => { + const resources = await extractFromXliff( + document( + 'TitleTítulo' + + 'Empty' + + 'Missing', + ), + ); + expect(resources).toEqual([{ key: 'common.title', value: 'Título', baseValue: 'Title' }]); + }); + it('should handle XLIFF with notes', async () => { + const resources = await extractFromXliff( + document( + 'WelcomeBienvenueGreeting', + ), + ); + expect(resources[0]?.comment).toBe('Greeting'); + }); + it('should throw error for invalid XLIFF', async () => { + await expect(extractFromXliff('not XML')).rejects.toThrow('Failed to parse XLIFF content'); + }); + }); + + describe('parseXliffImport', () => { + it('throws when the source file is missing', async () => { + const path = join(dir, 'missing.xliff'); + await expect(parseXliffImport(path)).rejects.toThrow(`Source file not found: ${path}`); + }); + it('throws for invalid XLIFF content', async () => { + const path = join(dir, 'bad.xliff'); + writeFileSync(path, 'not XML'); + await expect(parseXliffImport(path)).rejects.toThrow('Failed to parse XLIFF content'); + }); + it('reads a real file and reports progress', async () => { + const path = join(dir, 'messages.xliff'); + writeFileSync( + path, + document('OKAceptar'), + ); + const onProgress = vi.fn(); + await expect(parseXliffImport(path, { onProgress })).resolves.toEqual([ + { key: 'common.ok', value: 'Aceptar', baseValue: 'OK' }, + ]); + expect(onProgress.mock.calls.map(([message]) => message)).toEqual([ + `Reading XLIFF file: ${path}`, + 'Parsing XLIFF and extracting trans-units', + 'Extracted 1 resources from XLIFF', + ]); + }); + }); +}); diff --git a/libs/core/src/lib/import/parse-xliff-import.ts b/libs/core/src/lib/import/parse-xliff-import.ts new file mode 100644 index 00000000..0892214b --- /dev/null +++ b/libs/core/src/lib/import/parse-xliff-import.ts @@ -0,0 +1,129 @@ +import { existsSync, readFileSync } from 'node:fs'; +import * as xliff from 'xliff'; +import type { ImportedResource, ImportParseOptions } from './types'; + +/** + * Extracts translation resources from XLIFF 1.2 format content. + * + * XLIFF (XML Localization Interchange File Format) is an industry standard format + * used by professional translation services. This function parses XLIFF 1.2 files + * and extracts translation units (trans-units) with their source and target values. + * + * Each trans-unit is converted to an ImportedResource with: + * - `key`: The trans-unit id (translation key) + * - `value`: The target translation + * - `baseValue`: The source reference value + * - `comment`: Developer notes from elements + * + * Trans-units with empty or missing target values are automatically skipped. + * + * @param xliffContent - The raw XLIFF 1.2 XML content as a string + * @returns Array of imported resources extracted from all trans-units in the XLIFF file + * + * @throws {Error} If XLIFF content cannot be parsed or is malformed + * + * @example + * ```typescript + * const xliffXml = ` + * + * + * + * + * OK + * Aceptar + * Button text + * + * + * + * `; + * + * const resources = await extractFromXliff(xliffXml); + * // Returns: [{ + * // key: "common.ok", + * // value: "Aceptar", + * // baseValue: "OK", + * // comment: "Button text" + * // }] + * ``` + */ +export async function extractFromXliff(xliffContent: string): Promise { + const resources: ImportedResource[] = []; + + try { + // Parse XLIFF content using callback-based API + type ParsedXliff = { + resources: Record>; + }; + const parsed = await new Promise((resolve, reject) => { + xliff.xliff12ToJs(xliffContent, (err: Error | null, res: unknown) => { + if (err) reject(err); + else resolve(res as ParsedXliff); + }); + }); + + // Extract resources from each file + for (const fileData of Object.values(parsed.resources)) { + const transUnits = fileData as Record; + + for (const [key, unit] of Object.entries(transUnits)) { + // Skip if no target or target is empty + if (!unit.target || unit.target.trim() === '') { + continue; + } + + const resource: ImportedResource = { + key, + value: unit.target, + }; + + // Add base value from source + if (unit.source) { + resource.baseValue = unit.source; + } + + // Add comment from note + if (unit.note) { + resource.comment = unit.note; + } + + resources.push(resource); + } + } + } catch (error) { + throw new Error(`Failed to parse XLIFF content: ${error}`); + } + + return resources; +} + +/** + * Reads an XLIFF 1.2 import file and returns its resources: the XLIFF format adapter for + * {@link importResources}. Each trans-unit becomes a resource (`id` → key, `` → value, + * `` → baseValue, `` → comment); trans-units without a target are skipped. + * + * @param filePath - Path of the XLIFF file (relative paths resolve against the working directory) + * @throws {Error} The file does not exist or cannot be read, or it is not valid XLIFF. + */ +export async function parseXliffImport( + filePath: string, + options: ImportParseOptions = {}, +): Promise { + const { onProgress } = options; + if (!existsSync(filePath)) { + throw new Error(`Source file not found: ${filePath}`); + } + + onProgress?.(`Reading XLIFF file: ${filePath}`); + + let xliffContent: string; + try { + xliffContent = readFileSync(filePath, 'utf8'); + } catch (error) { + throw new Error(`Failed to read XLIFF file: ${error}`); + } + + onProgress?.('Parsing XLIFF and extracting trans-units'); + const resources = await extractFromXliff(xliffContent); + onProgress?.(`Extracted ${resources.length} resources from XLIFF`); + return resources; +} diff --git a/libs/core/src/lib/import/process-resource-group.spec.ts b/libs/core/src/lib/import/process-resource-group.spec.ts deleted file mode 100644 index 3fcea13c..00000000 --- a/libs/core/src/lib/import/process-resource-group.spec.ts +++ /dev/null @@ -1,1653 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; -import { join } from 'path'; -import { processResourceGroup } from './process-resource-group'; -import { calculateChecksum } from '../../resource/checksum'; -import type { ResourceGroup } from './resource-grouping'; - -describe('process-resource-group', () => { - const testDir = join(process.cwd(), 'test-temp-process-group'); - const folderPath = join(testDir, 'common', 'buttons'); - const entryResourcePath = join(folderPath, 'resource_entries.json'); - const entryMetaPath = join(folderPath, 'tracker_meta.json'); - - beforeEach(() => { - // Create test directory - if (!existsSync(testDir)) { - mkdirSync(testDir, { recursive: true }); - } - }); - - afterEach(() => { - // Clean up test directory - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); - } - }); - - describe('creating new resources', () => { - it('should create new resource when createMissing is true and baseValue is provided', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - baseValue: 'OK', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', createMissing: true }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('created'); - expect(changes[0].key).toBe('common.buttons.ok'); - expect(changes[0].newValue).toBe('Aceptar'); - expect(changes[0].newStatus).toBe('translated'); - - // Verify files were created - expect(existsSync(entryResourcePath)).toBe(true); - expect(existsSync(entryMetaPath)).toBe(true); - expect(filesModified.has(entryResourcePath)).toBe(true); - expect(filesModified.has(entryMetaPath)).toBe(true); - }); - - it('should fail to create resource when createMissing is false', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', createMissing: false }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('skipped'); - expect(changes[0].reason).toContain('strategy does not allow creation'); - expect(filesModified.size).toBe(0); - }); - - it('should fail to create resource when baseValue is missing', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - // No baseValue - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', createMissing: true }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('failed'); - expect(changes[0].reason).toContain('base value not provided'); - }); - }); - - describe('updating existing resources', () => { - beforeEach(() => { - // Create existing resource files - mkdirSync(folderPath, { recursive: true }); - writeFileSync( - entryResourcePath, - JSON.stringify({ - ok: { source: 'OK', es: 'Bien' }, - cancel: { source: 'Cancel', es: 'Cancelar' }, - }), - ); - writeFileSync( - entryMetaPath, - JSON.stringify({ - ok: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - cancel: { - en: { checksum: 'cancel-base' }, - es: { - checksum: 'cancel-checksum', - baseChecksum: 'cancel-base', - status: 'verified', - }, - }, - }), - ); - }); - - it('should update resource value when it changes', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', // Changed from 'Bien' - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'translation-service' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('value-changed'); - expect(changes[0].oldValue).toBe('Bien'); - expect(changes[0].newValue).toBe('Aceptar'); - expect(changes[0].newStatus).toBe('translated'); - - // Verify file was updated - const entries = JSON.parse(readFileSync(entryResourcePath, 'utf8')); - expect(entries.ok.es).toBe('Aceptar'); - }); - - it('should preserve existing status when value does not change', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Bien', // Same as existing - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'translation-service' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('updated'); - expect(changes[0].oldStatus).toBe('translated'); - expect(changes[0].newStatus).toBe('translated'); - }); - - it('should not downgrade a current verified value for translation-service', () => { - const currentBaseChecksum = calculateChecksum('OK'); - mkdirSync(folderPath, { recursive: true }); - writeFileSync(entryResourcePath, JSON.stringify({ ok: { source: 'OK', es: 'Bien' } })); - writeFileSync( - entryMetaPath, - JSON.stringify({ - ok: { - en: { checksum: currentBaseChecksum }, - es: { - checksum: calculateChecksum('Bien'), - baseChecksum: currentBaseChecksum, - status: 'verified', - }, - }, - }), - ); - - const filesModified = new Set(); - const warnings: string[] = []; - const changes = processResourceGroup( - { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [{ resource: { key: 'common.buttons.ok', value: 'Bien' }, entryKey: 'ok' }], - }, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'translation-service' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes[0]?.oldStatus).toBe('verified'); - expect(changes[0]?.newStatus).toBe('verified'); - expect(JSON.parse(readFileSync(entryMetaPath, 'utf8')).ok.es.status).toBe('verified'); - expect(filesModified.size).toBe(0); - }); - - it('should preserve existing status when value does not change with update strategy', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Bien', // Same as existing - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'update' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('updated'); - expect(changes[0].newStatus).toBe('translated'); - expect(filesModified.size).toBe(0); - }); - - describe('reconfirming unchanged values with stale metadata', () => { - beforeEach(() => { - writeFileSync( - entryMetaPath, - JSON.stringify({ - ok: { - en: { checksum: calculateChecksum('OK') }, - es: { - checksum: calculateChecksum('Bien'), - baseChecksum: calculateChecksum('Old source'), - status: 'stale', - }, - }, - }), - ); - }); - - function createUnchangedValueGroup(): ResourceGroup { - return { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Bien', - }, - entryKey: 'ok', - }, - ], - }; - } - - it('should refresh the base checksum and set stale values to translated for translation-service', () => { - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - createUnchangedValueGroup(), - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'translation-service' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes[0]?.oldStatus).toBe('stale'); - expect(changes[0]?.newStatus).toBe('translated'); - - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('translated'); - expect(meta.ok.es.baseChecksum).toBe(calculateChecksum('OK')); - expect(filesModified.has(entryMetaPath)).toBe(true); - }); - - it('should refresh the base checksum and set stale values to verified for verification', () => { - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - createUnchangedValueGroup(), - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'verification' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes[0]?.oldStatus).toBe('stale'); - expect(changes[0]?.newStatus).toBe('verified'); - - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('verified'); - expect(meta.ok.es.baseChecksum).toBe(calculateChecksum('OK')); - expect(filesModified.has(entryMetaPath)).toBe(true); - }); - - it('should leave stale metadata unchanged for update', () => { - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - createUnchangedValueGroup(), - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'update' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes[0]?.oldStatus).toBe('stale'); - expect(changes[0]?.newStatus).toBe('stale'); - - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('stale'); - expect(meta.ok.es.baseChecksum).toBe(calculateChecksum('Old source')); - expect(filesModified.size).toBe(0); - }); - }); - - it('should set status to verified for verification strategy', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Bien', // Same value - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'verification' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].newStatus).toBe('verified'); - - // Verify metadata was updated - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('verified'); - }); - - it('should warn on base value mismatch when validateBase is enabled', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - baseValue: 'Okay', // Different from stored 'OK' - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', validateBase: true }, - false, - false, - filesModified, - warnings, - ); - - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain('Base value mismatch'); - expect(warnings[0]).toContain('common.buttons.ok'); - }); - }); - - describe('dry run mode', () => { - it('should not write files in dry run mode', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - baseValue: 'OK', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', createMissing: true }, - true, // Dry run - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('created'); - - // Files should not exist - expect(existsSync(entryResourcePath)).toBe(false); - expect(existsSync(entryMetaPath)).toBe(false); - expect(filesModified.size).toBe(0); - }); - }); - - describe('comment and tag updates', () => { - beforeEach(() => { - mkdirSync(folderPath, { recursive: true }); - writeFileSync( - entryResourcePath, - JSON.stringify({ - ok: { - source: 'OK', - es: 'Bien', - comment: 'Old comment', - tags: ['old-tag'], - }, - }), - ); - writeFileSync( - entryMetaPath, - JSON.stringify({ - ok: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }), - ); - }); - - it('should update comment when updateComments is true', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - comment: 'New comment', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', updateComments: true }, - false, - false, - filesModified, - warnings, - ); - - const entries = JSON.parse(readFileSync(entryResourcePath, 'utf8')); - expect(entries.ok.comment).toBe('New comment'); - }); - - it('should not update comment when updateComments is false', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - comment: 'New comment', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', updateComments: false }, - false, - false, - filesModified, - warnings, - ); - - const entries = JSON.parse(readFileSync(entryResourcePath, 'utf8')); - expect(entries.ok.comment).toBe('Old comment'); - }); - - it('should update tags when updateTags is true', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - tags: ['new-tag', 'another-tag'], - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', updateTags: true }, - false, - false, - filesModified, - warnings, - ); - - const entries = JSON.parse(readFileSync(entryResourcePath, 'utf8')); - expect(entries.ok.tags).toEqual(['new-tag', 'another-tag']); - }); - }); - - describe('migration strategy - source status', () => { - function writeExistingOkResource(): void { - mkdirSync(folderPath, { recursive: true }); - writeFileSync( - entryResourcePath, - JSON.stringify({ - ok: { source: 'OK', es: 'Bien' }, - }), - ); - writeFileSync( - entryMetaPath, - JSON.stringify({ - ok: { - en: { checksum: 'base-checksum' }, - es: { - checksum: 'old-checksum', - baseChecksum: 'base-checksum', - status: 'translated', - }, - }, - }), - ); - } - - describe('new resources', () => { - it('should use source status when present', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - baseValue: 'OK', - status: 'verified', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'migration', createMissing: true }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('created'); - expect(changes[0].newStatus).toBe('verified'); - - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('verified'); - }); - - it('should fall back to translated when source status is missing', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - baseValue: 'OK', - // No status - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'migration', createMissing: true }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('created'); - expect(changes[0].newStatus).toBe('translated'); - - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('translated'); - }); - - it('should ignore source status when preserveStatus is false', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', - baseValue: 'OK', - status: 'verified', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { - source: 'test.json', - locale: 'es', - baseLocale: 'en', - strategy: 'migration', - createMissing: true, - preserveStatus: false, - }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('created'); - expect(changes[0].newStatus).toBe('translated'); - - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('translated'); - }); - }); - - describe('existing resources with value change', () => { - beforeEach(() => { - writeExistingOkResource(); - }); - - it('should use source status when present', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', // Changed from 'Bien' - status: 'verified', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'migration' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('value-changed'); - expect(changes[0].newStatus).toBe('verified'); - }); - - it('should fall back to translated when source status is missing', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', // Changed from 'Bien' - // No status - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'migration' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('value-changed'); - expect(changes[0].newStatus).toBe('translated'); - }); - - it('should ignore source status when preserveStatus is false', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', // Changed from 'Bien' - status: 'verified', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'migration', preserveStatus: false }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('value-changed'); - expect(changes[0].newStatus).toBe('translated'); - }); - }); - - describe('existing resources with unchanged value', () => { - beforeEach(() => { - writeExistingOkResource(); - }); - - it('should use source status when present', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Bien', // Same as existing - status: 'verified', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'migration' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('updated'); - expect(changes[0].oldStatus).toBe('translated'); - expect(changes[0].newStatus).toBe('verified'); - - expect(filesModified.size).toBeGreaterThan(0); - - // Verify metadata file was updated with the resolved status - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('verified'); - }); - - it('should preserve existing status when source status is missing', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Bien', // Same as existing - // No status - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'migration' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('updated'); - expect(changes[0].oldStatus).toBe('translated'); - expect(changes[0].newStatus).toBe('translated'); - expect(filesModified.size).toBe(0); - - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('translated'); - }); - - it('should ignore source status when preserveStatus is false', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Bien', // Same as existing - status: 'verified', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'migration', preserveStatus: false }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('updated'); - expect(changes[0].oldStatus).toBe('translated'); - expect(changes[0].newStatus).toBe('translated'); - expect(filesModified.size).toBe(0); - }); - }); - - describe('other strategies unaffected', () => { - beforeEach(() => { - writeExistingOkResource(); - }); - - it('should not use source status for translation-service strategy when preserveStatus is undefined', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', // Changed from 'Bien' - status: 'verified', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'translation-service' }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('value-changed'); - expect(changes[0].newStatus).toBe('translated'); - }); - - it('should use source status for translation-service strategy when preserveStatus is true', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Aceptar', // Changed from 'Bien' - status: 'verified', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'translation-service', preserveStatus: true }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('value-changed'); - expect(changes[0].newStatus).toBe('verified'); - }); - - it('should use source status for translation-service when preserveStatus is true and value unchanged', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Bien', // Same as existing - status: 'verified', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', strategy: 'translation-service', preserveStatus: true }, - false, - false, - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].newStatus).toBe('verified'); - - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.es.status).toBe('verified'); - }); - }); - }); - - describe('base locale imports', () => { - it('should create new resource in base locale when isBaseLocaleImport is true', () => { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.submit', - value: 'Submit', - comment: 'Form submit button', - tags: ['forms', 'buttons'], - }, - entryKey: 'submit', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'en', - 'en', - { - source: 'test.json', - locale: 'en', - baseLocale: 'en', - strategy: 'migration', - createMissing: true, - updateComments: true, - updateTags: true, - }, - false, - true, // isBaseLocaleImport - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('created'); - expect(changes[0].key).toBe('common.buttons.submit'); - expect(changes[0].newValue).toBe('Submit'); - expect(changes[0].newStatus).toBeUndefined(); - - // Verify resource entry created with source field - const entries = JSON.parse(readFileSync(entryResourcePath, 'utf8')); - expect(entries.submit.source).toBe('Submit'); - expect(entries.submit.comment).toBe('Form submit button'); - expect(entries.submit.tags).toEqual(['forms', 'buttons']); - - // Verify metadata only has checksum for base locale - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.submit.en.checksum).toBeDefined(); - expect(meta.submit.en.status).toBeUndefined(); - expect(meta.submit.en.baseChecksum).toBeUndefined(); - }); - - it('should update existing resource source value when isBaseLocaleImport is true', () => { - // Create existing resource - mkdirSync(folderPath, { recursive: true }); - writeFileSync( - entryResourcePath, - JSON.stringify({ - ok: { source: 'OK', es: 'Bien' }, - }), - ); - writeFileSync( - entryMetaPath, - JSON.stringify({ - ok: { - en: { checksum: 'old-checksum' }, - es: { - checksum: 'es-checksum', - baseChecksum: 'old-checksum', - status: 'translated', - }, - }, - }), - ); - - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'Okay', - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - const changes = processResourceGroup( - group, - 'en', - 'en', - { source: 'test.json', locale: 'en', strategy: 'migration' }, - false, - true, // isBaseLocaleImport - filesModified, - warnings, - ); - - expect(changes).toHaveLength(1); - expect(changes[0].type).toBe('value-changed'); - expect(changes[0].oldValue).toBe('OK'); - expect(changes[0].newValue).toBe('Okay'); - expect(changes[0].oldStatus).toBeUndefined(); - expect(changes[0].newStatus).toBeUndefined(); - - // Verify source field was updated - const entries = JSON.parse(readFileSync(entryResourcePath, 'utf8')); - expect(entries.ok.source).toBe('Okay'); - - // Verify base locale checksum was updated - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.en.checksum).not.toBe('old-checksum'); - expect(meta.ok.en.status).toBeUndefined(); - }); - - describe('staleness when the base value changes (regression)', () => { - const md5 = calculateChecksum; - - function writeExisting(): void { - mkdirSync(folderPath, { recursive: true }); - writeFileSync(entryResourcePath, JSON.stringify({ ok: { source: 'OK', es: 'Bien', fr: 'Okay', de: 'OK' } })); - writeFileSync( - entryMetaPath, - JSON.stringify({ - ok: { - en: { checksum: md5('OK') }, - es: { checksum: md5('Bien'), baseChecksum: md5('OK'), status: 'verified' }, - fr: { checksum: md5('Okay'), baseChecksum: md5('OK'), status: 'translated' }, - de: { checksum: md5('OK'), baseChecksum: md5('OK'), status: 'new' }, - }, - }), - ); - } - - function importBase(value: string): void { - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [{ resource: { key: 'common.buttons.ok', value }, entryKey: 'ok' }], - }; - processResourceGroup( - group, - 'en', - 'en', - { source: 'test.json', locale: 'en', strategy: 'migration' }, - false, - true, // isBaseLocaleImport - new Set(), - [], - ); - } - - it('marks translations stale and points them at the new base checksum', () => { - writeExisting(); - - importBase('Okay'); - - const meta = JSON.parse(readFileSync(entryMetaPath, 'utf8')); - expect(meta.ok.en).toEqual({ checksum: md5('Okay') }); - expect(meta.ok.es).toEqual({ checksum: md5('Bien'), baseChecksum: md5('Okay'), status: 'stale' }); - // A translation equal to the new base value is an untranslated copy: 'new', not 'stale' - expect(meta.ok.fr).toEqual({ checksum: md5('Okay'), baseChecksum: md5('Okay'), status: 'new' }); - expect(meta.ok.de).toEqual({ checksum: md5('OK'), baseChecksum: md5('Okay'), status: 'stale' }); - }); - - it('leaves translations alone when the base value is unchanged', () => { - writeExisting(); - const before = readFileSync(entryMetaPath, 'utf8'); - - importBase('OK'); - - expect(readFileSync(entryMetaPath, 'utf8')).toBe(before); - }); - }); - - it('should update comment and tags for base locale when flags are set', () => { - // Create existing resource - mkdirSync(folderPath, { recursive: true }); - writeFileSync( - entryResourcePath, - JSON.stringify({ - ok: { source: 'OK', comment: 'Old comment', tags: ['old'] }, - }), - ); - writeFileSync( - entryMetaPath, - JSON.stringify({ - ok: { - en: { checksum: 'checksum' }, - }, - }), - ); - - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.ok', - value: 'OK', - comment: 'New comment', - tags: ['new', 'tags'], - }, - entryKey: 'ok', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - processResourceGroup( - group, - 'en', - 'en', - { - source: 'test.json', - locale: 'en', - baseLocale: 'en', - strategy: 'migration', - updateComments: true, - updateTags: true, - }, - false, - true, // isBaseLocaleImport - filesModified, - warnings, - ); - - const entries = JSON.parse(readFileSync(entryResourcePath, 'utf8')); - expect(entries.ok.comment).toBe('New comment'); - expect(entries.ok.tags).toEqual(['new', 'tags']); - }); - - it('should not write files when base locale comment and tags are unchanged', () => { - // Write a resource that already has the comment and tags we are about to import. - // The stored checksum must match the real MD5 of 'Hello' so the checksum guard - // does not trigger a spurious write. - mkdirSync(folderPath, { recursive: true }); - writeFileSync( - entryResourcePath, - JSON.stringify({ - greeting: { source: 'Hello', comment: 'greeting', tags: ['ui'] }, - }), - ); - writeFileSync( - entryMetaPath, - JSON.stringify({ - greeting: { - en: { checksum: calculateChecksum('Hello') }, - }, - }), - ); - - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { - resource: { - key: 'common.buttons.greeting', - value: 'Hello', - comment: 'greeting', - tags: ['ui'], - }, - entryKey: 'greeting', - }, - ], - }; - - const filesModified = new Set(); - const warnings: string[] = []; - - processResourceGroup( - group, - 'en', - 'en', - { - source: 'test.json', - locale: 'en', - baseLocale: 'en', - strategy: 'migration', - updateComments: true, - updateTags: true, - }, - false, - true, // isBaseLocaleImport - filesModified, - warnings, - ); - - expect(filesModified.size).toBe(0); - }); - }); - - describe('protected terms verification (target locale)', () => { - const writeExisting = (entries: Record): void => { - if (!existsSync(folderPath)) { - mkdirSync(folderPath, { recursive: true }); - } - writeFileSync(entryResourcePath, JSON.stringify(entries)); - writeFileSync(entryMetaPath, JSON.stringify({})); - }; - - it('flags an altered protected term as failed, skips writing it, and records an error', () => { - writeExisting({ ok: { source: 'Get iPhone' } }); - - const filesModified = new Set(); - const warnings: string[] = []; - const errors: string[] = []; - - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [{ resource: { key: 'common.buttons.ok', value: 'Obtenez iphone' }, entryKey: 'ok' }], - }; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', protectedTerms: ['iPhone'] }, - false, - false, - filesModified, - warnings, - errors, - ); - - expect(changes[0]).toEqual({ - key: 'common.buttons.ok', - type: 'failed', - reason: 'Protected term(s) altered: iPhone', - }); - expect(errors).toContain('"common.buttons.ok" Protected term(s) altered: iPhone'); - expect(filesModified.size).toBe(0); - }); - - it('passes when the term is preserved verbatim', () => { - writeExisting({ ok: { source: 'Get iPhone' } }); - - const filesModified = new Set(); - const warnings: string[] = []; - const errors: string[] = []; - - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [{ resource: { key: 'common.buttons.ok', value: 'Obtenez iPhone' }, entryKey: 'ok' }], - }; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', protectedTerms: ['iPhone'] }, - false, - false, - filesModified, - warnings, - errors, - ); - - expect(changes[0].type).not.toBe('failed'); - expect(errors).toHaveLength(0); - const written = JSON.parse(readFileSync(entryResourcePath, 'utf8')); - expect(written.ok.es).toBe('Obtenez iPhone'); - }); - - it('still imports a valid sibling when a term-bearing entry fails', () => { - writeExisting({ ok: { source: 'Get iPhone' }, cancel: { source: 'Cancel' } }); - - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [ - { resource: { key: 'common.buttons.ok', value: 'Obtenez iphone' }, entryKey: 'ok' }, - { resource: { key: 'common.buttons.cancel', value: 'Cancelar' }, entryKey: 'cancel' }, - ], - }; - - const changes = processResourceGroup( - group, - 'es', - 'en', - { source: 'test.json', locale: 'es', protectedTerms: ['iPhone'] }, - false, - false, - new Set(), - [], - ); - - const failed = changes.find((c) => c.key === 'common.buttons.ok'); - const valid = changes.find((c) => c.key === 'common.buttons.cancel'); - expect(failed?.type).toBe('failed'); - expect(valid?.type).toBe('value-changed'); - - const written = JSON.parse(readFileSync(entryResourcePath, 'utf8')); - expect(written.ok.es).toBeUndefined(); - expect(written.cancel.es).toBe('Cancelar'); - }); - - it('skips the check for base locale imports', () => { - writeExisting({ ok: { source: 'Get iPhone' } }); - - const group: ResourceGroup = { - folderPath, - entryResourcePath, - entryMetaPath, - resources: [{ resource: { key: 'common.buttons.ok', value: 'Get iphone' }, entryKey: 'ok' }], - }; - - const changes = processResourceGroup( - group, - 'en', - 'en', - { source: 'test.json', locale: 'en', strategy: 'migration', protectedTerms: ['iPhone'] }, - false, - true, - new Set(), - [], - ); - - expect(changes[0].type).toBe('value-changed'); - }); - }); -}); diff --git a/libs/core/src/lib/import/process-resource-group.ts b/libs/core/src/lib/import/process-resource-group.ts index e9f8701e..cbfd22ec 100644 --- a/libs/core/src/lib/import/process-resource-group.ts +++ b/libs/core/src/lib/import/process-resource-group.ts @@ -9,8 +9,9 @@ import { calculateChecksum } from '../../resource/checksum'; import { openResourceFolder, type ResourceFolder } from '../resource/resource-folder'; import { describePreferredTermRule } from '../validate/validate-terminology'; import { determineNewResourceStatus, honouredSourceStatus } from './determine-status'; +import type { ImportSession, ResolvedImportOptions } from './import-session'; import type { ResourceGroup } from './resource-grouping'; -import type { ImportChange, ImportedResource, ImportOptions } from './types'; +import type { ImportChange, ImportedResource } from './types'; // --------------------------------------------------------------------------- // Internal context shared across all handlers in one processResourceGroup call @@ -19,7 +20,7 @@ import type { ImportChange, ImportedResource, ImportOptions } from './types'; interface GroupContext { readonly locale: string; readonly baseLocale: string; - readonly options: ImportOptions; + readonly options: ResolvedImportOptions; readonly folder: ResourceFolder; dataModified: boolean; } @@ -226,7 +227,7 @@ function handleUnchangedTargetLocaleValue( * Adds one warning per discouraged term in a base value this import wrote, or would * write in a dry run. Advisory: the value is imported regardless. */ -function warnAboutPreferredTerminology(change: ImportChange, options: ImportOptions, warnings: string[]): void { +function warnAboutPreferredTerminology(change: ImportChange, options: ResolvedImportOptions, warnings: string[]): void { const rules = options.preferredTerminology ?? []; if (rules.length === 0 || change.newValue === undefined) return; if (change.type === 'failed' || change.type === 'skipped') return; @@ -238,58 +239,29 @@ function warnAboutPreferredTerminology(change: ImportChange, options: ImportOpti } // --------------------------------------------------------------------------- -// Public entry point +// Entry point (internal to the import module) // --------------------------------------------------------------------------- /** - * Processes a group of resources that belong to the same folder. + * Applies the resources of one folder to that folder, and records the outcome in the session. * - * This function is the core of the import operation. It handles batch processing of resources - * that share the same resource_entries.json and tracker_meta.json files, minimizing file I/O - * by loading and saving files once per folder instead of per resource. + * The folder's `resource_entries.json` and `tracker_meta.json` are loaded once and saved once + * (only when something changed, and never in a dry run). For each resource: + * 1. **Missing resource**: skipped unless `createMissing`; a target-locale creation needs a + * `baseValue`. + * 2. **Base-locale import** (migration only): writes the base value; the Staleness rule updates + * every translation. Values written are checked against the preferred terminology. + * 3. **Target-locale import**: warns on a `baseValue` mismatch (unless `validateBase` is false), + * fails an entry whose value dropped a protected term of its source, and otherwise writes the + * value with the status from `resolveImportStatus` (strategy, old status, source status). + * 4. **Comment and tags**: updated when `updateComments` / `updateTags` are set. * - * The function performs these operations for each resource in the group: - * 1. **Resource Creation**: Creates new resources when `createMissing` is enabled and resource - * doesn't exist. Requires baseValue to be present (or value for base locale imports). - * 2. **Base Value Validation**: Compares imported baseValue against existing source values - * and warns on mismatches (when validateBase is enabled). - * 3. **Value Change Detection**: Determines if translation value has changed. - * 4. **Strategy-Specific Status Handling**: - * - `verification`: Sets status to 'verified' (even for unchanged values) - * - `update`: Preserves existing status - * - `translation-service`: Sets status to 'translated' - * - `migration`: Uses source status when present and `preserveStatus` is not `false`, - * otherwise defaults to 'translated' - * 5. **Metadata Updates**: Updates comment and tags when corresponding flags are enabled. - * 6. **Checksum Calculation**: Computes checksums for change tracking and stale detection. - * 7. **File Writing**: Atomically writes both resource_entries.json and tracker_meta.json - * when changes are detected (unless in dry-run mode). - * - * @param group - The resource group containing all resources in the same folder with their - * file paths and entry keys - * @param locale - Target locale code (e.g., 'es', 'fr', 'de') or base locale for migration imports - * @param baseLocale - Source locale code (typically 'en') - * @param options - Import configuration including strategy, flags, and validation settings - * @param dryRun - When true, performs all operations except file writes - * @param isBaseLocaleImport - Whether this is a base locale import (migration strategy only) - * @param filesModified - Set that accumulates paths of all modified files (for summary reporting) - * @param warnings - Array that accumulates non-fatal warnings (e.g., base value mismatches, and - * preferred-terminology findings on base-locale imports) - * @param errors - Optional array that accumulates fatal error messages (e.g., protected-term violations) - * @returns Array of ImportChange objects describing all changes made to resources in this group + * Appends one change per resource to `session.changes`; warnings, errors (protected-term + * violations) and written files go to the session too. */ -export function processResourceGroup( - group: ResourceGroup, - locale: string, - baseLocale: string, - options: ImportOptions, - dryRun: boolean, - isBaseLocaleImport: boolean, - filesModified: Set, - warnings: string[], - errors?: string[], -): ImportChange[] { - const changes: ImportChange[] = []; +export function processResourceGroup(session: ImportSession, group: ResourceGroup): void { + const { options, isBaseLocaleImport, changes, warnings, errors } = session; + const { baseLocale } = session.collection; let folder: ResourceFolder; try { @@ -298,10 +270,10 @@ export function processResourceGroup( for (const { resource } of group.resources) { changes.push({ key: resource.key, type: 'failed', reason: `Failed to read resource files: ${error}` }); } - return changes; + return; } - const ctx: GroupContext = { locale, baseLocale, options, folder, dataModified: false }; + const ctx: GroupContext = { locale: options.locale, baseLocale, options, folder, dataModified: false }; for (const { resource, entryKey } of group.resources) { const stored = folder.get(entryKey); @@ -347,7 +319,7 @@ export function processResourceGroup( const violations = findProtectedTermViolations(storedSource, resource.value, terms); if (violations.length > 0) { const reason = `Protected term(s) altered: ${violations.join(', ')}`; - errors?.push(`"${resource.key}" ${reason}`); + errors.push(`"${resource.key}" ${reason}`); changes.push({ key: resource.key, type: 'failed', reason }); continue; } @@ -359,11 +331,9 @@ export function processResourceGroup( // Write files once for the entire group, but only when in-memory state was actually mutated. // Logging an 'updated' change (e.g. update strategy with unchanged value) does not imply a // disk write is needed — `dataModified` is the authoritative signal for that. - if (!dryRun && ctx.dataModified) { + if (!options.dryRun && ctx.dataModified) { for (const filePath of folder.save().written) { - filesModified.add(filePath); + session.filesModified.add(filePath); } } - - return changes; } diff --git a/libs/core/src/lib/import/resource-grouping.spec.ts b/libs/core/src/lib/import/resource-grouping.spec.ts index 3c61b541..587ac515 100644 --- a/libs/core/src/lib/import/resource-grouping.spec.ts +++ b/libs/core/src/lib/import/resource-grouping.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; import { groupResourcesByFolder } from './resource-grouping'; import type { ImportedResource } from './types'; @@ -11,7 +11,7 @@ describe('groupResourcesByFolder', () => { { key: 'errors.notFound', value: 'Not Found' }, ]; - const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); + const groups = groupResourcesByFolder(resources, resolve('/project', 'src/translations')); expect(groups.size).toBe(2); expect(groups.has(resolve('/project', 'src/translations/common'))).toBe(true); @@ -35,7 +35,7 @@ describe('groupResourcesByFolder', () => { { key: 'goodbye', value: 'Goodbye' }, ]; - const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); + const groups = groupResourcesByFolder(resources, resolve('/project', 'src/translations')); expect(groups.size).toBe(1); expect(groups.has(resolve('/project', 'src/translations'))).toBe(true); @@ -54,7 +54,7 @@ describe('groupResourcesByFolder', () => { { key: 'apps.admin.settings.general.title', value: 'General Settings' }, ]; - const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); + const groups = groupResourcesByFolder(resources, resolve('/project', 'src/translations')); expect(groups.size).toBe(2); expect(groups.has(resolve('/project', 'src/translations/apps/admin/users/list'))).toBe(true); @@ -70,7 +70,7 @@ describe('groupResourcesByFolder', () => { it('should create correct file paths', () => { const resources: ImportedResource[] = [{ key: 'common.ok', value: 'OK' }]; - const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); + const groups = groupResourcesByFolder(resources, resolve('/project', 'src/translations')); const commonGroup = groups.get(resolve('/project', 'src/translations/common')); expect(commonGroup).toBeDefined(); @@ -88,7 +88,7 @@ describe('groupResourcesByFolder', () => { { key: 'apps.admin.title', value: 'Admin' }, ]; - const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); + const groups = groupResourcesByFolder(resources, resolve('/project', 'src/translations')); expect(groups.size).toBe(3); expect(groups.has(resolve('/project', 'src/translations'))).toBe(true); @@ -108,7 +108,7 @@ describe('groupResourcesByFolder', () => { }, ]; - const groups = groupResourcesByFolder(resources, 'src/translations', '/project'); + const groups = groupResourcesByFolder(resources, resolve('/project', 'src/translations')); const commonGroup = groups.get(resolve('/project', 'src/translations/common')); expect(commonGroup).toBeDefined(); diff --git a/libs/core/src/lib/import/resource-grouping.ts b/libs/core/src/lib/import/resource-grouping.ts index 35afc421..fcead851 100644 --- a/libs/core/src/lib/import/resource-grouping.ts +++ b/libs/core/src/lib/import/resource-grouping.ts @@ -1,5 +1,5 @@ -import type { ImportedResource } from './types'; import { resolveResourcePaths } from '../resource/resource-file-paths'; +import type { ImportedResource } from './types'; /** * Represents a group of resources that belong to the same folder path. @@ -47,8 +47,7 @@ export interface ResourceGroup { * - Maintains data consistency by processing related resources together * * @param resources - Array of resources to group by folder path - * @param translationsFolder - Base translations folder path (e.g., 'src/translations') - * @param cwd - Current working directory for resolving absolute paths + * @param translationsFolder - Absolute path of the translations folder * @returns Map of absolute folder paths to ResourceGroup objects containing grouped resources * * @example @@ -59,11 +58,7 @@ export interface ResourceGroup { * { key: 'errors.notFound', value: 'Not Found' } * ]; * - * const groups = groupResourcesByFolder( - * resources, - * 'src/translations', - * '/project' - * ); + * const groups = groupResourcesByFolder(resources, '/project/src/translations'); * * // Returns: * // Map { @@ -90,12 +85,11 @@ export interface ResourceGroup { export function groupResourcesByFolder( resources: ImportedResource[], translationsFolder: string, - cwd: string, ): Map { const groups = new Map(); for (const resource of resources) { - const paths = resolveResourcePaths({ key: resource.key, translationsFolder, cwd }); + const paths = resolveResourcePaths({ key: resource.key, translationsFolder }); let group = groups.get(paths.folderPath); if (!group) { diff --git a/libs/core/src/lib/import/types.spec.ts b/libs/core/src/lib/import/types.spec.ts index f269aef2..4619f9d6 100644 --- a/libs/core/src/lib/import/types.spec.ts +++ b/libs/core/src/lib/import/types.spec.ts @@ -1,13 +1,14 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { + ImportChange, + ImportChangeType, + ImportedResource, ImportFormat, + ImportResult, + ImportRunOptions, ImportStrategy, - ImportOptions, - ImportedResource, - ImportChangeType, - ImportChange, + ImportSummaryOptions, StatusTransition, - ImportResult, } from './types'; describe('import types', () => { @@ -30,24 +31,19 @@ describe('import types', () => { }); }); - describe('ImportOptions', () => { + describe('ImportRunOptions', () => { it('should create valid import options with required fields', () => { - const options: ImportOptions = { - source: '/path/to/file.xliff', + const options: ImportRunOptions = { locale: 'es', - baseLocale: 'en', }; - expect(options.source).toBe('/path/to/file.xliff'); expect(options.locale).toBe('es'); }); it('should create import options with all fields', () => { - const options: ImportOptions = { + const options: ImportSummaryOptions = { format: 'xliff', source: '/path/to/file.xliff', locale: 'es', - baseLocale: 'en', - collection: 'TestCollection', strategy: 'translation-service', updateComments: false, updateTags: false, @@ -56,8 +52,7 @@ describe('import types', () => { validateBase: true, dryRun: false, verbose: false, - backup: false, - onProgress: (msg) => console.log(msg), + onProgress: () => undefined, }; expect(options.format).toBe('xliff'); expect(options.strategy).toBe('translation-service'); @@ -156,9 +151,7 @@ describe('import types', () => { describe('ImportResult', () => { it('should create complete import result', () => { const result: ImportResult = { - format: 'xliff', strategy: 'translation-service', - sourceFile: '/path/to/file.xliff', locale: 'es', collection: 'TestCollection', resourcesImported: 100, diff --git a/libs/core/src/lib/import/types.ts b/libs/core/src/lib/import/types.ts index 104f00e9..64e5c506 100644 --- a/libs/core/src/lib/import/types.ts +++ b/libs/core/src/lib/import/types.ts @@ -12,40 +12,28 @@ export type ImportFormat = 'xliff' | 'json'; export type { ImportStrategy }; /** - * Options for importing translations + * Options for one import run ({@link importResources}). The collection supplies the + * translations folder and the base locale; these options say what to do with the resources. */ -export interface ImportOptions { - /** Import format (auto-detected from file extension if omitted) */ - format?: ImportFormat; - /** Path to import file (required) */ - source: string; - /** Target locale for import (e.g., 'es', 'fr-ca') */ +export interface ImportRunOptions { + /** Target locale (e.g. 'es', 'fr-ca'). The collection's base locale needs the `migration` strategy. */ locale: string; - /** Target collection to import into */ - collection?: string; - /** Import strategy */ + /** Import strategy. Default: `translation-service`. */ strategy?: ImportStrategy; - /** Update resource comments from import data */ + /** Update resource comments from import data. Default: from the strategy. */ updateComments?: boolean; - /** Update resource tags from rich JSON */ + /** Update resource tags from rich JSON. Default: from the strategy. */ updateTags?: boolean; /** Allow rich JSON to specify status (advanced) */ preserveStatus?: boolean; - /** Create new resources if they don't exist */ + /** Create new resources if they don't exist. Default: from the strategy. */ createMissing?: boolean; /** Warn if source base value differs from existing */ validateBase?: boolean; /** Show what would be imported without modifying files */ dryRun?: boolean; - /** Show detailed import progress (each resource) */ + /** Report each resource through `onProgress` */ verbose?: boolean; - /** Create backup before importing (.bak files) */ - backup?: boolean; - /** - * The collection's base locale (e.g. 'en'), normally `openCollection(...).baseLocale`. - * Decides whether the import writes base values and which locale stays untouched. - */ - baseLocale: string; /** * Protected terms (union of global + collection) that must survive translation * verbatim. On import, an entry whose source contains such a term but whose @@ -64,6 +52,18 @@ export interface ImportOptions { onProgress?: (message: string) => void; } +/** Options for the format adapters (`parseJsonImport`, `parseXliffImport`). */ +export interface ImportParseOptions { + onProgress?: (message: string) => void; +} + +/** Options for {@link generateImportSummary}: the run's options plus where the resources came from. */ +export interface ImportSummaryOptions extends ImportRunOptions { + format: ImportFormat; + /** Path of the import file, as the user gave it. */ + source: string; +} + /** * Represents a resource parsed from import data */ @@ -153,12 +153,8 @@ export interface ICUAutoFixError { * Result of an import operation */ export interface ImportResult { - /** Import format used */ - format: ImportFormat; /** Import strategy used */ strategy: ImportStrategy; - /** Source file path */ - sourceFile: string; /** Target locale */ locale: string; /** Target collection */ diff --git a/libs/domain/src/index.ts b/libs/domain/src/index.ts index 96f5e9a2..a1ba1941 100644 --- a/libs/domain/src/index.ts +++ b/libs/domain/src/index.ts @@ -20,3 +20,4 @@ export * from './lib/portable-plural-categories'; export * from './lib/icu-arguments'; export * from './lib/js-identifier'; export * from './lib/preferred-terminology'; +export * from './lib/reference-resolver'; diff --git a/libs/core/src/lib/import/reference-resolver.spec.ts b/libs/domain/src/lib/reference-resolver.spec.ts similarity index 93% rename from libs/core/src/lib/import/reference-resolver.spec.ts rename to libs/domain/src/lib/reference-resolver.spec.ts index 6a331f51..9ed1a17e 100644 --- a/libs/core/src/lib/import/reference-resolver.spec.ts +++ b/libs/domain/src/lib/reference-resolver.spec.ts @@ -1,6 +1,11 @@ -import { describe, it, expect } from 'vitest'; -import { hasReferences, extractReferences, resolveReferences, resolveAllReferences } from './reference-resolver'; -import type { ImportedResource } from './types'; +import { describe, expect, it } from 'vitest'; +import { + extractReferences, + hasReferences, + type KeyedValue, + resolveAllReferences, + resolveReferences, +} from './reference-resolver'; describe('reference-resolver', () => { describe('hasReferences', () => { @@ -179,7 +184,7 @@ describe('reference-resolver', () => { describe('resolveAllReferences', () => { it('should resolve references in all resources when enabled', () => { - const resources: ImportedResource[] = [ + const resources: KeyedValue[] = [ { key: 'greeting', value: 'Hello' }, { key: 'message', value: '{{greeting}} World' }, ]; @@ -194,7 +199,7 @@ describe('reference-resolver', () => { }); it('should not resolve when disabled', () => { - const resources: ImportedResource[] = [ + const resources: KeyedValue[] = [ { key: 'greeting', value: 'Hello' }, { key: 'message', value: '{{greeting}} World' }, ]; @@ -209,7 +214,7 @@ describe('reference-resolver', () => { }); it('should handle complex nested references', () => { - const resources: ImportedResource[] = [ + const resources: KeyedValue[] = [ { key: 'name', value: 'World' }, { key: 'target', value: '{{name}}' }, { key: 'greeting', value: 'Hello {{target}}' }, @@ -223,7 +228,7 @@ describe('reference-resolver', () => { }); it('should warn on circular references', () => { - const resources: ImportedResource[] = [ + const resources: KeyedValue[] = [ { key: 'a', value: '{{b}}' }, { key: 'b', value: '{{a}}' }, ]; @@ -238,7 +243,7 @@ describe('reference-resolver', () => { }); it('should warn on missing references', () => { - const resources: ImportedResource[] = [{ key: 'greeting', value: 'Hello {{missing}}' }]; + const resources: KeyedValue[] = [{ key: 'greeting', value: 'Hello {{missing}}' }]; const warnings: string[] = []; const result = resolveAllReferences(resources, true, warnings); @@ -249,7 +254,7 @@ describe('reference-resolver', () => { }); it('should preserve resources without references', () => { - const resources: ImportedResource[] = [ + const resources: KeyedValue[] = [ { key: 'simple', value: 'No references' }, { key: 'greeting', value: 'Hello World' }, ]; @@ -262,7 +267,7 @@ describe('reference-resolver', () => { }); it('should handle {{t()}} patterns', () => { - const resources: ImportedResource[] = [ + const resources: KeyedValue[] = [ { key: 'greeting', value: 'Hello' }, { key: 'message', value: "{{t('greeting')}} World" }, ]; @@ -274,7 +279,7 @@ describe('reference-resolver', () => { }); it('should preserve other resource properties', () => { - const resources: ImportedResource[] = [ + const resources: Array = [ { key: 'greeting', value: 'Hello', diff --git a/libs/core/src/lib/import/reference-resolver.ts b/libs/domain/src/lib/reference-resolver.ts similarity index 96% rename from libs/core/src/lib/import/reference-resolver.ts rename to libs/domain/src/lib/reference-resolver.ts index 95511c13..e42359db 100644 --- a/libs/core/src/lib/import/reference-resolver.ts +++ b/libs/domain/src/lib/reference-resolver.ts @@ -1,4 +1,8 @@ -import type { ImportedResource } from './types'; +/** Anything with a dot-delimited key and a translation value, such as an imported resource. */ +export interface KeyedValue { + key: string; + value: string; +} /** * Detects whether a string contains Transloco-style reference patterns. @@ -243,7 +247,7 @@ export function resolveReferences( * * @example * ```typescript - * const resources: ImportedResource[] = [ + * const resources: KeyedValue[] = [ * { key: 'common.ok', value: 'OK' }, * { key: 'common.cancel', value: 'Cancel' }, * { key: 'dialog.message', value: 'Click {{t("common.ok")}}' }, @@ -260,7 +264,7 @@ export function resolveReferences( * // ] * * // Circular reference detection - * const circular: ImportedResource[] = [ + * const circular: KeyedValue[] = [ * { key: 'a', value: '{{b}}' }, * { key: 'b', value: '{{a}}' } * ]; @@ -273,11 +277,11 @@ export function resolveReferences( * // Returns original resources unchanged (applyResolution = false) * ``` */ -export function resolveAllReferences( - resources: ImportedResource[], +export function resolveAllReferences( + resources: T[], applyResolution: boolean, warnings: string[], -): ImportedResource[] { +): T[] { if (!applyResolution) { return resources; } @@ -292,7 +296,7 @@ export function resolveAllReferences( } // Resolve references in each resource - const resolved: ImportedResource[] = []; + const resolved: T[] = []; for (const resource of resources) { if (hasReferences(resource.value)) { From bef29740cb07e74d055fe4ad3b9912beb40ecf22 Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 00:13:04 -0700 Subject: [PATCH 04/20] refactor(api): replace the cache mechanism with a CollectionIndex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core writes (add/edit/delete/move resource, create/delete/move folder, add/remove locale, update collection) now return the ResourceMutations they made. The API's 16-method CollectionCacheService is replaced by a CollectionIndex with tree, search, status and apply; the state machine, disk revalidation, incremental patching and fallback re-index live inside it. Controllers shrink to open → call core → apply(mutations). Also: collection updates/deletes and background translation jobs now invalidate the index; createFolder emits no mutation for an existing folder; moveFolder no longer deletes a source folder that still holds resources skipped by a destination collision. Co-Authored-By: Claude Fable 5.1 --- apps/api/src/app/app.module.ts | 4 +- .../cache/collection-cache.service.spec.ts | 629 --------------- .../src/app/cache/collection-cache.service.ts | 744 ------------------ .../cache/collection-index.service.spec.ts | 333 ++++++++ .../src/app/cache/collection-index.service.ts | 378 +++++++++ .../collections.controller.spec.ts | 65 +- .../app/collections/collections.controller.ts | 38 + .../folders/folders.controller.spec.ts | 29 +- .../collections/folders/folders.controller.ts | 60 +- .../locales/locales.controller.spec.ts | 32 +- .../collections/locales/locales.controller.ts | 22 +- .../resources/resources.controller.spec.ts | 400 +++------- .../resources/resources.controller.ts | 290 +------ .../translation-job.service.spec.ts | 19 +- .../translation-job.service.ts | 16 +- architecture-docs/README.md | 2 +- architecture-docs/api.md | 161 ++-- architecture-docs/core-library.md | 2 + architecture-docs/feature-matrix.md | 4 +- architecture-docs/glossary.md | 16 + architecture-docs/user-flows.md | 35 +- .../add-locale-to-collection.ts | 4 + .../remove-locale-from-collection.ts | 4 + .../update-collection.spec.ts | 8 +- .../collections-manager/update-collection.ts | 15 +- libs/core/src/lib/folder/create-folder.ts | 4 + libs/core/src/lib/folder/delete-folder.ts | 7 + .../lib/folder/move-folder.real-fs.spec.ts | 42 + libs/core/src/lib/folder/move-folder.ts | 22 +- libs/core/src/lib/resource/index.ts | 2 +- .../src/lib/resource/metadata-operations.ts | 41 - .../resource-mutation.real-fs.spec.ts | 167 ++++ .../src/lib/resource/resource-mutation.ts | 55 ++ .../translate-existing-resource.ts | 13 +- libs/core/src/resource/add-resource.ts | 7 +- libs/core/src/resource/delete-resource.ts | 6 + libs/core/src/resource/edit-resource.ts | 6 + .../resource/move-resource.real-fs.spec.ts | 10 +- libs/core/src/resource/move-resource.ts | 15 +- 39 files changed, 1515 insertions(+), 2192 deletions(-) delete mode 100644 apps/api/src/app/cache/collection-cache.service.spec.ts delete mode 100644 apps/api/src/app/cache/collection-cache.service.ts create mode 100644 apps/api/src/app/cache/collection-index.service.spec.ts create mode 100644 apps/api/src/app/cache/collection-index.service.ts delete mode 100644 libs/core/src/lib/resource/metadata-operations.ts create mode 100644 libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts create mode 100644 libs/core/src/lib/resource/resource-mutation.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4dd60779..0e6bb70c 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -3,7 +3,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { BundleJobService } from './bundles/bundle-job.service'; import { BundlesController } from './bundles/bundles.controller'; -import { CollectionCacheService } from './cache/collection-cache.service'; +import { CollectionIndex } from './cache/collection-index.service'; import { CollectionsController } from './collections/collections.controller'; import { FoldersController } from './collections/folders/folders.controller'; import { LocalesController } from './collections/locales/locales.controller'; @@ -23,7 +23,7 @@ import { TranslationJobService } from './translation-job/translation-job.service LocalesController, BundlesController, ], - providers: [AppService, ConfigService, CollectionCacheService, TranslationJobService, BundleJobService, Logger], + providers: [AppService, ConfigService, CollectionIndex, TranslationJobService, BundleJobService, Logger], }) export class AppModule { constructor() { diff --git a/apps/api/src/app/cache/collection-cache.service.spec.ts b/apps/api/src/app/cache/collection-cache.service.spec.ts deleted file mode 100644 index 0693dd03..00000000 --- a/apps/api/src/app/cache/collection-cache.service.spec.ts +++ /dev/null @@ -1,629 +0,0 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { Test, type TestingModule } from '@nestjs/testing'; -import { CollectionCacheService, CacheStatus } from './collection-cache.service'; -import * as core from '@simoncodes-ca/core'; -import type { ResourceTreeNode } from '@simoncodes-ca/core'; - -jest.mock('@simoncodes-ca/core', () => { - const actual = jest.requireActual('@simoncodes-ca/core'); - return { - ...actual, - loadResourceTree: jest.fn(), - extractResourcesRecursively: jest.fn(), - }; -}); - -const mockCore = core as jest.Mocked; - -// Revalidation is throttled in production; tests that are not about the throttle need every -// call to actually scan. Set before the service is constructed, since it reads this once. -process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS = '0'; - -describe('CollectionCacheService', () => { - let service: CollectionCacheService; - - const createMockTree = (folderPath: string[] = []): ResourceTreeNode => ({ - folderPathSegments: folderPath, - resources: [ - { - key: 'test.key', - source: 'Test Source', - translations: { fr: 'Test Français' }, - metadata: { - fr: { - checksum: 'def456', - baseChecksum: 'abc123', - status: 'translated', - }, - }, - }, - ], - children: [], - }); - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CollectionCacheService], - }).compile(); - - module.useLogger(false); - - service = module.get(CollectionCacheService); - - jest.clearAllMocks(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('getCacheStatus', () => { - it('should return NOT_STARTED when no collection is cached', () => { - const status = service.getCacheStatus('Main'); - expect(status).toBe(CacheStatus.NOT_STARTED); - }); - - it('should return NOT_STARTED when different collection is cached', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - const status = service.getCacheStatus('Admin'); - expect(status).toBe(CacheStatus.NOT_STARTED); - }); - - it('should return current status for cached collection', () => { - service.setCacheStatus('Main', CacheStatus.INDEXING); - const status = service.getCacheStatus('Main'); - expect(status).toBe(CacheStatus.INDEXING); - }); - - it('should return READY status for successfully indexed collection', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - const status = service.getCacheStatus('Main'); - expect(status).toBe(CacheStatus.READY); - }); - - it('should return ERROR status for failed collection', () => { - service.setCacheStatus('Main', CacheStatus.ERROR, undefined, 'Test error'); - const status = service.getCacheStatus('Main'); - expect(status).toBe(CacheStatus.ERROR); - }); - }); - - describe('getCache', () => { - it('should return null when no collection is cached', () => { - const cache = service.getCache('Main'); - expect(cache).toBeNull(); - }); - - it('should return null when different collection is cached', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - const cache = service.getCache('Admin'); - expect(cache).toBeNull(); - }); - - it('should return null when collection is in INDEXING status', () => { - service.setCacheStatus('Main', CacheStatus.INDEXING); - const cache = service.getCache('Main'); - expect(cache).toBeNull(); - }); - - it('should return null when collection is in ERROR status', () => { - service.setCacheStatus('Main', CacheStatus.ERROR, undefined, 'Test error'); - const cache = service.getCache('Main'); - expect(cache).toBeNull(); - }); - - it('should return tree data when collection is READY', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - const cache = service.getCache('Main'); - expect(cache).toEqual(mockTree); - }); - - it('should return null when collection is in NOT_STARTED status', () => { - service.setCacheStatus('Main', CacheStatus.NOT_STARTED); - const cache = service.getCache('Main'); - expect(cache).toBeNull(); - }); - }); - - describe('setCacheStatus', () => { - it('should create new cached collection with NOT_STARTED status', () => { - service.setCacheStatus('Main', CacheStatus.NOT_STARTED); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); - }); - - it('should create new cached collection with INDEXING status', () => { - service.setCacheStatus('Main', CacheStatus.INDEXING); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.INDEXING); - }); - - it('should set READY status with tree data', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - expect(service.getCache('Main')).toEqual(mockTree); - }); - - it('should set ERROR status with error message', () => { - service.setCacheStatus('Main', CacheStatus.ERROR, undefined, 'Test error message'); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); - }); - - it('should keep an existing collection cached when another collection is added', () => { - const mainTree = createMockTree(['main']); - mockCore.extractResourcesRecursively.mockReturnValue(mainTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mainTree, undefined, 3); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - - service.setCacheStatus('Admin', CacheStatus.INDEXING); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - expect(service.getCache('Main')).toEqual(mainTree); - expect(service.getCacheStatus('Admin')).toBe(CacheStatus.INDEXING); - }); - - it('should update status for same collection without clearing', () => { - service.setCacheStatus('Main', CacheStatus.INDEXING); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.INDEXING); - - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - expect(service.getCache('Main')).toEqual(mockTree); - }); - - it('should preserve tree data when updating to ERROR status', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - service.setCacheStatus('Main', CacheStatus.ERROR, undefined, 'Something went wrong'); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); - }); - - it('should set indexedAt when transitioning to READY status', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.INDEXING); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - - const status = service.getCacheStatus('Main'); - expect(status).toBe(CacheStatus.READY); - }); - }); - - describe('getCacheStats', () => { - it('should return null when no collection is cached', () => { - const stats = service.getCacheStats('Main'); - expect(stats).toBeNull(); - }); - - it('should return null when different collection is cached', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - const stats = service.getCacheStats('Admin'); - expect(stats).toBeNull(); - }); - - it('should return null when collection is not READY', () => { - service.setCacheStatus('Main', CacheStatus.INDEXING); - const stats = service.getCacheStats('Main'); - expect(stats).toBeNull(); - }); - - it('should return stats when collection is READY', () => { - const mockTree = createMockTree(); - const mockResources = [mockTree.resources[0]]; - mockCore.extractResourcesRecursively.mockReturnValue(mockResources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - const stats = service.getCacheStats('Main'); - expect(stats).toEqual({ - totalKeys: 1, - localeCount: 3, - }); - }); - - it('should return correct totalKeys count from extractResourcesRecursively', () => { - const mockTree = createMockTree(); - const mockResources = [ - mockTree.resources[0], - { ...mockTree.resources[0], key: 'test.key2' }, - { ...mockTree.resources[0], key: 'test.key3' }, - ]; - - // Mock must be set before calling setCacheStatus because it's called during that method - mockCore.extractResourcesRecursively.mockReturnValue(mockResources); - - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 5); - const stats = service.getCacheStats('Main'); - expect(stats).toEqual({ - totalKeys: 3, - localeCount: 5, - }); - - // Verify extractResourcesRecursively was called with the tree - expect(mockCore.extractResourcesRecursively).toHaveBeenCalledWith(mockTree); - }); - }); - - describe('clearCache', () => { - it('should clear cached collection', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - - service.clearCache('Main'); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); - expect(service.getCache('Main')).toBeNull(); - }); - - it('should handle clearing when no collection is cached', () => { - expect(() => service.clearCache('Main')).not.toThrow(); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); - }); - - it('should allow new collection to be cached after clearing', () => { - const mockTree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - service.clearCache('Main'); - service.setCacheStatus('Admin', CacheStatus.INDEXING); - expect(service.getCacheStatus('Admin')).toBe(CacheStatus.INDEXING); - }); - - it('should leave other collections untouched', () => { - const mainTree = createMockTree(['main']); - const adminTree = createMockTree(['admin']); - mockCore.extractResourcesRecursively.mockReturnValue(mainTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mainTree, undefined, 3); - service.setCacheStatus('Admin', CacheStatus.READY, adminTree, undefined, 2); - - service.clearCache('Main'); - - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); - expect(service.getCacheStatus('Admin')).toBe(CacheStatus.READY); - expect(service.getCache('Admin')).toEqual(adminTree); - }); - - it('should drop every collection when all caches are cleared', () => { - const mainTree = createMockTree(['main']); - const adminTree = createMockTree(['admin']); - mockCore.extractResourcesRecursively.mockReturnValue(mainTree.resources); - service.setCacheStatus('Main', CacheStatus.READY, mainTree, undefined, 3); - service.setCacheStatus('Admin', CacheStatus.READY, adminTree, undefined, 2); - - service.clearAllCaches(); - - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); - expect(service.getCacheStatus('Admin')).toBe(CacheStatus.NOT_STARTED); - expect(service.getCachedCollectionNames()).toEqual([]); - }); - }); - - describe('cache limit', () => { - it('should evict the least recently used collection once the limit is reached', () => { - process.env.LINGO_TRACKER_MAX_CACHED_COLLECTIONS = '2'; - const limitedService = new CollectionCacheService(); - delete process.env.LINGO_TRACKER_MAX_CACHED_COLLECTIONS; - - const tree = createMockTree(); - mockCore.extractResourcesRecursively.mockReturnValue(tree.resources); - - limitedService.setCacheStatus('First', CacheStatus.READY, tree, undefined, 1); - limitedService.setCacheStatus('Second', CacheStatus.READY, tree, undefined, 1); - - // Touching First makes Second the least recently used entry. - limitedService.getCache('First'); - - limitedService.setCacheStatus('Third', CacheStatus.READY, tree, undefined, 1); - - expect(limitedService.getCacheStatus('Second')).toBe(CacheStatus.NOT_STARTED); - expect(limitedService.getCacheStatus('First')).toBe(CacheStatus.READY); - expect(limitedService.getCacheStatus('Third')).toBe(CacheStatus.READY); - }); - - it('should never evict a collection that is still indexing', () => { - process.env.LINGO_TRACKER_MAX_CACHED_COLLECTIONS = '1'; - const limitedService = new CollectionCacheService(); - delete process.env.LINGO_TRACKER_MAX_CACHED_COLLECTIONS; - - limitedService.setCacheStatus('First', CacheStatus.INDEXING); - limitedService.setCacheStatus('Second', CacheStatus.INDEXING); - - expect(limitedService.getCacheStatus('First')).toBe(CacheStatus.INDEXING); - expect(limitedService.getCacheStatus('Second')).toBe(CacheStatus.INDEXING); - }); - }); - - describe('indexCollection', () => { - it('should transition from NOT_STARTED to INDEXING to READY on success', async () => { - const mockTree = createMockTree(); - mockCore.loadResourceTree.mockReturnValue(mockTree); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); - - await service.indexCollection('Main', 'src/i18n', 3); - - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - expect(service.getCache('Main')).toEqual(mockTree); - - const stats = service.getCacheStats('Main'); - expect(stats).toEqual({ - totalKeys: 1, - localeCount: 3, - }); - }); - - it('should transition from NOT_STARTED to INDEXING to ERROR on failure', async () => { - const errorMessage = 'Failed to load resource tree'; - mockCore.loadResourceTree.mockImplementation(() => { - throw new Error(errorMessage); - }); - - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); - - const promise = service.indexCollection('Main', 'src/i18n'); - await expect(promise).rejects.toThrow(errorMessage); - - expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); - expect(service.getCache('Main')).toBeNull(); - }); - - it('should call loadResourceTree with correct parameters', async () => { - const mockTree = createMockTree(); - mockCore.loadResourceTree.mockReturnValue(mockTree); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - - await service.indexCollection('Main', 'src/i18n', 3); - - expect(mockCore.loadResourceTree).toHaveBeenCalledWith({ - translationsFolder: 'src/i18n', - path: '', - depth: Infinity, - cwd: process.cwd(), - }); - }); - - it('should prevent concurrent indexing of same collection', async () => { - const mockTree = createMockTree(); - mockCore.loadResourceTree.mockReturnValue(mockTree); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - - // First index completes synchronously - await service.indexCollection('Main', 'src/i18n', 3); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - - // Second call should detect READY status and skip if we set to INDEXING - service.setCacheStatus('Main', CacheStatus.INDEXING); - await service.indexCollection('Main', 'src/i18n', 3); - - // Should only be called once for the first indexing request - expect(mockCore.loadResourceTree).toHaveBeenCalledTimes(1); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.INDEXING); - }); - - it('should allow indexing of different collection after previous is complete', async () => { - const mainTree = createMockTree(['main']); - const adminTree = createMockTree(['admin']); - - mockCore.loadResourceTree.mockReturnValueOnce(mainTree).mockReturnValueOnce(adminTree); - mockCore.extractResourcesRecursively - .mockReturnValueOnce(mainTree.resources) - .mockReturnValueOnce(adminTree.resources); - - // Index Main first - await service.indexCollection('Main', 'src/i18n', 3); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - expect(service.getCache('Main')).toEqual(mainTree); - - // Index Admin alongside Main - await service.indexCollection('Admin', 'src/admin/i18n', 2); - - expect(service.getCacheStatus('Admin')).toBe(CacheStatus.READY); - expect(service.getCache('Admin')).toEqual(adminTree); - - // Main is untouched by Admin's indexing - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - }); - - it('should store error message when indexing fails', async () => { - const errorMessage = 'Folder not found: /invalid/path'; - mockCore.loadResourceTree.mockImplementation(() => { - throw new Error(errorMessage); - }); - - const promise = service.indexCollection('Main', 'invalid/path'); - await expect(promise).rejects.toThrow(errorMessage); - - expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); - }); - - it('should handle non-Error exceptions during indexing', async () => { - mockCore.loadResourceTree.mockImplementation(() => { - throw 'String error'; - }); - - const promise = service.indexCollection('Main', 'src/i18n'); - await expect(promise).rejects.toBe('String error'); - - expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); - }); - - it('should keep both collections cached when indexing a second one', async () => { - const mainTree = createMockTree(['main']); - const adminTree = createMockTree(['admin']); - - mockCore.loadResourceTree.mockReturnValueOnce(mainTree).mockReturnValueOnce(adminTree); - mockCore.extractResourcesRecursively - .mockReturnValueOnce(mainTree.resources) - .mockReturnValueOnce(adminTree.resources); - - await service.indexCollection('Main', 'src/i18n', 3); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - expect(service.getCache('Main')).toEqual(mainTree); - - await service.indexCollection('Admin', 'src/admin/i18n', 2); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - expect(service.getCache('Main')).toEqual(mainTree); - expect(service.getCacheStatus('Admin')).toBe(CacheStatus.READY); - expect(service.getCache('Admin')).toEqual(adminTree); - }); - - it('should keep the stats of each cached collection separate', async () => { - const mainTree = createMockTree(['main']); - const adminTree = createMockTree(['admin']); - - mockCore.loadResourceTree.mockReturnValueOnce(mainTree).mockReturnValueOnce(adminTree); - mockCore.extractResourcesRecursively - .mockReturnValueOnce(mainTree.resources) - .mockReturnValueOnce(adminTree.resources); - - await service.indexCollection('Main', 'src/i18n', 3); - await service.indexCollection('Admin', 'src/admin/i18n', 2); - - expect(service.getCacheStats('Main')?.localeCount).toBe(3); - expect(service.getCacheStats('Admin')?.localeCount).toBe(2); - }); - - it('should handle error in one collection then successfully index another', async () => { - const adminTree = createMockTree(['admin']); - const mainError = new Error('Main collection error'); - - mockCore.loadResourceTree - .mockImplementationOnce(() => { - throw mainError; - }) - .mockReturnValueOnce(adminTree); - mockCore.extractResourcesRecursively.mockReturnValue(adminTree.resources); - - // Try to index Main - should fail and set ERROR status - try { - await service.indexCollection('Main', 'src/i18n', 3); - fail('Should have thrown error'); - } catch (error) { - expect(error).toBe(mainError); - } - expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); - - // Index Admin - Main keeps its own error state, Admin succeeds independently - await service.indexCollection('Admin', 'src/admin/i18n', 2); - expect(service.getCacheStatus('Admin')).toBe(CacheStatus.READY); - expect(service.getCache('Admin')).toEqual(adminTree); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); - }); - }); - describe('revalidate', () => { - let tempDir: string; - - const writeEntries = (entries: Record): void => { - const folderPath = path.join(tempDir, 'common'); - fs.mkdirSync(folderPath, { recursive: true }); - fs.writeFileSync(path.join(folderPath, 'resource_entries.json'), JSON.stringify(entries, null, 2), 'utf8'); - fs.writeFileSync(path.join(folderPath, 'tracker_meta.json'), JSON.stringify({}, null, 2), 'utf8'); - }; - - const indexTempCollection = async (): Promise => { - const mockTree = createMockTree(); - mockCore.loadResourceTree.mockReturnValue(mockTree); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - await service.indexCollection('Main', tempDir, 1); - }; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lingo-cache-revalidate-')); - writeEntries({ ok: { source: 'OK' } }); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); - }); - - it('does nothing when no collection is cached', () => { - expect(service.revalidate('Main', tempDir)).toBe(false); - }); - - it('does nothing when the cached collection is a different one', async () => { - await indexTempCollection(); - - expect(service.revalidate('Admin', tempDir)).toBe(false); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - }); - - it('keeps the cache when nothing changed on disk', async () => { - await indexTempCollection(); - - expect(service.revalidate('Main', tempDir)).toBe(false); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - }); - - it('drops the cache when a resource file changed outside the process', async () => { - await indexTempCollection(); - - writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' } }); - - expect(service.revalidate('Main', tempDir)).toBe(true); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); - }); - - it('drops the cache when a resource folder is removed outside the process', async () => { - await indexTempCollection(); - - fs.rmSync(path.join(tempDir, 'common'), { recursive: true, force: true }); - - expect(service.revalidate('Main', tempDir)).toBe(true); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); - }); - - it('does not read its own write as an outside change', async () => { - await indexTempCollection(); - - // What an API mutation does: write to disk, then patch the cached tree. - writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' } }); - service.addResourceToCache('Main', createMockTree().resources[0], ''); - - expect(service.revalidate('Main', tempDir)).toBe(false); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - }); - - it('detects an outside change made after its own write settled', async () => { - await indexTempCollection(); - - writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' } }); - service.addResourceToCache('Main', createMockTree().resources[0], ''); - service.refreshFingerprint('Main'); - - writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' }, later: { source: 'Later' } }); - - expect(service.revalidate('Main', tempDir)).toBe(true); - }); - - it('scans at most once per revalidation interval', async () => { - process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS = '60000'; - const throttledService = new CollectionCacheService(); - process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS = '0'; - - const mockTree = createMockTree(); - mockCore.loadResourceTree.mockReturnValue(mockTree); - mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); - await throttledService.indexCollection('Main', tempDir, 1); - - writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' } }); - - // Indexing takes a fingerprint, so the change is inside the interval that follows. - expect(throttledService.revalidate('Main', tempDir)).toBe(false); - expect(throttledService.getCacheStatus('Main')).toBe(CacheStatus.READY); - }); - }); -}); diff --git a/apps/api/src/app/cache/collection-cache.service.ts b/apps/api/src/app/cache/collection-cache.service.ts deleted file mode 100644 index f61b02bd..00000000 --- a/apps/api/src/app/cache/collection-cache.service.ts +++ /dev/null @@ -1,744 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import type { FolderChild, ResourceTreeEntry, ResourceTreeNode, TreeFingerprint } from '@simoncodes-ca/core'; -import * as core from '@simoncodes-ca/core'; -import { computeTreeFingerprint, extractResourcesRecursively, treeFingerprintsMatch } from '@simoncodes-ca/core'; - -/** - * How long a disk fingerprint is trusted before it is recomputed, in milliseconds. - * - * The scan is stat-only and costs a few milliseconds on a typical collection, but it runs - * on read paths, so it is throttled rather than run per request. - */ -const DEFAULT_REVALIDATION_INTERVAL_MS = 2000; - -/** - * How many collections may be held in memory at once. - * - * Each entry holds a collection's whole tree, so the ceiling is a memory budget: several - * tabs on different collections each keep their own index instead of evicting each other, - * but an unbounded map would let a large workspace grow without limit. The least recently - * used entry is dropped when the cap is reached. - */ -const DEFAULT_MAX_CACHED_COLLECTIONS = 4; - -export enum CacheStatus { - NOT_STARTED = 'not-started', - INDEXING = 'indexing', - READY = 'ready', - ERROR = 'error', -} - -export interface CachedCollection { - readonly collectionName: string; - status: CacheStatus; - tree: ResourceTreeNode | null; - indexedAt: Date | null; - error?: string; - totalKeys: number; - localeCount: number; - /** Folder the tree was indexed from, needed to re-scan it later */ - translationsFolder: string | null; - /** Disk state as of the last index or self-write, used to spot outside changes */ - fingerprint: TreeFingerprint | null; - /** - * Monotonic use counter, bumped on every read and write of this entry. A counter rather - * than a clock: several collections can be touched within the same millisecond, and - * eviction still needs a strict order between them. - */ - accessSequence: number; - /** Throttle stamp for this entry's disk fingerprint check */ - lastRevalidationAt: number; - /** Deferred fingerprint refresh covering this entry's own writes */ - pendingFingerprintRefresh: NodeJS.Timeout | null; -} - -@Injectable() -export class CollectionCacheService { - readonly #logger = new Logger(CollectionCacheService.name); - readonly #collections = new Map(); - - readonly #revalidationIntervalMs = Number( - process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS ?? DEFAULT_REVALIDATION_INTERVAL_MS, - ); - - #accessSequence = 0; - - readonly #maxCachedCollections = Math.max( - 1, - Number(process.env.LINGO_TRACKER_MAX_CACHED_COLLECTIONS ?? DEFAULT_MAX_CACHED_COLLECTIONS), - ); - - getCacheStatus(collectionName: string): CacheStatus { - const cached = this.#collections.get(collectionName); - - if (!cached) { - return CacheStatus.NOT_STARTED; - } - - cached.accessSequence = ++this.#accessSequence; - - return cached.status; - } - - getCache(collectionName: string): ResourceTreeNode | null { - const cached = this.#collections.get(collectionName); - - if (!cached) { - return null; - } - - if (cached.status !== CacheStatus.READY) { - return null; - } - - cached.accessSequence = ++this.#accessSequence; - - return cached.tree; - } - - getCacheMetadata(collectionName: string): { indexedAt: Date | null; error?: string } | null { - const cached = this.#collections.get(collectionName); - - if (!cached) { - return null; - } - - return { - indexedAt: cached.indexedAt, - error: cached.error, - }; - } - - getCacheStats(collectionName: string): { totalKeys: number; localeCount: number } | null { - const cached = this.#collections.get(collectionName); - - if (!cached) { - return null; - } - - if (cached.status !== CacheStatus.READY) { - return null; - } - - return { - totalKeys: cached.totalKeys, - localeCount: cached.localeCount, - }; - } - - setCacheStatus( - collectionName: string, - status: CacheStatus, - tree?: ResourceTreeNode, - error?: string, - localeCount?: number, - ): void { - const existing = this.#collections.get(collectionName); - - if (!existing) { - this.#evictLeastRecentlyUsed(collectionName); - - this.#collections.set(collectionName, { - collectionName, - status, - tree: tree ?? null, - indexedAt: status === CacheStatus.READY ? new Date() : null, - error, - totalKeys: status === CacheStatus.READY && tree ? extractResourcesRecursively(tree).length : 0, - localeCount: status === CacheStatus.READY ? (localeCount ?? 0) : 0, - translationsFolder: null, - fingerprint: null, - accessSequence: ++this.#accessSequence, - lastRevalidationAt: 0, - pendingFingerprintRefresh: null, - }); - } else { - existing.status = status; - existing.tree = tree ?? existing.tree; - existing.error = error; - existing.accessSequence = ++this.#accessSequence; - - if (status === CacheStatus.READY) { - existing.indexedAt = new Date(); - - if (tree) { - existing.totalKeys = extractResourcesRecursively(tree).length; - existing.localeCount = localeCount ?? 0; - } - } - } - - this.#logger.log(`Cache status set to ${status} for collection: ${collectionName}`); - } - - /** - * Drops one collection's cache. Other collections are left alone: a write against one - * collection must never force a re-index of a collection somebody else is viewing. - * - * @param collectionName - The collection whose cache should be dropped - */ - clearCache(collectionName: string): void { - const cached = this.#collections.get(collectionName); - - if (!cached) { - return; - } - - this.#logger.log(`Clearing cache for collection: ${collectionName}`); - this.#dropEntry(cached); - } - - /** - * Drops every cached collection. Reserved for changes that invalidate all of them, such - * as a config reload. - */ - clearAllCaches(): void { - if (this.#collections.size === 0) { - return; - } - - this.#logger.log(`Clearing cache for all ${this.#collections.size} cached collection(s)`); - - for (const cached of [...this.#collections.values()]) { - this.#dropEntry(cached); - } - } - - /** Names of the collections currently held in memory, most recently used last. */ - getCachedCollectionNames(): string[] { - return [...this.#collections.values()] - .sort((a, b) => a.accessSequence - b.accessSequence) - .map((entry) => entry.collectionName); - } - - #dropEntry(cached: CachedCollection): void { - this.#cancelPendingFingerprintRefresh(cached); - this.#collections.delete(cached.collectionName); - } - - /** - * Makes room for a new entry once the cap is reached by dropping the least recently used - * collection. A collection that is still indexing is never chosen — discarding it would - * throw away in-flight work and leave the request that started it waiting for nothing — - * so the map is allowed to overflow briefly when every entry is busy. - * - * @param incomingCollectionName - Collection about to be cached, never a victim - */ - #evictLeastRecentlyUsed(incomingCollectionName: string): void { - if (this.#collections.size < this.#maxCachedCollections) { - return; - } - - let victim: CachedCollection | null = null; - - for (const entry of this.#collections.values()) { - if (entry.collectionName === incomingCollectionName || entry.status === CacheStatus.INDEXING) { - continue; - } - - if (!victim || entry.accessSequence < victim.accessSequence) { - victim = entry; - } - } - - if (!victim) { - this.#logger.warn( - `Cache is at its ${this.#maxCachedCollections}-collection limit and every entry is still indexing; ` + - `caching ${incomingCollectionName} without evicting`, - ); - return; - } - - this.#logger.log( - `Evicting least recently used collection to stay within the cache limit: ${victim.collectionName}`, - ); - this.#dropEntry(victim); - } - - /** - * Adds a folder to the cached tree without requiring a full re-index. - * @param collectionName - The collection name - * @param folderName - The name of the new folder - * @param parentPath - The parent path (dot-delimited) or undefined for root - * @returns true if the folder was added, false if cache wasn't ready or parent not found - */ - addFolderToCache(collectionName: string, folderName: string, parentPath?: string): boolean { - const cached = this.#collections.get(collectionName); - - if (!cached) { - this.#logger.warn(`Cannot add folder to cache: no cache for collection ${collectionName}`); - return false; - } - - if (cached.status !== CacheStatus.READY || !cached.tree) { - this.#logger.warn(`Cannot add folder to cache: cache not ready for collection ${collectionName}`); - return false; - } - - cached.accessSequence = ++this.#accessSequence; - - const tree = cached.tree; - const parentSegments = parentPath ? parentPath.split('.') : []; - const fullPathSegments = [...parentSegments, folderName]; - - // Find the parent node - let parentNode: ResourceTreeNode = tree; - for (const segment of parentSegments) { - const child = parentNode.children.find((c) => c.name === segment); - if (!child || !child.tree) { - this.#logger.warn(`Cannot add folder to cache: parent path "${parentPath}" not found or not loaded`); - return false; - } - parentNode = child.tree; - } - - // Check if folder already exists - const existingChild = parentNode.children.find((c) => c.name === folderName); - if (existingChild) { - this.#logger.log(`Folder "${folderName}" already exists in cache at path "${parentPath || 'root'}"`); - this.#scheduleFingerprintRefresh(cached); - return true; - } - - // Create the new folder child entry - const newFolderChild = { - name: folderName, - fullPathSegments, - loaded: true, - tree: { - folderPathSegments: fullPathSegments, - resources: [], - children: [], - }, - }; - - // Add to parent's children and sort alphabetically - parentNode.children.push(newFolderChild); - parentNode.children.sort((a, b) => a.name.localeCompare(b.name)); - - this.#logger.log(`Added folder "${folderName}" to cache at path "${parentPath || 'root'}"`); - this.#scheduleFingerprintRefresh(cached); - return true; - } - - /** - * Adds a resource to the cached tree without requiring a full re-index. - * @param collectionName - The collection name - * @param resourceEntry - The resource entry to add - * @param folderPath - The dot-delimited folder path where the resource belongs - * @returns true if the resource was added, false if cache wasn't ready or folder not found - */ - addResourceToCache(collectionName: string, resourceEntry: ResourceTreeEntry, folderPath: string): boolean { - const cached = this.#collections.get(collectionName); - - if (!cached) { - this.#logger.warn(`Cannot add resource to cache: no cache for collection ${collectionName}`); - return false; - } - - if (cached.status !== CacheStatus.READY || !cached.tree) { - this.#logger.warn(`Cannot add resource to cache: cache not ready for collection ${collectionName}`); - return false; - } - - cached.accessSequence = ++this.#accessSequence; - - const tree = cached.tree; - - // Navigate to the target folder - let targetNode: ResourceTreeNode = tree; - if (folderPath) { - const pathSegments = folderPath.split('.'); - for (const segment of pathSegments) { - const child = targetNode.children.find((c) => c.name === segment); - if (!child || !child.tree) { - this.#logger.warn(`Cannot add resource to cache: folder path "${folderPath}" not found or not loaded`); - return false; - } - targetNode = child.tree; - } - } - - // Check if resource already exists (update) or is new (add) - const existingIndex = targetNode.resources.findIndex((r) => r.key === resourceEntry.key); - if (existingIndex >= 0) { - // Update existing resource - targetNode.resources[existingIndex] = resourceEntry; - this.#logger.log(`Updated resource "${resourceEntry.key}" in cache at path "${folderPath || 'root'}"`); - } else { - // Add new resource and sort alphabetically by key - targetNode.resources.push(resourceEntry); - targetNode.resources.sort((a, b) => a.key.localeCompare(b.key)); - // Update total keys count - cached.totalKeys++; - this.#logger.log(`Added resource "${resourceEntry.key}" to cache at path "${folderPath || 'root'}"`); - } - - this.#scheduleFingerprintRefresh(cached); - return true; - } - - /** - * Removes a folder from the cached tree without requiring a full re-index. - * @param collectionName - The collection name - * @param folderPath - The dot-delimited path to the folder to remove - * @returns true if the folder was removed, false if cache wasn't ready or folder not found - */ - removeFolderFromCache(collectionName: string, folderPath: string): boolean { - const cached = this.#collections.get(collectionName); - - if (!cached) { - this.#logger.warn(`Cannot remove folder from cache: no cache for collection ${collectionName}`); - return false; - } - - if (cached.status !== CacheStatus.READY || !cached.tree) { - this.#logger.warn(`Cannot remove folder from cache: cache not ready for collection ${collectionName}`); - return false; - } - - cached.accessSequence = ++this.#accessSequence; - - const tree = cached.tree; - const pathSegments = folderPath.split('.'); - - if (pathSegments.length === 0) { - this.#logger.warn(`Cannot remove folder from cache: invalid empty path`); - return false; - } - - // Navigate to the parent of the folder to be removed - const folderNameToRemove = pathSegments[pathSegments.length - 1]; - const parentSegments = pathSegments.slice(0, -1); - - let parentNode: ResourceTreeNode = tree; - for (const segment of parentSegments) { - const child = parentNode.children.find((c) => c.name === segment); - if (!child || !child.tree) { - this.#logger.warn(`Cannot remove folder from cache: parent path not found or not loaded`); - return false; - } - parentNode = child.tree; - } - - // Find and remove the folder from parent's children - const initialChildCount = parentNode.children.length; - parentNode.children = parentNode.children.filter((child) => child.name !== folderNameToRemove); - - if (parentNode.children.length === initialChildCount) { - this.#logger.warn(`Cannot remove folder from cache: folder "${folderPath}" not found`); - return false; - } - - this.#logger.log(`Removed folder "${folderPath}" from cache`); - this.#scheduleFingerprintRefresh(cached); - return true; - } - - /** - * Removes a single resource entry from a specific folder in the cached tree - * without requiring a full re-index. - * @param collectionName - The collection name - * @param resourceKey - The entry key of the resource to remove (last segment only) - * @param folderPath - The dot-delimited folder path where the resource currently lives - * @returns true if the resource was found and removed, false otherwise - */ - removeResourceFromCache(collectionName: string, resourceKey: string, folderPath: string): boolean { - const cached = this.#collections.get(collectionName); - - if (!cached) { - this.#logger.warn(`Cannot remove resource from cache: no cache for collection ${collectionName}`); - return false; - } - - if (cached.status !== CacheStatus.READY || !cached.tree) { - this.#logger.warn(`Cannot remove resource from cache: cache not ready for collection ${collectionName}`); - return false; - } - - cached.accessSequence = ++this.#accessSequence; - - const tree = cached.tree; - - // Navigate to the target folder - let targetNode: ResourceTreeNode = tree; - if (folderPath) { - const pathSegments = folderPath.split('.'); - for (const segment of pathSegments) { - const child = targetNode.children.find((c) => c.name === segment); - if (!child || !child.tree) { - this.#logger.warn(`Cannot remove resource from cache: folder path "${folderPath}" not found or not loaded`); - return false; - } - targetNode = child.tree; - } - } - - const initialCount = targetNode.resources.length; - targetNode.resources = targetNode.resources.filter((r) => r.key !== resourceKey); - - if (targetNode.resources.length === initialCount) { - this.#logger.warn( - `Cannot remove resource from cache: resource "${resourceKey}" not found at path "${folderPath || 'root'}"`, - ); - return false; - } - - cached.totalKeys--; - this.#logger.log(`Removed resource "${resourceKey}" from cache at path "${folderPath || 'root'}"`); - this.#scheduleFingerprintRefresh(cached); - return true; - } - - /** - * Moves a folder in the cached tree without requiring a full re-index. - * Removes the folder from its source location, updates all path references recursively, - * and inserts it at the destination location while keeping the cache in READY state. - * @param collectionName - The collection name - * @param sourceFolderPath - The dot-delimited path to the folder to move - * @param destinationFolderPath - The dot-delimited destination path (empty string for root) - * @returns true if the folder was moved successfully, false if cache wasn't ready or operation failed - */ - moveFolderInCache(collectionName: string, sourceFolderPath: string, destinationFolderPath: string): boolean { - const cached = this.#collections.get(collectionName); - - if (!cached) { - this.#logger.warn(`Cannot move folder in cache: no cache for collection ${collectionName}`); - return false; - } - - if (cached.status !== CacheStatus.READY || !cached.tree) { - this.#logger.warn(`Cannot move folder in cache: cache not ready for collection ${collectionName}`); - return false; - } - - cached.accessSequence = ++this.#accessSequence; - - const tree = cached.tree; - const sourceSegments = sourceFolderPath.split('.'); - const folderName = sourceSegments[sourceSegments.length - 1]; - const sourceParentSegments = sourceSegments.slice(0, -1); - - // Find and remove from source parent - let sourceParent: ResourceTreeNode = tree; - for (const segment of sourceParentSegments) { - const child = sourceParent.children.find((c) => c.name === segment); - if (!child?.tree) { - this.#logger.warn(`Cannot move folder in cache: source parent path not found or not loaded`); - return false; - } - sourceParent = child.tree; - } - - const sourceIndex = sourceParent.children.findIndex((c) => c.name === folderName); - if (sourceIndex === -1) { - this.#logger.warn(`Cannot move folder in cache: source folder "${sourceFolderPath}" not found`); - return false; - } - - const [movedChild] = sourceParent.children.splice(sourceIndex, 1); - - // Calculate new path prefix - const destSegments = destinationFolderPath ? destinationFolderPath.split('.') : []; - const newFolderSegments = [...destSegments, folderName]; - - // Find destination parent before modifying paths (so we can restore on failure) - let destParent: ResourceTreeNode = tree; - for (const segment of destSegments) { - const child = destParent.children.find((c) => c.name === segment); - if (!child?.tree) { - // Fallback: restore source if destination not found - sourceParent.children.splice(sourceIndex, 0, movedChild); - this.#logger.warn(`Cannot move folder in cache: destination path "${destinationFolderPath}" not found`); - return false; - } - destParent = child.tree; - } - - // Recursively update paths for all descendants - const updatePaths = (child: FolderChild, parentSegments: string[]): void => { - const newFullPath = [...parentSegments, child.name]; - child.fullPathSegments = newFullPath; - if (child.tree) { - child.tree.folderPathSegments = newFullPath; - for (const grandchild of child.tree.children) { - updatePaths(grandchild, newFullPath); - } - } - }; - - // Update the moved folder's paths - movedChild.fullPathSegments = newFolderSegments; - if (movedChild.tree) { - movedChild.tree.folderPathSegments = newFolderSegments; - for (const grandchild of movedChild.tree.children) { - updatePaths(grandchild, newFolderSegments); - } - } - - destParent.children.push(movedChild); - destParent.children.sort((a, b) => a.name.localeCompare(b.name)); - - this.#logger.log(`Moved folder "${sourceFolderPath}" to "${destinationFolderPath || 'root'}" in cache`); - this.#scheduleFingerprintRefresh(cached); - return true; - } - - /** - * Drops the cache when the translations folder has changed underneath it. - * - * The app caches a collection's whole tree in memory, so a CLI command, a `git checkout` - * or a hand edit would otherwise stay invisible until a restart — a browser refresh does - * not help, because it re-reads the same cache. Filesystem watching cannot fix this - * portably: inotify never fires for Windows-side writes on a WSL `/mnt/c` mount, and the - * same holds for several network and container mounts. So the check happens on read, - * against a stat-only fingerprint, throttled so it costs almost nothing. - * - * @param collectionName - The collection being read - * @param translationsFolder - The collection's translations folder - * @param cwd - Directory `translationsFolder` is resolved against - * @returns true when the cache was dropped and needs re-indexing - */ - revalidate(collectionName: string, translationsFolder: string, cwd?: string): boolean { - const cached = this.#collections.get(collectionName); - - if (!cached || cached.status !== CacheStatus.READY) { - return false; - } - - const now = Date.now(); - if (now - cached.lastRevalidationAt < this.#revalidationIntervalMs) { - return false; - } - cached.lastRevalidationAt = now; - cached.accessSequence = ++this.#accessSequence; - - const fingerprint = computeTreeFingerprint({ translationsFolder, cwd }); - - // A write of our own is still waiting for its deferred baseline refresh. Adopt the - // fingerprint now instead of reading our own change as somebody else's. - if (cached.pendingFingerprintRefresh !== null) { - this.#cancelPendingFingerprintRefresh(cached); - cached.fingerprint = fingerprint; - return false; - } - - if (treeFingerprintsMatch(cached.fingerprint, fingerprint)) { - return false; - } - - this.#logger.log(`Translations folder changed on disk for collection ${collectionName}, dropping cache`); - this.clearCache(collectionName); - return true; - } - - /** - * Re-takes the disk fingerprint so the cache's own writes do not later read as external - * changes. Safe to call when no collection is cached. - */ - refreshFingerprint(collectionName: string): void { - const cached = this.#collections.get(collectionName); - if (!cached) { - return; - } - - this.#cancelPendingFingerprintRefresh(cached); - - if (!cached.translationsFolder) { - return; - } - - cached.fingerprint = computeTreeFingerprint({ translationsFolder: cached.translationsFolder }); - } - - /** - * Queues a fingerprint refresh for the end of the current tick. - * - * Bulk endpoints mutate the cache once per resource in a synchronous loop, so deferring - * collapses a whole batch into a single scan. - */ - #scheduleFingerprintRefresh(cached: CachedCollection): void { - if (cached.pendingFingerprintRefresh !== null || !cached.translationsFolder) { - return; - } - - cached.pendingFingerprintRefresh = setTimeout(() => { - cached.pendingFingerprintRefresh = null; - this.refreshFingerprint(cached.collectionName); - }, 0); - - // A pending refresh must never hold the process open on its own. - cached.pendingFingerprintRefresh.unref?.(); - } - - #cancelPendingFingerprintRefresh(cached: CachedCollection): void { - if (cached.pendingFingerprintRefresh !== null) { - clearTimeout(cached.pendingFingerprintRefresh); - cached.pendingFingerprintRefresh = null; - } - } - - async indexCollection(collectionName: string, translationsFolder: string, localeCount?: number): Promise { - const currentStatus = this.getCacheStatus(collectionName); - - if (currentStatus === CacheStatus.INDEXING) { - this.#logger.warn(`Collection ${collectionName} is already being indexed, skipping duplicate request`); - return; - } - - this.setCacheStatus(collectionName, CacheStatus.INDEXING); - const startTime = Date.now(); - const indexingCollectionName = collectionName; - // Identity, not name: a clearCache during the load replaces the entry, and the results - // of this run must not be written onto its successor. - const indexingEntry = this.#collections.get(collectionName); - - this.#logger.log(`Starting indexing for collection: ${collectionName}`); - - try { - // Taken before the load: a write that lands mid-load then disagrees with this - // fingerprint, which costs one extra re-index but never loses the change. - const fingerprint = computeTreeFingerprint({ translationsFolder }); - - const tree = core.loadResourceTree({ - translationsFolder, - path: '', - depth: Infinity, - cwd: process.cwd(), - }); - - const duration = Date.now() - startTime; - this.#logger.log(`Successfully indexed collection ${indexingCollectionName} in ${duration}ms`); - - if (this.#collections.get(indexingCollectionName) !== indexingEntry) { - this.#logger.log(`Discarding indexing results for ${indexingCollectionName} - its cache entry was dropped`); - return; - } - - this.setCacheStatus(indexingCollectionName, CacheStatus.READY, tree, undefined, localeCount); - - const cached = this.#collections.get(indexingCollectionName); - if (cached) { - cached.translationsFolder = translationsFolder; - cached.fingerprint = fingerprint; - cached.lastRevalidationAt = Date.now(); - } - } catch (error) { - const duration = Date.now() - startTime; - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - - this.#logger.error( - `Failed to index collection ${indexingCollectionName} after ${duration}ms: ${errorMessage}`, - error instanceof Error ? error.stack : undefined, - ); - - if (this.#collections.get(indexingCollectionName) !== indexingEntry) { - this.#logger.log(`Discarding error state for ${indexingCollectionName} - its cache entry was dropped`); - return; - } - - this.setCacheStatus(indexingCollectionName, CacheStatus.ERROR, undefined, errorMessage); - throw error; - } - } -} diff --git a/apps/api/src/app/cache/collection-index.service.spec.ts b/apps/api/src/app/cache/collection-index.service.spec.ts new file mode 100644 index 00000000..7cc0ea9a --- /dev/null +++ b/apps/api/src/app/cache/collection-index.service.spec.ts @@ -0,0 +1,333 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Logger } from '@nestjs/common'; +import { + addLocaleToCollection, + addResource, + type Collection, + createFolder, + deleteFolder, + deleteResource, + editResource, + type FolderChild, + type LingoTrackerConfig, + loadResourceTree, + moveFolder, + moveResource, + openCollection, + openResourceFolder, + type ResourceTreeNode, +} from '@simoncodes-ca/core'; +import { CollectionIndex, type TreeRead } from './collection-index.service'; + +describe('CollectionIndex', () => { + let root: string; + let index: CollectionIndex; + + function config(...names: string[]): LingoTrackerConfig { + return { + exportFolder: 'export', + importFolder: 'import', + baseLocale: 'en', + locales: ['en', 'fr'], + collections: Object.fromEntries(names.map((name) => [name, { translationsFolder: path.join(root, name) }])), + }; + } + + function collection(name = 'main'): Collection { + return openCollection(config(name), name); + } + + /** Writes one entry (with metadata) the way core stores it. */ + function writeEntry(collectionName: string, key: string, value: string): void { + const segments = key.split('.'); + const folder = openResourceFolder(path.join(root, collectionName, ...segments.slice(0, -1))); + folder.setBase(segments[segments.length - 1], value); + folder.save(); + } + + /** Reads the tree; indexes first when needed (indexing runs within the triggering read). */ + function readyTree(target: Collection = collection(), treePath = ''): ResourceTreeNode | null { + let read: TreeRead = index.tree(target, treePath); + if (read.status !== 'ready') read = index.tree(target, treePath); + if (read.status !== 'ready') throw new Error(`index is ${read.status}`); + return read.tree; + } + + /** Children and resources sorted, so an index tree compares equal to a fresh load of the disk. */ + function normalized(node: ResourceTreeNode | null): unknown { + if (!node) return node; + return { + folderPathSegments: node.folderPathSegments, + resources: [...node.resources].sort((a, b) => a.key.localeCompare(b.key)), + children: [...node.children] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((child: FolderChild) => ({ ...child, tree: normalized(child.tree ?? null) })), + }; + } + + function expectIndexMatchesDisk(target: Collection = collection()): void { + const fromDisk = loadResourceTree({ + translationsFolder: target.translationsFolder, + depth: Number.POSITIVE_INFINITY, + }); + expect(normalized(readyTree(target))).toEqual(normalized(fromDisk)); + } + + function keysOf(node: ResourceTreeNode | null): string[] { + return (node?.resources.map((resource) => resource.key) ?? []).sort(); + } + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'lingo-index-')); + // Tests that are not about the throttle need every read to check the disk. + process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS = '0'; + index = new CollectionIndex(); + writeEntry('main', 'common.ok', 'OK'); + writeEntry('main', 'common.cancel', 'Cancel'); + writeEntry('main', 'apps.title', 'Title'); + }); + + afterEach(() => { + delete process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS; + delete process.env.LINGO_TRACKER_MAX_CACHED_COLLECTIONS; + fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + }); + + describe('reading', () => { + it('starts indexing on the first read and serves the tree after that', () => { + expect(index.tree(collection())).toEqual({ status: 'not-started' }); + + expectIndexMatchesDisk(); + }); + + it('returns the subtree at a path, and null for a path that does not exist', () => { + expect(keysOf(readyTree(collection(), 'common'))).toEqual(['cancel', 'ok']); + expect(readyTree(collection(), 'missing.path')).toBeNull(); + }); + + it('reports status for the cache-status endpoint', () => { + expect(index.status(collection())).toEqual({ status: 'not-started', collectionName: 'main' }); + + const ready = index.status(collection()); + expect(ready).toEqual({ + status: 'ready', + collectionName: 'main', + indexedAt: expect.any(String), + stats: { totalKeys: 3, localeCount: 2 }, + }); + }); + + it('reports an indexing failure and retries it on the next tree read', () => { + // A file where the translations folder should be cannot be read as a folder. + fs.writeFileSync(path.join(root, 'broken'), 'not a folder'); + const broken = openCollection(config('broken'), 'broken'); + + expect(index.tree(broken)).toEqual({ status: 'not-started' }); + expect(index.status(broken)).toEqual(expect.objectContaining({ status: 'error', error: expect.any(String) })); + + fs.rmSync(path.join(root, 'broken')); + writeEntry('broken', 'fixed', 'Fixed'); + + expect(index.tree(broken)).toEqual({ status: 'error' }); + expect(keysOf(readyTree(broken))).toEqual(['fixed']); + }); + + it('searches the disk before indexing, without starting it, and the index after', () => { + expect(index.search(collection(), 'cancel', 10).map((result) => result.key)).toEqual(['common.cancel']); + expect(index.tree(collection())).toEqual({ status: 'not-started' }); + + expect(index.search(collection(), 'cancel', 10).map((result) => result.key)).toEqual(['common.cancel']); + }); + }); + + describe('applying core mutations', () => { + beforeEach(() => { + readyTree(); + }); + + it('adds a resource, creating its folders', async () => { + const result = await addResource(collection().translationsFolder, { + key: 'apps.dialogs.confirm.yes', + baseValue: 'Yes', + }); + index.apply(result.mutations); + + expect(keysOf(readyTree(collection(), 'apps.dialogs.confirm'))).toEqual(['yes']); + expectIndexMatchesDisk(); + }); + + it('replaces an edited resource in place', async () => { + const result = await editResource(collection().translationsFolder, { key: 'common.ok', baseValue: 'Okay' }); + index.apply(result.mutations); + + expect(readyTree(collection(), 'common')?.resources.find((r) => r.key === 'ok')?.source).toBe('Okay'); + expectIndexMatchesDisk(); + }); + + it('removes deleted resources', () => { + index.apply(deleteResource(collection().translationsFolder, { keys: ['common.ok', 'apps.title'] }).mutations); + + expect(keysOf(readyTree(collection(), 'common'))).toEqual(['cancel']); + expectIndexMatchesDisk(); + }); + + it('moves resources by pattern', async () => { + const result = await moveResource(collection().translationsFolder, { source: 'common.*', destination: 'shared' }); + index.apply(result.mutations); + + expect(keysOf(readyTree(collection(), 'shared'))).toEqual(['cancel', 'ok']); + expect(keysOf(readyTree(collection(), 'common'))).toEqual([]); + expectIndexMatchesDisk(); + }); + + it('moves a resource to another collection and updates both', async () => { + writeEntry('other', 'existing', 'Existing'); + const other = openCollection(config('other'), 'other'); + readyTree(other); + + const result = await moveResource(collection().translationsFolder, { + source: 'common.ok', + destination: 'imported.ok', + destinationTranslationsFolder: other.translationsFolder, + }); + index.apply(result.mutations); + + expect(keysOf(readyTree(collection(), 'common'))).toEqual(['cancel']); + expect(keysOf(readyTree(other, 'imported'))).toEqual(['ok']); + expectIndexMatchesDisk(); + expectIndexMatchesDisk(other); + }); + + it('creates, moves and deletes folders', async () => { + index.apply(createFolder(collection().translationsFolder, { folderName: 'empty', parentPath: 'apps' }).mutations); + expect(readyTree(collection(), 'apps.empty')).not.toBeNull(); + + const moved = await moveFolder(collection().translationsFolder, { + sourceFolderPath: 'common', + destinationFolderPath: 'apps', + }); + index.apply(moved.mutations); + expect(keysOf(readyTree(collection(), 'apps.common'))).toEqual(['cancel', 'ok']); + expect(readyTree(collection(), 'common')).toBeNull(); + + index.apply(deleteFolder(collection().translationsFolder, { folderPath: 'apps.empty' }).mutations); + expect(readyTree(collection(), 'apps.empty')).toBeNull(); + + expectIndexMatchesDisk(); + }); + + it('re-indexes a collection whose locales changed', async () => { + const configPath = path.join(root, '.lingo-tracker.json'); + fs.writeFileSync(configPath, JSON.stringify(config('main'))); + + const result = await addLocaleToCollection('main', 'de', { cwd: root }); + index.apply(result.mutations); + + expect(index.tree(collection())).toEqual({ status: 'not-started' }); + expect(readyTree(collection(), 'common')?.resources.every((r) => r.translations.de !== undefined)).toBe(true); + }); + + it('re-indexes when a mutation does not match the indexed tree', () => { + index.apply([{ kind: 'remove', translationsFolder: collection().translationsFolder, key: 'common.unknown' }]); + + expect(index.tree(collection())).toEqual({ status: 'not-started' }); + expectIndexMatchesDisk(); + }); + + it('ignores mutations for collections that are not indexed', async () => { + const result = await addResource(path.join(root, 'elsewhere'), { key: 'ok', baseValue: 'OK' }); + index.apply(result.mutations); + + expect(index.tree(collection()).status).toBe('ready'); + }); + }); + + describe('revalidation against the disk', () => { + beforeEach(() => { + readyTree(); + }); + + it('keeps the index when nothing changed on disk', () => { + expect(index.tree(collection()).status).toBe('ready'); + }); + + it('drops the index when a resource file changed outside the process', () => { + writeEntry('main', 'common.later', 'Later'); + + expect(index.tree(collection())).toEqual({ status: 'not-started' }); + expect(keysOf(readyTree(collection(), 'common'))).toEqual(['cancel', 'later', 'ok']); + }); + + it('drops the index when a resource folder is removed outside the process', () => { + fs.rmSync(path.join(root, 'main', 'common'), { recursive: true, force: true }); + + expect(index.tree(collection())).toEqual({ status: 'not-started' }); + expect(readyTree(collection(), 'common')).toBeNull(); + }); + + it('does not read its own write as an outside change', async () => { + index.apply( + (await addResource(collection().translationsFolder, { key: 'common.yes', baseValue: 'Yes' })).mutations, + ); + + expect(index.tree(collection()).status).toBe('ready'); + }); + + it('detects an outside change made after its own write settled', async () => { + index.apply( + (await addResource(collection().translationsFolder, { key: 'common.yes', baseValue: 'Yes' })).mutations, + ); + // Let the deferred fingerprint refresh run. + await new Promise((resolve) => setTimeout(resolve, 5)); + + writeEntry('main', 'common.later', 'Later'); + + expect(index.tree(collection())).toEqual({ status: 'not-started' }); + }); + + it('checks the disk at most once per revalidation interval', () => { + process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS = '60000'; + const throttled = new CollectionIndex(); + readyTreeOf(throttled); + + writeEntry('main', 'common.later', 'Later'); + + // Indexing takes a fingerprint, so the change falls inside the interval that follows. + expect(throttled.tree(collection()).status).toBe('ready'); + }); + + function readyTreeOf(target: CollectionIndex): void { + target.tree(collection()); + expect(target.tree(collection()).status).toBe('ready'); + } + }); + + describe('memory cap', () => { + it('evicts the least recently used collection once the cap is reached', () => { + process.env.LINGO_TRACKER_MAX_CACHED_COLLECTIONS = '2'; + const capped = new CollectionIndex(); + const [first, second, third] = ['first', 'second', 'third'].map((name) => { + writeEntry(name, 'ok', 'OK'); + return openCollection(config(name), name); + }); + + capped.tree(first); + capped.tree(second); + // Reading first makes second the least recently used entry. + expect(capped.tree(first).status).toBe('ready'); + + capped.tree(third); + + expect(capped.tree(first).status).toBe('ready'); + expect(capped.tree(third).status).toBe('ready'); + expect(capped.tree(second)).toEqual({ status: 'not-started' }); + }); + }); +}); diff --git a/apps/api/src/app/cache/collection-index.service.ts b/apps/api/src/app/cache/collection-index.service.ts new file mode 100644 index 00000000..a19d6fcf --- /dev/null +++ b/apps/api/src/app/cache/collection-index.service.ts @@ -0,0 +1,378 @@ +import { resolve } from 'node:path'; +import { Injectable, Logger } from '@nestjs/common'; +import { + type Collection, + computeTreeFingerprint, + extractSubtree, + loadResourceTree, + type ResourceMutation, + type ResourceTreeNode, + type SearchResult, + searchResourceTree, + searchTranslations, + type TreeFingerprint, + treeFingerprintsMatch, +} from '@simoncodes-ca/core'; +import type { CacheStatusDto } from '@simoncodes-ca/data-transfer'; + +/** + * How long a disk fingerprint is trusted before it is computed again, in milliseconds. + * + * The scan is stat-only and costs a few milliseconds on a typical collection, but it runs + * on read paths, so it is throttled rather than run per request. + */ +const DEFAULT_REVALIDATION_INTERVAL_MS = 2000; + +/** + * How many collections may be held in memory at once. + * + * Each entry holds a collection's whole tree, so the ceiling is a memory budget: several + * tabs on different collections each keep their own index instead of evicting each other, + * but an unbounded map would let a large workspace grow without limit. The least recently + * used entry is dropped when the cap is reached. + */ +const DEFAULT_MAX_INDEXED_COLLECTIONS = 4; + +/** + * Result of reading a collection's tree. + * - `ready`: the tree (or the subtree at the requested path; `null` when that path does not exist). + * - `not-started` / `error`: the collection was not indexed (or the last attempt failed), so + * indexing was started by this read. Ask again shortly. + * - `indexing`: indexing is already running. + */ +export type TreeRead = + | { readonly status: 'ready'; readonly tree: ResourceTreeNode | null } + | { readonly status: 'indexing' | 'not-started' | 'error' }; + +interface IndexEntry { + /** The collection as it was when indexed. Its folder identifies which mutations apply. */ + readonly collection: Collection; + status: 'indexing' | 'ready' | 'error'; + tree: ResourceTreeNode | null; + indexedAt: Date | null; + error?: string; + /** Disk state as of the last index or own write, used to spot outside changes. */ + fingerprint: TreeFingerprint | null; + /** + * Monotonic use counter, bumped on every read and write of this entry. A counter rather + * than a clock: several collections can be touched within the same millisecond, and + * eviction still needs a strict order between them. + */ + accessSequence: number; + /** Throttle stamp for the disk fingerprint check. */ + lastRevalidationAt: number; + /** Deferred fingerprint refresh that covers this entry's own writes. */ + pendingFingerprintRefresh: NodeJS.Timeout | null; +} + +/** + * Collection Index — the API's in-memory copy of each open collection's resource tree. + * + * Callers read with `tree()`, `search()` and `status()`, and report their writes with + * `apply()`. Everything else is internal: indexing on first read, dropping the copy when + * the disk changed outside this process, patching the tree after a write (or dropping it + * when a patch cannot be applied), and the memory cap. + */ +@Injectable() +export class CollectionIndex { + readonly #logger = new Logger(CollectionIndex.name); + readonly #entries = new Map(); + #accessSequence = 0; + + readonly #revalidationIntervalMs = Number( + process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS ?? DEFAULT_REVALIDATION_INTERVAL_MS, + ); + + readonly #maxEntries = Math.max( + 1, + Number(process.env.LINGO_TRACKER_MAX_CACHED_COLLECTIONS ?? DEFAULT_MAX_INDEXED_COLLECTIONS), + ); + + /** Reads the tree (or the subtree at `path`). Starts indexing when the collection is not indexed. */ + tree(collection: Collection, path = ''): TreeRead { + const entry = this.#read(collection); + + if (!entry || entry.status === 'error') { + this.#index(collection); + return { status: entry ? 'error' : 'not-started' }; + } + + if (entry.status === 'indexing' || !entry.tree) { + return { status: 'indexing' }; + } + + return { status: 'ready', tree: extractSubtree(entry.tree, path) }; + } + + /** Searches the indexed tree, or the disk when the collection is not indexed. Never starts indexing. */ + search(collection: Collection, query: string, maxResults: number): SearchResult[] { + const entry = this.#read(collection); + const options = { query, maxResults, baseLocale: collection.baseLocale }; + + return entry?.status === 'ready' && entry.tree + ? searchResourceTree({ tree: entry.tree, ...options }) + : searchTranslations({ translationsFolder: collection.translationsFolder, ...options }); + } + + /** Index state for the cache-status endpoint. Starts indexing when the collection is not indexed. */ + status(collection: Collection): CacheStatusDto { + const entry = this.#read(collection); + + if (!entry) { + this.#index(collection); + return { status: 'not-started', collectionName: collection.name }; + } + + const status: CacheStatusDto = { status: entry.status, collectionName: collection.name }; + if (entry.indexedAt) status.indexedAt = entry.indexedAt.toISOString(); + if (entry.error) status.error = entry.error; + if (entry.status === 'ready' && entry.tree) { + status.stats = { totalKeys: countResources(entry.tree), localeCount: entry.collection.locales.length }; + } + return status; + } + + /** + * Updates every indexed collection that a write changed. A collection whose tree does not + * match what a mutation expects (or that got a `reindex`) is dropped, and the next read + * indexes it again. + */ + apply(mutations: readonly ResourceMutation[]): void { + const patched = new Set(); + + for (const mutation of mutations) { + const folder = resolve(mutation.translationsFolder); + + for (const entry of [...this.#entries.values()]) { + if (resolve(entry.collection.translationsFolder) !== folder) continue; + + if (mutation.kind === 'reindex') { + this.#drop(entry, 'a write asked for a re-index'); + continue; + } + if (entry.status !== 'ready' || !entry.tree) continue; + + try { + patchTree(entry.tree, mutation); + entry.accessSequence = ++this.#accessSequence; + patched.add(entry); + } catch (error) { + this.#drop(entry, error instanceof Error ? error.message : String(error)); + } + } + } + + for (const entry of patched) { + if (this.#entries.get(entry.collection.name) === entry) this.#scheduleFingerprintRefresh(entry); + } + } + + /** Returns the entry for a read, after dropping it when the disk changed outside this process. */ + #read(collection: Collection): IndexEntry | undefined { + const entry = this.#entries.get(collection.name); + if (!entry) return undefined; + + entry.accessSequence = ++this.#accessSequence; + + if (entry.status === 'ready' && this.#changedOnDisk(entry, collection)) { + this.#drop(entry, 'its translations folder changed on disk'); + return undefined; + } + return entry; + } + + /** + * The app holds a collection's whole tree in memory, so a CLI command, a `git checkout` or + * a hand edit would otherwise stay invisible until a restart. Filesystem watching cannot + * fix this portably: inotify never fires for Windows-side writes on a WSL `/mnt/c` mount, + * and the same holds for several network and container mounts. So the check happens on + * read, against a stat-only fingerprint, throttled so it costs almost nothing. + */ + #changedOnDisk(entry: IndexEntry, collection: Collection): boolean { + const now = Date.now(); + if (now - entry.lastRevalidationAt < this.#revalidationIntervalMs) return false; + entry.lastRevalidationAt = now; + + const fingerprint = computeTreeFingerprint({ translationsFolder: collection.translationsFolder }); + + // An own write is still waiting for its deferred fingerprint refresh. Adopt the + // fingerprint now instead of reading our own change as somebody else's. + if (entry.pendingFingerprintRefresh !== null) { + this.#cancelFingerprintRefresh(entry); + entry.fingerprint = fingerprint; + return false; + } + + return !treeFingerprintsMatch(entry.fingerprint, fingerprint); + } + + #index(collection: Collection): void { + const existing = this.#entries.get(collection.name); + if (existing?.status === 'indexing') return; + + if (existing) { + this.#cancelFingerprintRefresh(existing); + } else { + this.#evictLeastRecentlyUsed(collection.name); + } + + const entry: IndexEntry = { + collection, + status: 'indexing', + tree: null, + indexedAt: null, + fingerprint: null, + accessSequence: ++this.#accessSequence, + lastRevalidationAt: 0, + pendingFingerprintRefresh: null, + }; + this.#entries.set(collection.name, entry); + + const startedAt = Date.now(); + try { + // Taken before the load: a write that lands mid-load then disagrees with this + // fingerprint, which costs one extra re-index but never loses the change. + entry.fingerprint = computeTreeFingerprint({ translationsFolder: collection.translationsFolder }); + entry.tree = loadResourceTree({ + translationsFolder: collection.translationsFolder, + path: '', + depth: Number.POSITIVE_INFINITY, + }); + entry.status = 'ready'; + entry.indexedAt = new Date(); + entry.lastRevalidationAt = Date.now(); + this.#logger.log(`Indexed collection ${collection.name} in ${Date.now() - startedAt}ms`); + } catch (error) { + entry.status = 'error'; + entry.error = error instanceof Error ? error.message : 'Unknown error occurred'; + this.#logger.error( + `Failed to index collection ${collection.name}: ${entry.error}`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + #drop(entry: IndexEntry, reason: string): void { + this.#logger.log(`Dropping index of collection ${entry.collection.name}: ${reason}`); + this.#cancelFingerprintRefresh(entry); + this.#entries.delete(entry.collection.name); + } + + /** Makes room for a new entry once the cap is reached by dropping the least recently used one. */ + #evictLeastRecentlyUsed(incomingName: string): void { + if (this.#entries.size < this.#maxEntries) return; + + let victim: IndexEntry | undefined; + for (const entry of this.#entries.values()) { + if (entry.collection.name === incomingName) continue; + if (!victim || entry.accessSequence < victim.accessSequence) victim = entry; + } + + if (victim) this.#drop(victim, `the index holds at most ${this.#maxEntries} collections`); + } + + /** + * Queues a fingerprint refresh for the end of the current tick, so own writes do not later + * read as outside changes. Bulk endpoints apply mutations once per resource in a loop, so + * deferring collapses a whole batch into a single scan. + */ + #scheduleFingerprintRefresh(entry: IndexEntry): void { + if (entry.pendingFingerprintRefresh !== null) return; + + entry.pendingFingerprintRefresh = setTimeout(() => { + entry.pendingFingerprintRefresh = null; + entry.fingerprint = computeTreeFingerprint({ translationsFolder: entry.collection.translationsFolder }); + }, 0); + + // A pending refresh must never hold the process open on its own. + entry.pendingFingerprintRefresh.unref?.(); + } + + #cancelFingerprintRefresh(entry: IndexEntry): void { + if (entry.pendingFingerprintRefresh !== null) { + clearTimeout(entry.pendingFingerprintRefresh); + entry.pendingFingerprintRefresh = null; + } + } +} + +/** Applies one write to an indexed tree. Throws when the tree does not match what the write expects. */ +function patchTree(tree: ResourceTreeNode, mutation: Exclude): void { + switch (mutation.kind) { + case 'upsert': { + const { folder, name } = splitKey(mutation.key); + const resources = folderAt(tree, folder, true).resources; + const index = resources.findIndex((resource) => resource.key === name); + if (index >= 0) { + resources[index] = mutation.entry; + } else { + resources.push(mutation.entry); + resources.sort((a, b) => a.key.localeCompare(b.key)); + } + return; + } + case 'remove': { + const { folder, name } = splitKey(mutation.key); + const resources = folderAt(tree, folder, false).resources; + const index = resources.findIndex((resource) => resource.key === name); + if (index < 0) throw new Error(`resource "${mutation.key}" is not in the index`); + resources.splice(index, 1); + return; + } + case 'add-folder': + folderAt(tree, segmentsOf(mutation.path), true); + return; + case 'remove-folder': { + const { folder, name } = splitKey(mutation.path); + const children = folderAt(tree, folder, false).children; + const index = children.findIndex((child) => child.name === name); + if (index < 0) throw new Error(`folder "${mutation.path}" is not in the index`); + children.splice(index, 1); + return; + } + } +} + +/** Walks to the folder at `segments`. With `create`, missing folders are added (as on disk). */ +function folderAt(tree: ResourceTreeNode, segments: readonly string[], create: boolean): ResourceTreeNode { + let node = tree; + + for (let depth = 0; depth < segments.length; depth++) { + const name = segments[depth]; + const fullPathSegments = segments.slice(0, depth + 1); + let child = node.children.find((candidate) => candidate.name === name); + + if (!child && create) { + child = { + name, + fullPathSegments, + loaded: true, + tree: { folderPathSegments: fullPathSegments, resources: [], children: [] }, + }; + node.children.push(child); + node.children.sort((a, b) => a.name.localeCompare(b.name)); + } + + if (!child?.tree) throw new Error(`folder "${fullPathSegments.join('.')}" is not in the index`); + node = child.tree; + } + + return node; +} + +function segmentsOf(path: string): string[] { + return path.split('.').filter((segment) => segment.length > 0); +} + +/** Splits `a.b.c` into the folder `['a', 'b']` and the name `c`. */ +function splitKey(key: string): { folder: string[]; name: string } { + const segments = segmentsOf(key); + return { folder: segments.slice(0, -1), name: segments[segments.length - 1] ?? '' }; +} + +function countResources(node: ResourceTreeNode): number { + return node.children.reduce( + (total, child) => total + (child.tree ? countResources(child.tree) : 0), + node.resources.length, + ); +} diff --git a/apps/api/src/app/collections/collections.controller.spec.ts b/apps/api/src/app/collections/collections.controller.spec.ts index 1a2dd6b0..d5b6cd21 100644 --- a/apps/api/src/app/collections/collections.controller.spec.ts +++ b/apps/api/src/app/collections/collections.controller.spec.ts @@ -1,14 +1,22 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { HttpException } from '@nestjs/common'; +import { resolve } from 'node:path'; import { CollectionsController } from './collections.controller'; +import { ConfigService } from '../config/config.service'; +import { CollectionIndex } from '../cache/collection-index.service'; import * as core from '@simoncodes-ca/core'; - -// Mock the core module -jest.mock('@simoncodes-ca/core', () => ({ - deleteCollectionByName: jest.fn(), - addCollection: jest.fn(), - updateCollection: jest.fn(), -})); +import type { UpdateCollectionDto } from '@simoncodes-ca/data-transfer'; + +// Mock the core writes; keep the real config resolution and mutation helpers +jest.mock('@simoncodes-ca/core', () => { + const actual = jest.requireActual('@simoncodes-ca/core'); + return { + ...actual, + deleteCollectionByName: jest.fn(), + addCollection: jest.fn(), + updateCollection: jest.fn(), + }; +}); // Mock the mapper jest.mock('../mappers/collection.mapper', () => ({ @@ -19,9 +27,22 @@ describe('CollectionsController', () => { let collectionsModule: TestingModule; let collectionsController: CollectionsController; + const mockConfig = { + baseLocale: 'en', + locales: ['en'], + collections: { + 'test-collection': { translationsFolder: './translations/test' }, + }, + }; + const mockIndex = { apply: jest.fn() }; + beforeAll(async () => { collectionsModule = await Test.createTestingModule({ controllers: [CollectionsController], + providers: [ + { provide: ConfigService, useValue: { getConfig: jest.fn().mockReturnValue(mockConfig) } }, + { provide: CollectionIndex, useValue: mockIndex }, + ], }).compile(); collectionsController = collectionsModule.get(CollectionsController); @@ -78,6 +99,17 @@ describe('CollectionsController', () => { await expect(collectionsController.deleteCollection('test-collection')).rejects.toThrow(HttpException); expect(deleteCollectionByName).toHaveBeenCalledWith('test-collection'); + expect(mockIndex.apply).not.toHaveBeenCalled(); + }); + + it('drops the index entry for the deleted collection folder', async () => { + (core.deleteCollectionByName as jest.Mock).mockReturnValue(undefined); + + await collectionsController.deleteCollection('test-collection'); + + expect(mockIndex.apply).toHaveBeenCalledWith([ + { kind: 'reindex', translationsFolder: resolve('./translations/test') }, + ]); }); }); @@ -125,6 +157,7 @@ describe('CollectionsController', () => { const updateCollection = core.updateCollection as jest.Mock; updateCollection.mockReturnValue({ message: 'Collection "old-name" updated to "new-name" successfully', + mutations: [], }); const dto = { @@ -146,6 +179,7 @@ describe('CollectionsController', () => { const updateCollection = core.updateCollection as jest.Mock; updateCollection.mockReturnValue({ message: 'Collection "My Collection" updated successfully', + mutations: [], }); const dto = { @@ -174,6 +208,23 @@ describe('CollectionsController', () => { }; await expect(collectionsController.updateCollectionByName('old-name', dto as any)).rejects.toThrow(HttpException); + expect(mockIndex.apply).not.toHaveBeenCalled(); + }); + + it('drops the index entry for the collection folder, after its locale mutations', async () => { + const localeMutation = { kind: 'reindex', translationsFolder: resolve('./translations/test') }; + (core.updateCollection as jest.Mock).mockResolvedValue({ + message: 'Collection "test-collection" updated successfully', + mutations: [localeMutation], + }); + + const dto: UpdateCollectionDto = { collection: { translationsFolder: './translations/test' } }; + await collectionsController.updateCollectionByName('test-collection', dto); + + expect(mockIndex.apply).toHaveBeenCalledWith([ + localeMutation, + { kind: 'reindex', translationsFolder: resolve('./translations/test') }, + ]); }); }); }); diff --git a/apps/api/src/app/collections/collections.controller.ts b/apps/api/src/app/collections/collections.controller.ts index 36ece301..dd58dc49 100644 --- a/apps/api/src/app/collections/collections.controller.ts +++ b/apps/api/src/app/collections/collections.controller.ts @@ -2,11 +2,16 @@ import { Controller, Delete, Param, HttpException, HttpStatus, Post, Body, Put } import { addCollection, deleteCollectionByName, + openCollection, + reindexMutation, + type ResourceMutation, setCollectionProtectedTerms, updateCollection, } from '@simoncodes-ca/core'; import { isUnderNodeModules } from '@simoncodes-ca/domain'; import type { CreateCollectionDto, UpdateCollectionDto } from '@simoncodes-ca/data-transfer'; +import { CollectionIndex } from '../cache/collection-index.service'; +import { ConfigService } from '../config/config.service'; import { mapDtoToCollection } from '../mappers/collection.mapper'; /** @@ -26,11 +31,39 @@ function writeCollectionProtectedTerms(collectionName: string, terms: string[] | @Controller('collections') export class CollectionsController { + readonly #configService: ConfigService; + readonly #index: CollectionIndex; + + constructor(configService: ConfigService, index: CollectionIndex) { + this.#configService = configService; + this.#index = index; + } + + /** + * The collection's absolute translations folder per the current config, or `undefined` + * when it cannot be resolved (the core call that follows reports that error). + */ + #translationsFolderOf(collectionName: string): string | undefined { + try { + return openCollection(this.#configService.getConfig(), collectionName).translationsFolder; + } catch { + return undefined; + } + } + + /** Drops the index entries for the given folders; a config change is too broad to patch. */ + #reindex(folders: ReadonlyArray, mutations: readonly ResourceMutation[] = []): void { + const unique = [...new Set(folders.filter((folder): folder is string => folder !== undefined))]; + this.#index.apply([...mutations, ...unique.map((folder) => reindexMutation(folder))]); + } + @Delete(':collectionName') async deleteCollection(@Param('collectionName') collectionName: string): Promise<{ message: string }> { try { const decodedCollectionName = decodeURIComponent(collectionName); + const translationsFolder = this.#translationsFolderOf(decodedCollectionName); deleteCollectionByName(decodedCollectionName); + this.#reindex([translationsFolder]); return { message: `Collection "${decodedCollectionName}" deleted successfully`, }; @@ -73,7 +106,12 @@ export class CollectionsController { try { const decodedCollectionName = decodeURIComponent(collectionName); const { name, collection } = body; + const oldTranslationsFolder = this.#translationsFolderOf(decodedCollectionName); const result = await updateCollection(decodedCollectionName, name, mapDtoToCollection(collection)); + this.#reindex( + [oldTranslationsFolder, this.#translationsFolderOf(name || decodedCollectionName)], + result.mutations, + ); writeCollectionProtectedTerms(name ?? decodedCollectionName, collection.protectedTerms); return { message: result.message }; } catch (error: unknown) { diff --git a/apps/api/src/app/collections/folders/folders.controller.spec.ts b/apps/api/src/app/collections/folders/folders.controller.spec.ts index 6c35daef..680308f9 100644 --- a/apps/api/src/app/collections/folders/folders.controller.spec.ts +++ b/apps/api/src/app/collections/folders/folders.controller.spec.ts @@ -3,7 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { ForbiddenException, HttpException, NotFoundException } from '@nestjs/common'; import { FoldersController } from './folders.controller'; import { ConfigService } from '../../config/config.service'; -import { CollectionCacheService } from '../../cache/collection-cache.service'; +import { CollectionIndex } from '../../cache/collection-index.service'; import * as core from '@simoncodes-ca/core'; // Mock the core module @@ -21,7 +21,6 @@ describe('FoldersController', () => { let foldersModule: TestingModule; let foldersController: FoldersController; let _configService: ConfigService; - let cacheService: CollectionCacheService; const mockConfig = { exportFolder: 'dist/lingo-export', @@ -42,12 +41,7 @@ describe('FoldersController', () => { }, }; - const mockCacheService = { - addFolderToCache: jest.fn(), - removeFolderFromCache: jest.fn(), - moveFolderInCache: jest.fn().mockReturnValue(true), - clearCache: jest.fn(), - }; + const mockIndex = { apply: jest.fn() }; beforeEach(async () => { foldersModule = await Test.createTestingModule({ @@ -60,15 +54,14 @@ describe('FoldersController', () => { }, }, { - provide: CollectionCacheService, - useValue: mockCacheService, + provide: CollectionIndex, + useValue: mockIndex, }, ], }).compile(); foldersController = foldersModule.get(FoldersController); _configService = foldersModule.get(ConfigService); - cacheService = foldersModule.get(CollectionCacheService); // Reset all mocks before each test jest.clearAllMocks(); @@ -100,12 +93,6 @@ describe('FoldersController', () => { destinationTranslationsFolder: undefined, }); - expect(cacheService.moveFolderInCache).toHaveBeenCalledWith( - 'test-collection', - 'apps.common.buttons', - 'apps.shared', - ); - expect(result).toEqual({ movedCount: 5, foldersDeleted: 1, @@ -246,7 +233,6 @@ describe('FoldersController', () => { await expect(foldersController.move('test-collection', moveFolderDto)).rejects.toThrow(HttpException); expect(core.moveFolder).toHaveBeenCalled(); - expect(cacheService.clearCache).not.toHaveBeenCalled(); }); it('should throw HttpException for invalid path segments', async () => { @@ -267,7 +253,7 @@ describe('FoldersController', () => { await expect(foldersController.move('test-collection', moveFolderDto)).rejects.toThrow(HttpException); }); - it('should not clear cache when no resources were moved', async () => { + it('should report a move of an empty folder', async () => { const moveFolderDto = { sourceFolderPath: 'apps.empty', destinationFolderPath: 'apps.shared', @@ -284,7 +270,6 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(cacheService.clearCache).not.toHaveBeenCalled(); expect(result.movedCount).toBe(0); expect(result.foldersDeleted).toBe(1); expect(result.warnings).toHaveLength(1); @@ -355,8 +340,6 @@ describe('FoldersController', () => { parentPath: 'apps.common', }); - expect(cacheService.addFolderToCache).toHaveBeenCalledWith('test-collection', 'buttons', 'apps.common'); - expect(result.created).toBe(true); expect(result.folderPath).toBe('apps.common.buttons'); }); @@ -382,8 +365,6 @@ describe('FoldersController', () => { folderPath: 'apps.common.buttons', }); - expect(cacheService.removeFolderFromCache).toHaveBeenCalledWith('test-collection', 'apps.common.buttons'); - expect(result.deleted).toBe(true); expect(result.resourcesDeleted).toBe(5); }); diff --git a/apps/api/src/app/collections/folders/folders.controller.ts b/apps/api/src/app/collections/folders/folders.controller.ts index ae6bf592..5c866ceb 100644 --- a/apps/api/src/app/collections/folders/folders.controller.ts +++ b/apps/api/src/app/collections/folders/folders.controller.ts @@ -20,7 +20,7 @@ import type { MoveFolderResponseDto, } from '@simoncodes-ca/data-transfer'; import { ConfigService } from '../../config/config.service'; -import { CollectionCacheService } from '../../cache/collection-cache.service'; +import { CollectionIndex } from '../../cache/collection-index.service'; import { WritableCollectionGuard } from '../guards/writable-collection.guard'; import { openDestinationCollection, openRouteCollection } from '../open-route-collection'; @@ -29,7 +29,7 @@ import { openDestinationCollection, openRouteCollection } from '../open-route-co export class FoldersController { constructor( private readonly configService: ConfigService, - private readonly cacheService: CollectionCacheService, + private readonly index: CollectionIndex, ) {} @Post() @@ -38,24 +38,14 @@ export class FoldersController { @Body() createFolderDto: CreateFolderDto, ): Promise { try { - const { name: decodedCollectionName, translationsFolder } = openRouteCollection( - this.configService.getConfig(), - collectionName, - ); + const { translationsFolder } = openRouteCollection(this.configService.getConfig(), collectionName); const result = createFolder(translationsFolder, { folderName: createFolderDto.folderName, parentPath: createFolderDto.parentPath, }); - // Update cache incrementally after successful folder creation - if (result.created) { - this.cacheService.addFolderToCache( - decodedCollectionName, - createFolderDto.folderName, - createFolderDto.parentPath, - ); - } + this.index.apply(result.mutations); // Build the folder node for the frontend to insert into tree const fullPath = createFolderDto.parentPath @@ -104,19 +94,13 @@ export class FoldersController { @Body() deleteFolderDto: DeleteFolderDto, ): Promise { try { - const { name: decodedCollectionName, translationsFolder } = openRouteCollection( - this.configService.getConfig(), - collectionName, - ); + const { translationsFolder } = openRouteCollection(this.configService.getConfig(), collectionName); const result = deleteFolder(translationsFolder, { folderPath: deleteFolderDto.folderPath, }); - // Update cache incrementally after successful folder deletion - if (result.deleted) { - this.cacheService.removeFolderFromCache(decodedCollectionName, deleteFolderDto.folderPath); - } + this.index.apply(result.mutations); return { deleted: result.deleted, @@ -151,7 +135,7 @@ export class FoldersController { ): Promise { try { const config = this.configService.getConfig(); - const { name: decodedCollectionName, translationsFolder } = openRouteCollection(config, collectionName); + const { translationsFolder } = openRouteCollection(config, collectionName); if ( !moveFolderDto.sourceFolderPath || @@ -165,13 +149,9 @@ export class FoldersController { } // Handle cross-collection moves - let destinationTranslationsFolder: string | undefined; - let destinationCollectionName: string | undefined; - if (moveFolderDto.toCollection) { - const destination = openDestinationCollection(config, moveFolderDto.toCollection); - destinationCollectionName = destination.name; - destinationTranslationsFolder = destination.translationsFolder; - } + const destinationTranslationsFolder = moveFolderDto.toCollection + ? openDestinationCollection(config, moveFolderDto.toCollection).translationsFolder + : undefined; // Perform the move const result = await moveFolder(translationsFolder, { @@ -182,25 +162,7 @@ export class FoldersController { destinationTranslationsFolder, }); - // Update cache incrementally after successful folder move - if (result.movedCount > 0) { - if (destinationCollectionName && destinationCollectionName !== decodedCollectionName) { - // A cross-collection move rewrites two trees; the incremental update only knows how - // to relocate a folder within one, so both caches are dropped instead. - this.cacheService.clearCache(decodedCollectionName); - this.cacheService.clearCache(destinationCollectionName); - } else { - const moved = this.cacheService.moveFolderInCache( - decodedCollectionName, - moveFolderDto.sourceFolderPath, - moveFolderDto.destinationFolderPath, - ); - if (!moved) { - // Fallback: clear cache if incremental update failed - this.cacheService.clearCache(decodedCollectionName); - } - } - } + this.index.apply(result.mutations); // Check for critical errors that should return 400 const hasCriticalError = result.errors.some( diff --git a/apps/api/src/app/collections/locales/locales.controller.spec.ts b/apps/api/src/app/collections/locales/locales.controller.spec.ts index ed51c9ae..77f09e55 100644 --- a/apps/api/src/app/collections/locales/locales.controller.spec.ts +++ b/apps/api/src/app/collections/locales/locales.controller.spec.ts @@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { HttpException, NotFoundException } from '@nestjs/common'; import { LocalesController } from './locales.controller'; import { ConfigService } from '../../config/config.service'; -import { CollectionCacheService } from '../../cache/collection-cache.service'; +import { CollectionIndex } from '../../cache/collection-index.service'; import * as core from '@simoncodes-ca/core'; jest.mock('@simoncodes-ca/core', () => { @@ -17,7 +17,6 @@ jest.mock('@simoncodes-ca/core', () => { describe('LocalesController', () => { let localesModule: TestingModule; let localesController: LocalesController; - let cacheService: CollectionCacheService; const mockConfig = { baseLocale: 'en', @@ -31,9 +30,7 @@ describe('LocalesController', () => { }, }; - const mockCacheService = { - clearCache: jest.fn(), - }; + const mockIndex = { apply: jest.fn() }; beforeEach(async () => { localesModule = await Test.createTestingModule({ @@ -46,14 +43,13 @@ describe('LocalesController', () => { }, }, { - provide: CollectionCacheService, - useValue: mockCacheService, + provide: CollectionIndex, + useValue: mockIndex, }, ], }).compile(); localesController = localesModule.get(LocalesController); - cacheService = localesModule.get(CollectionCacheService); jest.clearAllMocks(); }); @@ -65,12 +61,14 @@ describe('LocalesController', () => { entriesBackfilled: 4, filesUpdated: 2, }; - (core.addLocaleToCollection as jest.Mock).mockResolvedValue(mockResult); + const mutations = [{ kind: 'reindex', translationsFolder: '/t' }]; + (core.addLocaleToCollection as jest.Mock).mockResolvedValue({ ...mockResult, mutations }); const result = await localesController.addLocale('test-collection', { locale: 'de' }); expect(core.addLocaleToCollection).toHaveBeenCalledWith('test-collection', 'de'); - expect(cacheService.clearCache).toHaveBeenCalled(); + expect(mockIndex.apply).toHaveBeenCalledWith(mutations); + // The mutations are for the index; the response is unchanged. expect(result).toEqual(mockResult); }); @@ -136,10 +134,10 @@ describe('LocalesController', () => { expect((error as HttpException).getStatus()).toBe(403); }); - it('does not clear cache when collection lookup fails before core is called', async () => { + it('does not touch the index when collection lookup fails before core is called', async () => { await localesController.addLocale('nonexistent-collection', { locale: 'de' }).catch(() => undefined); - expect(cacheService.clearCache).not.toHaveBeenCalled(); + expect(mockIndex.apply).not.toHaveBeenCalled(); }); }); @@ -150,12 +148,14 @@ describe('LocalesController', () => { entriesPurged: 3, filesUpdated: 2, }; - (core.removeLocaleFromCollection as jest.Mock).mockResolvedValue(mockResult); + const mutations = [{ kind: 'reindex', translationsFolder: '/t' }]; + (core.removeLocaleFromCollection as jest.Mock).mockResolvedValue({ ...mockResult, mutations }); const result = await localesController.removeLocale('test-collection', 'fr'); expect(core.removeLocaleFromCollection).toHaveBeenCalledWith('test-collection', 'fr'); - expect(cacheService.clearCache).toHaveBeenCalled(); + expect(mockIndex.apply).toHaveBeenCalledWith(mutations); + // The mutations are for the index; the response is unchanged. expect(result).toEqual(mockResult); }); @@ -209,10 +209,10 @@ describe('LocalesController', () => { expect((error as HttpException).getStatus()).toBe(403); }); - it('does not clear cache when collection lookup fails before core is called', async () => { + it('does not touch the index when collection lookup fails before core is called', async () => { await localesController.removeLocale('nonexistent-collection', 'fr').catch(() => undefined); - expect(cacheService.clearCache).not.toHaveBeenCalled(); + expect(mockIndex.apply).not.toHaveBeenCalled(); }); it('passes collection name and locale directly to core function', async () => { diff --git a/apps/api/src/app/collections/locales/locales.controller.ts b/apps/api/src/app/collections/locales/locales.controller.ts index 3b44994f..e68e9296 100644 --- a/apps/api/src/app/collections/locales/locales.controller.ts +++ b/apps/api/src/app/collections/locales/locales.controller.ts @@ -13,7 +13,7 @@ import { import { addLocaleToCollection, ReadOnlyCollectionError, removeLocaleFromCollection } from '@simoncodes-ca/core'; import type { AddLocaleDto, AddLocaleResponseDto, RemoveLocaleResponseDto } from '@simoncodes-ca/data-transfer'; import { ConfigService } from '../../config/config.service'; -import { CollectionCacheService } from '../../cache/collection-cache.service'; +import { CollectionIndex } from '../../cache/collection-index.service'; import { WritableCollectionGuard } from '../guards/writable-collection.guard'; import { openRouteCollection } from '../open-route-collection'; @@ -21,11 +21,11 @@ import { openRouteCollection } from '../open-route-collection'; @Controller('collections/:collectionName/locales') export class LocalesController { readonly #configService: ConfigService; - readonly #cacheService: CollectionCacheService; + readonly #index: CollectionIndex; - constructor(configService: ConfigService, cacheService: CollectionCacheService) { + constructor(configService: ConfigService, index: CollectionIndex) { this.#configService = configService; - this.#cacheService = cacheService; + this.#index = index; } @Post() @@ -36,11 +36,10 @@ export class LocalesController { try { const { name } = openRouteCollection(this.#configService.getConfig(), collectionName); - const result = await addLocaleToCollection(name, body.locale); + const { mutations, ...response } = await addLocaleToCollection(name, body.locale); + this.#index.apply(mutations); - this.#cacheService.clearCache(name); - - return result; + return response; } catch (error: unknown) { if (error instanceof NotFoundException || error instanceof HttpException) { throw error; @@ -77,11 +76,10 @@ export class LocalesController { try { const { name } = openRouteCollection(this.#configService.getConfig(), collectionName); - const result = await removeLocaleFromCollection(name, locale); - - this.#cacheService.clearCache(name); + const { mutations, ...response } = await removeLocaleFromCollection(name, locale); + this.#index.apply(mutations); - return result; + return response; } catch (error: unknown) { if (error instanceof NotFoundException || error instanceof HttpException) { throw error; diff --git a/apps/api/src/app/collections/resources/resources.controller.spec.ts b/apps/api/src/app/collections/resources/resources.controller.spec.ts index 68b2497f..2c8010ba 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -6,7 +6,7 @@ import type { TranslationStatus, LocaleMetadata } from '@simoncodes-ca/domain'; import type { ResourceTreeDto } from '@simoncodes-ca/data-transfer'; import { ResourcesController } from './resources.controller'; import { ConfigService } from '../../config/config.service'; -import { CollectionCacheService, CacheStatus } from '../../cache/collection-cache.service'; +import { CollectionIndex } from '../../cache/collection-index.service'; import { TranslationJobService } from '../../translation-job/translation-job.service'; import * as core from '@simoncodes-ca/core'; @@ -21,12 +21,8 @@ jest.mock('@simoncodes-ca/core', () => { moveResourcesByPattern: jest.fn(), editResource: jest.fn(), translateExistingResource: jest.fn(), - loadResourceTree: jest.fn(), - extractSubtree: jest.fn(), createDefaultTranslations: jest.fn(), extractResourcesRecursively: jest.fn(), - searchResourceTree: jest.fn(), - searchTranslations: jest.fn(), }; }); @@ -94,7 +90,6 @@ describe('ResourcesController', () => { let resourcesModule: TestingModule; let resourcesController: ResourcesController; let configService: ConfigService; - let cacheService: CollectionCacheService; const mockConfig = { exportFolder: 'dist/lingo-export', @@ -110,16 +105,11 @@ describe('ResourcesController', () => { }, }; - const mockCacheService = { - getCacheStatus: jest.fn(), - getCache: jest.fn(), - getCacheMetadata: jest.fn(), - getCacheStats: jest.fn(), - indexCollection: jest.fn(), - clearCache: jest.fn(), - revalidate: jest.fn().mockReturnValue(false), - addResourceToCache: jest.fn().mockReturnValue(true), - removeResourceFromCache: jest.fn().mockReturnValue(true), + const mockIndex = { + tree: jest.fn(), + search: jest.fn(), + status: jest.fn(), + apply: jest.fn(), }; beforeEach(async () => { @@ -133,8 +123,8 @@ describe('ResourcesController', () => { }, }, { - provide: CollectionCacheService, - useValue: mockCacheService, + provide: CollectionIndex, + useValue: mockIndex, }, { provide: TranslationJobService, @@ -148,7 +138,6 @@ describe('ResourcesController', () => { resourcesController = resourcesModule.get(ResourcesController); configService = resourcesModule.get(ConfigService); - cacheService = resourcesModule.get(CollectionCacheService); }); afterEach(() => { @@ -1059,32 +1048,42 @@ describe('ResourcesController', () => { children: [], }; - it('should return full cached tree when cache is READY and no path provided', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCache = cacheService.getCache as jest.Mock; + const mockResponse = () => ({ status: jest.fn().mockReturnThis() }); - getCacheStatus.mockReturnValue(CacheStatus.READY); - getCache.mockReturnValue(mockTreeNode); + it('should return the tree read from the index', async () => { + mockIndex.tree.mockReturnValue({ status: 'ready', tree: mockTreeNode }); + const response = mockResponse(); - const result = await resourcesController.getTree('test-collection', ''); - const tree = result as ResourceTreeDto; + const tree = (await resourcesController.getTree( + 'test-collection', + undefined, + undefined, + response as any, + )) as ResourceTreeDto; - expect(tree).toHaveProperty('path', ''); - expect(tree).toHaveProperty('resources'); - expect(tree.resources).toHaveLength(1); - expect(tree.resources[0].key).toBe('title'); - expect(getCacheStatus).toHaveBeenCalledWith('test-collection'); - expect(getCache).toHaveBeenCalledWith('test-collection'); + expect(tree.path).toBe(''); + expect(tree.resources.map((r) => r.key)).toEqual(['title']); + expect(mockIndex.tree).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-collection' }), ''); + expect(response.status).not.toHaveBeenCalled(); }); - it('should list every resource recursively at the root when includeNested is set', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCache = cacheService.getCache as jest.Mock; - const extractSubtree = core.extractSubtree as jest.Mock; - const extractResourcesRecursively = core.extractResourcesRecursively as jest.Mock; + it('should pass the path to the index', async () => { + mockIndex.tree.mockReturnValue({ status: 'ready', tree: { ...mockTreeNode, folderPathSegments: ['apps'] } }); + + const tree = (await resourcesController.getTree( + 'test-collection', + 'apps', + undefined, + mockResponse() as any, + )) as ResourceTreeDto; + + expect(tree.path).toBe('apps'); + expect(mockIndex.tree).toHaveBeenCalledWith(expect.anything(), 'apps'); + }); - getCacheStatus.mockReturnValue(CacheStatus.READY); - getCache.mockReturnValue(mockTreeNode); + it('should list every resource recursively when includeNested is set', async () => { + const extractResourcesRecursively = core.extractResourcesRecursively as jest.Mock; + mockIndex.tree.mockReturnValue({ status: 'ready', tree: mockTreeNode }); extractResourcesRecursively.mockReturnValue([ ...mockTreeNode.resources, { @@ -1098,325 +1097,120 @@ describe('ResourcesController', () => { }, ]); - const result = await resourcesController.getTree('test-collection', '', 'true'); - const tree = result as ResourceTreeDto; + const tree = (await resourcesController.getTree( + 'test-collection', + '', + 'true', + mockResponse() as any, + )) as ResourceTreeDto; - // The root is a folder like any other: it honours includeNested and never goes - // through extractSubtree, which has no path to extract. - expect(extractSubtree).not.toHaveBeenCalled(); expect(extractResourcesRecursively).toHaveBeenCalledWith(mockTreeNode); expect(tree.resources.map((r) => r.key)).toEqual(['title', 'save']); }); - it('should extract and return subtree when cache is READY and path is provided', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCache = cacheService.getCache as jest.Mock; - const extractSubtree = core.extractSubtree as jest.Mock; - - const mockSubtree = { - folderPathSegments: ['apps'], - resources: [ - { - key: 'test', - source: 'Test', - translations: { es: 'Prueba' }, - metadata: { - en: { checksum: 't' }, - }, - }, - ], - children: [], - }; - - getCacheStatus.mockReturnValue(CacheStatus.READY); - getCache.mockReturnValue(mockTreeNode); - extractSubtree.mockReturnValue(mockSubtree); - - const result = await resourcesController.getTree('test-collection', 'apps'); - const tree = result as ResourceTreeDto; - - expect(tree.path).toBe('apps'); - expect(tree.resources).toHaveLength(1); - expect(tree.resources[0].key).toBe('test'); - expect(extractSubtree).toHaveBeenCalledWith(mockTreeNode, 'apps'); - }); - - it('should return 202 when cache is NOT_STARTED and trigger indexing', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const indexCollection = cacheService.indexCollection as jest.Mock; - - getCacheStatus.mockReturnValue(CacheStatus.NOT_STARTED); - indexCollection.mockResolvedValue(undefined); - - const mockResponse = { - status: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - }; - - await resourcesController.getTree('test-collection', '', mockResponse as any); - - expect(indexCollection).toHaveBeenCalledWith('test-collection', resolve('./translations/test'), 3); - expect(mockResponse.status).toHaveBeenCalledWith(202); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'not-ready', - message: expect.stringContaining('indexing started'), - }), - ); - }); - - it('should return 202 when cache is INDEXING', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; + it.each([ + ['not-started', { status: 'not-ready', message: expect.stringContaining('indexing started') }], + ['error', { status: 'not-ready', message: expect.stringContaining('re-indexing') }], + ['indexing', { status: 'indexing', message: expect.stringContaining('currently being indexed') }], + ])('should return 202 when the index is %s', async (status, expected) => { + mockIndex.tree.mockReturnValue({ status }); + const response = mockResponse(); - getCacheStatus.mockReturnValue(CacheStatus.INDEXING); + const result = await resourcesController.getTree('test-collection', '', undefined, response as any); - const mockResponse = { - status: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - }; - - await resourcesController.getTree('test-collection', '', mockResponse as any); - - expect(mockResponse.status).toHaveBeenCalledWith(202); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'indexing', - message: expect.stringContaining('currently being indexed'), - }), - ); + expect(response.status).toHaveBeenCalledWith(202); + expect(result).toEqual(expected); }); - it('should return 202 when cache is ERROR and trigger re-indexing', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const indexCollection = cacheService.indexCollection as jest.Mock; - - getCacheStatus.mockReturnValue(CacheStatus.ERROR); - indexCollection.mockResolvedValue(undefined); - - const mockResponse = { - status: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - }; + it('should return 404 when the path is not in the tree', async () => { + mockIndex.tree.mockReturnValue({ status: 'ready', tree: null }); - await resourcesController.getTree('test-collection', '', mockResponse as any); - - expect(indexCollection).toHaveBeenCalledWith('test-collection', resolve('./translations/test'), 3); - expect(mockResponse.status).toHaveBeenCalledWith(202); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'not-ready', - message: expect.stringContaining('re-indexing'), - }), - ); + await expect( + resourcesController.getTree('test-collection', 'nonexistent.path', undefined, mockResponse() as any), + ).rejects.toThrow(NotFoundException); }); - it('should return 404 when path is not found in cached tree', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCache = cacheService.getCache as jest.Mock; - const extractSubtree = core.extractSubtree as jest.Mock; - - getCacheStatus.mockReturnValue(CacheStatus.READY); - getCache.mockReturnValue(mockTreeNode); - extractSubtree.mockReturnValue(null); + it('should return 404 for non-existent collection', async () => { + jest.spyOn(configService, 'getConfig').mockReturnValue({ ...mockConfig, collections: {} }); - await expect(resourcesController.getTree('test-collection', 'nonexistent.path')).rejects.toThrow( + await expect(resourcesController.getTree('nonexistent', '', undefined, mockResponse() as any)).rejects.toThrow( NotFoundException, ); }); - it('should return 404 for non-existent collection', async () => { - const configWithoutCollection = { - ...mockConfig, - collections: {}, - }; - jest.spyOn(configService, 'getConfig').mockReturnValue(configWithoutCollection); - - await expect(resourcesController.getTree('nonexistent', '')).rejects.toThrow(NotFoundException); - }); - - it('should throw error when cache is READY but tree is null', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCache = cacheService.getCache as jest.Mock; - - getCacheStatus.mockReturnValue(CacheStatus.READY); - getCache.mockReturnValue(null); + it('should return 500 when reading the index throws', async () => { + mockIndex.tree.mockImplementationOnce(() => { + throw new Error('boom'); + }); - await expect(resourcesController.getTree('test-collection', '')).rejects.toThrow(HttpException); + await expect( + resourcesController.getTree('test-collection', '', undefined, mockResponse() as any), + ).rejects.toThrow(HttpException); }); }); describe('getCacheStatus', () => { - it('should return cache status READY with indexedAt', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCacheMetadata = cacheService.getCacheMetadata as jest.Mock; - const getCacheStats = cacheService.getCacheStats as jest.Mock; - - const indexedAt = new Date('2026-01-21T12:00:00Z'); - getCacheStatus.mockReturnValue(CacheStatus.READY); - getCacheMetadata.mockReturnValue({ indexedAt, error: undefined }); - getCacheStats.mockReturnValue({ totalKeys: 42, localeCount: 3 }); - - const result = await resourcesController.getCacheStatus('test-collection'); - - expect(result).toEqual({ - status: 'ready', - collectionName: 'test-collection', - indexedAt: indexedAt.toISOString(), - stats: { - totalKeys: 42, - localeCount: 3, - }, - }); - expect(getCacheStatus).toHaveBeenCalledWith('test-collection'); - expect(getCacheMetadata).toHaveBeenCalledWith('test-collection'); - expect(getCacheStats).toHaveBeenCalledWith('test-collection'); - }); - - it('should return cache status INDEXING', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCacheMetadata = cacheService.getCacheMetadata as jest.Mock; - - getCacheStatus.mockReturnValue(CacheStatus.INDEXING); - getCacheMetadata.mockReturnValue({ indexedAt: null, error: undefined }); - - const result = await resourcesController.getCacheStatus('test-collection'); - - expect(result).toEqual({ - status: 'indexing', - collectionName: 'test-collection', - }); - }); - - it('should return cache status ERROR with error message', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCacheMetadata = cacheService.getCacheMetadata as jest.Mock; - - getCacheStatus.mockReturnValue(CacheStatus.ERROR); - getCacheMetadata.mockReturnValue({ - indexedAt: null, - error: 'Failed to load tree', - }); - - const result = await resourcesController.getCacheStatus('test-collection'); - - expect(result).toEqual({ - status: 'error', - collectionName: 'test-collection', - error: 'Failed to load tree', - }); - }); - - it('should trigger indexing when status is NOT_STARTED', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCacheMetadata = cacheService.getCacheMetadata as jest.Mock; - const indexCollection = cacheService.indexCollection as jest.Mock; - - getCacheStatus.mockReturnValue(CacheStatus.NOT_STARTED); - getCacheMetadata.mockReturnValue(null); - indexCollection.mockResolvedValue(undefined); - - const result = await resourcesController.getCacheStatus('test-collection'); + it('should return the index status', async () => { + const status = { status: 'ready', collectionName: 'test-collection', stats: { totalKeys: 42, localeCount: 3 } }; + mockIndex.status.mockReturnValue(status); - expect(result).toEqual({ - status: 'not-started', - collectionName: 'test-collection', - }); - expect(indexCollection).toHaveBeenCalledWith('test-collection', resolve('./translations/test'), 3); + await expect(resourcesController.getCacheStatus('test-collection')).resolves.toEqual(status); }); it('should return 404 for non-existent collection', async () => { - const configWithoutCollection = { - ...mockConfig, - collections: {}, - }; - jest.spyOn(configService, 'getConfig').mockReturnValue(configWithoutCollection); + jest.spyOn(configService, 'getConfig').mockReturnValue({ ...mockConfig, collections: {} }); await expect(resourcesController.getCacheStatus('nonexistent')).rejects.toThrow(NotFoundException); }); it('should URI decode collection names with special characters', async () => { - const getCacheStatus = cacheService.getCacheStatus as jest.Mock; - const getCacheMetadata = cacheService.getCacheMetadata as jest.Mock; - const getCacheStats = cacheService.getCacheStats as jest.Mock; - - const configWithEncodedName = { + jest.spyOn(configService, 'getConfig').mockReturnValue({ ...mockConfig, - collections: { - 'My Collection': { - translationsFolder: './translations/my-collection', - }, - }, - }; - jest.spyOn(configService, 'getConfig').mockReturnValue(configWithEncodedName); - - getCacheStatus.mockReturnValue(CacheStatus.READY); - getCacheMetadata.mockReturnValue({ - indexedAt: new Date(), - error: undefined, + collections: { 'My Collection': { translationsFolder: './translations/my-collection' } }, }); - getCacheStats.mockReturnValue({ totalKeys: 10, localeCount: 2 }); + mockIndex.status.mockReturnValue({ status: 'ready', collectionName: 'My Collection' }); - const result = await resourcesController.getCacheStatus('My%20Collection'); + await resourcesController.getCacheStatus('My%20Collection'); - expect(result.collectionName).toBe('My Collection'); - expect(getCacheStatus).toHaveBeenCalledWith('My Collection'); + expect(mockIndex.status).toHaveBeenCalledWith(expect.objectContaining({ name: 'My Collection' })); }); }); describe('search', () => { - it('should successfully search for translations using cache', async () => { - const searchResourceTree = core.searchResourceTree as jest.Mock; - const mockResults = [ + it('should map the search results from the index', async () => { + mockIndex.search.mockReturnValue([ { key: 'app.title', source: 'LingoTracker', translations: { es: 'LingoTracker' }, metadata: { en: { checksum: 'a' }, es: { status: 'translated', checksum: 'b', baseChecksum: 'a' } }, }, - ]; - searchResourceTree.mockReturnValue(mockResults); - (cacheService.getCache as jest.Mock).mockReturnValue({ resources: [], children: [] }); + ]); const result = await resourcesController.search('test-collection', { query: 'lingo' }); expect(result.query).toBe('lingo'); - expect(result.results).toHaveLength(1); - expect(result.results[0].key).toBe('app.title'); - expect(searchResourceTree).toHaveBeenCalled(); - }); - - it('should fall back to disk-based search when cache is not available', async () => { - const searchTranslations = core.searchTranslations as jest.Mock; - searchTranslations.mockReturnValue([]); - (cacheService.getCache as jest.Mock).mockReturnValue(null); - - await resourcesController.search('test-collection', { query: 'lingo' }); - - expect(searchTranslations).toHaveBeenCalledWith( - expect.objectContaining({ - translationsFolder: resolve('./translations/test'), - query: 'lingo', - }), - ); + expect(result.results.map((r) => r.key)).toEqual(['app.title']); + expect(result.limited).toBe(false); + expect(mockIndex.search).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-collection' }), 'lingo', 101); }); it('should return empty results for empty query', async () => { const result = await resourcesController.search('test-collection', { query: '' }); expect(result.results).toEqual([]); + expect(mockIndex.search).not.toHaveBeenCalled(); }); - it('should cap maxResults at 500', async () => { - const searchResourceTree = core.searchResourceTree as jest.Mock; - searchResourceTree.mockReturnValue([]); - (cacheService.getCache as jest.Mock).mockReturnValue({ resources: [], children: [] }); + it('should cap maxResults at 500 and report limited results', async () => { + mockIndex.search.mockReturnValue( + Array.from({ length: 501 }, (_, i) => ({ key: `k${i}`, source: 'x', translations: {}, metadata: {} })), + ); - await resourcesController.search('test-collection', { query: 'test', maxResults: 1000 }); + const result = await resourcesController.search('test-collection', { query: 'test', maxResults: 1000 }); - expect(searchResourceTree).toHaveBeenCalledWith( - expect.objectContaining({ - maxResults: 501, // 500 + 1 for limited detection - }), - ); + expect(mockIndex.search).toHaveBeenCalledWith(expect.anything(), 'test', 501); + expect(result.limited).toBe(true); + expect(result.results).toHaveLength(500); }); }); @@ -1539,19 +1333,21 @@ describe('ResourcesController', () => { ); }); - it('should update the cache after a successful translation', async () => { + it('should hand the translation mutations to the index', async () => { (configService.getConfig as jest.Mock).mockReturnValue(configWithTranslation); + const mutations = [{ kind: 'upsert', translationsFolder: '/t', key: 'buttons.save', entry: mockEntry }]; const translateExistingResource = core.translateExistingResource as jest.Mock; translateExistingResource.mockResolvedValue({ translatedCount: 1, skippedLocales: [], entry: mockEntry, + mutations, }); await resourcesController.translateResource('test-collection', { key: 'buttons.save' }); - expect(mockCacheService.addResourceToCache).toHaveBeenCalledWith('test-collection', mockEntry, 'buttons'); + expect(mockIndex.apply).toHaveBeenCalledWith(mutations); }); it('should include skipped locales in the response', async () => { diff --git a/apps/api/src/app/collections/resources/resources.controller.ts b/apps/api/src/app/collections/resources/resources.controller.ts index 2a7664fd..a4b11671 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -12,7 +12,6 @@ import { ForbiddenException, NotFoundException, Res, - Logger, UseGuards, } from '@nestjs/common'; import type { Response } from 'express'; @@ -24,14 +23,8 @@ import { editResource, translateExistingResource, TranslationError, - searchTranslations, - searchResourceTree, - extractSubtree, extractResourcesRecursively, - createResourceMetadata, type Collection, - type SearchResult, - type ResourceTreeEntry, } from '@simoncodes-ca/core'; import type { CreateResourceDto, @@ -57,7 +50,7 @@ import { ConfigService } from '../../config/config.service'; import { mapDtoToAddResourceParams } from '../../mappers/resource.mapper'; import { mapResourceTreeToDto, mapResourceEntryToSummary } from '../../mappers/resource-tree.mapper'; import { mapSearchResultsToDto } from '../../mappers/search-result.mapper'; -import { CollectionCacheService, CacheStatus } from '../../cache/collection-cache.service'; +import { CollectionIndex } from '../../cache/collection-index.service'; import { TranslationJobService } from '../../translation-job/translation-job.service'; import { WritableCollectionGuard } from '../guards/writable-collection.guard'; import { openDestinationCollection, openRouteCollection } from '../open-route-collection'; @@ -65,18 +58,13 @@ import { openDestinationCollection, openRouteCollection } from '../open-route-co @UseGuards(WritableCollectionGuard) @Controller('collections/:collectionName/resources') export class ResourcesController { - readonly #logger = new Logger(ResourcesController.name); readonly #configService: ConfigService; - readonly #cacheService: CollectionCacheService; + readonly #index: CollectionIndex; readonly #translationJobService: TranslationJobService; - constructor( - configService: ConfigService, - cacheService: CollectionCacheService, - translationJobService: TranslationJobService, - ) { + constructor(configService: ConfigService, index: CollectionIndex, translationJobService: TranslationJobService) { this.#configService = configService; - this.#cacheService = cacheService; + this.#index = index; this.#translationJobService = translationJobService; } @@ -102,7 +90,7 @@ export class ResourcesController { cwd: process.cwd(), }); - this.#cacheService.addResourceToCache(collection.name, result.entry, dto.key.split('.').slice(0, -1).join('.')); + this.#index.apply(result.mutations); const resource = mapResourceEntryToSummary(result.entry, collection.tags); @@ -137,7 +125,7 @@ export class ResourcesController { ): Promise { try { const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const { name: decodedCollectionName, translationsFolder, baseLocale, locales, translationConfig } = collection; + const { translationsFolder, baseLocale, locales, translationConfig } = collection; // Normalize to array const resources = Array.isArray(body) ? body : [body]; @@ -185,38 +173,7 @@ export class ResourcesController { allSkippedLocales.push(...result.skippedLocales); } - // Add resource to cache instead of clearing it - const resolvedKeyParts = result.resolvedKey.split('.'); - const entryKey = resolvedKeyParts.pop() || ''; - const folderPath = resolvedKeyParts.join('.'); - - // Build translations record from actual result (includes auto-translated values) - const translationsRecord: Record = {}; - const actualTranslations = result.translations?.length > 0 ? result.translations : translations || []; - for (const t of actualTranslations) { - if (t.locale !== resourceBaseLocale) { - translationsRecord[t.locale] = t.value; - } - } - - // Create metadata for cache entry - const metadata = createResourceMetadata({ - entryKey, - baseValue: resource.baseValue, - baseLocale: resourceBaseLocale, - translations: actualTranslations, - }); - - const cacheEntry: ResourceTreeEntry = { - key: entryKey, - source: resource.baseValue, - translations: translationsRecord, - metadata, - ...(resource.comment && { comment: resource.comment }), - ...(resource.tags && resource.tags.length > 0 && { tags: resource.tags }), - }; - - this.#cacheService.addResourceToCache(decodedCollectionName, cacheEntry, folderPath); + this.#index.apply(result.mutations); } catch (error: unknown) { // Validation errors (invalid key, etc.) should return 400 const errorMessage = error instanceof Error ? error.message : ''; @@ -256,10 +213,7 @@ export class ResourcesController { @Body() dto: DeleteResourceDto, ): Promise { try { - const { name: decodedCollectionName, translationsFolder } = openRouteCollection( - this.#configService.getConfig(), - collectionName, - ); + const { translationsFolder } = openRouteCollection(this.#configService.getConfig(), collectionName); if (!dto.keys || !Array.isArray(dto.keys) || dto.keys.length === 0) { throw new HttpException( @@ -269,11 +223,7 @@ export class ResourcesController { } const result = deleteResource(translationsFolder, { keys: dto.keys }); - - // Clear cache after successful resource deletion - if (result.entriesDeleted > 0) { - this.#cacheService.clearCache(decodedCollectionName); - } + this.#index.apply(result.mutations); return { entriesDeleted: result.entriesDeleted, @@ -300,7 +250,7 @@ export class ResourcesController { ): Promise { try { const config = this.#configService.getConfig(); - const { name: decodedCollectionName, translationsFolder } = openRouteCollection(config, collectionName); + const { translationsFolder } = openRouteCollection(config, collectionName); const result: MoveResourceResponseDto = { movedCount: 0, @@ -315,10 +265,6 @@ export class ResourcesController { ); } - // Every collection a move touched, so each one's cache is dropped and no untouched - // collection's cache is. - const affectedCollections = new Set([decodedCollectionName]); - for (const moveOp of dto.moves) { let destinationTranslationsFolder: string | undefined; @@ -335,7 +281,6 @@ export class ResourcesController { continue; } destinationTranslationsFolder = destination.translationsFolder; - affectedCollections.add(destination.name); } const moveResult = await moveResource(translationsFolder, { @@ -344,6 +289,7 @@ export class ResourcesController { override: moveOp.override, destinationTranslationsFolder: destinationTranslationsFolder, }); + this.#index.apply(moveResult.mutations); result.movedCount += moveResult.movedCount; if (moveResult.warnings && result.warnings) { @@ -354,13 +300,6 @@ export class ResourcesController { } } - // Clear cache after successful resource move - if (result.movedCount > 0) { - for (const affected of affectedCollections) { - this.#cacheService.clearCache(affected); - } - } - return result; } catch (error: unknown) { if (error instanceof NotFoundException || error instanceof HttpException) { @@ -379,7 +318,6 @@ export class ResourcesController { ): Promise { try { const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const decodedCollectionName = collection.name; const result = await editResource(collection.translationsFolder, { ...dto, @@ -388,24 +326,9 @@ export class ResourcesController { allLocales: collection.locales, }); - let resourceDto: ResourceSummaryDto | undefined; - - if (result.updated && result.entry) { - const keyParts = dto.key.split('.'); - const entryKey = keyParts[keyParts.length - 1]; - const oldFolderPath = keyParts.slice(0, -1).join('.'); - - if (dto.targetFolder !== undefined && dto.targetFolder !== oldFolderPath) { - // Resource moved to a different folder — remove from old location, insert at new - this.#cacheService.removeResourceFromCache(decodedCollectionName, entryKey, oldFolderPath); - this.#cacheService.addResourceToCache(decodedCollectionName, result.entry, dto.targetFolder ?? ''); - } else { - // In-place edit — upsert in the same folder - this.#cacheService.addResourceToCache(decodedCollectionName, result.entry, oldFolderPath); - } - - resourceDto = mapResourceEntryToSummary(result.entry, collection.tags); - } + this.#index.apply(result.mutations); + const resourceDto: ResourceSummaryDto | undefined = + result.updated && result.entry ? mapResourceEntryToSummary(result.entry, collection.tags) : undefined; return { resolvedKey: result.resolvedKey, @@ -436,110 +359,44 @@ export class ResourcesController { @Get('tree') async getTree( @Param('collectionName') collectionName: string, - @Query('path') path = '', - @Query('includeNested') includeNested?: string, - @Res() response?: Response, + @Query('path') path: string | undefined, + @Query('includeNested') includeNested: string | undefined, + @Res({ passthrough: true }) response: Response, ): Promise { try { - // Support two calling styles for tests and consumers: - // 1) (collectionName, path, includeNested, response) - // 2) (collectionName, path, response) - tests pass response as third arg - // Detect when includeNested is actually the Response object and adjust accordingly. - let responseObj: Response | undefined = response; - let isIncludeNested = includeNested === 'true'; - - if ( - includeNested !== undefined && - typeof includeNested === 'object' && - includeNested !== null && - typeof (includeNested as Record).status === 'function' && - typeof (includeNested as Record).json === 'function' - ) { - responseObj = includeNested as unknown as Response; - isIncludeNested = false; - } - const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const { name: decodedCollectionName, translationsFolder } = collection; - - // Pick up changes made outside this process (CLI commands, git checkouts, hand edits) - // before trusting the cache. - this.#cacheService.revalidate(decodedCollectionName, translationsFolder); - - // Check cache status - const cacheStatus = this.#cacheService.getCacheStatus(decodedCollectionName); - - // Handle cache states - if (cacheStatus === CacheStatus.NOT_STARTED || cacheStatus === CacheStatus.ERROR) { - // Trigger indexing asynchronously (don't await) - this.#cacheService - .indexCollection(decodedCollectionName, translationsFolder, collection.locales.length) - .catch((error) => { - this.#logger.warn(`Async indexing failed for ${decodedCollectionName}`, error); - }); - - const statusResponse: TreeStatusResponseDto = { - status: 'not-ready', - message: - cacheStatus === CacheStatus.ERROR - ? 'Cache indexing failed, re-indexing collection. Please try again shortly.' - : 'Collection indexing started. Please try again shortly.', - }; - - if (responseObj) { - responseObj.status(HttpStatus.ACCEPTED).json(statusResponse); - return statusResponse; - } - return statusResponse; - } - - if (cacheStatus === CacheStatus.INDEXING) { - const statusResponse: TreeStatusResponseDto = { - status: 'indexing', - message: 'Collection is currently being indexed. Please try again shortly.', - }; - - if (responseObj) { - responseObj.status(HttpStatus.ACCEPTED).json(statusResponse); - return statusResponse; - } - return statusResponse; - } - - // Cache is READY - retrieve cached tree - const cachedTree = this.#cacheService.getCache(decodedCollectionName); - - if (!cachedTree) { - throw new HttpException('Cache is marked as ready but tree is not available', HttpStatus.INTERNAL_SERVER_ERROR); + const read = this.#index.tree(collection, path ?? ''); + + if (read.status !== 'ready') { + response.status(HttpStatus.ACCEPTED); + return read.status === 'indexing' + ? { status: 'indexing', message: 'Collection is currently being indexed. Please try again shortly.' } + : { + status: 'not-ready', + message: + read.status === 'error' + ? 'Cache indexing failed, re-indexing collection. Please try again shortly.' + : 'Collection indexing started. Please try again shortly.', + }; + } + + if (!read.tree) { + throw new NotFoundException(`Path "${path}" not found in collection tree`); } // An empty path addresses the collection root, which the artificial root node in the // Tracker sidebar selects. It is a folder like any other here, so it honours // includeNested too and can list every resource in the collection. - const subtree = !path || path.trim() === '' ? cachedTree : extractSubtree(cachedTree, path); + const treeDto = mapResourceTreeToDto(read.tree, collection.tags); - if (!subtree) { - throw new NotFoundException(`Path "${path}" not found in collection tree`); - } - - const treeDto = mapResourceTreeToDto(subtree, collection.tags); - - if (isIncludeNested) { - treeDto.resources = extractResourcesRecursively(subtree).map((res) => + if (includeNested === 'true') { + treeDto.resources = extractResourcesRecursively(read.tree).map((res) => mapResourceEntryToSummary(res, collection.tags), ); } - if (responseObj) { - responseObj.status(HttpStatus.OK).json(treeDto); - return treeDto; - } return treeDto; } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } - if (error instanceof HttpException) { throw error; } @@ -552,54 +409,8 @@ export class ResourcesController { @Get('cache/status') async getCacheStatus(@Param('collectionName') collectionName: string): Promise { try { - const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const { name: decodedCollectionName, translationsFolder } = collection; - this.#cacheService.revalidate(decodedCollectionName, translationsFolder); - - const cacheStatus = this.#cacheService.getCacheStatus(decodedCollectionName); - - // If cache is not started, trigger indexing asynchronously - if (cacheStatus === CacheStatus.NOT_STARTED) { - this.#cacheService - .indexCollection(decodedCollectionName, translationsFolder, collection.locales.length) - .catch((error) => { - this.#logger.warn(`Async indexing failed for ${decodedCollectionName}`, error); - }); - } - - // Get additional cache metadata - const metadata = this.#cacheService.getCacheMetadata(decodedCollectionName); - - // Map CacheStatus enum to CacheStatusType string literal - const statusType = cacheStatus as 'not-started' | 'indexing' | 'ready' | 'error'; - - const statusDto: CacheStatusDto = { - status: statusType, - collectionName: decodedCollectionName, - }; - - if (metadata?.indexedAt) { - statusDto.indexedAt = metadata.indexedAt.toISOString(); - } - - if (metadata?.error) { - statusDto.error = metadata.error; - } - - // Include stats when cache is ready - if (cacheStatus === CacheStatus.READY) { - const stats = this.#cacheService.getCacheStats(decodedCollectionName); - if (stats) { - statusDto.stats = stats; - } - } - - return statusDto; + return this.#index.status(openRouteCollection(this.#configService.getConfig(), collectionName)); } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } - if (error instanceof HttpException) { throw error; } @@ -616,7 +427,6 @@ export class ResourcesController { ): Promise { try { const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const { name: decodedCollectionName, translationsFolder, baseLocale } = collection; // Validate query if (!dto.query || dto.query.trim().length === 0) { @@ -631,28 +441,8 @@ export class ResourcesController { // Default maxResults to 100, cap at 500 const maxResults = Math.min(dto.maxResults || 100, 500); - // Try to use cached tree for faster search - this.#cacheService.revalidate(decodedCollectionName, translationsFolder); - const cachedTree = this.#cacheService.getCache(decodedCollectionName); - let searchResults: SearchResult[]; - - if (cachedTree) { - // Use in-memory search on cached tree - searchResults = searchResourceTree({ - tree: cachedTree, - query: dto.query, - maxResults: maxResults + 1, // Request one extra to detect if limited - baseLocale, - }); - } else { - // Fall back to disk-based search - searchResults = searchTranslations({ - translationsFolder, - query: dto.query, - maxResults: maxResults + 1, // Request one extra to detect if limited - baseLocale, - }); - } + // Request one extra result to detect whether the results were limited. + const searchResults = this.#index.search(collection, dto.query, maxResults + 1); // Check if results were limited const limited = searchResults.length > maxResults; diff --git a/apps/api/src/app/translation-job/translation-job.service.spec.ts b/apps/api/src/app/translation-job/translation-job.service.spec.ts index 864d9987..9a248149 100644 --- a/apps/api/src/app/translation-job/translation-job.service.spec.ts +++ b/apps/api/src/app/translation-job/translation-job.service.spec.ts @@ -1,5 +1,6 @@ import { Logger } from '@nestjs/common'; import { TranslationJobService } from './translation-job.service'; +import type { CollectionIndex } from '../cache/collection-index.service'; import { TranslationError } from '@simoncodes-ca/core'; import type { TranslateLocaleResult, TranslateLocaleProgress } from '@simoncodes-ca/core'; @@ -36,11 +37,12 @@ const makeStartJobParams = () => ({ describe('TranslationJobService', () => { let service: TranslationJobService; let mockLogger: jest.Mocked>; + const mockIndex = { apply: jest.fn() }; beforeEach(() => { jest.clearAllMocks(); mockLogger = { error: jest.fn(), log: jest.fn(), warn: jest.fn() }; - service = new TranslationJobService(mockLogger as unknown as Logger); + service = new TranslationJobService(mockLogger as unknown as Logger, mockIndex as unknown as CollectionIndex); }); it('startJob returns a non-empty job ID', () => { @@ -119,6 +121,21 @@ describe('TranslationJobService', () => { expect(job?.status).toBe('failed'); }); + it.each([ + ['completes', () => mockTranslateLocale.mockResolvedValue(makeSuccessResult())], + ['fails', () => mockTranslateLocale.mockRejectedValue(new Error('Unexpected network failure'))], + ])('drops the collection index for the translations folder when the job %s', async (_outcome, arrange) => { + arrange(); + + service.startJob(makeStartJobParams()); + expect(mockIndex.apply).not.toHaveBeenCalled(); + + await Promise.resolve(); + await Promise.resolve(); + + expect(mockIndex.apply).toHaveBeenCalledWith([{ kind: 'reindex', translationsFolder: '/path/to/translations' }]); + }); + it('getJob omits optional fields when there are no failures or skipped keys', async () => { mockTranslateLocale.mockResolvedValue(makeSuccessResult({ failures: [], skippedKeys: [] })); diff --git a/apps/api/src/app/translation-job/translation-job.service.ts b/apps/api/src/app/translation-job/translation-job.service.ts index a30fcf90..60db5d8a 100644 --- a/apps/api/src/app/translation-job/translation-job.service.ts +++ b/apps/api/src/app/translation-job/translation-job.service.ts @@ -1,8 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { translateLocale, TranslationError } from '@simoncodes-ca/core'; +import { resolve } from 'node:path'; +import { reindexMutation, translateLocale, TranslationError } from '@simoncodes-ca/core'; import type { TranslateLocaleParams, TranslateLocaleProgress } from '@simoncodes-ca/core'; import type { TranslateLocaleJobDto } from '@simoncodes-ca/data-transfer'; +import { CollectionIndex } from '../cache/collection-index.service'; interface TranslationJob { jobId: string; @@ -23,10 +25,12 @@ interface TranslationJob { @Injectable() export class TranslationJobService { readonly #logger: Logger; + readonly #index: CollectionIndex; readonly #jobs = new Map(); - constructor(logger: Logger) { + constructor(logger: Logger, index: CollectionIndex) { this.#logger = logger; + this.#index = index; } /** @@ -88,8 +92,15 @@ export class TranslationJobService { runningJob.status = 'running'; runningJob.startedAt = new Date(); + // translateLocale writes resource files (even when it fails part-way), so the index is dropped either way. + const reindex = (): void => + this.#index.apply([ + reindexMutation(resolve(translateParams.cwd ?? process.cwd(), translateParams.translationsFolder)), + ]); + translateLocale({ ...translateParams, onProgress }) .then((result) => { + reindex(); const completedJob = this.#jobs.get(jobId); if (!completedJob) { @@ -106,6 +117,7 @@ export class TranslationJobService { completedJob.skippedKeys = [...result.skippedKeys]; }) .catch((error: unknown) => { + reindex(); const failedJob = this.#jobs.get(jobId); if (!failedJob) { diff --git a/architecture-docs/README.md b/architecture-docs/README.md index b05f8f8d..aa17b0b5 100644 --- a/architecture-docs/README.md +++ b/architecture-docs/README.md @@ -117,7 +117,7 @@ apps (cli, api, tracker) | [`data-flows.md`](data-flows.md) | Placeholder | Sequence diagrams for import/export pipelines, bundle generation, and the checksum-based staleness detection flow. | | [`apps-cli.md`](apps-cli.md) | Placeholder | Full CLI command inventory with options, interactive vs. non-interactive modes, and usage examples. | | [`cli.md`](cli.md) | Available | CLI command table, interactive vs. non-interactive TTY decision flowchart, config loading and collection resolution flow, and shared utilities overview. | -| [`api.md`](api.md) | Available | REST API endpoint reference, NestJS module structure, mapper pattern, collection cache service, and Swagger location. | +| [`api.md`](api.md) | Available | REST API endpoint reference, NestJS module structure, mapper pattern, the Collection Index (in-memory tree per open collection), and Swagger location. | | [`apps-tracker.md`](apps-tracker.md) | Placeholder | Angular UI architecture: route structure, NgRx Signal Store feature files, component hierarchy, and Transloco integration. | | [`frontend.md`](frontend.md) | Available | Tracker UI deep-dive: component trees for both feature areas, BrowserStore feature composition diagram, store feature breakdown, virtual scrolling, optimistic updates, drag-and-drop, lazy dialogs, theming (light/dark/system, Material M2 watercolor palette), and Transloco typed-token integration. | | [`libs-domain.md`](libs-domain.md) | Placeholder | Pure business logic library: resource key parsing and validation, status helpers, ICU-to-Transloco conversion, validation utilities. | diff --git a/architecture-docs/api.md b/architecture-docs/api.md index 33538108..43d12d0f 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -1,6 +1,6 @@ # REST API (`apps/api`) -The NestJS API is LingoTracker's HTTP interface. It exposes all translation management operations over REST, serves the Angular Tracker UI as static files from the same process, and owns two cross-cutting systems: a single-collection in-memory cache that makes the resource tree fast to browse, and an async job runner for long-running locale translation operations. All API routes are prefixed with `/api`; Swagger docs are available at `/api` when the server is running. +The NestJS API is LingoTracker's HTTP interface. It exposes all translation management operations over REST, serves the Angular Tracker UI as static files from the same process, and owns two cross-cutting systems: an in-memory [Collection Index](glossary.md#collection-index) that makes the resource tree fast to browse, and an async job runner for long-running locale translation operations. All API routes are prefixed with `/api`; Swagger docs are available at `/api` when the server is running. Return to [architecture README](README.md). @@ -11,10 +11,11 @@ Return to [architecture README](README.md). - [Endpoint Reference](#endpoint-reference) - [Component Diagram](#component-diagram) - [Static File Serving](#static-file-serving) -- [Collection Cache](#collection-cache) - - [Single-Collection Design](#single-collection-design) - - [Cache State Machine](#cache-state-machine) - - [Incremental Updates vs Full Clear](#incremental-updates-vs-full-clear) +- [Collection Index](#collection-index) + - [Interface](#interface) + - [Bounded Multi-Collection Design](#bounded-multi-collection-design) + - [Index State Machine](#index-state-machine) + - [Writes: Resource Mutations](#writes-resource-mutations) - [Polling Flow from the Frontend](#polling-flow-from-the-frontend) - [Translation Job System](#translation-job-system) - [Mapper Layer](#mapper-layer) @@ -55,8 +56,8 @@ All paths are relative to the `/api` global prefix. URL path parameters that con | `DELETE` | `/collections/:collectionName/resources` | Delete one or more resources by key | `DeleteResourceDto` | `DeleteResourceResponseDto` | | `POST` | `/collections/:collectionName/resources/move` | Move or rename resources (single key or wildcard pattern, cross-collection supported) | `MoveResourceDto` | `MoveResourceResponseDto` | | `POST` | `/collections/:collectionName/resources/translate` | Auto-translate a single resource via the configured provider | `TranslateResourceDto` | `TranslateResourceResponseDto` | -| `GET` | `/collections/:collectionName/resources/tree` | Fetch the resource [tree](glossary.md#resource-tree) (or subtree) from cache | query: `path`, `includeNested` | `ResourceTreeDto \| TreeStatusResponseDto` | -| `GET` | `/collections/:collectionName/resources/cache/status` | Poll the cache [indexing](glossary.md#indexing) state | — | `CacheStatusDto` | +| `GET` | `/collections/:collectionName/resources/tree` | Fetch the resource [tree](glossary.md#resource-tree) (or subtree) from the Collection Index | query: `path`, `includeNested` | `ResourceTreeDto \| TreeStatusResponseDto` | +| `GET` | `/collections/:collectionName/resources/cache/status` | Poll the [Collection Index](glossary.md#collection-index) state (starts indexing) | — | `CacheStatusDto` | | `GET` | `/collections/:collectionName/resources/search` | Full-text search across the collection | query: `SearchTranslationsDto` | `SearchResultsDto` | | `POST` | `/collections/:collectionName/resources/translate-locale` | Fire-and-forget: start a bulk locale translation job | `TranslateLocaleRequestDto` | `TranslateLocaleJobDto` (202 Accepted) | | `GET` | `/collections/:collectionName/resources/translate-locale/:jobId` | Poll a translation job by ID | — | `TranslateLocaleJobDto` | @@ -65,16 +66,16 @@ All paths are relative to the `/api` global prefix. URL path parameters that con | Method | Path | Purpose | Request DTO | Response DTO | |--------|------|---------|-------------|--------------| -| `POST` | `/collections/:collectionName/folders` | Create a [folder](glossary.md#folder) (incremental cache update) | `CreateFolderDto` | `CreateFolderResponseDto` | -| `DELETE` | `/collections/:collectionName/folders` | Delete a folder and all its contents (incremental cache update) | `DeleteFolderDto` | `DeleteFolderResponseDto` | +| `POST` | `/collections/:collectionName/folders` | Create a [folder](glossary.md#folder) | `CreateFolderDto` | `CreateFolderResponseDto` | +| `DELETE` | `/collections/:collectionName/folders` | Delete a folder and all its contents | `DeleteFolderDto` | `DeleteFolderResponseDto` | | `POST` | `/collections/:collectionName/folders/move` | Move a folder within or across collections | `MoveFolderDto` | `MoveFolderResponseDto` | ### Locales | Method | Path | Purpose | Request DTO | Response DTO | |--------|------|---------|-------------|--------------| -| `POST` | `/collections/:collectionName/locales` | Add a locale to a collection (clears cache) | `AddLocaleDto` | `AddLocaleResponseDto` | -| `DELETE` | `/collections/:collectionName/locales/:locale` | Remove a locale from a collection (clears cache) | — | `RemoveLocaleResponseDto` | +| `POST` | `/collections/:collectionName/locales` | Add a locale to a collection (re-indexes) | `AddLocaleDto` | `AddLocaleResponseDto` | +| `DELETE` | `/collections/:collectionName/locales/:locale` | Remove a locale from a collection (re-indexes) | — | `RemoveLocaleResponseDto` | ### Bundles @@ -112,7 +113,7 @@ graph TD subgraph services["Services / Infrastructure"] CONFIGS["ConfigService\ncore loadConfig() on every request\n(errors → 404 / 500)"] - CACHE["CollectionCacheService\nSingle-collection in-memory\nResourceTreeNode cache"] + INDEX["CollectionIndex\ntree · search · status · apply\nin-memory ResourceTreeNode per collection"] JOBS["TranslationJobService\nIn-memory job map\nUUID → TranslationJob"] end @@ -135,12 +136,12 @@ graph TD TRACKER -->|"Static files"| STATIC CLI -.->|"Some flows use API"| controllers - RESC --> CACHE + RESC --> INDEX RESC --> CONFIGS RESC --> JOBS - FOLDC --> CACHE + FOLDC --> INDEX FOLDC --> CONFIGS - LOCALEC --> CACHE + LOCALEC --> INDEX LOCALEC --> CONFIGS COLLC --> CONFIGS CONFIGC --> CONFIGS @@ -153,7 +154,7 @@ graph TD COLLC --> COLMAP CONFIGS -->|"reads .lingo-tracker.json"| COREOPS - CACHE -->|"core.loadResourceTree()"| COREOPS + INDEX -->|"core.loadResourceTree()"| COREOPS RESC -->|"delegate writes"| COREOPS FOLDC -->|"delegate writes"| COREOPS LOCALEC -->|"delegate writes"| COREOPS @@ -166,7 +167,7 @@ graph TD style core fill:#d4edda,stroke:#28a745,color:#000 ``` -Controllers are the only layer that knows HTTP. They read the config from `ConfigService` (a thin wrapper over core `loadConfig()` that maps `ConfigNotFoundError` to 404 and parse/read failures to 500), turn the `:collectionName` route param into the effective `Collection` with `openRouteCollection()` (`collections/open-route-collection.ts`: decodes the name, calls core `openCollection()`, maps `CollectionNotFoundError` to 404), delegate business operations to `@simoncodes-ca/core` (see [core-library.md](core-library.md)), apply mappers at the boundary, and update `CollectionCacheService` incrementally after successful writes. +Controllers are the only layer that knows HTTP. They read the config from `ConfigService` (a thin wrapper over core `loadConfig()` that maps `ConfigNotFoundError` to 404 and parse/read failures to 500), turn the `:collectionName` route param into the effective `Collection` with `openRouteCollection()` (`collections/open-route-collection.ts`: decodes the name, calls core `openCollection()`, maps `CollectionNotFoundError` to 404), delegate business operations to `@simoncodes-ca/core` (see [core-library.md](core-library.md)), apply mappers at the boundary, and pass the `mutations` of every successful core write to `CollectionIndex.apply()`. **Read-only enforcement.** `WritableCollectionGuard` (`collections/guards/writable-collection.guard.ts`) is applied at the class level to the `Resources`, `Locales`, and `Folders` controllers. For any non-`GET` request it reads the `:collectionName` route param, opens the collection with core `openCollection(config, name, { writable: true })`, and maps `ReadOnlyCollectionError` to `403 Forbidden` (unknown collections pass through so the controller returns its 404). This is the single API choke-point for read-only enforcement. The `Collections` controller is intentionally **not** guarded: updating a collection's config entry or unregistering it (`PUT`/`DELETE /collections/:name`) is permitted even for read-only collections, since the lock protects resources, not the registration. On create, the controller defaults `readOnly` to `true` for `node_modules` paths (via the `isUnderNodeModules` domain helper) when the DTO omits it. @@ -184,110 +185,114 @@ This means a single `node apps/api/main.js` process serves both the UI and the A --- -## Collection Cache +## Collection Index -### Bounded Multi-Collection Design +`CollectionIndex` (`apps/api/src/app/cache/collection-index.service.ts`) is a singleton Nest provider. It holds an in-memory copy of each open collection's [resource tree](glossary.md#resource-tree), so the Tracker can browse and search a collection without reading the disk on each request. -`CollectionCacheService` holds a `Map` of `CachedCollection` entries keyed by collection name, capped at `LINGO_TRACKER_MAX_CACHED_COLLECTIONS` (default 4) and evicted least-recently-used. +### Interface -**Why more than one.** Opening a second collection in another browser tab is a real usage pattern. With a single slot, each tab's 2-second `/cache/status` poll evicted the other tab's cache, so neither ever reached `READY`, both polled forever, and the server re-indexed continuously. Independent entries remove the contention entirely. +```typescript +tree(collection: Collection, path?: string): TreeRead; // { status: 'ready', tree | null } | { status: 'not-started' | 'indexing' | 'error' } +search(collection: Collection, query: string, maxResults: number): SearchResult[]; +status(collection: Collection): CacheStatusDto; // for GET .../cache/status +apply(mutations: readonly ResourceMutation[]): void; // after every core write +``` -**Why bounded.** A fully-loaded [resource tree](glossary.md#resource-tree) for a large collection (thousands of keys, multiple locales, full translation values and metadata) can be tens of megabytes of JavaScript heap, and that cost multiplies per cached collection. The cap is a memory budget; lower it to 1 to restore the old single-slot behaviour. +Controllers do not know how the index works. They read with `tree()`, `search()` and `status()`, and give the `mutations` of each core write to `apply()`. These items are internal to the index: -**Eviction.** On inserting a new entry at the cap, the entry with the lowest `accessSequence` is dropped. `accessSequence` is a monotonic counter bumped on every read and write, not a clock — several collections can be touched inside the same millisecond and eviction still needs a strict order. An entry in `INDEXING` state is never chosen: discarding in-flight work would leave the request that started it waiting for nothing, so the map is allowed to overflow briefly when every entry is busy. +- **Indexing.** `tree()` indexes a collection that is not indexed or whose last attempt failed. `status()` indexes only a collection that is not indexed, and reports `error` as it is. Both report the state that they found, so the first read answers `not-started` (and `/tree` returns `202`). `search()` never starts indexing. It searches the disk until the collection is indexed. +- **Revalidation.** Before each read, a ready entry compares a stat-only disk fingerprint (`computeTreeFingerprint`) with the fingerprint from its last index or own write. If they differ, the entry is dropped and indexed again. This makes CLI commands, `git checkout` and hand edits visible without a restart. Filesystem watching is not used, because inotify does not fire for Windows-side writes on a WSL `/mnt/c` mount, and the same is true for some network and container mounts. The check runs at most once per `LINGO_TRACKER_REVALIDATE_INTERVAL_MS` (default 2000 ms) for each entry. +- **Own writes.** After `apply()` patches an entry, the index refreshes that entry's fingerprint at the end of the tick. A bulk endpoint that applies mutations in a loop causes one scan, not one per resource. A read that comes before the refresh adopts the new fingerprint, so an own write is never read as an outside change. +- **Patching.** One tree-walk helper applies each mutation to the tree. When a mutation does not match the tree (for example, a `remove` of a key that the index does not have), the index drops that collection. The next read indexes it again. A wrong patch never stays in memory. -**Per-entry state.** Fingerprint, revalidation throttle stamp and the deferred fingerprint-refresh timer all live on the entry. A read of one collection therefore cannot postpone another collection's staleness check. +### Bounded Multi-Collection Design -### Cache State Machine +The index holds a `Map` of entries keyed by collection name. The map is capped at `LINGO_TRACKER_MAX_CACHED_COLLECTIONS` (default 4). When the cap is reached, the least recently used entry is evicted. - +**Why more than one.** Opening a second collection in another browser tab is a real usage pattern. With a single slot, each tab's 2-second `/cache/status` poll evicted the other tab's entry, so neither reached `ready`, both polled forever, and the server re-indexed continuously. Independent entries remove the contention. -```mermaid -stateDiagram-v2 - [*] --> NOT_STARTED : server start\nor cache eviction +**Why bounded.** A fully loaded tree for a large collection (thousands of keys, many locales, full values and metadata) can use tens of megabytes of JavaScript heap, and that cost multiplies per collection. The cap is a memory budget. Set it to 1 to get the old single-slot behaviour. - NOT_STARTED --> INDEXING : indexCollection() called\n(triggered by first /tree or /cache/status request) - ERROR --> INDEXING : indexCollection() called\n(auto-retry on next /tree request) +**Eviction.** Each read or patch increments the entry's `accessSequence` (a monotonic counter, not a clock, because several collections can be touched in the same millisecond). When a new entry is added at the cap, the entry with the lowest `accessSequence` is dropped. - INDEXING --> READY : core.loadResourceTree() succeeds - INDEXING --> ERROR : core.loadResourceTree() throws +**Per-entry state.** The fingerprint, the revalidation throttle stamp and the deferred fingerprint-refresh timer are stored on the entry. A read of one collection cannot postpone the staleness check of a different collection. - READY --> NOT_STARTED : clearCache(name) called\n(delete/move resource or locale change) - READY --> NOT_STARTED : evicted as least recently used\n(cache at its collection limit) +### Index State Machine - READY --> READY : incremental update\n(addResourceToCache, addFolderToCache,\nremoveFolderFromCache, removeResourceFromCache,\nmoveFolderInCache) -``` + -State values are the string literals from the `CacheStatus` enum in `collection-cache.service.ts`: +```mermaid +stateDiagram-v2 + [*] --> not_started : server start, +eviction, disk change, +reindex or failed patch -| State | String value | Meaning | -|-------|-------------|---------| -| `NOT_STARTED` | `"not-started"` | No cache exists for this collection. Indexing has not been requested yet. | -| `INDEXING` | `"indexing"` | `core.loadResourceTree()` is running asynchronously. Read requests must wait. | -| `READY` | `"ready"` | Tree is in memory. Read requests are served instantly from the entry's `tree`. | -| `ERROR` | `"error"` | The last indexing attempt threw. The error message is stored on the entry. The next `/tree` or `/cache/status` request automatically re-triggers indexing. | + not_started --> indexing : first tree() or status() read + error --> indexing : next tree() read (retry) -### Incremental Updates vs Full Cache Clear + indexing --> ready : core.loadResourceTree() succeeds + indexing --> error : core.loadResourceTree() throws + + ready --> not_started : entry dropped + ready --> ready : apply() patches the tree +``` -After a successful write operation the cache is updated by one of two strategies: +| State | Meaning | +|-------|---------| +| `not-started` | The index has no entry for this collection. The read that reported it has started indexing. | +| `indexing` | `core.loadResourceTree()` is running. `loadResourceTree()` is synchronous, so in practice the read that starts indexing also finishes it; the state is part of the HTTP contract. | +| `ready` | The tree is in memory. Reads are served from it. | +| `error` | The last attempt threw. The message is reported by `status()`. The next `tree()` read tries again. | -**Incremental update** — for operations where the exact structural change is known and bounded. The controller calls a targeted method on `CollectionCacheService` that mutates only the affected subtree of `ResourceTreeNode` in memory, leaving the rest of the tree intact. The cache stays in `READY` state throughout. +### Writes: Resource Mutations -| Cache method | Triggered by | -|---|---| -| `addResourceToCache()` | `POST /resources` (create), `PATCH /resources` (edit in-place or move-to-new-folder) | -| `removeResourceFromCache()` | `PATCH /resources` (when resource moves folder — removes from old location) | -| `addFolderToCache()` | `POST /folders` | -| `removeFolderFromCache()` | `DELETE /folders` | -| `moveFolderInCache()` | `POST /folders/move` within one collection (with `clearCache(name)` fallback if structural navigation fails; a cross-collection move clears both collections instead) | +Each core write returns `mutations: ResourceMutation[]` (see [core-library.md](core-library.md) and the [glossary](glossary.md#resource-mutation)), which describe what changed on disk. The controller calls `index.apply(result.mutations)`. The index finds every entry whose translations folder is the mutation's `translationsFolder`, so a cross-collection move updates the source and the destination with no controller logic. -**Full cache clear** — for operations where the breadth of changes cannot be tracked in a single incremental call, or where correctness risk outweighs the cost of a re-index. `clearCache(collectionName)` drops that one collection's entry, returning it to `NOT_STARTED`; the next request to `/tree` or `/cache/status` triggers a fresh `indexCollection()`. It is always scoped to the collection that was written — a write against one collection must never force a re-index of a collection somebody else is viewing. Cross-collection moves clear the source and every destination collection they touched. `clearAllCaches()` exists for changes that invalidate everything. +| Mutation | Returned by | Index action | +|---|---|---| +| `upsert` (key, entry) | `addResource`, `editResource`, `translateExistingResource`, `moveResource` / `moveFolder` (destination) | Insert or replace the entry. Missing folders are created, as on disk. | +| `remove` (key) | `deleteResource`, `moveResource` / `moveFolder` (source) | Remove the entry. Missing entry → drop the collection. | +| `add-folder` (path) | `createFolder` | Create the folder node (and missing parents). | +| `remove-folder` (path) | `deleteFolder`, `moveFolder` (deleted source folder) | Remove the folder node. Missing folder → drop the collection. | +| `reindex` | `addLocaleToCollection`, `removeLocaleFromCollection` | Drop the collection. Every folder's metadata changed. | -| Operation | Why full clear | -|---|---| -| `DELETE /resources` | Keys may span multiple folders; tracking all removals is error-prone. | -| `POST /resources/move` | Wildcard pattern moves affect an unbounded set of folders. | -| `POST /locales` (add) | Every folder's `tracker_meta.json` gains a new locale entry; the cached tree would be stale everywhere. | -| `DELETE /locales/:locale` | Same — locale removal touches all metadata nodes. | +A folder move is a list of per-key `upsert` + `remove` pairs and then a `remove-folder`. Thus the index follows partial moves, merges into an existing folder, and `nestUnderDestination: false` in the same way as the disk. Writes that do not go through the API (the translate-locale job, CLI commands, imports) are found by revalidation. ### Polling Flow from the Frontend -The Tracker UI polls the cache endpoints when it needs the resource tree. For the full sequence, see [user-flows.md — Cache Indexing Flow](user-flows.md#6-cache-indexing-flow). The protocol is: +The Tracker UI polls the index endpoints when it needs the resource tree. For the full sequence, see [user-flows.md — Cache Indexing Flow](user-flows.md#6-cache-indexing-flow). The protocol is: ```mermaid sequenceDiagram participant UI as Tracker UI participant API as ResourcesController - participant Cache as CollectionCacheService + participant Index as CollectionIndex participant Core as @simoncodes-ca/core UI->>API: GET /api/collections/{name}/resources/tree - API->>Cache: getCacheStatus(name) + API->>Index: tree(collection, path) + Index->>Index: revalidate against disk fingerprint - alt Cache is NOT_STARTED or ERROR - Cache-->>API: NOT_STARTED | ERROR - API->>Cache: indexCollection() [fire, no await] - Cache->>Core: loadResourceTree() [async] + alt Not indexed or last attempt failed + Index->>Core: loadResourceTree() + Index-->>API: { status: "not-started" | "error" } API-->>UI: 202 Accepted { status: "not-ready", message: "..." } - UI->>UI: wait ~1s, then retry + UI->>UI: wait, then retry end - alt Cache is INDEXING - Cache-->>API: INDEXING + alt Indexing + Index-->>API: { status: "indexing" } API-->>UI: 202 Accepted { status: "indexing", message: "..." } - UI->>UI: wait ~1s, then retry end - alt Cache is READY - Core-->>Cache: ResourceTreeNode - Cache-->>API: setCacheStatus(READY, tree) - Cache-->>API: tree (ResourceTreeNode) + alt Ready + Index-->>API: { status: "ready", tree } API->>API: mapResourceTreeToDto(tree) - API-->>UI: 200 OK ResourceTreeDto + API-->>UI: 200 OK ResourceTreeDto (404 when the path is not in the tree) end ``` -A 202 Accepted response always means "retry shortly". A 200 OK carries the full or partial tree. The frontend is responsible for the retry loop; there is no server-sent event or WebSocket involved. +A 202 Accepted response always means "retry shortly". A 200 OK carries the full or partial tree. The route uses `@Res({ passthrough: true })` only to set the 202 status; Nest serializes the returned DTO. The frontend owns the retry loop; there is no server-sent event or WebSocket. --- diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index a49ab5ac..a4e2af3a 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -245,6 +245,8 @@ Resource CRUD is implemented across four functions in `libs/core/src/resource/`. **All writes go through `ResourceFolder`.** `openResourceFolder(folderPath, { baseLocale })` in `lib/resource/resource-folder.ts` is the only owner of a [resource folder](glossary.md#resource-folder) (`resource_entries.json` + `tracker_meta.json`). Add, edit, delete, move, import, normalize, translate-locale, translate-existing-resource, and add/remove-locale all load the pair through it, change it with `setBase` / `setTranslation` / `setStatus` / `setDetails` / `setEntry` / `seedLocale` / `dropLocale` / `remove`, and persist with `save()` (which deletes both files when the folder becomes empty). `ResourceFolder` computes the checksums and applies the domain [staleness rule](glossary.md#staleness-rule) (`applyBaseChange`, `recordTranslation` in `libs/domain/src/lib/staleness.ts`), so no caller builds `{ checksum, baseChecksum, status }` by hand. Readers (tree loading, search, folder move/delete, folder cleanup) use it too, and `resolveResourcePaths()` is the only function that maps a key to its folder. +**Writes return what changed.** Every write (add, edit, delete, move, translate-existing-resource, folder create/delete/move, add/remove-locale) returns `mutations: ResourceMutation[]` (`lib/resource/resource-mutation.ts`) next to its other results: an `upsert` with the stored entry as `ResourceFolder.treeEntry()` reads it, a `remove`, an `add-folder` / `remove-folder`, or a `reindex` when the change is too broad to describe. Each mutation carries the absolute translations folder it applies to. A move returns an `upsert` at the destination and a `remove` at the source for each moved key, and a folder move adds a `remove-folder` for the deleted source. The API's [Collection Index](glossary.md#collection-index) uses them to follow the disk without reading it again; the CLI ignores them. See [Resource Mutation](glossary.md#resource-mutation). + ### add-resource **Entry point:** `addResource(translationsFolder, params, options)` diff --git a/architecture-docs/feature-matrix.md b/architecture-docs/feature-matrix.md index 33814a86..5d0aa070 100644 --- a/architecture-docs/feature-matrix.md +++ b/architecture-docs/feature-matrix.md @@ -75,7 +75,7 @@ Each cell shows whether the operation is supported (`Yes`), not supported (`—` | Validate all resources (CI gate) | Yes (`validate`) | — | — | | View resource status per locale | — | — | Yes (status badge per locale row in item) | | **Cache / Indexing** | | | | -| Poll [indexing](glossary.md#indexing) cache state | — | Yes (`GET /collections/:name/resources/cache/status`) | Yes (via `withCacheStatusFeature` — auto-polls on collection load) | +| Poll [Collection Index](glossary.md#collection-index) state | — | Yes (`GET /collections/:name/resources/cache/status`) | Yes (via `withCacheStatusFeature` — auto-polls on collection load) | | Trigger cache re-index | — | Yes (implicit on `GET /tree` when state is `NOT_STARTED` or `ERROR`) | Yes (implicit on collection switch in `BrowserStore`) | --- @@ -84,7 +84,7 @@ Each cell shows whether the operation is supported (`Yes`), not supported (`—` ### API Only -- **In-memory collection cache** — `CollectionCacheService` is an API-only system. It holds a single-slot, incrementally-updated in-memory tree of the active collection, making browsing fast for the Tracker UI. The CLI bypasses the cache entirely and reads files directly on every invocation. See [`api.md — Collection Cache`](api.md#collection-cache). +- **In-memory Collection Index** — `CollectionIndex` is an API-only system. It holds an incrementally updated in-memory tree of each open collection (at most 4 by default), making browsing fast for the Tracker UI. The CLI reads files directly on every invocation and ignores the `mutations` that core writes return. See [`api.md — Collection Index`](api.md#collection-index). - **Async translation jobs** — The `TranslationJobService` (fire-and-forget job map with UUID-based polling) is API-only. The CLI's `translate-locale` command runs synchronously in-process and prints progress inline. - **Async bundle jobs** — `BundleJobService` runs bundle generation through a sequential job queue with UUID-based polling. The CLI's `bundle` command runs synchronously in-process. See [`bundle-generation.md`](bundle-generation.md) for the shared pipeline. - **Batch resource creation** — The `POST /collections/:name/resources` endpoint accepts an array of `CreateResourceDto` objects. The CLI's `add-resource` and the UI's editor dialog only create one resource at a time. diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index c3760176..10341616 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -57,6 +57,14 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [` --- +### Collection Index + +The API's in-memory copy of each open [collection's](#collection) [resource tree](#resource-tree). In code, `CollectionIndex` in `apps/api/src/app/cache/collection-index.service.ts` has four methods: `tree(collection, path)` and `search(collection, query, maxResults)` read, `status(collection)` answers the `cache/status` endpoint, and `apply(mutations)` takes the [resource mutations](#resource-mutation) of a write. Indexing on first read, revalidation against a disk fingerprint, patching, and the memory cap (least recently used eviction) are internal. When a patch does not match the tree, the index drops that collection and indexes it again on the next read. The HTTP endpoints and the Tracker UI still call it the "cache". + +Explained in context: [`api.md`](api.md#collection-index) + +--- + ## E ### Export Run @@ -181,6 +189,14 @@ Explained in context: [`core-library.md`](core-library.md#resource-crud-flows) --- +### Resource Mutation + +One change that a core write made to a translations folder: `upsert` (key and the stored entry), `remove` (key), `add-folder` / `remove-folder` (path), or `reindex` (the change is too broad to describe, for example a locale was added). Each carries the absolute `translationsFolder` it applies to. `addResource`, `editResource`, `translateExistingResource`, `deleteResource`, `moveResource`, `createFolder`, `deleteFolder`, `moveFolder`, `addLocaleToCollection` and `removeLocaleFromCollection` return them as `mutations`. The type is in `libs/core/src/lib/resource/resource-mutation.ts`. The [Collection Index](#collection-index) uses them to update itself without reading the disk again. + +Explained in context: [`api.md`](api.md#writes-resource-mutations) + +--- + ### Resource Key A dot-delimited string that uniquely identifies a [resource entry](#resource-entry) within a [collection](#collection). Segments may contain only alphanumeric characters, underscores, and hyphens (`[A-Za-z0-9_-]`). diff --git a/architecture-docs/user-flows.md b/architecture-docs/user-flows.md index 0d6fea15..411195e9 100644 --- a/architecture-docs/user-flows.md +++ b/architecture-docs/user-flows.md @@ -42,29 +42,29 @@ sequenceDiagram Domain-->>Core: valid Core->>Domain: resolveResourceKey() → folderPath Core->>FS: ensureDirectoryExists(folderPath) - Core->>FS: readResourceEntries() + readTrackerMetadata() + Core->>FS: openResourceFolder(folderPath) — reads resource_entries.json + tracker_meta.json Core->>Domain: translocoToICU("OK") → "OK" Core->>Core: autoTranslateResource() [if translationConfig.enabled] Core->>Provider: translate("OK", en→fr, en→de, ...) Provider-->>Core: { fr: "OK", de: "OK", ... } - Core->>Core: createResourceMetadata() — MD5 checksums, status=translated - Core->>FS: writeJsonFile(resource_entries.json) - Core->>FS: writeJsonFile(tracker_meta.json) + Core->>Core: ResourceFolder.setBase() + setTranslation() — MD5 checksums, status=translated + Core->>FS: ResourceFolder.save() — writes resource_entries.json + tracker_meta.json Core-->>CLI: AddResourceResult CLI-->>Dev: "Resource created" Note over Dev,FS: 2. Edit base value — triggers stale Dev->>CLI: edit-resource apps.common.ok "OK" --base "Confirm" CLI->>Core: editResource(translationsFolder, options) - Core->>FS: readResourceEntries() + readTrackerMetadata() + Core->>FS: openResourceFolder(folderPath) — reads resource_entries.json + tracker_meta.json Core->>Domain: translocoToICU("Confirm") → "Confirm" - Core->>Core: updateMetadataForBaseValueChange() + Core->>Core: ResourceFolder.setBase() — applies the Staleness rule Note right of Core: new baseChecksum ≠ stored baseChecksum
for each locale → status = "stale" - Core->>FS: writeJsonFile() — persists stale status before API call + Core->>Core: ResourceFolder.setTranslation() / setStatus() [explicit locale edits] + Core->>FS: ResourceFolder.save() — persists stale status before API call Core->>Core: autoTranslateResource() [on base value change] Core->>Provider: translate("Confirm", en→fr, ...) Provider-->>Core: { fr: "Confirmer", ... } - Core->>FS: writeJsonFile() — second pass with translated values + Core->>FS: ResourceFolder.setTranslation() + save() — second pass with translated values Note over Dev,FS: 3. Manual re-translate (UI trigger) Dev->>CLI: translate-resource apps.common.ok @@ -326,7 +326,7 @@ sequenceDiagram BS->>BS: patchState({ isSearchLoading: true, searchError: null }) BS->>API: GET /api/collections/{name}/resources/search?query=confirm - Note right of API: searchTranslations() in @simoncodes-ca/core
walks cached ResourceTreeNode in memory
or falls back to disk if cache not ready + Note right of API: CollectionIndex.search() walks the indexed
ResourceTreeNode in memory (searchResourceTree)
or searches the disk (searchTranslations) if not indexed API-->>BS: SearchResultsDto { results: SearchResultDto[] } BS->>BS: patchState({ searchResults, isSearchLoading: false }) @@ -358,9 +358,9 @@ sequenceDiagram participant FN as FolderNode (drop target) participant BS as BrowserStore participant API as ResourcesController / FoldersController - participant Cache as CollectionCacheService + participant Index as CollectionIndex - Note over Dev,Cache: A. Drag a resource + Note over Dev,Index: A. Drag a resource Dev->>TI: dragStart on TranslationItem TI->>TB: dragStarted output → activeDragData = { type: "resource", key, folderPath } TB->>FN: pass activeDragData as input → FolderNode highlights valid drop targets @@ -378,8 +378,7 @@ sequenceDiagram BS->>BS: patchState({ isDisabled: true }) BS->>API: POST /api/collections/{name}/resources/move
{ source: "apps.common.ok", destination: "apps.navigation.ok" } - API->>Cache: clearCache() — wildcard-safe full clear - Cache-->>API: cache state = NOT_STARTED + API->>Index: apply(moveResult.mutations)
— upsert at destination, remove at source API-->>BS: MoveResourceResponseDto { success: true } Note over BS: Success path @@ -396,7 +395,7 @@ sequenceDiagram BS->>BS: notifications.error(errorMessage) end - Note over Dev,Cache: C. Drag a folder (abbreviated — same pattern) + Note over Dev,Index: C. Drag a folder (abbreviated — same pattern) Dev->>FN: dragStart on FolderNode (type: "folder") Dev->>FN: drop on destination FolderNode FN->>BS: moveFolder({ sourceFolderPath, destinationFolderPath }) @@ -420,7 +419,7 @@ sequenceDiagram ## 6. Cache Indexing Flow -The sequence from opening a collection to having a fully populated resource tree in the browser store. This flow is driven by `withCacheStatusFeature.checkCacheStatus` (which polls every 2 seconds using `interval(2000)`) and `CollectionCacheService` on the API. The cache state machine is documented in [api.md — Cache State Machine](api.md#cache-state-machine). +The sequence from opening a collection to having a fully populated resource tree in the browser store. This flow is driven by `withCacheStatusFeature.checkCacheStatus` (which polls every 2 seconds using `interval(2000)`) and the [Collection Index](glossary.md#collection-index) (`CollectionIndex`) on the API. The state machine is documented in [api.md — Index State Machine](api.md#index-state-machine). @@ -439,7 +438,7 @@ flowchart TD STATUS_CHECK -- "not-started" --> TRIGGER_INDEX STATUS_CHECK -- "indexing" --> WAIT_LOOP - TRIGGER_INDEX["CollectionCacheService.indexCollection()\n[API fires, does not await]\nCore.loadResourceTree() starts async"] + TRIGGER_INDEX["CollectionIndex.status() found no entry\nand indexed the collection\n(core.loadResourceTree())"] TRIGGER_INDEX --> WAIT_LOOP @@ -448,7 +447,7 @@ flowchart TD WAIT_LOOP --> POLL_START STATUS_CHECK -- "error" --> SHOW_ERROR - SHOW_ERROR["patchState({ cacheStatus: 'error', cacheError })\nError shown in UI\nNext /tree request will re-trigger indexCollection()"] + SHOW_ERROR["patchState({ cacheStatus: 'error', cacheError })\nError shown in UI\nNext /tree request will retry indexing"] STATUS_CHECK -- "ready" --> MARK_READY["patchState({ cacheStatus: 'ready', collectionStats })\ntakeWhile stops the interval — polling ends"] @@ -479,4 +478,4 @@ flowchart TD - Poll interval: `2000 ms` (hard-coded in `withCacheStatusFeature` via `interval(2000)`) - The interval uses `takeWhile(..., true)` — the final `"ready"` emission is included before the stream completes, which is what triggers `loadRootFolders()` - There is no WebSocket or server-sent event. The retry loop is entirely client-driven. -- `CollectionCacheService` holds at most one collection at a time. Switching collections immediately discards the previous collection's tree from memory. See [api.md — Single-Collection Design](api.md#single-collection-design). +- `CollectionIndex` holds up to `LINGO_TRACKER_MAX_CACHED_COLLECTIONS` (default 4) collections and evicts the least recently used one. Switching back to a recently opened collection does not re-index it. See [api.md — Bounded Multi-Collection Design](api.md#bounded-multi-collection-design). diff --git a/libs/core/src/collections-manager/add-locale-to-collection.ts b/libs/core/src/collections-manager/add-locale-to-collection.ts index 6e7e6ad8..cb835492 100644 --- a/libs/core/src/collections-manager/add-locale-to-collection.ts +++ b/libs/core/src/collections-manager/add-locale-to-collection.ts @@ -5,6 +5,7 @@ import { updateConfig } from '../lib/config/config-file-operations'; import { openCollection } from '../lib/config/open-collection'; import { walkFolders } from '../lib/normalize/iterative-folder-walker'; import { openResourceFolder } from '../lib/resource/resource-folder'; +import { reindexMutation, type ResourceMutation } from '../lib/resource/resource-mutation'; import { ErrorMessages } from '../lib/errors/error-messages'; import { RESOURCE_ENTRIES_FILENAME } from '../constants'; @@ -16,6 +17,8 @@ export interface AddLocaleToCollectionResult { readonly message: string; readonly entriesBackfilled: number; readonly filesUpdated: number; + /** A `reindex` of the collection: every folder's metadata changed. */ + readonly mutations: ResourceMutation[]; } export async function addLocaleToCollection( @@ -81,5 +84,6 @@ export async function addLocaleToCollection( message: `Locale "${locale}" added to collection "${collectionName}" successfully`, entriesBackfilled, filesUpdated, + mutations: [reindexMutation(collection.translationsFolder)], }; } diff --git a/libs/core/src/collections-manager/remove-locale-from-collection.ts b/libs/core/src/collections-manager/remove-locale-from-collection.ts index 9179ca8a..5cacbd5a 100644 --- a/libs/core/src/collections-manager/remove-locale-from-collection.ts +++ b/libs/core/src/collections-manager/remove-locale-from-collection.ts @@ -5,6 +5,7 @@ import { updateConfig } from '../lib/config/config-file-operations'; import { openCollection } from '../lib/config/open-collection'; import { walkFolders } from '../lib/normalize/iterative-folder-walker'; import { openResourceFolder } from '../lib/resource/resource-folder'; +import { reindexMutation, type ResourceMutation } from '../lib/resource/resource-mutation'; import { ErrorMessages } from '../lib/errors/error-messages'; import { RESOURCE_ENTRIES_FILENAME } from '../constants'; @@ -16,6 +17,8 @@ export interface RemoveLocaleFromCollectionResult { readonly message: string; readonly entriesPurged: number; readonly filesUpdated: number; + /** A `reindex` of the collection: every folder's metadata changed. */ + readonly mutations: ResourceMutation[]; } export async function removeLocaleFromCollection( @@ -79,5 +82,6 @@ export async function removeLocaleFromCollection( message: `Locale "${locale}" removed from collection "${collectionName}" successfully`, entriesPurged, filesUpdated, + mutations: [reindexMutation(collection.translationsFolder)], }; } diff --git a/libs/core/src/collections-manager/update-collection.spec.ts b/libs/core/src/collections-manager/update-collection.spec.ts index f12b7f63..c8cf61a5 100644 --- a/libs/core/src/collections-manager/update-collection.spec.ts +++ b/libs/core/src/collections-manager/update-collection.spec.ts @@ -7,10 +7,14 @@ import type { SafeAny } from '../constants'; vi.mock('node:fs'); vi.mock('./add-locale-to-collection', () => ({ - addLocaleToCollection: vi.fn().mockResolvedValue({ message: 'ok', entriesBackfilled: 0, filesUpdated: 0 }), + addLocaleToCollection: vi + .fn() + .mockResolvedValue({ message: 'ok', entriesBackfilled: 0, filesUpdated: 0, mutations: [] }), })); vi.mock('./remove-locale-from-collection', () => ({ - removeLocaleFromCollection: vi.fn().mockResolvedValue({ message: 'ok', entriesPurged: 0, filesUpdated: 0 }), + removeLocaleFromCollection: vi + .fn() + .mockResolvedValue({ message: 'ok', entriesPurged: 0, filesUpdated: 0, mutations: [] }), })); describe('updateCollection', () => { diff --git a/libs/core/src/collections-manager/update-collection.ts b/libs/core/src/collections-manager/update-collection.ts index f77a422e..2bfeb1ae 100644 --- a/libs/core/src/collections-manager/update-collection.ts +++ b/libs/core/src/collections-manager/update-collection.ts @@ -3,6 +3,7 @@ import type { LingoTrackerCollection } from '../config/lingo-tracker-collection' import { createConfigFileOperations, updateConfig } from '../lib/config/config-file-operations'; import { openCollection } from '../lib/config/open-collection'; import { ErrorMessages } from '../lib/errors/error-messages'; +import type { ResourceMutation } from '../lib/resource/resource-mutation'; import { addLocaleToCollection } from './add-locale-to-collection'; import { removeLocaleFromCollection } from './remove-locale-from-collection'; @@ -19,19 +20,24 @@ export interface UpdateCollectionOptions { * `exportFolder`, `importFolder`, `locales`) is therefore dropped from the entry. Callers * performing a partial update must send the full desired collection config, not just the * changed fields. + * + * `mutations` holds what the locale changes (if any) wrote to the translation files. The + * config change itself is not a resource mutation; callers that cache a collection's tree + * must also drop it for the old and new translations folders. */ export async function updateCollection( collectionName: string, newCollectionName: string | undefined, collection: LingoTrackerCollection, options: UpdateCollectionOptions = {}, -): Promise<{ message: string }> { +): Promise<{ message: string; mutations: ResourceMutation[] }> { if (!collection || !collection.translationsFolder || !collection.translationsFolder.trim()) { throw new Error('translationsFolder is required'); } const { cwd } = options; const newLocales = collection.locales; + const mutations: ResourceMutation[] = []; // Only diff when caller provides an explicit, non-empty locales array. // An empty/undefined list means "inherit from global" — no translation files are touched. @@ -45,11 +51,11 @@ export async function updateCollection( const removedLocales = existingLocales.filter((l) => !newLocales.includes(l) && l !== baseLocale); for (const locale of removedLocales) { - await removeLocaleFromCollection(collectionName, locale, { cwd }); + mutations.push(...(await removeLocaleFromCollection(collectionName, locale, { cwd })).mutations); } for (const locale of addedLocales) { - await addLocaleToCollection(collectionName, locale, { cwd }); + mutations.push(...(await addLocaleToCollection(collectionName, locale, { cwd })).mutations); } } @@ -117,7 +123,8 @@ export async function updateCollection( if (isRename) { return { message: `Collection "${collectionName}" renamed to "${targetName}" and updated successfully`, + mutations, }; } - return { message: `Collection "${collectionName}" updated successfully` }; + return { message: `Collection "${collectionName}" updated successfully`, mutations }; } diff --git a/libs/core/src/lib/folder/create-folder.ts b/libs/core/src/lib/folder/create-folder.ts index 6af22d54..91d793ae 100644 --- a/libs/core/src/lib/folder/create-folder.ts +++ b/libs/core/src/lib/folder/create-folder.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { isValidSegment } from '@simoncodes-ca/domain'; import { ensureDirectoryExists } from '../file-io/directory-operations'; +import { folderMutation, type ResourceMutation } from '../resource/resource-mutation'; export interface CreateFolderParams { /** The folder name to create (dot-delimited path segments) */ @@ -15,6 +16,8 @@ export interface CreateFolderResult { readonly folderPath: string; /** Whether the folder was newly created (true) or already existed (false) */ readonly created: boolean; + /** An `add-folder` when the folder was created; empty when it already existed. */ + readonly mutations: ResourceMutation[]; } /** @@ -98,5 +101,6 @@ export function createFolder(translationsFolder: string, params: CreateFolderPar return { folderPath: absoluteFolderPath, created: !alreadyExists, + mutations: alreadyExists ? [] : [folderMutation('add-folder', translationsFolder, fullDotPath)], }; } diff --git a/libs/core/src/lib/folder/delete-folder.ts b/libs/core/src/lib/folder/delete-folder.ts index 58f59912..7af9b08b 100644 --- a/libs/core/src/lib/folder/delete-folder.ts +++ b/libs/core/src/lib/folder/delete-folder.ts @@ -3,6 +3,7 @@ import { resolve, join } from 'node:path'; import { walkFolders } from '../normalize/iterative-folder-walker'; import { isValidSegment } from '@simoncodes-ca/domain'; import { openResourceFolder } from '../resource/resource-folder'; +import { folderMutation, type ResourceMutation } from '../resource/resource-mutation'; export interface DeleteFolderParams { /** The folder path to delete (dot-delimited path like "apps.common.buttons") */ @@ -18,6 +19,8 @@ export interface DeleteFolderResult { readonly resourcesDeleted: number; /** Error message if deletion failed */ readonly error?: string; + /** A `remove-folder` when the folder was deleted, otherwise empty. */ + readonly mutations: ResourceMutation[]; } /** @@ -72,6 +75,7 @@ export function deleteFolder(translationsFolder: string, params: DeleteFolderPar folderPath, deleted: false, resourcesDeleted: 0, + mutations: [], error: `Folder not found: ${absoluteFolderPath}`, }; } @@ -83,6 +87,7 @@ export function deleteFolder(translationsFolder: string, params: DeleteFolderPar folderPath, deleted: false, resourcesDeleted: 0, + mutations: [], error: `Path is not a directory: ${absoluteFolderPath}`, }; } @@ -97,12 +102,14 @@ export function deleteFolder(translationsFolder: string, params: DeleteFolderPar folderPath, deleted: true, resourcesDeleted, + mutations: [folderMutation('remove-folder', translationsFolder, folderPath)], }; } catch (error) { return { folderPath, deleted: false, resourcesDeleted: 0, + mutations: [], error: error instanceof Error ? error.message : String(error), }; } diff --git a/libs/core/src/lib/folder/move-folder.real-fs.spec.ts b/libs/core/src/lib/folder/move-folder.real-fs.spec.ts index 624572ea..d84a9c0a 100644 --- a/libs/core/src/lib/folder/move-folder.real-fs.spec.ts +++ b/libs/core/src/lib/folder/move-folder.real-fs.spec.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { addResource } from '../../resource/add-resource'; +import { openResourceFolder } from '../resource/resource-folder'; import { moveFolder } from './move-folder'; /** @@ -53,3 +55,43 @@ describe('moveFolder with an unreadable folder (real fs)', () => { expect(readFileSync(join(root, 'apps', 'bad', 'resource_entries.json'), 'utf8')).toBe(entries); }); }); + +/** + * Regression: with override off, a destination collision skipped that resource, but the source + * folder was still deleted because another resource moved, losing the skipped resource. + */ +describe('moveFolder with a destination collision (real fs)', () => { + let root: string; + + beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'move-folder-collision-')); + await addResource(root, { key: 'src.a', baseValue: 'Source A' }); + await addResource(root, { key: 'src.b', baseValue: 'Source B' }); + await addResource(root, { key: 'dst.src.a', baseValue: 'Existing A' }); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('moves the other resources, keeps the source folder with the skipped one, and reports matching mutations', async () => { + const result = await moveFolder(root, { sourceFolderPath: 'src', destinationFolderPath: 'dst', override: false }); + + expect(result.movedCount).toBe(1); + expect(result.foldersDeleted).toBe(0); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual(expect.arrayContaining([expect.stringContaining('src.a')])); + + expect(existsSync(join(root, 'src'))).toBe(true); + expect(openResourceFolder(join(root, 'src')).keys()).toEqual(['a']); + expect(openResourceFolder(join(root, 'dst', 'src')).get('b')?.entry.source).toBe('Source B'); + expect(openResourceFolder(join(root, 'dst', 'src')).get('a')?.entry.source).toBe('Existing A'); + + expect(result.mutations.some((mutation) => mutation.kind === 'remove-folder')).toBe(false); + expect(result.mutations.some((mutation) => mutation.kind === 'remove' && mutation.key === 'src.a')).toBe(false); + expect(result.mutations.map((mutation) => [mutation.kind, 'key' in mutation ? mutation.key : ''])).toEqual([ + ['upsert', 'dst.src.b'], + ['remove', 'src.b'], + ]); + }); +}); diff --git a/libs/core/src/lib/folder/move-folder.ts b/libs/core/src/lib/folder/move-folder.ts index a6a35232..fc445aa5 100644 --- a/libs/core/src/lib/folder/move-folder.ts +++ b/libs/core/src/lib/folder/move-folder.ts @@ -5,6 +5,7 @@ import { isValidSegment } from '@simoncodes-ca/domain'; import { moveResource, type MoveResourceResult } from '../../resource/move-resource'; import { deleteFolder, type DeleteFolderResult } from './delete-folder'; import { openResourceFolder } from '../resource/resource-folder'; +import type { ResourceMutation } from '../resource/resource-mutation'; export interface MoveFolderParams { /** The source folder path to move (dot-delimited like "apps.common.buttons") */ @@ -32,6 +33,8 @@ export interface MoveFolderResult { warnings: string[]; /** Error messages */ errors: string[]; + /** Per moved key an `upsert` and a `remove`, then a `remove-folder` if every key moved and the folder was deleted. */ + mutations: ResourceMutation[]; } /** @@ -42,7 +45,7 @@ export interface MoveFolderResult { * 2. Prevents circular dependencies (moving folder into its own descendant) * 3. Extracts all resources in the source folder tree recursively * 4. Moves each resource to the corresponding destination path - * 5. Deletes the now-empty source folder after all moves complete + * 5. Deletes the source folder once every resource in it was moved (otherwise keeps it and warns) * * @param translationsFolder - Root translations folder path * @param params - Folder move parameters @@ -81,6 +84,7 @@ export async function moveFolder(translationsFolder: string, params: MoveFolderP foldersDeleted: 0, warnings: [], errors: [], + mutations: [], }; try { @@ -162,6 +166,7 @@ export async function moveFolder(translationsFolder: string, params: MoveFolderP result.warnings.push('No resources found in source folder. Nothing to move.'); // Still delete the empty folder const deleteResult = deleteFolder(translationsFolder, { folderPath: sourceFolderPath }); + result.mutations.push(...deleteResult.mutations); if (deleteResult.deleted) { result.foldersDeleted++; } else if (deleteResult.error) { @@ -175,6 +180,8 @@ export async function moveFolder(translationsFolder: string, params: MoveFolderP const sourceDepth = sourceFolderSegments.length; const destDepth = destinationFolderSegments.length; const lastSourceSegment = sourceFolderSegments[sourceFolderSegments.length - 1]; + // Keys that stayed in the source (collision without override, or an error); the source folder must be kept. + const keptKeys: string[] = []; for (const sourceKey of resourceKeys) { // Calculate destination key by replacing source folder prefix with destination folder prefix @@ -227,11 +234,20 @@ export async function moveFolder(translationsFolder: string, params: MoveFolderP result.movedCount += moveResult.movedCount; result.warnings.push(...moveResult.warnings); result.errors.push(...moveResult.errors); + result.mutations.push(...moveResult.mutations); + if (moveResult.movedCount === 0) { + keptKeys.push(sourceKey); + } + } + + if (keptKeys.length > 0) { + result.warnings.push(`Source folder kept; resources not moved: ${keptKeys.join(', ')}`); } - // After all resources moved successfully, delete the source folder - if (result.movedCount > 0 && result.errors.length === 0) { + // Only delete the source folder when every resource in it was moved + if (keptKeys.length === 0 && result.errors.length === 0) { const deleteResult: DeleteFolderResult = deleteFolder(translationsFolder, { folderPath: sourceFolderPath }); + result.mutations.push(...deleteResult.mutations); if (deleteResult.deleted) { result.foldersDeleted++; } else if (deleteResult.error) { diff --git a/libs/core/src/lib/resource/index.ts b/libs/core/src/lib/resource/index.ts index 8eda2b88..46b843d9 100644 --- a/libs/core/src/lib/resource/index.ts +++ b/libs/core/src/lib/resource/index.ts @@ -1,8 +1,8 @@ export * from './resource-file-paths'; -export * from './metadata-operations'; export * from './load-resource-tree'; export * from './load-full-resource-tree'; export * from './extract-subtree'; export * from './search'; export * from './tree-fingerprint'; export * from './resource-folder'; +export * from './resource-mutation'; diff --git a/libs/core/src/lib/resource/metadata-operations.ts b/libs/core/src/lib/resource/metadata-operations.ts deleted file mode 100644 index 589ef721..00000000 --- a/libs/core/src/lib/resource/metadata-operations.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; -import type { TranslationStatus } from '@simoncodes-ca/domain'; -import { calculateChecksum } from '../../resource/checksum'; -import { isUntranslatedCopy, recordTranslation } from '@simoncodes-ca/domain'; - -export interface CreateResourceMetadataParams { - /** The entry key for this resource */ - readonly entryKey: string; - /** Base locale value */ - readonly baseValue: string; - /** Base locale code */ - readonly baseLocale: string; - /** Translations with locale, value, and status */ - readonly translations?: ReadonlyArray<{ - readonly locale: string; - readonly value: string; - readonly status: TranslationStatus; - }>; -} - -/** - * Builds the metadata `addResource` writes for a new entry, without touching disk. - * Used by the API to update its cache after an add. - * - * Follows the Staleness rules: a translation that is an untranslated copy of the base is `new`, - * whatever status was provided. - */ -export function createResourceMetadata(params: CreateResourceMetadataParams): ResourceEntryMetadata { - const { baseValue, baseLocale, translations = [] } = params; - - const baseChecksum = calculateChecksum(baseValue); - let metadata: ResourceEntryMetadata = { [baseLocale]: { checksum: baseChecksum } }; - - for (const { locale, value, status } of translations) { - if (locale === baseLocale) continue; - const finalStatus = isUntranslatedCopy(value, baseValue) ? 'new' : status; - metadata = recordTranslation(metadata, locale, calculateChecksum(value), baseChecksum, finalStatus); - } - - return metadata; -} diff --git a/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts b/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts new file mode 100644 index 00000000..b324ae18 --- /dev/null +++ b/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts @@ -0,0 +1,167 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { addResource } from '../../resource/add-resource'; +import { deleteResource } from '../../resource/delete-resource'; +import { editResource } from '../../resource/edit-resource'; +import { moveResource } from '../../resource/move-resource'; +import { createFolder } from '../folder/create-folder'; +import { deleteFolder } from '../folder/delete-folder'; +import { moveFolder } from '../folder/move-folder'; +import { openResourceFolder } from './resource-folder'; + +/** Core writes report what they changed, so an in-memory index can follow without re-reading disk. */ +describe('mutations returned by core writes (real fs)', () => { + let root: string; + + beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'resource-mutation-')); + await addResource(root, { key: 'common.ok', baseValue: 'OK' }); + await addResource(root, { key: 'common.cancel', baseValue: 'Cancel' }); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('addResource returns the stored entry, as the tree loader would read it', async () => { + const result = await addResource(root, { + key: 'apps.greeting', + baseValue: 'Hello {{ name }}', + comment: 'Shown on login', + tags: ['ui'], + translations: [{ locale: 'fr', value: 'Bonjour {{ name }}', status: 'translated' }], + }); + + expect(result.mutations).toEqual([ + { + kind: 'upsert', + translationsFolder: root, + key: 'apps.greeting', + entry: openResourceFolder(join(root, 'apps')).treeEntry('greeting'), + }, + ]); + // The entry holds the stored (ICU) form, not the Transloco input. + expect(result.mutations[0]).toMatchObject({ + entry: { source: 'Hello {name}', translations: { fr: 'Bonjour {name}' } }, + }); + }); + + it('addResource on an existing key returns one upsert with the replaced entry', async () => { + const result = await addResource(root, { key: 'common.ok', baseValue: 'Okay' }); + + expect(result.created).toBe(false); + expect(result.mutations).toEqual([ + { + kind: 'upsert', + translationsFolder: root, + key: 'common.ok', + entry: openResourceFolder(join(root, 'common')).treeEntry('ok'), + }, + ]); + expect(result.mutations[0]).toMatchObject({ entry: { source: 'Okay' } }); + }); + + it('editResource returns the updated entry, and nothing when nothing changed', async () => { + const edited = await editResource(root, { key: 'common.ok', baseValue: 'Okay' }); + expect(edited.mutations).toEqual([ + { + kind: 'upsert', + translationsFolder: root, + key: 'common.ok', + entry: expect.objectContaining({ source: 'Okay' }), + }, + ]); + + const unchanged = await editResource(root, { key: 'common.ok', baseValue: 'Okay' }); + expect(unchanged.mutations).toEqual([]); + }); + + it('editResource with targetFolder returns an upsert keyed by the fully resolved key', async () => { + const result = await editResource(root, { key: 'ok', targetFolder: 'common', baseValue: 'Okay' }); + + expect(result.mutations).toEqual([ + { + kind: 'upsert', + translationsFolder: root, + key: 'common.ok', + entry: expect.objectContaining({ source: 'Okay' }), + }, + ]); + }); + + it('deleteResource returns a remove for each deleted key only', () => { + const result = deleteResource(root, { keys: ['common.ok', 'common.missing'] }); + + expect(result.mutations).toEqual([{ kind: 'remove', translationsFolder: root, key: 'common.ok' }]); + }); + + it('moveResource by pattern returns an upsert and a remove per moved key', async () => { + const result = await moveResource(root, { source: 'common.*', destination: 'shared' }); + + expect(result.mutations.map((mutation) => [mutation.kind, 'key' in mutation ? mutation.key : ''])).toEqual( + expect.arrayContaining([ + ['upsert', 'shared.ok'], + ['remove', 'common.ok'], + ['upsert', 'shared.cancel'], + ['remove', 'common.cancel'], + ]), + ); + expect(result.mutations).toHaveLength(4); + }); + + it('moveResource to another translations folder puts the upsert there', async () => { + const other = mkdtempSync(join(tmpdir(), 'resource-mutation-other-')); + try { + const result = await moveResource(root, { + source: 'common.ok', + destination: 'imported.ok', + destinationTranslationsFolder: other, + }); + + expect(result.mutations).toEqual([ + { + kind: 'upsert', + translationsFolder: other, + key: 'imported.ok', + entry: expect.objectContaining({ key: 'ok' }), + }, + { kind: 'remove', translationsFolder: root, key: 'common.ok' }, + ]); + } finally { + rmSync(other, { recursive: true, force: true }); + } + }); + + it('moveFolder returns the per-key moves, then the removal of the source folder', async () => { + const result = await moveFolder(root, { sourceFolderPath: 'common', destinationFolderPath: 'apps' }); + + expect(result.mutations).toHaveLength(5); + expect(result.mutations[result.mutations.length - 1]).toEqual({ + kind: 'remove-folder', + translationsFolder: root, + path: 'common', + }); + expect(result.mutations.filter((mutation) => mutation.kind === 'upsert').map((mutation) => mutation.key)).toEqual( + expect.arrayContaining(['apps.common.ok', 'apps.common.cancel']), + ); + }); + + it('createFolder and deleteFolder return the folder change', () => { + expect(createFolder(root, { folderName: 'empty', parentPath: 'apps' }).mutations).toEqual([ + { kind: 'add-folder', translationsFolder: root, path: 'apps.empty' }, + ]); + expect(deleteFolder(root, { folderPath: 'apps.empty' }).mutations).toEqual([ + { kind: 'remove-folder', translationsFolder: root, path: 'apps.empty' }, + ]); + expect(deleteFolder(root, { folderPath: 'apps.empty' }).mutations).toEqual([]); + }); + + it('createFolder on an existing folder returns created: false and no mutations', () => { + const result = createFolder(root, { folderName: 'common' }); + + expect(result.created).toBe(false); + expect(result.mutations).toEqual([]); + }); +}); diff --git a/libs/core/src/lib/resource/resource-mutation.ts b/libs/core/src/lib/resource/resource-mutation.ts new file mode 100644 index 00000000..703b89e5 --- /dev/null +++ b/libs/core/src/lib/resource/resource-mutation.ts @@ -0,0 +1,55 @@ +import { resolve } from 'node:path'; +import type { ResourceTreeEntry } from './load-resource-tree'; + +/** + * One change that a core write made to a translations folder. + * + * Each write returns the changes it made, so an in-memory view of the folder (the API's + * Collection Index) can update itself without reading the disk again. + * - `translationsFolder` is absolute. It identifies the collection that changed. + * - `key` is a full dot-delimited resource key (`apps.common.ok`). + * - `path` is a dot-delimited folder path (`apps.common`). + * - `reindex` means that the change is too broad to describe (for example, a locale was added). + */ +export type ResourceMutation = + | { + readonly kind: 'upsert'; + readonly translationsFolder: string; + readonly key: string; + readonly entry: ResourceTreeEntry; + } + | { readonly kind: 'remove'; readonly translationsFolder: string; readonly key: string } + | { readonly kind: 'add-folder'; readonly translationsFolder: string; readonly path: string } + | { readonly kind: 'remove-folder'; readonly translationsFolder: string; readonly path: string } + | { readonly kind: 'reindex'; readonly translationsFolder: string }; + +/** + * The mutation for a resource that was written. When the stored entry cannot be read back + * (it has no metadata), the result is a `reindex` of the folder, which is always safe. + */ +export function upsertMutation( + translationsFolder: string, + key: string, + entry: ResourceTreeEntry | undefined, +): ResourceMutation { + const folder = resolve(translationsFolder); + return entry + ? { kind: 'upsert', translationsFolder: folder, key, entry } + : { kind: 'reindex', translationsFolder: folder }; +} + +export function removeMutation(translationsFolder: string, key: string): ResourceMutation { + return { kind: 'remove', translationsFolder: resolve(translationsFolder), key }; +} + +export function folderMutation( + kind: 'add-folder' | 'remove-folder', + translationsFolder: string, + path: string, +): ResourceMutation { + return { kind, translationsFolder: resolve(translationsFolder), path }; +} + +export function reindexMutation(translationsFolder: string): ResourceMutation { + return { kind: 'reindex', translationsFolder: resolve(translationsFolder) }; +} diff --git a/libs/core/src/lib/translation/translate-existing-resource.ts b/libs/core/src/lib/translation/translate-existing-resource.ts index 0f9da95b..9e8ac27b 100644 --- a/libs/core/src/lib/translation/translate-existing-resource.ts +++ b/libs/core/src/lib/translation/translate-existing-resource.ts @@ -1,8 +1,10 @@ +import { resolve } from 'node:path'; import { needsTranslation } from '@simoncodes-ca/domain'; import type { TranslationConfig } from '../../config/translation-config'; import type { ResourceTreeEntry } from '../resource/load-resource-tree'; import { validateAndResolvePaths } from '../resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../resource/resource-folder'; +import { type ResourceMutation, upsertMutation } from '../resource/resource-mutation'; import { autoTranslateResource } from './auto-translate-resources'; export interface TranslateExistingResourceOptions { @@ -18,6 +20,8 @@ export interface TranslateExistingResourceResult { readonly translatedCount: number; readonly skippedLocales: string[]; readonly entry: ResourceTreeEntry; + /** What changed on disk (empty when nothing was translated). */ + readonly mutations: ResourceMutation[]; } /** @@ -58,6 +62,7 @@ export async function translateExistingResource( translatedCount: 0, skippedLocales: [], entry: requireTreeEntry(folder, paths.entryKey, paths.resolvedKey), + mutations: [], }; } @@ -76,10 +81,16 @@ export async function translateExistingResource( folder.save(); } + const updatedEntry = requireTreeEntry(folder, paths.entryKey, paths.resolvedKey); + return { translatedCount: translatedEntries.length, skippedLocales, - entry: requireTreeEntry(folder, paths.entryKey, paths.resolvedKey), + entry: updatedEntry, + mutations: + translatedEntries.length > 0 + ? [upsertMutation(resolve(cwd, translationsFolder), paths.resolvedKey, updatedEntry)] + : [], }; } diff --git a/libs/core/src/resource/add-resource.ts b/libs/core/src/resource/add-resource.ts index 77a12250..c7144fd8 100644 --- a/libs/core/src/resource/add-resource.ts +++ b/libs/core/src/resource/add-resource.ts @@ -1,7 +1,9 @@ +import { resolve } from 'node:path'; import type { TranslationStatus } from '@simoncodes-ca/domain'; import { validateAndResolvePaths } from '../lib/resource/resource-file-paths'; import { ensureDirectoryExists } from '../lib/file-io/directory-operations'; import { openResourceFolder } from '../lib/resource/resource-folder'; +import { type ResourceMutation, upsertMutation } from '../lib/resource/resource-mutation'; import type { TranslationConfig } from '../config/translation-config'; import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; import { translocoToICU, normalizeTags, isUntranslatedCopy } from '@simoncodes-ca/domain'; @@ -49,7 +51,8 @@ export interface AddResourceParams { * @param params - Resource creation parameters * @param options - Additional options (e.g., cwd, translationConfig) * @returns Object with the resolved key, status, actual translations written to disk, - * and any locales skipped due to ICU format (only present when auto-translation ran) + * any locales skipped due to ICU format (only present when auto-translation ran), + * and the mutation that describes the stored entry */ export async function addResource( translationsFolder: string, @@ -60,6 +63,7 @@ export async function addResource( created: boolean; translations: Array<{ locale: string; value: string; status: TranslationStatus }>; skippedLocales?: string[]; + mutations: ResourceMutation[]; }> { const { cwd = process.cwd(), translationConfig } = options; const baseLocale = params.baseLocale || 'en'; @@ -126,6 +130,7 @@ export async function addResource( created: isNewEntry, translations: normalizedTranslations ?? [], ...(resolveResult?.skippedLocales !== undefined && { skippedLocales: resolveResult.skippedLocales }), + mutations: [upsertMutation(resolve(cwd, translationsFolder), paths.resolvedKey, folder.treeEntry(paths.entryKey))], }; } diff --git a/libs/core/src/resource/delete-resource.ts b/libs/core/src/resource/delete-resource.ts index 41c3effc..abbc0ddf 100644 --- a/libs/core/src/resource/delete-resource.ts +++ b/libs/core/src/resource/delete-resource.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { resolveResourcePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder } from '../lib/resource/resource-folder'; +import { removeMutation, type ResourceMutation } from '../lib/resource/resource-mutation'; import { validateKey } from '@simoncodes-ca/domain'; export interface DeleteResourceParams { @@ -13,17 +14,21 @@ export interface DeleteResourceResult { key: string; error: string; }>; + /** One `remove` per deleted key. */ + mutations: ResourceMutation[]; } export function deleteResource(translationsFolder: string, params: DeleteResourceParams): DeleteResourceResult { let entriesDeleted = 0; const errors: Array<{ key: string; error: string }> = []; + const mutations: ResourceMutation[] = []; for (const key of params.keys) { try { const deletionSucceeded = deleteSingleResource(translationsFolder, key); if (deletionSucceeded) { entriesDeleted++; + mutations.push(removeMutation(translationsFolder, key)); } } catch (caughtError) { errors.push({ @@ -36,6 +41,7 @@ export function deleteResource(translationsFolder: string, params: DeleteResourc return { entriesDeleted, errors: errors.length > 0 ? errors : undefined, + mutations, }; } diff --git a/libs/core/src/resource/edit-resource.ts b/libs/core/src/resource/edit-resource.ts index eb62f111..f52c0026 100644 --- a/libs/core/src/resource/edit-resource.ts +++ b/libs/core/src/resource/edit-resource.ts @@ -1,6 +1,8 @@ +import { resolve } from 'node:path'; import { validateAndResolvePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../lib/resource/resource-folder'; import type { ResourceTreeEntry } from '../lib/resource/load-resource-tree'; +import { type ResourceMutation, upsertMutation } from '../lib/resource/resource-mutation'; import type { TranslationConfig } from '../config/translation-config'; import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; import type { TranslationStatus } from '@simoncodes-ca/domain'; @@ -29,6 +31,8 @@ export interface EditResourceResult { message?: string; entry?: ResourceTreeEntry; skippedLocales?: string[]; + /** What changed on disk (empty when nothing was updated). */ + mutations: ResourceMutation[]; } /** @@ -112,6 +116,7 @@ export async function editResource( resolvedKey: paths.resolvedKey, updated: false, message: 'No changes detected', + mutations: [], }; } @@ -151,6 +156,7 @@ export async function editResource( resolvedKey: paths.resolvedKey, updated: true, entry: updatedEntry, + mutations: [upsertMutation(resolve(cwd, translationsFolder), paths.resolvedKey, updatedEntry)], ...(autoTranslateSkippedLocales !== undefined && { skippedLocales: autoTranslateSkippedLocales }), }; } diff --git a/libs/core/src/resource/move-resource.real-fs.spec.ts b/libs/core/src/resource/move-resource.real-fs.spec.ts index c5749498..cef3fab3 100644 --- a/libs/core/src/resource/move-resource.real-fs.spec.ts +++ b/libs/core/src/resource/move-resource.real-fs.spec.ts @@ -48,7 +48,15 @@ describe('moving resources keeps metadata (real fs)', () => { const result = await moveResource(root, { source: 'common.ok', destination: 'shared.buttons.confirm' }); - expect(result).toEqual({ movedCount: 1, warnings: [], errors: [] }); + expect(result).toEqual({ + movedCount: 1, + warnings: [], + errors: [], + mutations: [ + { kind: 'upsert', translationsFolder: root, key: 'shared.buttons.confirm', entry: expect.any(Object) }, + { kind: 'remove', translationsFolder: root, key: 'common.ok' }, + ], + }); expect(read('resource_entries.json', 'shared', 'buttons')).toEqual({ confirm: entries.ok }); expect(read('tracker_meta.json', 'shared', 'buttons')).toEqual({ confirm: meta.ok }); // Source folder had only this entry, so both files are removed diff --git a/libs/core/src/resource/move-resource.ts b/libs/core/src/resource/move-resource.ts index 6f3a2ff4..f3724aea 100644 --- a/libs/core/src/resource/move-resource.ts +++ b/libs/core/src/resource/move-resource.ts @@ -5,6 +5,7 @@ import { deleteResource } from './delete-resource'; import { validateKey } from '@simoncodes-ca/domain'; import { resolveResourcePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../lib/resource/resource-folder'; +import { type ResourceMutation, upsertMutation } from '../lib/resource/resource-mutation'; import { RESOURCE_ENTRIES_FILENAME } from '../constants'; export interface MoveResourceParams { @@ -18,6 +19,8 @@ export interface MoveResourceResult { movedCount: number; warnings: string[]; errors: string[]; + /** Per moved key: an `upsert` in the destination folder and a `remove` from the source. */ + mutations: ResourceMutation[]; } /** @@ -49,6 +52,7 @@ async function moveSingleResource( movedCount: 0, warnings: [], errors: [], + mutations: [], }; try { @@ -98,6 +102,13 @@ async function moveSingleResource( destinationFolder.setEntry(destinationPaths.entryKey, sourceData.entry, sourceData.meta ?? {}); destinationFolder.save(); + result.mutations.push( + upsertMutation( + destinationTranslationsFolder, + destinationKey, + destinationFolder.treeEntry(destinationPaths.entryKey), + ), + ); } catch (error) { result.errors.push(`Failed to create destination resource: ${(error as Error).message}`); return result; @@ -105,7 +116,7 @@ async function moveSingleResource( // Delete from source try { - deleteResource(sourceTranslationsFolder, { keys: [sourceKey] }); + result.mutations.push(...deleteResource(sourceTranslationsFolder, { keys: [sourceKey] }).mutations); } catch (error) { result.warnings.push( `Resource moved to ${destinationKey} but failed to delete source ${sourceKey}: ${(error as Error).message}`, @@ -130,6 +141,7 @@ async function moveResourcesByPattern( movedCount: 0, warnings: [], errors: [], + mutations: [], }; const prefix = pattern.slice(0, -1); // remove '*' @@ -183,6 +195,7 @@ async function moveResourcesByPattern( result.movedCount += singleResult.movedCount; result.warnings.push(...singleResult.warnings); result.errors.push(...singleResult.errors); + result.mutations.push(...singleResult.mutations); } return result; From ef5b28728bd7df156fc98770a1cbd5320146c3f6 Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 00:47:12 -0700 Subject: [PATCH 05/20] refactor: typed core errors with one HTTP filter and one CLI reporter Core now throws LingoTrackerError subclasses with stable codes (ResourceNotFoundError, InvalidResourceKeyError, InvalidFolderPathError, Locale*, Bundle*, InvalidBundleDefinitionError; TranslationError and PreferredTerminologyValidationError join the hierarchy). Messages are unchanged. The API maps them in a single global exception filter and the controllers' message-sniffing try/catch blocks are gone; unexpected errors are logged and answered with a generic 500. TranslationError maps by provider code (400/429/500/502). The CLI replaces the "cancelled" string sentinel with PromptCancelledError and gains exitWithError. Co-Authored-By: Claude Fable 5.1 --- apps/api/src/app/app.module.ts | 12 +- .../app/bundles/bundles.controller.spec.ts | 32 +- .../api/src/app/bundles/bundles.controller.ts | 33 +- .../folders/folders.controller.spec.ts | 18 + .../collections/folders/folders.controller.ts | 247 ++++----- .../locales/locales.controller.spec.ts | 53 +- .../collections/locales/locales.controller.ts | 89 +--- .../resources/resources.controller.spec.ts | 126 +++-- .../resources/resources.controller.ts | 500 +++++++----------- .../lingo-tracker-exception.filter.spec.ts | 226 ++++++++ .../errors/lingo-tracker-exception.filter.ts | 136 +++++ .../cli/src/add-resource/add-resource.test.ts | 38 ++ apps/cli/src/add-resource/add-resource.ts | 14 +- apps/cli/src/commands/bundle.test.ts | 17 +- apps/cli/src/commands/bundle.ts | 16 +- apps/cli/src/commands/export-cmd.test.ts | 7 +- apps/cli/src/commands/export-cmd.ts | 14 +- apps/cli/src/commands/glossary.ts | 4 +- apps/cli/src/commands/import-cmd.ts | 17 +- apps/cli/src/commands/normalize.test.ts | 21 + apps/cli/src/commands/normalize.ts | 14 +- apps/cli/src/commands/protected-terms.spec.ts | 14 +- apps/cli/src/commands/protected-terms.ts | 10 +- apps/cli/src/commands/translate-locale.ts | 5 +- apps/cli/src/utils/index.ts | 1 + apps/cli/src/utils/prompt-utils.ts | 6 +- apps/cli/src/utils/report-error.spec.ts | 48 ++ apps/cli/src/utils/report-error.ts | 25 + architecture-docs/api.md | 33 +- architecture-docs/cli.md | 31 +- architecture-docs/core-library.md | 37 +- architecture-docs/glossary.md | 8 + .../src/collections-manager/add-collection.ts | 4 +- .../add-locale-to-collection.ts | 10 +- .../assert-valid-locale.ts | 11 + .../delete-collection-by-name.ts | 4 +- .../remove-locale-from-collection.ts | 10 +- .../set-protected-terms.ts | 6 +- .../collections-manager/update-collection.ts | 6 +- .../bundle/bundle-definition-operations.ts | 18 +- .../lib/config/preferred-terminology-file.ts | 6 +- libs/core/src/lib/errors/error-messages.ts | 13 +- .../lib/errors/lingo-tracker-error.spec.ts | 149 ++++++ .../src/lib/errors/lingo-tracker-error.ts | 150 +++++- libs/core/src/lib/folder/create-folder.ts | 5 +- libs/core/src/lib/folder/delete-folder.ts | 3 +- libs/core/src/lib/folder/move-folder.ts | 7 +- .../src/lib/resource/resource-file-paths.ts | 14 +- .../translate-existing-resource.ts | 5 +- .../lib/translation/translation-provider.ts | 10 +- libs/core/src/resource/edit-resource.ts | 5 +- 51 files changed, 1515 insertions(+), 773 deletions(-) create mode 100644 apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts create mode 100644 apps/api/src/app/errors/lingo-tracker-exception.filter.ts create mode 100644 apps/cli/src/utils/report-error.spec.ts create mode 100644 apps/cli/src/utils/report-error.ts create mode 100644 libs/core/src/collections-manager/assert-valid-locale.ts create mode 100644 libs/core/src/lib/errors/lingo-tracker-error.spec.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 0e6bb70c..15241927 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,4 +1,5 @@ import { Logger, Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { BundleJobService } from './bundles/bundle-job.service'; @@ -10,6 +11,7 @@ import { LocalesController } from './collections/locales/locales.controller'; import { ResourcesController } from './collections/resources/resources.controller'; import { ConfigController } from './config/config.controller'; import { ConfigService } from './config/config.service'; +import { LingoTrackerExceptionFilter } from './errors/lingo-tracker-exception.filter'; import { TranslationJobService } from './translation-job/translation-job.service'; @Module({ @@ -23,7 +25,15 @@ import { TranslationJobService } from './translation-job/translation-job.service LocalesController, BundlesController, ], - providers: [AppService, ConfigService, CollectionIndex, TranslationJobService, BundleJobService, Logger], + providers: [ + AppService, + ConfigService, + CollectionIndex, + TranslationJobService, + BundleJobService, + Logger, + { provide: APP_FILTER, useClass: LingoTrackerExceptionFilter }, + ], }) export class AppModule { constructor() { diff --git a/apps/api/src/app/bundles/bundles.controller.spec.ts b/apps/api/src/app/bundles/bundles.controller.spec.ts index 60198bde..e8089621 100644 --- a/apps/api/src/app/bundles/bundles.controller.spec.ts +++ b/apps/api/src/app/bundles/bundles.controller.spec.ts @@ -5,10 +5,12 @@ import * as core from '@simoncodes-ca/core'; import type { BundleDefinitionDto } from '@simoncodes-ca/data-transfer'; import type { Response } from 'express'; import { ConfigService } from '../config/config.service'; +import { toHttpException } from '../errors/lingo-tracker-exception.filter'; import { BundleJobService } from './bundle-job.service'; import { BundlesController } from './bundles.controller'; jest.mock('@simoncodes-ca/core', () => ({ + ...jest.requireActual('@simoncodes-ca/core'), addBundleDefinition: jest.fn(), updateBundleDefinition: jest.fn(), deleteBundleDefinition: jest.fn(), @@ -60,14 +62,14 @@ const plan: BundlePlan = { warnings: [], }; +/** The status the global exception filter answers with for what `fn` throws. */ const statusOf = (fn: () => unknown): number => { try { fn(); } catch (error: unknown) { - if (error instanceof HttpException) return error.getStatus(); - throw error; + return toHttpException(error).getStatus(); } - throw new Error('expected an HttpException'); + throw new Error('expected the handler to throw'); }; const makeResponse = (): { response: Response; status: jest.Mock; json: jest.Mock } => { @@ -151,10 +153,25 @@ describe('BundlesController', () => { it('returns 409 when core reports the bundle already exists', () => { (core.addBundleDefinition as jest.Mock).mockImplementation(() => { - throw new Error('Bundle "main" already exists'); + throw new core.BundleAlreadyExistsError('main'); }); - expect(() => controller.createBundle({ name: 'main', bundle: requestDefinition })).toThrow(ConflictException); + expect(() => controller.createBundle({ name: 'main', bundle: requestDefinition })).toThrow( + core.BundleAlreadyExistsError, + ); + expect(statusOf(() => controller.createBundle({ name: 'main', bundle: requestDefinition }))).toBe( + HttpStatus.CONFLICT, + ); + }); + + it('returns 400 for a failure core does not type', () => { + (core.addBundleDefinition as jest.Mock).mockImplementation(() => { + throw new Error('Failed to write configuration file'); + }); + + expect(statusOf(() => controller.createBundle({ name: 'main', bundle: requestDefinition }))).toBe( + HttpStatus.BAD_REQUEST, + ); }); }); @@ -221,10 +238,11 @@ describe('BundlesController', () => { it('maps a core not-found error to 404', () => { (core.deleteBundleDefinition as jest.Mock).mockImplementation(() => { - throw new Error('Bundle "tracker" not found'); + throw new core.BundleNotFoundError('tracker'); }); - expect(() => controller.deleteBundle('tracker')).toThrow(NotFoundException); + expect(() => controller.deleteBundle('tracker')).toThrow(core.BundleNotFoundError); + expect(statusOf(() => controller.deleteBundle('tracker'))).toBe(HttpStatus.NOT_FOUND); }); }); diff --git a/apps/api/src/app/bundles/bundles.controller.ts b/apps/api/src/app/bundles/bundles.controller.ts index 08a54170..aa9be94e 100644 --- a/apps/api/src/app/bundles/bundles.controller.ts +++ b/apps/api/src/app/bundles/bundles.controller.ts @@ -16,6 +16,7 @@ import type { BundleDefinition, LingoTrackerConfig } from '@simoncodes-ca/core'; import { addBundleDefinition, deleteBundleDefinition, + LingoTrackerError, planBundle, updateBundleDefinition, validateBundleDefinition, @@ -66,7 +67,7 @@ export class BundlesController { return mapBundlePlanToDto(plan); } catch (error: unknown) { - throw this.#toHttpException(error, 'Error planning bundle'); + this.#rethrow(error, 'Error planning bundle'); } } @@ -90,7 +91,7 @@ export class BundlesController { return addBundleDefinition(name, definition, { cwd: process.cwd() }); } catch (error: unknown) { - throw this.#toHttpException(error, 'Error creating bundle'); + this.#rethrow(error, 'Error creating bundle'); } } @@ -114,7 +115,7 @@ export class BundlesController { ...(newName !== undefined && newName !== decodedName && { newKey: newName }), }); } catch (error: unknown) { - throw this.#toHttpException(error, 'Error updating bundle'); + this.#rethrow(error, 'Error updating bundle'); } } @@ -127,7 +128,7 @@ export class BundlesController { return deleteBundleDefinition(decodedName, { cwd: process.cwd() }); } catch (error: unknown) { - throw this.#toHttpException(error, 'Error deleting bundle'); + this.#rethrow(error, 'Error deleting bundle'); } } @@ -204,22 +205,16 @@ export class BundlesController { return [...locales]; } - #toHttpException(error: unknown, fallback: string): HttpException { - if (error instanceof HttpException) { - return error; + /** + * Rethrows HTTP and typed core errors (`LingoTrackerExceptionFilter` maps the latter: + * missing bundle 404, duplicate 409, invalid definition 400). Any other failure of a + * bundle route answers 400. + */ + #rethrow(error: unknown, fallback: string): never { + if (error instanceof HttpException || error instanceof LingoTrackerError) { + throw error; } - - const message = error instanceof Error ? error.message : fallback; - - if (message.includes('not found')) { - return new NotFoundException(message); - } - - if (message.includes('already exists')) { - return new ConflictException(message); - } - - return new HttpException(message, HttpStatus.BAD_REQUEST); + throw new HttpException(error instanceof Error ? error.message : fallback, HttpStatus.BAD_REQUEST); } } diff --git a/apps/api/src/app/collections/folders/folders.controller.spec.ts b/apps/api/src/app/collections/folders/folders.controller.spec.ts index 680308f9..da76631a 100644 --- a/apps/api/src/app/collections/folders/folders.controller.spec.ts +++ b/apps/api/src/app/collections/folders/folders.controller.spec.ts @@ -4,6 +4,7 @@ import { ForbiddenException, HttpException, NotFoundException } from '@nestjs/co import { FoldersController } from './folders.controller'; import { ConfigService } from '../../config/config.service'; import { CollectionIndex } from '../../cache/collection-index.service'; +import { toHttpException } from '../../errors/lingo-tracker-exception.filter'; import * as core from '@simoncodes-ca/core'; // Mock the core module @@ -343,6 +344,23 @@ describe('FoldersController', () => { expect(result.created).toBe(true); expect(result.folderPath).toBe('apps.common.buttons'); }); + + it('lets an invalid folder name propagate; the exception filter answers 400', async () => { + (core.createFolder as jest.Mock).mockImplementation(() => { + throw new core.InvalidFolderPathError('folder name', 'bad name'); + }); + + const error = await foldersController + .create('test-collection', { folderName: 'bad name' }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(core.InvalidFolderPathError); + const http = toHttpException(error); + expect(http.getStatus()).toBe(400); + expect(http.message).toBe( + 'Validation error: Invalid folder name segment "bad name". Segments must match pattern [A-Za-z0-9_-]+', + ); + }); }); describe('DELETE /folders', () => { diff --git a/apps/api/src/app/collections/folders/folders.controller.ts b/apps/api/src/app/collections/folders/folders.controller.ts index 5c866ceb..82ea42ee 100644 --- a/apps/api/src/app/collections/folders/folders.controller.ts +++ b/apps/api/src/app/collections/folders/folders.controller.ts @@ -1,14 +1,4 @@ -import { - Controller, - Post, - Delete, - Param, - Body, - HttpException, - HttpStatus, - NotFoundException, - UseGuards, -} from '@nestjs/common'; +import { Controller, Post, Delete, Param, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'; import { createFolder, deleteFolder, moveFolder } from '@simoncodes-ca/core'; import type { CreateFolderDto, @@ -37,95 +27,58 @@ export class FoldersController { @Param('collectionName') collectionName: string, @Body() createFolderDto: CreateFolderDto, ): Promise { - try { - const { translationsFolder } = openRouteCollection(this.configService.getConfig(), collectionName); - - const result = createFolder(translationsFolder, { - folderName: createFolderDto.folderName, - parentPath: createFolderDto.parentPath, - }); - - this.index.apply(result.mutations); - - // Build the folder node for the frontend to insert into tree - const fullPath = createFolderDto.parentPath - ? `${createFolderDto.parentPath}.${createFolderDto.folderName}` - : createFolderDto.folderName; - - const folderNode: FolderNodeDto = { - name: createFolderDto.folderName, - fullPath, - loaded: true, - tree: { - path: fullPath, - resources: [], - children: [], - }, - }; - - return { - folderPath: result.folderPath, - created: result.created, - folder: folderNode, - }; - } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } - - if (error instanceof HttpException) { - throw error; - } - - // Validation errors (invalid folder name, etc.) should return 400 - const errorMessage = error instanceof Error ? error.message : ''; - if (errorMessage.includes('Invalid') || errorMessage.includes('cannot be empty')) { - throw new HttpException(`Validation error: ${errorMessage}`, HttpStatus.BAD_REQUEST); - } - - // File system errors or other unexpected errors - throw new HttpException(errorMessage || 'Error creating folder', HttpStatus.INTERNAL_SERVER_ERROR); - } + const { translationsFolder } = openRouteCollection(this.configService.getConfig(), collectionName); + + const result = createFolder(translationsFolder, { + folderName: createFolderDto.folderName, + parentPath: createFolderDto.parentPath, + }); + + this.index.apply(result.mutations); + + // Build the folder node for the frontend to insert into tree + const fullPath = createFolderDto.parentPath + ? `${createFolderDto.parentPath}.${createFolderDto.folderName}` + : createFolderDto.folderName; + + const folderNode: FolderNodeDto = { + name: createFolderDto.folderName, + fullPath, + loaded: true, + tree: { + path: fullPath, + resources: [], + children: [], + }, + }; + + return { + folderPath: result.folderPath, + created: result.created, + folder: folderNode, + }; } + /** Core `deleteFolder` reports every failure in `error`, so this answers 200 even then. */ @Delete() async delete( @Param('collectionName') collectionName: string, @Body() deleteFolderDto: DeleteFolderDto, ): Promise { - try { - const { translationsFolder } = openRouteCollection(this.configService.getConfig(), collectionName); - - const result = deleteFolder(translationsFolder, { - folderPath: deleteFolderDto.folderPath, - }); - - this.index.apply(result.mutations); - - return { - deleted: result.deleted, - folderPath: result.folderPath, - resourcesDeleted: result.resourcesDeleted, - error: result.error, - }; - } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } - - if (error instanceof HttpException) { - throw error; - } - - // Validation errors (invalid folder path, etc.) should return 400 - const errorMessage = error instanceof Error ? error.message : ''; - if (errorMessage.includes('Invalid') || errorMessage.includes('not found')) { - throw new HttpException(`Validation error: ${errorMessage}`, HttpStatus.BAD_REQUEST); - } - - // File system errors or other unexpected errors - throw new HttpException(errorMessage || 'Error deleting folder', HttpStatus.INTERNAL_SERVER_ERROR); - } + const { translationsFolder } = openRouteCollection(this.configService.getConfig(), collectionName); + + const result = deleteFolder(translationsFolder, { + folderPath: deleteFolderDto.folderPath, + }); + + this.index.apply(result.mutations); + + return { + deleted: result.deleted, + folderPath: result.folderPath, + resourcesDeleted: result.resourcesDeleted, + error: result.error, + }; } @Post('move') @@ -133,73 +86,51 @@ export class FoldersController { @Param('collectionName') collectionName: string, @Body() moveFolderDto: MoveFolderDto, ): Promise { - try { - const config = this.configService.getConfig(); - const { translationsFolder } = openRouteCollection(config, collectionName); - - if ( - !moveFolderDto.sourceFolderPath || - moveFolderDto.destinationFolderPath === undefined || - moveFolderDto.destinationFolderPath === null - ) { - throw new HttpException( - 'Invalid request: sourceFolderPath and destinationFolderPath are required', - HttpStatus.BAD_REQUEST, - ); - } - - // Handle cross-collection moves - const destinationTranslationsFolder = moveFolderDto.toCollection - ? openDestinationCollection(config, moveFolderDto.toCollection).translationsFolder - : undefined; - - // Perform the move - const result = await moveFolder(translationsFolder, { - sourceFolderPath: moveFolderDto.sourceFolderPath, - destinationFolderPath: moveFolderDto.destinationFolderPath, - override: moveFolderDto.override, - nestUnderDestination: moveFolderDto.nestUnderDestination, - destinationTranslationsFolder, - }); - - this.index.apply(result.mutations); - - // Check for critical errors that should return 400 - const hasCriticalError = result.errors.some( - (err) => - err.includes('Invalid') || - err.includes('not found') || - err.includes('circular') || - err.includes('descendant'), + const config = this.configService.getConfig(); + const { translationsFolder } = openRouteCollection(config, collectionName); + + if ( + !moveFolderDto.sourceFolderPath || + moveFolderDto.destinationFolderPath === undefined || + moveFolderDto.destinationFolderPath === null + ) { + throw new HttpException( + 'Invalid request: sourceFolderPath and destinationFolderPath are required', + HttpStatus.BAD_REQUEST, ); + } - if (hasCriticalError && result.movedCount === 0) { - throw new HttpException(`Validation error: ${result.errors.join(', ')}`, HttpStatus.BAD_REQUEST); - } - - return { - movedCount: result.movedCount, - foldersDeleted: result.foldersDeleted, - warnings: result.warnings, - errors: result.errors, - }; - } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } - - if (error instanceof HttpException) { - throw error; - } - - // Validation errors should return 400 - const errorMessage = error instanceof Error ? error.message : ''; - if (errorMessage.includes('Invalid') || errorMessage.includes('not found')) { - throw new HttpException(`Validation error: ${errorMessage}`, HttpStatus.BAD_REQUEST); - } - - // File system errors or other unexpected errors - throw new HttpException(errorMessage || 'Error moving folder', HttpStatus.INTERNAL_SERVER_ERROR); + // Handle cross-collection moves + const destinationTranslationsFolder = moveFolderDto.toCollection + ? openDestinationCollection(config, moveFolderDto.toCollection).translationsFolder + : undefined; + + // Perform the move. Core `moveFolder` never throws; it reports failures in `errors`. + const result = await moveFolder(translationsFolder, { + sourceFolderPath: moveFolderDto.sourceFolderPath, + destinationFolderPath: moveFolderDto.destinationFolderPath, + override: moveFolderDto.override, + nestUnderDestination: moveFolderDto.nestUnderDestination, + destinationTranslationsFolder, + }); + + this.index.apply(result.mutations); + + // Check for critical errors that should return 400 + const hasCriticalError = result.errors.some( + (err) => + err.includes('Invalid') || err.includes('not found') || err.includes('circular') || err.includes('descendant'), + ); + + if (hasCriticalError && result.movedCount === 0) { + throw new HttpException(`Validation error: ${result.errors.join(', ')}`, HttpStatus.BAD_REQUEST); } + + return { + movedCount: result.movedCount, + foldersDeleted: result.foldersDeleted, + warnings: result.warnings, + errors: result.errors, + }; } } diff --git a/apps/api/src/app/collections/locales/locales.controller.spec.ts b/apps/api/src/app/collections/locales/locales.controller.spec.ts index 77f09e55..72c038c2 100644 --- a/apps/api/src/app/collections/locales/locales.controller.spec.ts +++ b/apps/api/src/app/collections/locales/locales.controller.spec.ts @@ -3,6 +3,7 @@ import { HttpException, NotFoundException } from '@nestjs/common'; import { LocalesController } from './locales.controller'; import { ConfigService } from '../../config/config.service'; import { CollectionIndex } from '../../cache/collection-index.service'; +import { toHttpException } from '../../errors/lingo-tracker-exception.filter'; import * as core from '@simoncodes-ca/core'; jest.mock('@simoncodes-ca/core', () => { @@ -74,14 +75,14 @@ describe('LocalesController', () => { it('returns 400 when locale already exists in collection', async () => { (core.addLocaleToCollection as jest.Mock).mockRejectedValue( - new Error('Locale "fr" already exists in collection "test-collection"'), + new core.LocaleAlreadyExistsError('fr', 'test-collection'), ); - await expect(localesController.addLocale('test-collection', { locale: 'fr' })).rejects.toThrow(HttpException); + await expect(localesController.addLocale('test-collection', { locale: 'fr' })).rejects.toThrow( + core.LocaleAlreadyExistsError, + ); - const error = await localesController - .addLocale('test-collection', { locale: 'fr' }) - .catch((e: HttpException) => e); + const error = await localesController.addLocale('test-collection', { locale: 'fr' }).catch(toHttpException); expect((error as HttpException).getStatus()).toBe(400); }); @@ -93,23 +94,21 @@ describe('LocalesController', () => { }); it('returns 400 when trying to add the base locale', async () => { - (core.addLocaleToCollection as jest.Mock).mockRejectedValue( - new Error('Cannot add or remove the base locale "en"'), - ); + (core.addLocaleToCollection as jest.Mock).mockRejectedValue(new core.BaseLocaleImmutableError('en')); - const error = await localesController - .addLocale('test-collection', { locale: 'en' }) - .catch((e: HttpException) => e); + const error = await localesController.addLocale('test-collection', { locale: 'en' }).catch(toHttpException); expect((error as HttpException).getStatus()).toBe(400); }); it('returns 400 when locale format is invalid', async () => { - (core.addLocaleToCollection as jest.Mock).mockRejectedValue(new Error('Invalid locale format: "not-valid-123"')); + (core.addLocaleToCollection as jest.Mock).mockRejectedValue( + new core.InvalidLocaleError('not-valid-123', 'Invalid locale format: "not-valid-123"'), + ); const error = await localesController .addLocale('test-collection', { locale: 'not-valid-123' }) - .catch((e: HttpException) => e); + .catch(toHttpException); expect((error as HttpException).getStatus()).toBe(400); }); @@ -117,9 +116,7 @@ describe('LocalesController', () => { it('returns 500 for unexpected errors', async () => { (core.addLocaleToCollection as jest.Mock).mockRejectedValue(new Error('Disk write failure')); - const error = await localesController - .addLocale('test-collection', { locale: 'de' }) - .catch((e: HttpException) => e); + const error = await localesController.addLocale('test-collection', { locale: 'de' }).catch(toHttpException); expect((error as HttpException).getStatus()).toBe(500); }); @@ -127,9 +124,7 @@ describe('LocalesController', () => { it('returns 403 when core refuses a read-only collection', async () => { (core.addLocaleToCollection as jest.Mock).mockRejectedValue(new core.ReadOnlyCollectionError('test-collection')); - const error = await localesController - .addLocale('test-collection', { locale: 'de' }) - .catch((e: HttpException) => e); + const error = await localesController.addLocale('test-collection', { locale: 'de' }).catch(toHttpException); expect((error as HttpException).getStatus()).toBe(403); }); @@ -165,28 +160,28 @@ describe('LocalesController', () => { it('returns 400 when locale is not in the collection', async () => { (core.removeLocaleFromCollection as jest.Mock).mockRejectedValue( - new Error('Locale "ja" not found in collection "test-collection"'), + new core.LocaleNotFoundError('ja', 'test-collection'), ); - const error = await localesController.removeLocale('test-collection', 'ja').catch((e: HttpException) => e); + const error = await localesController.removeLocale('test-collection', 'ja').catch(toHttpException); expect((error as HttpException).getStatus()).toBe(400); }); it('returns 400 when trying to remove the base locale', async () => { - (core.removeLocaleFromCollection as jest.Mock).mockRejectedValue( - new Error('Cannot add or remove the base locale "en"'), - ); + (core.removeLocaleFromCollection as jest.Mock).mockRejectedValue(new core.BaseLocaleImmutableError('en')); - const error = await localesController.removeLocale('test-collection', 'en').catch((e: HttpException) => e); + const error = await localesController.removeLocale('test-collection', 'en').catch(toHttpException); expect((error as HttpException).getStatus()).toBe(400); }); it('returns 400 when locale format is invalid', async () => { - (core.removeLocaleFromCollection as jest.Mock).mockRejectedValue(new Error('Invalid locale format: "bad!"')); + (core.removeLocaleFromCollection as jest.Mock).mockRejectedValue( + new core.InvalidLocaleError('bad!', 'Invalid locale format: "bad!"'), + ); - const error = await localesController.removeLocale('test-collection', 'bad!').catch((e: HttpException) => e); + const error = await localesController.removeLocale('test-collection', 'bad!').catch(toHttpException); expect((error as HttpException).getStatus()).toBe(400); }); @@ -194,7 +189,7 @@ describe('LocalesController', () => { it('returns 500 for unexpected errors', async () => { (core.removeLocaleFromCollection as jest.Mock).mockRejectedValue(new Error('Disk write failure')); - const error = await localesController.removeLocale('test-collection', 'fr').catch((e: HttpException) => e); + const error = await localesController.removeLocale('test-collection', 'fr').catch(toHttpException); expect((error as HttpException).getStatus()).toBe(500); }); @@ -204,7 +199,7 @@ describe('LocalesController', () => { new core.ReadOnlyCollectionError('test-collection'), ); - const error = await localesController.removeLocale('test-collection', 'fr').catch((e: HttpException) => e); + const error = await localesController.removeLocale('test-collection', 'fr').catch(toHttpException); expect((error as HttpException).getStatus()).toBe(403); }); diff --git a/apps/api/src/app/collections/locales/locales.controller.ts b/apps/api/src/app/collections/locales/locales.controller.ts index e68e9296..05e99d62 100644 --- a/apps/api/src/app/collections/locales/locales.controller.ts +++ b/apps/api/src/app/collections/locales/locales.controller.ts @@ -1,22 +1,15 @@ -import { - Controller, - Post, - Delete, - Param, - Body, - HttpException, - HttpStatus, - ForbiddenException, - NotFoundException, - UseGuards, -} from '@nestjs/common'; -import { addLocaleToCollection, ReadOnlyCollectionError, removeLocaleFromCollection } from '@simoncodes-ca/core'; +import { Controller, Post, Delete, Param, Body, UseGuards } from '@nestjs/common'; +import { addLocaleToCollection, removeLocaleFromCollection } from '@simoncodes-ca/core'; import type { AddLocaleDto, AddLocaleResponseDto, RemoveLocaleResponseDto } from '@simoncodes-ca/data-transfer'; import { ConfigService } from '../../config/config.service'; import { CollectionIndex } from '../../cache/collection-index.service'; import { WritableCollectionGuard } from '../guards/writable-collection.guard'; import { openRouteCollection } from '../open-route-collection'; +/** + * Core locale errors (invalid, missing, duplicate, base locale; read-only collection) + * propagate to `LingoTrackerExceptionFilter`, which answers 400 / 403 / 404. + */ @UseGuards(WritableCollectionGuard) @Controller('collections/:collectionName/locales') export class LocalesController { @@ -33,39 +26,12 @@ export class LocalesController { @Param('collectionName') collectionName: string, @Body() body: AddLocaleDto, ): Promise { - try { - const { name } = openRouteCollection(this.#configService.getConfig(), collectionName); + const { name } = openRouteCollection(this.#configService.getConfig(), collectionName); - const { mutations, ...response } = await addLocaleToCollection(name, body.locale); - this.#index.apply(mutations); + const { mutations, ...response } = await addLocaleToCollection(name, body.locale); + this.#index.apply(mutations); - return response; - } catch (error: unknown) { - if (error instanceof NotFoundException || error instanceof HttpException) { - throw error; - } - - // WritableCollectionGuard normally refuses these first; core enforces it too. - if (error instanceof ReadOnlyCollectionError) { - throw new ForbiddenException(error.message); - } - - const errorMessage = error instanceof Error ? error.message : 'Error adding locale'; - - if (errorMessage.includes('not found') || errorMessage.includes('not found in collection')) { - throw new NotFoundException(errorMessage); - } - - if ( - errorMessage.includes('already exists') || - errorMessage.includes('base locale') || - errorMessage.includes('Invalid locale') - ) { - throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); - } - - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + return response; } @Delete(':locale') @@ -73,38 +39,11 @@ export class LocalesController { @Param('collectionName') collectionName: string, @Param('locale') locale: string, ): Promise { - try { - const { name } = openRouteCollection(this.#configService.getConfig(), collectionName); - - const { mutations, ...response } = await removeLocaleFromCollection(name, locale); - this.#index.apply(mutations); - - return response; - } catch (error: unknown) { - if (error instanceof NotFoundException || error instanceof HttpException) { - throw error; - } - - // WritableCollectionGuard normally refuses these first; core enforces it too. - if (error instanceof ReadOnlyCollectionError) { - throw new ForbiddenException(error.message); - } - - const errorMessage = error instanceof Error ? error.message : 'Error removing locale'; - - if (errorMessage.includes('Collection') && errorMessage.includes('not found')) { - throw new NotFoundException(errorMessage); - } + const { name } = openRouteCollection(this.#configService.getConfig(), collectionName); - if ( - errorMessage.includes('not found in collection') || - errorMessage.includes('base locale') || - errorMessage.includes('Invalid locale') - ) { - throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); - } + const { mutations, ...response } = await removeLocaleFromCollection(name, locale); + this.#index.apply(mutations); - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + return response; } } diff --git a/apps/api/src/app/collections/resources/resources.controller.spec.ts b/apps/api/src/app/collections/resources/resources.controller.spec.ts index 2c8010ba..f1abe1a4 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -8,8 +8,15 @@ import { ResourcesController } from './resources.controller'; import { ConfigService } from '../../config/config.service'; import { CollectionIndex } from '../../cache/collection-index.service'; import { TranslationJobService } from '../../translation-job/translation-job.service'; +import { toHttpException } from '../../errors/lingo-tracker-exception.filter'; import * as core from '@simoncodes-ca/core'; +/** What the handler rejects with, as the HTTP exception the global exception filter answers with. */ +const httpErrorOf = (promise: Promise): Promise => + promise.then(() => { + throw new Error('expected the handler to reject'); + }, toHttpException); + // Mock the core module jest.mock('@simoncodes-ca/core', () => { const actual = jest.requireActual('@simoncodes-ca/core'); @@ -341,10 +348,11 @@ describe('ResourcesController', () => { await expect(resourcesController.createResources('test-collection', [])).rejects.toThrow(HttpException); }); - it('should throw HttpException (400) for invalid key validation', async () => { + it('should answer 400 for invalid key validation', async () => { + const message = 'Key validation: Invalid key segment "invalid@key". Segments must match pattern [A-Za-z0-9_-]+'; const addResource = core.addResource as jest.Mock; addResource.mockImplementation(() => { - throw new Error('Invalid key segment "invalid@key". Segments must match pattern [A-Za-z0-9_-]+'); + throw new core.InvalidResourceKeyError('invalid@key', message); }); const dto = { @@ -352,20 +360,19 @@ describe('ResourcesController', () => { baseValue: 'OK', }; - await expect(resourcesController.createResources('test-collection', dto)).rejects.toThrow(HttpException); + await expect(resourcesController.createResources('test-collection', dto)).rejects.toThrow( + core.InvalidResourceKeyError, + ); - try { - await resourcesController.createResources('test-collection', dto); - } catch (error: any) { - expect(error.status).toBe(400); - expect(error.message).toContain('Validation error'); - } + const error = await httpErrorOf(resourcesController.createResources('test-collection', dto)); + expect(error.getStatus()).toBe(400); + expect(error.message).toBe(message); }); - it('should throw HttpException (400) for empty key', async () => { + it('should answer 400 for empty key', async () => { const addResource = core.addResource as jest.Mock; addResource.mockImplementation(() => { - throw new Error('Key cannot be empty'); + throw new core.InvalidResourceKeyError('', 'Key validation: Key cannot be empty'); }); const dto = { @@ -373,16 +380,23 @@ describe('ResourcesController', () => { baseValue: 'OK', }; - await expect(resourcesController.createResources('test-collection', dto)).rejects.toThrow(HttpException); + const error = await httpErrorOf(resourcesController.createResources('test-collection', dto)); + expect(error.getStatus()).toBe(400); + }); - try { - await resourcesController.createResources('test-collection', dto); - } catch (error: any) { - expect(error.status).toBe(400); - } + it('should answer 502 when the translation provider fails during auto-translation', async () => { + const addResource = core.addResource as jest.Mock; + addResource.mockImplementation(() => { + throw new TranslationError('Google Translate server error: backend down', 'SERVER_ERROR', true); + }); + + const error = await httpErrorOf( + resourcesController.createResources('test-collection', { key: 'app.button.ok', baseValue: 'OK' }), + ); + expect(error.getStatus()).toBe(502); }); - it('should throw HttpException (500) for unexpected errors', async () => { + it('should answer a generic 500 that hides the message for unexpected errors', async () => { const addResource = core.addResource as jest.Mock; addResource.mockImplementation(() => { throw new Error('Unexpected file system error'); @@ -393,13 +407,13 @@ describe('ResourcesController', () => { baseValue: 'OK', }; - await expect(resourcesController.createResources('test-collection', dto)).rejects.toThrow(HttpException); + await expect(resourcesController.createResources('test-collection', dto)).rejects.toThrow( + 'Unexpected file system error', + ); - try { - await resourcesController.createResources('test-collection', dto); - } catch (error: any) { - expect(error.status).toBe(500); - } + const error = await httpErrorOf(resourcesController.createResources('test-collection', dto)); + expect(error.getStatus()).toBe(500); + expect(error.message).toBe('Internal server error'); }); it('should handle resource with all optional fields', async () => { @@ -730,7 +744,7 @@ describe('ResourcesController', () => { } }); - it('should throw HttpException (500) for unexpected errors', async () => { + it('should answer 500 for unexpected errors', async () => { const deleteResource = core.deleteResource as jest.Mock; deleteResource.mockImplementation(() => { throw new Error('Unexpected file system error'); @@ -740,13 +754,8 @@ describe('ResourcesController', () => { keys: ['app.button.ok'], }; - await expect(resourcesController.delete('test-collection', dto)).rejects.toThrow(HttpException); - - try { - await resourcesController.delete('test-collection', dto); - } catch (error: any) { - expect(error.status).toBe(500); - } + const error = await httpErrorOf(resourcesController.delete('test-collection', dto)); + expect(error.getStatus()).toBe(500); }); it('should successfully delete nested resource', async () => { @@ -998,36 +1007,35 @@ describe('ResourcesController', () => { }); }); - it('should throw NotFoundException when resource not found', async () => { + it('should answer 404 when resource not found', async () => { const editResource = core.editResource as jest.Mock; editResource.mockImplementation(() => { - throw new Error('Resource not found: app.button.missing'); + throw new core.ResourceNotFoundError('app.button.missing'); }); const dto = { key: 'app.button.missing', }; - await expect(resourcesController.update('test-collection', dto)).rejects.toThrow(NotFoundException); + await expect(resourcesController.update('test-collection', dto)).rejects.toThrow(core.ResourceNotFoundError); + + const error = await httpErrorOf(resourcesController.update('test-collection', dto)); + expect(error).toBeInstanceOf(NotFoundException); + expect(error.message).toBe('Resource not found: app.button.missing'); }); - it('should throw BadRequestException for validation errors', async () => { + it('should answer 400 for validation errors', async () => { const editResource = core.editResource as jest.Mock; editResource.mockImplementation(() => { - throw new Error('Invalid key segment'); + throw new core.InvalidResourceKeyError('invalid..key', 'Key validation: Invalid key format "invalid..key"'); }); const dto = { key: 'invalid..key', }; - await expect(resourcesController.update('test-collection', dto)).rejects.toThrow(HttpException); - - try { - await resourcesController.update('test-collection', dto); - } catch (error: any) { - expect(error.status).toBe(400); - } + const error = await httpErrorOf(resourcesController.update('test-collection', dto)); + expect(error.getStatus()).toBe(400); }); }); @@ -1138,14 +1146,16 @@ describe('ResourcesController', () => { ); }); - it('should return 500 when reading the index throws', async () => { + it('should return a generic 500 when reading the index throws', async () => { mockIndex.tree.mockImplementationOnce(() => { throw new Error('boom'); }); - await expect( + const error = await httpErrorOf( resourcesController.getTree('test-collection', '', undefined, mockResponse() as any), - ).rejects.toThrow(HttpException); + ); + expect(error.getStatus()).toBe(500); + expect(error.message).toBe('Internal server error'); }); }); @@ -1261,25 +1271,27 @@ describe('ResourcesController', () => { (configService.getConfig as jest.Mock).mockReturnValue(configWithTranslation); const translateExistingResource = core.translateExistingResource as jest.Mock; - translateExistingResource.mockRejectedValue(new Error('Resource not found: buttons.save')); + translateExistingResource.mockRejectedValue(new core.ResourceNotFoundError('buttons.save')); - await expect(resourcesController.translateResource('test-collection', { key: 'buttons.save' })).rejects.toThrow( - NotFoundException, + const error = await httpErrorOf( + resourcesController.translateResource('test-collection', { key: 'buttons.save' }), ); + expect(error).toBeInstanceOf(NotFoundException); }); it('should return 502 when the translation provider throws a TranslationError', async () => { (configService.getConfig as jest.Mock).mockReturnValue(configWithTranslation); const translateExistingResource = core.translateExistingResource as jest.Mock; - translateExistingResource.mockRejectedValue(new TranslationError('Rate limit exceeded', 'RATE_LIMIT', true)); + translateExistingResource.mockRejectedValue( + new TranslationError('Google Translate server error: backend down', 'SERVER_ERROR', true), + ); - try { - await resourcesController.translateResource('test-collection', { key: 'buttons.save' }); - } catch (error: unknown) { - expect(error).toBeInstanceOf(HttpException); - expect((error as HttpException).getStatus()).toBe(502); - } + const error = await httpErrorOf( + resourcesController.translateResource('test-collection', { key: 'buttons.save' }), + ); + expect(error.getStatus()).toBe(502); + expect(error.message).toBe('Translation provider error: Google Translate server error: backend down'); }); it('should return a TranslateResourceResponseDto with translated resource on success', async () => { diff --git a/apps/api/src/app/collections/resources/resources.controller.ts b/apps/api/src/app/collections/resources/resources.controller.ts index a4b11671..93dd7637 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -22,7 +22,6 @@ import { moveResource, editResource, translateExistingResource, - TranslationError, extractResourcesRecursively, type Collection, } from '@simoncodes-ca/core'; @@ -73,49 +72,29 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Body() dto: TranslateResourceDto, ): Promise { - try { - const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const { translationConfig } = collection; - - if (!translationConfig?.enabled) { - throw new HttpException('Auto-translation is not enabled for this collection', HttpStatus.UNPROCESSABLE_ENTITY); - } - - const result = await translateExistingResource({ - key: dto.key, - translationsFolder: collection.translationsFolder, - translationConfig, - allLocales: collection.locales, - baseLocale: collection.baseLocale, - cwd: process.cwd(), - }); - - this.#index.apply(result.mutations); - - const resource = mapResourceEntryToSummary(result.entry, collection.tags); - - return { - resource, - skippedLocales: result.skippedLocales, - translatedCount: result.translatedCount, - }; - } catch (error: unknown) { - if (error instanceof NotFoundException || error instanceof HttpException) { - throw error; - } + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const { translationConfig } = collection; - if (error instanceof TranslationError) { - throw new HttpException(`Translation provider error: ${error.message}`, HttpStatus.BAD_GATEWAY); - } + if (!translationConfig?.enabled) { + throw new HttpException('Auto-translation is not enabled for this collection', HttpStatus.UNPROCESSABLE_ENTITY); + } - const errorMessage = error instanceof Error ? error.message : 'Error translating resource'; + const result = await translateExistingResource({ + key: dto.key, + translationsFolder: collection.translationsFolder, + translationConfig, + allLocales: collection.locales, + baseLocale: collection.baseLocale, + cwd: process.cwd(), + }); - if (errorMessage.includes('Resource not found')) { - throw new NotFoundException(errorMessage); - } + this.#index.apply(result.mutations); - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + return { + resource: mapResourceEntryToSummary(result.entry, collection.tags), + skippedLocales: result.skippedLocales, + translatedCount: result.translatedCount, + }; } @Post() @@ -123,88 +102,64 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Body() body: CreateResourceDto | CreateResourceDto[], ): Promise { - try { - const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const { translationsFolder, baseLocale, locales, translationConfig } = collection; - - // Normalize to array - const resources = Array.isArray(body) ? body : [body]; + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const { translationsFolder, baseLocale, locales, translationConfig } = collection; - if (resources.length === 0) { - throw new HttpException('At least one resource is required', HttpStatus.BAD_REQUEST); - } + // Normalize to array + const resources = Array.isArray(body) ? body : [body]; - let entriesCreated = 0; - let hasCreated = false; - const allSkippedLocales: string[] = []; + if (resources.length === 0) { + throw new HttpException('At least one resource is required', HttpStatus.BAD_REQUEST); + } - for (const resource of resources) { - try { - const resourceBaseLocale = resource.baseLocale || baseLocale; - const hasExplicitTranslations = resource.translations && resource.translations.length > 0; - const canAutoTranslate = translationConfig?.enabled && !hasExplicitTranslations; - - // When auto-translation is enabled and no explicit translations provided, - // let addResource handle translation via the configured provider. - // Otherwise, fall back to default translations (copies base value with 'new' status). - const translations = hasExplicitTranslations - ? resource.translations - : canAutoTranslate - ? undefined - : createDefaultTranslations(locales, resourceBaseLocale, resource.baseValue); - - const params = mapDtoToAddResourceParams({ - ...resource, - baseLocale: resourceBaseLocale, - translations, - ...(canAutoTranslate && { allLocales: locales }), - }); - - const result = canAutoTranslate - ? await addResource(translationsFolder, params, { translationConfig }) - : await addResource(translationsFolder, params); - - if (result.created) { - entriesCreated++; - hasCreated = true; - } - - if (result.skippedLocales?.length) { - allSkippedLocales.push(...result.skippedLocales); - } - - this.#index.apply(result.mutations); - } catch (error: unknown) { - // Validation errors (invalid key, etc.) should return 400 - const errorMessage = error instanceof Error ? error.message : ''; - if (errorMessage.includes('Invalid') || errorMessage.includes('cannot be empty')) { - throw new HttpException(`Validation error for resource: ${errorMessage}`, HttpStatus.BAD_REQUEST); - } - // Re-throw other errors to be caught by outer catch - throw error; - } - } + let entriesCreated = 0; + let hasCreated = false; + const allSkippedLocales: string[] = []; + + for (const resource of resources) { + const resourceBaseLocale = resource.baseLocale || baseLocale; + const hasExplicitTranslations = resource.translations && resource.translations.length > 0; + const canAutoTranslate = translationConfig?.enabled && !hasExplicitTranslations; + + // When auto-translation is enabled and no explicit translations provided, + // let addResource handle translation via the configured provider. + // Otherwise, fall back to default translations (copies base value with 'new' status). + const translations = hasExplicitTranslations + ? resource.translations + : canAutoTranslate + ? undefined + : createDefaultTranslations(locales, resourceBaseLocale, resource.baseValue); + + const params = mapDtoToAddResourceParams({ + ...resource, + baseLocale: resourceBaseLocale, + translations, + ...(canAutoTranslate && { allLocales: locales }), + }); - const uniqueSkippedLocales = [...new Set(allSkippedLocales)]; + const result = canAutoTranslate + ? await addResource(translationsFolder, params, { translationConfig }) + : await addResource(translationsFolder, params); - return { - entriesCreated, - created: hasCreated, - ...(uniqueSkippedLocales.length > 0 && { skippedLocales: uniqueSkippedLocales }), - }; - } catch (error: unknown) { - if (error instanceof HttpException) { - throw error; + if (result.created) { + entriesCreated++; + hasCreated = true; } - if (error instanceof NotFoundException) { - throw error; + if (result.skippedLocales?.length) { + allSkippedLocales.push(...result.skippedLocales); } - // File system errors or other unexpected errors - const errorMessage = error instanceof Error ? error.message : 'Error creating resources'; - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); + this.#index.apply(result.mutations); } + + const uniqueSkippedLocales = [...new Set(allSkippedLocales)]; + + return { + entriesCreated, + created: hasCreated, + ...(uniqueSkippedLocales.length > 0 && { skippedLocales: uniqueSkippedLocales }), + }; } @Delete() @@ -212,35 +167,19 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Body() dto: DeleteResourceDto, ): Promise { - try { - const { translationsFolder } = openRouteCollection(this.#configService.getConfig(), collectionName); - - if (!dto.keys || !Array.isArray(dto.keys) || dto.keys.length === 0) { - throw new HttpException( - 'Invalid request: keys array is required and must not be empty', - HttpStatus.BAD_REQUEST, - ); - } + const { translationsFolder } = openRouteCollection(this.#configService.getConfig(), collectionName); - const result = deleteResource(translationsFolder, { keys: dto.keys }); - this.#index.apply(result.mutations); - - return { - entriesDeleted: result.entriesDeleted, - errors: result.errors, - }; - } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } + if (!dto.keys || !Array.isArray(dto.keys) || dto.keys.length === 0) { + throw new HttpException('Invalid request: keys array is required and must not be empty', HttpStatus.BAD_REQUEST); + } - if (error instanceof HttpException) { - throw error; - } + const result = deleteResource(translationsFolder, { keys: dto.keys }); + this.#index.apply(result.mutations); - const errorMessage = error instanceof Error ? error.message : 'Error deleting resources'; - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + return { + entriesDeleted: result.entriesDeleted, + errors: result.errors, + }; } @Post('move') @@ -248,67 +187,55 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Body() dto: MoveResourceDto, ): Promise { - try { - const config = this.#configService.getConfig(); - const { translationsFolder } = openRouteCollection(config, collectionName); - - const result: MoveResourceResponseDto = { - movedCount: 0, - warnings: [], - errors: [], - }; + const config = this.#configService.getConfig(); + const { translationsFolder } = openRouteCollection(config, collectionName); - if (!dto.moves || !Array.isArray(dto.moves) || dto.moves.length === 0) { - throw new HttpException( - 'Invalid request: moves array is required and must not be empty', - HttpStatus.BAD_REQUEST, - ); - } + const result: MoveResourceResponseDto = { + movedCount: 0, + warnings: [], + errors: [], + }; - for (const moveOp of dto.moves) { - let destinationTranslationsFolder: string | undefined; - - if (moveOp.toCollection) { - let destination: Collection; - try { - destination = openDestinationCollection(config, moveOp.toCollection); - } catch (error: unknown) { - if (!(error instanceof NotFoundException || error instanceof ForbiddenException)) throw error; - // Missing or read-only destination: for consistency with other bulk ops, report it - // for this move op and continue. - result.errors = result.errors || []; - result.errors.push(error.message); - continue; - } - destinationTranslationsFolder = destination.translationsFolder; - } + if (!dto.moves || !Array.isArray(dto.moves) || dto.moves.length === 0) { + throw new HttpException('Invalid request: moves array is required and must not be empty', HttpStatus.BAD_REQUEST); + } - const moveResult = await moveResource(translationsFolder, { - source: moveOp.source, - destination: moveOp.destination, - override: moveOp.override, - destinationTranslationsFolder: destinationTranslationsFolder, - }); - this.#index.apply(moveResult.mutations); - - result.movedCount += moveResult.movedCount; - if (moveResult.warnings && result.warnings) { - result.warnings.push(...moveResult.warnings); - } - if (moveResult.errors && result.errors) { - result.errors.push(...moveResult.errors); + for (const moveOp of dto.moves) { + let destinationTranslationsFolder: string | undefined; + + if (moveOp.toCollection) { + let destination: Collection; + try { + destination = openDestinationCollection(config, moveOp.toCollection); + } catch (error: unknown) { + if (!(error instanceof NotFoundException || error instanceof ForbiddenException)) throw error; + // Missing or read-only destination: for consistency with other bulk ops, report it + // for this move op and continue. + result.errors = result.errors || []; + result.errors.push(error.message); + continue; } + destinationTranslationsFolder = destination.translationsFolder; } - return result; - } catch (error: unknown) { - if (error instanceof NotFoundException || error instanceof HttpException) { - throw error; - } + const moveResult = await moveResource(translationsFolder, { + source: moveOp.source, + destination: moveOp.destination, + override: moveOp.override, + destinationTranslationsFolder: destinationTranslationsFolder, + }); + this.#index.apply(moveResult.mutations); - const errorMessage = error instanceof Error ? error.message : 'Error moving resources'; - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); + result.movedCount += moveResult.movedCount; + if (moveResult.warnings && result.warnings) { + result.warnings.push(...moveResult.warnings); + } + if (moveResult.errors && result.errors) { + result.errors.push(...moveResult.errors); + } } + + return result; } @Patch() @@ -316,44 +243,26 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Body() dto: UpdateResourceDto, ): Promise { - try { - const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - - const result = await editResource(collection.translationsFolder, { - ...dto, - baseLocale: collection.baseLocale, - translationConfig: collection.translationConfig, - allLocales: collection.locales, - }); - - this.#index.apply(result.mutations); - const resourceDto: ResourceSummaryDto | undefined = - result.updated && result.entry ? mapResourceEntryToSummary(result.entry, collection.tags) : undefined; - - return { - resolvedKey: result.resolvedKey, - updated: result.updated, - message: result.message, - resource: resourceDto, - skippedLocales: result.skippedLocales, - }; - } catch (error: unknown) { - if (error instanceof NotFoundException || error instanceof HttpException) { - throw error; - } - - const errorMessage = error instanceof Error ? error.message : 'Error updating resource'; - - if (errorMessage.includes('Resource not found')) { - throw new NotFoundException(errorMessage); - } + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - if (errorMessage.includes('Invalid')) { - throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); - } + const result = await editResource(collection.translationsFolder, { + ...dto, + baseLocale: collection.baseLocale, + translationConfig: collection.translationConfig, + allLocales: collection.locales, + }); - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + this.#index.apply(result.mutations); + const resourceDto: ResourceSummaryDto | undefined = + result.updated && result.entry ? mapResourceEntryToSummary(result.entry, collection.tags) : undefined; + + return { + resolvedKey: result.resolvedKey, + updated: result.updated, + message: result.message, + resource: resourceDto, + skippedLocales: result.skippedLocales, + }; } @Get('tree') @@ -363,61 +272,43 @@ export class ResourcesController { @Query('includeNested') includeNested: string | undefined, @Res({ passthrough: true }) response: Response, ): Promise { - try { - const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const read = this.#index.tree(collection, path ?? ''); - - if (read.status !== 'ready') { - response.status(HttpStatus.ACCEPTED); - return read.status === 'indexing' - ? { status: 'indexing', message: 'Collection is currently being indexed. Please try again shortly.' } - : { - status: 'not-ready', - message: - read.status === 'error' - ? 'Cache indexing failed, re-indexing collection. Please try again shortly.' - : 'Collection indexing started. Please try again shortly.', - }; - } - - if (!read.tree) { - throw new NotFoundException(`Path "${path}" not found in collection tree`); - } - - // An empty path addresses the collection root, which the artificial root node in the - // Tracker sidebar selects. It is a folder like any other here, so it honours - // includeNested too and can list every resource in the collection. - const treeDto = mapResourceTreeToDto(read.tree, collection.tags); + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const read = this.#index.tree(collection, path ?? ''); + + if (read.status !== 'ready') { + response.status(HttpStatus.ACCEPTED); + return read.status === 'indexing' + ? { status: 'indexing', message: 'Collection is currently being indexed. Please try again shortly.' } + : { + status: 'not-ready', + message: + read.status === 'error' + ? 'Cache indexing failed, re-indexing collection. Please try again shortly.' + : 'Collection indexing started. Please try again shortly.', + }; + } - if (includeNested === 'true') { - treeDto.resources = extractResourcesRecursively(read.tree).map((res) => - mapResourceEntryToSummary(res, collection.tags), - ); - } + if (!read.tree) { + throw new NotFoundException(`Path "${path}" not found in collection tree`); + } - return treeDto; - } catch (error: unknown) { - if (error instanceof HttpException) { - throw error; - } + // An empty path addresses the collection root, which the artificial root node in the + // Tracker sidebar selects. It is a folder like any other here, so it honours + // includeNested too and can list every resource in the collection. + const treeDto = mapResourceTreeToDto(read.tree, collection.tags); - const errorMessage = error instanceof Error ? error.message : 'Error loading resource tree'; - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); + if (includeNested === 'true') { + treeDto.resources = extractResourcesRecursively(read.tree).map((res) => + mapResourceEntryToSummary(res, collection.tags), + ); } + + return treeDto; } @Get('cache/status') async getCacheStatus(@Param('collectionName') collectionName: string): Promise { - try { - return this.#index.status(openRouteCollection(this.#configService.getConfig(), collectionName)); - } catch (error: unknown) { - if (error instanceof HttpException) { - throw error; - } - - const errorMessage = error instanceof Error ? error.message : 'Error retrieving cache status'; - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + return this.#index.status(openRouteCollection(this.#configService.getConfig(), collectionName)); } @Get('search') @@ -425,48 +316,35 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Query() dto: SearchTranslationsDto, ): Promise { - try { - const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - - // Validate query - if (!dto.query || dto.query.trim().length === 0) { - return { - query: dto.query || '', - results: [], - totalFound: 0, - limited: false, - }; - } - - // Default maxResults to 100, cap at 500 - const maxResults = Math.min(dto.maxResults || 100, 500); - - // Request one extra result to detect whether the results were limited. - const searchResults = this.#index.search(collection, dto.query, maxResults + 1); - - // Check if results were limited - const limited = searchResults.length > maxResults; - const coreResults = limited ? searchResults.slice(0, maxResults) : searchResults; - const results = mapSearchResultsToDto(coreResults, collection.tags); + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + // Validate query + if (!dto.query || dto.query.trim().length === 0) { return { - query: dto.query, - results, - totalFound: limited ? maxResults : results.length, - limited, + query: dto.query || '', + results: [], + totalFound: 0, + limited: false, }; - } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } + } - if (error instanceof HttpException) { - throw error; - } + // Default maxResults to 100, cap at 500 + const maxResults = Math.min(dto.maxResults || 100, 500); - const errorMessage = error instanceof Error ? error.message : 'Error searching translations'; - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + // Request one extra result to detect whether the results were limited. + const searchResults = this.#index.search(collection, dto.query, maxResults + 1); + + // Check if results were limited + const limited = searchResults.length > maxResults; + const coreResults = limited ? searchResults.slice(0, maxResults) : searchResults; + const results = mapSearchResultsToDto(coreResults, collection.tags); + + return { + query: dto.query, + results, + totalFound: limited ? maxResults : results.length, + limited, + }; } @Post('translate-locale') diff --git a/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts b/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts new file mode 100644 index 00000000..a16164fe --- /dev/null +++ b/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts @@ -0,0 +1,226 @@ +import { Controller, Get, HttpException, type INestApplication, Logger, NotFoundException } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import { + BaseLocaleImmutableError, + BundleAlreadyExistsError, + BundleNotFoundError, + CollectionNotFoundError, + InvalidBundleDefinitionError, + InvalidFolderPathError, + InvalidLocaleError, + InvalidResourceKeyError, + LingoTrackerError, + LocaleAlreadyExistsError, + LocaleNotFoundError, + ReadOnlyCollectionError, + ResourceNotFoundError, + TranslationError, +} from '@simoncodes-ca/core'; +import { LingoTrackerExceptionFilter, toHttpException } from './lingo-tracker-exception.filter'; + +describe('toHttpException', () => { + it.each([ + [ + new CollectionNotFoundError('app'), + 404, + { message: 'Collection "app" not found', error: 'Not Found', statusCode: 404 }, + ], + [ + new ResourceNotFoundError('a.b'), + 404, + { message: 'Resource not found: a.b', error: 'Not Found', statusCode: 404 }, + ], + [new BundleNotFoundError('main'), 404, { message: 'Bundle "main" not found', error: 'Not Found', statusCode: 404 }], + [ + new ReadOnlyCollectionError('vendor'), + 403, + { + message: 'Collection "vendor" is read-only. Its resources cannot be modified.', + error: 'Forbidden', + statusCode: 403, + }, + ], + [ + new BundleAlreadyExistsError('main'), + 409, + { message: 'Bundle "main" already exists', error: 'Conflict', statusCode: 409 }, + ], + [ + new InvalidFolderPathError('folder name', 'a b'), + 400, + { + message: 'Validation error: Invalid folder name segment "a b". Segments must match pattern [A-Za-z0-9_-]+', + error: 'Bad Request', + statusCode: 400, + }, + ], + [ + new InvalidResourceKeyError('a..b', 'Key validation: bad'), + 400, + { message: 'Key validation: bad', error: 'Bad Request', statusCode: 400 }, + ], + [ + new InvalidLocaleError('x!', 'Invalid locale format: "x!"'), + 400, + { message: 'Invalid locale format: "x!"', error: 'Bad Request', statusCode: 400 }, + ], + [ + new LocaleNotFoundError('ja', 'app'), + 400, + { message: 'Locale "ja" not found in collection "app"', error: 'Bad Request', statusCode: 400 }, + ], + [ + new LocaleAlreadyExistsError('fr', 'app'), + 400, + { message: 'Locale "fr" already exists in collection "app"', error: 'Bad Request', statusCode: 400 }, + ], + [ + new BaseLocaleImmutableError('en'), + 400, + { message: 'Cannot add or remove the base locale "en"', error: 'Bad Request', statusCode: 400 }, + ], + [ + new InvalidBundleDefinitionError(['a', 'b']), + 400, + { message: 'Invalid bundle definition: a; b', error: 'Bad Request', statusCode: 400 }, + ], + [ + new LingoTrackerError('Unmapped', 'UNMAPPED'), + 500, + { message: 'Unmapped', error: 'Internal Server Error', statusCode: 500 }, + ], + [ + new Error('Disk write failure'), + 500, + { message: 'Internal server error', error: 'Internal Server Error', statusCode: 500 }, + ], + ['not an error', 500, { message: 'Internal server error', error: 'Internal Server Error', statusCode: 500 }], + ])('maps %p to %i', (error, status, response) => { + const http = toHttpException(error); + + expect(http.getStatus()).toBe(status); + expect(http.getResponse()).toEqual(response); + }); + + it.each([ + ['INVALID_REQUEST', 400, 'Bad Request'], + ['MISSING_API_KEY', 500, 'Internal Server Error'], + ['UNKNOWN_PROVIDER', 500, 'Internal Server Error'], + ['AUTH_ERROR', 500, 'Internal Server Error'], + ['RATE_LIMIT', 429, 'Too Many Requests'], + ['SERVER_ERROR', 502, 'Bad Gateway'], + ['SOMETHING_NEW', 502, 'Bad Gateway'], + ])('maps a TranslationError with code %s to %i', (code, status, error) => { + const http = toHttpException(new TranslationError('Provider said no', code, false)); + + expect(http.getStatus()).toBe(status); + expect(http.getResponse()).toEqual({ + message: 'Translation provider error: Provider said no', + error, + statusCode: status, + }); + }); + + it('returns an HttpException unchanged', () => { + const exception = new NotFoundException('Path "x" not found in collection tree'); + + expect(toHttpException(exception)).toBe(exception); + }); +}); + +@Controller('throw') +class ThrowingController { + @Get('resource') + resource(): never { + throw new ResourceNotFoundError('a.b'); + } + + @Get('locale') + locale(): never { + throw new LocaleAlreadyExistsError('fr', 'app'); + } + + @Get('http') + http(): never { + throw new HttpException('Auto-translation is not enabled for this collection', 422); + } + + @Get('plain') + plain(): never { + throw new Error('Disk write failure'); + } + + @Get('status-code') + statusCode(): never { + throw Object.assign(new Error('request entity too large'), { statusCode: 413 }); + } +} + +describe('LingoTrackerExceptionFilter (registered with APP_FILTER)', () => { + let app: INestApplication; + let baseUrl: string; + let loggerError: jest.SpyInstance; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [ThrowingController], + providers: [{ provide: APP_FILTER, useClass: LingoTrackerExceptionFilter }], + }).compile(); + + app = moduleRef.createNestApplication({ logger: false }); + await app.listen(0); + baseUrl = await app.getUrl(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + loggerError = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + loggerError.mockRestore(); + }); + + const get = async (path: string): Promise<{ status: number; body: unknown }> => { + const response = await fetch(`${baseUrl.replace('[::1]', 'localhost')}/throw/${path}`); + return { status: response.status, body: await response.json() }; + }; + + it('answers a typed core error with its mapped status and the Nest body', async () => { + await expect(get('resource')).resolves.toEqual({ + status: 404, + body: { message: 'Resource not found: a.b', error: 'Not Found', statusCode: 404 }, + }); + await expect(get('locale')).resolves.toEqual({ + status: 400, + body: { statusCode: 400, message: 'Locale "fr" already exists in collection "app"', error: 'Bad Request' }, + }); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('passes an HttpException through', async () => { + await expect(get('http')).resolves.toEqual({ + status: 422, + body: { statusCode: 422, message: 'Auto-translation is not enabled for this collection' }, + }); + }); + + it('answers an unexpected error with a generic 500 that hides its message, and logs it', async () => { + await expect(get('plain')).resolves.toEqual({ + status: 500, + body: { statusCode: 500, message: 'Internal server error', error: 'Internal Server Error' }, + }); + expect(loggerError).toHaveBeenCalledWith('Disk write failure', expect.any(String)); + }); + + it("leaves an error that carries its own statusCode to Nest's default handling", async () => { + await expect(get('status-code')).resolves.toEqual({ + status: 413, + body: { statusCode: 413, message: 'request entity too large' }, + }); + }); +}); diff --git a/apps/api/src/app/errors/lingo-tracker-exception.filter.ts b/apps/api/src/app/errors/lingo-tracker-exception.filter.ts new file mode 100644 index 00000000..aadb5a54 --- /dev/null +++ b/apps/api/src/app/errors/lingo-tracker-exception.filter.ts @@ -0,0 +1,136 @@ +import { + type ArgumentsHost, + BadGatewayException, + BadRequestException, + Catch, + ConflictException, + ForbiddenException, + HttpException, + HttpStatus, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; +import { + BaseLocaleImmutableError, + BundleAlreadyExistsError, + BundleNotFoundError, + CollectionNotFoundError, + InvalidBundleDefinitionError, + InvalidFolderPathError, + InvalidLocaleError, + InvalidResourceKeyError, + LingoTrackerError, + LocaleAlreadyExistsError, + LocaleNotFoundError, + ReadOnlyCollectionError, + ResourceNotFoundError, + TranslationError, +} from '@simoncodes-ca/core'; + +/** + * The HTTP answer for a typed core error. This is the only place the API maps a core + * error to a status; controllers let core errors propagate. + * + * Some statuses are kept from before the mapping moved here, although they are not + * what the class name suggests: locale conflicts and missing locales answer 400 (bundle + * conflicts answer 409). + * + * Every mapped answer has the same `{ statusCode, message, error }` body. + */ +export function lingoTrackerErrorToHttp(error: LingoTrackerError): HttpException { + const { message } = error; + + if ( + error instanceof CollectionNotFoundError || + error instanceof ResourceNotFoundError || + error instanceof BundleNotFoundError + ) { + return new NotFoundException(message); + } + if (error instanceof ReadOnlyCollectionError) { + return new ForbiddenException(message); + } + if (error instanceof BundleAlreadyExistsError) { + return new ConflictException(message); + } + if (error instanceof InvalidFolderPathError) { + return new BadRequestException(`Validation error: ${message}`); + } + if ( + error instanceof InvalidResourceKeyError || + error instanceof InvalidLocaleError || + error instanceof LocaleNotFoundError || + error instanceof LocaleAlreadyExistsError || + error instanceof BaseLocaleImmutableError || + error instanceof InvalidBundleDefinitionError + ) { + return new BadRequestException(message); + } + if (error instanceof TranslationError) { + return translationErrorToHttp(error); + } + return new InternalServerErrorException(message); +} + +/** Translation codes that mean the server's translation setup is wrong, not the request. */ +const TRANSLATION_CONFIGURATION_CODES: ReadonlySet = new Set([ + 'MISSING_API_KEY', + 'UNKNOWN_PROVIDER', + 'AUTH_ERROR', +]); + +function translationErrorToHttp(error: TranslationError): HttpException { + const message = `Translation provider error: ${error.message}`; + + if (error.code === 'INVALID_REQUEST') { + return new BadRequestException(message); + } + if (TRANSLATION_CONFIGURATION_CODES.has(error.code)) { + return new InternalServerErrorException(message); + } + if (error.code === 'RATE_LIMIT') { + const status = HttpStatus.TOO_MANY_REQUESTS; + return new HttpException(HttpException.createBody(message, 'Too Many Requests', status), status); + } + return new BadGatewayException(message); +} + +/** + * The HTTP answer for anything a handler throws: an `HttpException` as is, a + * `LingoTrackerError` by `lingoTrackerErrorToHttp`, and anything else as a generic 500 + * (`Internal server error`) that does not disclose the error's message. + */ +export function toHttpException(error: unknown): HttpException { + if (error instanceof HttpException) { + return error; + } + if (error instanceof LingoTrackerError) { + return lingoTrackerErrorToHttp(error); + } + return new InternalServerErrorException('Internal server error'); +} + +/** + * Global filter (registered with `APP_FILTER`). It turns typed core errors and unexpected + * errors into HTTP exceptions with `toHttpException`, then lets Nest's base filter write + * the body. An unexpected error is logged (message and stack) and answered with a generic + * 500, so internal details stay on the server. Errors from HTTP middleware that carry their + * own `statusCode` (body-parser, for example) keep Nest's default handling. + */ +@Catch() +export class LingoTrackerExceptionFilter extends BaseExceptionFilter { + readonly #logger = new Logger(LingoTrackerExceptionFilter.name); + + override catch(exception: unknown, host: ArgumentsHost): void { + if (exception instanceof HttpException || exception instanceof LingoTrackerError) { + super.catch(toHttpException(exception), host); + } else if (exception instanceof Error && !('statusCode' in exception)) { + this.#logger.error(exception.message, exception.stack); + super.catch(toHttpException(exception), host); + } else { + super.catch(exception, host); + } + } +} diff --git a/apps/cli/src/add-resource/add-resource.test.ts b/apps/cli/src/add-resource/add-resource.test.ts index 2672114a..3e830245 100644 --- a/apps/cli/src/add-resource/add-resource.test.ts +++ b/apps/cli/src/add-resource/add-resource.test.ts @@ -454,4 +454,42 @@ describe('addResourceCommand', () => { expect(core.loadPreferredTerminology).not.toHaveBeenCalled(); }); }); + + it('reports a cancelled prompt and returns without exiting or adding the resource', async () => { + const config = { + collections: { TestCollection: { translationsFolder: 'translations', baseLocale: 'en' } }, + baseLocale: 'en', + }; + vi.mocked(utils.loadConfiguration).mockReturnValue({ + config, + configPath: '/test/.lingo-tracker.json', + cwd: '/test', + }); + vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); + vi.mocked(utils.resolveWritableCollection).mockReturnValue( + core.openCollection(config, 'TestCollection', { cwd: '/test' }), + ); + const originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + // The user presses Esc: prompts calls onCancel, which throws PromptCancelledError. + vi.mocked(prompts).mockImplementation(async (questions, options) => { + const [question] = Array.isArray(questions) ? questions : [questions]; + options?.onCancel?.(question, {}); + return {}; + }); + + try { + await expect(addResourceCommand({})).resolves.toBeUndefined(); + + expect(log).toHaveBeenCalledWith('❌ ❌ Add resource cancelled.'); + expect(core.addResource).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + exit.mockRestore(); + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); + } + }); }); diff --git a/apps/cli/src/add-resource/add-resource.ts b/apps/cli/src/add-resource/add-resource.ts index 97a0e532..41703820 100644 --- a/apps/cli/src/add-resource/add-resource.ts +++ b/apps/cli/src/add-resource/add-resource.ts @@ -11,6 +11,7 @@ import { resolveWritableCollection, warnAboutPreferredTerminology, } from '../utils'; +import { PromptCancelledError } from '../utils/report-error'; export interface AddResourceOptions { collection?: string; @@ -37,7 +38,16 @@ export async function addResourceCommand(options: AddResourceOptions): Promise>; + try { + answers = await promptForMissing(options, collection); + } catch (error) { + if (error instanceof PromptCancelledError) { + ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED(error.operation)); + return; + } + throw error; + } try { // Check if resource already exists @@ -181,7 +191,7 @@ async function promptForMissing( if (questions.length > 0 && process.stdout.isTTY) { const result = await prompts(questions, { onCancel: () => { - throw new Error('Add resource cancelled'); + throw new PromptCancelledError('Add resource'); }, }); Object.assign(responses, result); diff --git a/apps/cli/src/commands/bundle.test.ts b/apps/cli/src/commands/bundle.test.ts index c01a3517..62003b00 100644 --- a/apps/cli/src/commands/bundle.test.ts +++ b/apps/cli/src/commands/bundle.test.ts @@ -655,12 +655,21 @@ describe('bundleCommand', () => { expect(mockGenerateBundle).not.toHaveBeenCalled(); }); - it('should handle prompt cancellation', async () => { - vi.mocked(prompts).mockImplementation(() => { - throw new Error('Bundle generation cancelled'); + it('should report prompt cancellation and return without exiting or generating', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + // The user presses Esc: prompts calls onCancel, which throws PromptCancelledError. + vi.mocked(prompts).mockImplementation(async (questions, options) => { + const [question] = Array.isArray(questions) ? questions : [questions]; + options?.onCancel?.(question, {}); + return {}; }); - await expect(bundleCommand({})).rejects.toThrow('Bundle generation cancelled'); + await expect(bundleCommand({})).resolves.toBeUndefined(); + + expect(console.log).toHaveBeenCalledWith('❌ ❌ Bundle generation cancelled.'); + expect(mockGenerateBundle).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + exit.mockRestore(); }); }); }); diff --git a/apps/cli/src/commands/bundle.ts b/apps/cli/src/commands/bundle.ts index ee20f6e5..90d57a0a 100644 --- a/apps/cli/src/commands/bundle.ts +++ b/apps/cli/src/commands/bundle.ts @@ -1,7 +1,8 @@ import prompts from 'prompts'; import type { LingoTrackerConfig, TokenCasing } from '@simoncodes-ca/core'; import { generateBundle, hasTypeDistConfigured } from '@simoncodes-ca/core'; -import { loadConfiguration, parseCommaSeparatedList, ConsoleFormatter } from '../utils'; +import { loadConfiguration, parseCommaSeparatedList, ConsoleFormatter, ErrorMessages } from '../utils'; +import { PromptCancelledError } from '../utils/report-error'; export interface BundleOptions { name?: string; @@ -48,7 +49,16 @@ export async function bundleCommand(options: BundleOptions): Promise { return; } - const answers = await promptForMissing(options, config); + let answers: Awaited>; + try { + answers = await promptForMissing(options, config); + } catch (error) { + if (error instanceof PromptCancelledError) { + ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED(error.operation)); + return; + } + throw error; + } // Determine which bundles to process const bundlesToProcess: string[] = []; @@ -265,7 +275,7 @@ async function promptForMissing( if (questions.length > 0) { const result = await prompts(questions, { onCancel: () => { - throw new Error('Bundle generation cancelled'); + throw new PromptCancelledError('Bundle generation'); }, }); diff --git a/apps/cli/src/commands/export-cmd.test.ts b/apps/cli/src/commands/export-cmd.test.ts index 066e9338..8ac2d529 100644 --- a/apps/cli/src/commands/export-cmd.test.ts +++ b/apps/cli/src/commands/export-cmd.test.ts @@ -361,8 +361,11 @@ describe('exportCommand', () => { }); it('should handle user cancellation gracefully', async () => { - vi.mocked(prompts).mockImplementation(() => { - throw new Error('Export cancelled'); + // The user presses Esc: prompts calls onCancel, which throws PromptCancelledError. + vi.mocked(prompts).mockImplementation(async (questions, options) => { + const [question] = Array.isArray(questions) ? questions : [questions]; + options?.onCancel?.(question, {}); + return {}; }); await exportCommand({}); diff --git a/apps/cli/src/commands/export-cmd.ts b/apps/cli/src/commands/export-cmd.ts index 72046546..6f0a4177 100644 --- a/apps/cli/src/commands/export-cmd.ts +++ b/apps/cli/src/commands/export-cmd.ts @@ -24,6 +24,7 @@ import { parseCommaSeparatedList, processMultiselectWithAll, } from '../utils'; +import { exitWithError, PromptCancelledError } from '../utils/report-error'; export interface ExportCommandOptions { format?: ExportFormat; @@ -57,7 +58,7 @@ export async function exportCommand(options: ExportCommandOptions): Promise console.log(` ${msg}`) : undefined, }); } catch (error) { - ConsoleFormatter.error((error as Error).message); - process.exit(1); + exitWithError(error); } displayResults(result); @@ -492,7 +490,7 @@ async function promptForMissing( if (questions.length > 0 && process.stdout.isTTY) { const result = await prompts(questions, { onCancel: () => { - throw new Error('Export cancelled'); + throw new PromptCancelledError('Export'); }, }); diff --git a/apps/cli/src/commands/glossary.ts b/apps/cli/src/commands/glossary.ts index 6bc73be2..5863b97f 100644 --- a/apps/cli/src/commands/glossary.ts +++ b/apps/cli/src/commands/glossary.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { loadResourcesFromCollections, openCollection } from '@simoncodes-ca/core'; import type { Collection, LingoTrackerConfig } from '@simoncodes-ca/core'; import { ConsoleFormatter, loadConfiguration, parseCommaSeparatedList, resolveCollection } from '../utils'; +import { exitWithError } from '../utils/report-error'; import { resolveExtractor, type CandidateExtractor, type ExtractorMode } from './glossary-extractor'; import { matchGlossary, type FlatEntry } from './glossary-matcher'; @@ -125,8 +126,7 @@ export async function glossaryCommand(options: GlossaryCommandOptions): Promise< try { extractor = resolveExtractor(options.extractor ?? 'ngram'); } catch (error) { - ConsoleFormatter.error((error as Error).message); - process.exit(1); + exitWithError(error); } const candidates = extractor(block); diff --git a/apps/cli/src/commands/import-cmd.ts b/apps/cli/src/commands/import-cmd.ts index 1e170365..d728aa6a 100644 --- a/apps/cli/src/commands/import-cmd.ts +++ b/apps/cli/src/commands/import-cmd.ts @@ -23,6 +23,7 @@ import { promptForCollection, resolveWritableCollection, } from '../utils'; +import { PromptCancelledError } from '../utils/report-error'; export const LARGE_FILE_SIZE_THRESHOLD = 5; @@ -59,7 +60,7 @@ export async function importCommand(options: ImportCommandOptions): Promise ({ NO_COLLECTIONS: 'no collections', COLLECTION_READ_ONLY: (name: string) => `❌ Collection "${name}" is read-only. Its resources cannot be modified.`, MISSING_OPTIONS: (opts: string[]) => `missing: ${opts.join(', ')}`, + OPERATION_CANCELLED: (operation: string) => `❌ ${operation} cancelled.`, }, })); @@ -107,4 +109,23 @@ describe('normalizeCommand', () => { expect(ConsoleFormatter.info).toHaveBeenCalledWith('Skipping read-only collection: Lib'); }); }); + + it('reports a cancelled prompt and returns without exiting or normalizing', async () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + // The user presses Esc: prompts calls onCancel, which throws PromptCancelledError. + vi.mocked(prompts).mockImplementation(async (questions, options) => { + const [question] = Array.isArray(questions) ? questions : [questions]; + options?.onCancel?.(question, {}); + return {}; + }); + + await expect(normalizeCommand({})).resolves.toBeUndefined(); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith('❌ Normalize cancelled.'); + expect(normalize).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + exit.mockRestore(); + }); }); diff --git a/apps/cli/src/commands/normalize.ts b/apps/cli/src/commands/normalize.ts index c07007de..85a21f37 100644 --- a/apps/cli/src/commands/normalize.ts +++ b/apps/cli/src/commands/normalize.ts @@ -8,6 +8,7 @@ import { ErrorMessages, aggregateNumericFields, } from '../utils'; +import { PromptCancelledError } from '../utils/report-error'; export interface NormalizeOptions { collection?: string; @@ -46,7 +47,16 @@ export async function normalizeCommand(options: NormalizeOptions): Promise if (!loaded) return; const { config, cwd } = loaded; - const answers = await promptForMissing(options, config); + let answers: Awaited>; + try { + answers = await promptForMissing(options, config); + } catch (error) { + if (error instanceof PromptCancelledError) { + ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED(error.operation)); + return; + } + throw error; + } // Determine which collections to process const collectionsToProcess: string[] = []; @@ -233,7 +243,7 @@ async function promptForMissing( if (questions.length > 0 && process.stdout.isTTY) { const result = await prompts(questions, { onCancel: () => { - throw new Error('Normalize cancelled'); + throw new PromptCancelledError('Normalize'); }, }); diff --git a/apps/cli/src/commands/protected-terms.spec.ts b/apps/cli/src/commands/protected-terms.spec.ts index 1be7f1f2..13e21697 100644 --- a/apps/cli/src/commands/protected-terms.spec.ts +++ b/apps/cli/src/commands/protected-terms.spec.ts @@ -12,17 +12,17 @@ vi.mock('@simoncodes-ca/core', () => ({ resolveCollectionProtectedTermsFilePath: vi.fn(() => undefined), })); -vi.mock('../utils', () => ({ +vi.mock('../utils', async (importOriginal) => ({ + ...(await importOriginal()), loadConfiguration: vi.fn(), - ConsoleFormatter: { - section: vi.fn(), - keyValue: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, })); import { loadConfiguration, ConsoleFormatter } from '../utils'; + +// Spy on the real formatter object, which `exitWithError` prints through too. +for (const method of ['section', 'keyValue', 'error', 'success'] as const) { + vi.spyOn(ConsoleFormatter, method).mockImplementation(() => undefined); +} import { readCollectionProtectedTerms, readGlobalProtectedTerms, diff --git a/apps/cli/src/commands/protected-terms.ts b/apps/cli/src/commands/protected-terms.ts index c1879ad8..7e4430f6 100644 --- a/apps/cli/src/commands/protected-terms.ts +++ b/apps/cli/src/commands/protected-terms.ts @@ -11,6 +11,7 @@ import { } from '@simoncodes-ca/core'; import { effectiveProtectedTerms, normalizeProtectedTerms } from '@simoncodes-ca/domain'; import { loadConfiguration, ConsoleFormatter } from '../utils'; +import { exitWithError } from '../utils/report-error'; export interface ProtectedTermsOptions { collection?: string; @@ -68,8 +69,7 @@ export async function protectedTermsCommand(options: ProtectedTermsOptions): Pro : setGlobalProtectedTermsFile(pointer, { cwd }); ConsoleFormatter.success(result.message); } catch (error) { - ConsoleFormatter.error(error instanceof Error ? error.message : String(error)); - process.exit(1); + exitWithError(error); return; } } @@ -85,8 +85,7 @@ export async function protectedTermsCommand(options: ProtectedTermsOptions): Pro globalTerms = readGlobalProtectedTerms(currentConfig, cwd); collectionTerms = currentCollection ? readCollectionProtectedTerms(currentCollection, cwd) : []; } catch (error) { - ConsoleFormatter.error(error instanceof Error ? error.message : String(error)); - process.exit(1); + exitWithError(error); return; } @@ -149,8 +148,7 @@ export async function protectedTermsCommand(options: ProtectedTermsOptions): Pro ConsoleFormatter.success(`${scopeLabel} protected terms updated: ${next.join(', ')} ${where}`); } } catch (error) { - ConsoleFormatter.error(error instanceof Error ? error.message : String(error)); - process.exit(1); + exitWithError(error); } } } diff --git a/apps/cli/src/commands/translate-locale.ts b/apps/cli/src/commands/translate-locale.ts index 66041734..bc3b4e68 100644 --- a/apps/cli/src/commands/translate-locale.ts +++ b/apps/cli/src/commands/translate-locale.ts @@ -1,6 +1,7 @@ import prompts from 'prompts'; import { translateLocale } from '@simoncodes-ca/core'; import { loadConfiguration, resolveWritableCollection, ConsoleFormatter, ErrorMessages } from '../utils'; +import { exitWithError } from '../utils/report-error'; export interface TranslateLocaleOptions { collection?: string; @@ -157,8 +158,6 @@ export async function translateLocaleCommand(options: TranslateLocaleOptions): P process.exit(1); } } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ConsoleFormatter.error(`Translation failed: ${message}`); - process.exit(1); + exitWithError(error, 'Translation failed: '); } } diff --git a/apps/cli/src/utils/index.ts b/apps/cli/src/utils/index.ts index f91edb77..0e776165 100644 --- a/apps/cli/src/utils/index.ts +++ b/apps/cli/src/utils/index.ts @@ -5,6 +5,7 @@ export * from './console-formatter'; export * from './error-messages'; export * from './preferred-terminology-warnings'; export * from './prompt-utils'; +export * from './report-error'; export * from './result-aggregator'; export * from './string-parsers'; export * from './summary-path'; diff --git a/apps/cli/src/utils/prompt-utils.ts b/apps/cli/src/utils/prompt-utils.ts index 2cf5768e..598d2727 100644 --- a/apps/cli/src/utils/prompt-utils.ts +++ b/apps/cli/src/utils/prompt-utils.ts @@ -1,4 +1,5 @@ import prompts from 'prompts'; +import { PromptCancelledError } from './report-error'; /** * Sentinel value used to represent "all items" in multiselect prompts @@ -95,7 +96,7 @@ export interface PromptExecutionOptions( if (isInteractiveTerminal()) { const result = await prompts(questions, { onCancel: () => { - const opName = operationName || 'Operation'; - throw new Error(`${opName} cancelled`); + throw new PromptCancelledError(operationName || 'Operation'); }, }); diff --git a/apps/cli/src/utils/report-error.spec.ts b/apps/cli/src/utils/report-error.spec.ts new file mode 100644 index 00000000..3f792b8c --- /dev/null +++ b/apps/cli/src/utils/report-error.spec.ts @@ -0,0 +1,48 @@ +import { CollectionNotFoundError } from '@simoncodes-ca/core'; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { exitWithError, PromptCancelledError } from './report-error'; + +describe('exitWithError', () => { + let log: MockInstance; + let exit: MockInstance; + + beforeEach(() => { + log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('prints the error message and exits with code 1', () => { + exitWithError(new Error('Output directory is not writable.')); + + expect(log).toHaveBeenCalledWith('❌ Output directory is not writable.'); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('prints a typed core error by its message', () => { + exitWithError(new CollectionNotFoundError('app')); + + expect(log).toHaveBeenCalledWith('❌ Collection "app" not found'); + }); + + it('adds the prefix and stringifies a non-Error value', () => { + exitWithError('boom', 'Translation failed: '); + + expect(log).toHaveBeenCalledWith('❌ Translation failed: boom'); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe('PromptCancelledError', () => { + it('keeps the " cancelled" message and the operation', () => { + const error = new PromptCancelledError('Import'); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('PromptCancelledError'); + expect(error.message).toBe('Import cancelled'); + expect(error.operation).toBe('Import'); + }); +}); diff --git a/apps/cli/src/utils/report-error.ts b/apps/cli/src/utils/report-error.ts new file mode 100644 index 00000000..182d335b --- /dev/null +++ b/apps/cli/src/utils/report-error.ts @@ -0,0 +1,25 @@ +import { ConsoleFormatter } from './console-formatter'; + +/** + * Thrown when the user cancels an interactive prompt. Commands catch it with `instanceof`; + * the message is ` cancelled`, as before. + */ +export class PromptCancelledError extends Error { + readonly operation: string; + + constructor(operation: string) { + super(`${operation} cancelled`); + this.name = 'PromptCancelledError'; + this.operation = operation; + } +} + +/** + * Reports a failure that ends the command: prints `❌ ` and exits with + * code 1. A core `LingoTrackerError` carries the user-facing text in its message, so the + * CLI shows it as is. + */ +export function exitWithError(error: unknown, prefix = ''): never { + ConsoleFormatter.error(`${prefix}${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/architecture-docs/api.md b/architecture-docs/api.md index 43d12d0f..60d9e416 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -10,6 +10,7 @@ Return to [architecture README](README.md). - [Endpoint Reference](#endpoint-reference) - [Component Diagram](#component-diagram) +- [Error Mapping](#error-mapping) - [Static File Serving](#static-file-serving) - [Collection Index](#collection-index) - [Interface](#interface) @@ -167,12 +168,42 @@ graph TD style core fill:#d4edda,stroke:#28a745,color:#000 ``` -Controllers are the only layer that knows HTTP. They read the config from `ConfigService` (a thin wrapper over core `loadConfig()` that maps `ConfigNotFoundError` to 404 and parse/read failures to 500), turn the `:collectionName` route param into the effective `Collection` with `openRouteCollection()` (`collections/open-route-collection.ts`: decodes the name, calls core `openCollection()`, maps `CollectionNotFoundError` to 404), delegate business operations to `@simoncodes-ca/core` (see [core-library.md](core-library.md)), apply mappers at the boundary, and pass the `mutations` of every successful core write to `CollectionIndex.apply()`. +Controllers are the only layer that knows HTTP. They read the config from `ConfigService` (a thin wrapper over core `loadConfig()` that maps `ConfigNotFoundError` to 404 and parse/read failures to 500), turn the `:collectionName` route param into the effective `Collection` with `openRouteCollection()` (`collections/open-route-collection.ts`: decodes the name, calls core `openCollection()`, maps `CollectionNotFoundError` to 404), delegate business operations to `@simoncodes-ca/core` (see [core-library.md](core-library.md)), apply mappers at the boundary, and pass the `mutations` of every successful core write to `CollectionIndex.apply()`. Controllers do not catch core errors; the global exception filter maps them (see [Error Mapping](#error-mapping)). **Read-only enforcement.** `WritableCollectionGuard` (`collections/guards/writable-collection.guard.ts`) is applied at the class level to the `Resources`, `Locales`, and `Folders` controllers. For any non-`GET` request it reads the `:collectionName` route param, opens the collection with core `openCollection(config, name, { writable: true })`, and maps `ReadOnlyCollectionError` to `403 Forbidden` (unknown collections pass through so the controller returns its 404). This is the single API choke-point for read-only enforcement. The `Collections` controller is intentionally **not** guarded: updating a collection's config entry or unregistering it (`PUT`/`DELETE /collections/:name`) is permitted even for read-only collections, since the lock protects resources, not the registration. On create, the controller defaults `readOnly` to `true` for `node_modules` paths (via the `isUnderNodeModules` domain helper) when the DTO omits it. --- +## Error Mapping + +`LingoTrackerExceptionFilter` (`errors/lingo-tracker-exception.filter.ts`) is registered globally with `APP_FILTER` in `app.module.ts`. It is the only place that maps a core [typed error](glossary.md#typed-errors) to an HTTP status. It uses `instanceof`, never the message text. `toHttpException(error)` holds the mapping and is exported for controller specs. The filter then hands the result to Nest's `BaseExceptionFilter`. Every mapped answer has the same body shape, `{ statusCode, message, error }`, because the mapping uses Nest's dedicated exception classes (and `HttpException.createBody` for 429, which has no class). An unexpected error never discloses its message: the filter logs its message and stack on the server and answers a generic 500. + +| Thrown | Status | Body `message` | +|---|---|---| +| `HttpException` (thrown by a controller, guard, or `ConfigService`) | its own | its own | +| `CollectionNotFoundError`, `ResourceNotFoundError`, `BundleNotFoundError` | 404 (`NotFoundException`) | error message | +| `ReadOnlyCollectionError` | 403 (`ForbiddenException`) | error message | +| `BundleAlreadyExistsError` | 409 (`ConflictException`) | error message | +| `InvalidFolderPathError` | 400 (`BadRequestException`) | `Validation error: ` | +| `InvalidResourceKeyError`, `InvalidLocaleError`, `LocaleNotFoundError`, `LocaleAlreadyExistsError`, `BaseLocaleImmutableError`, `InvalidBundleDefinitionError` | 400 (`BadRequestException`) | error message | +| `TranslationError` with code `INVALID_REQUEST` | 400 (`BadRequestException`) | `Translation provider error: ` | +| `TranslationError` with code `MISSING_API_KEY`, `UNKNOWN_PROVIDER`, or `AUTH_ERROR` (server misconfiguration) | 500 (`InternalServerErrorException`) | `Translation provider error: ` | +| `TranslationError` with code `RATE_LIMIT` | 429 (`HttpException`, error `Too Many Requests`) | `Translation provider error: ` | +| `TranslationError` with any other code (for example `SERVER_ERROR`) | 502 (`BadGatewayException`) | `Translation provider error: ` | +| any other `LingoTrackerError` | 500 (`InternalServerErrorException`) | error message | +| any other `Error` (message and stack logged on the server) | 500 (`InternalServerErrorException`) | `Internal server error` | +| an error with its own numeric `statusCode` (for example from body-parser) | Nest default | Nest default | + +Statuses that are kept from before the filter, although they do not match the class name: + +- `LocaleNotFoundError` and `LocaleAlreadyExistsError` answer **400**, not 404 / 409. Bundle conflicts answer 409. +- The `Collections` and `Config` controllers keep their own catch that answers **400** for every failure. So `CollectionNotFoundError` from `DELETE`/`PUT /collections/:name` is 400 (not 404), `CollectionAlreadyExistsError` is 400, and `PreferredTerminologyValidationError` is 400 with `{ message, errors }`. These errors never reach the filter. +- The `Bundles` controller answers **400** for an untyped failure (the other controllers answer 500). +- Route-level resolution keeps its own Nest exceptions, because the messages are route-specific: `openRouteCollection` / `openDestinationCollection` (404 `Collection "x" not found` / `Destination collection "x" not found`, 403 read-only), `WritableCollectionGuard` (403), and `ConfigService` (404 `Configuration file not found`, 500 `Invalid configuration file format` / `Failed to read configuration file`). +- `POST /collections/:name/folders/move` still answers 400 when `moveFolder` reports an error whose text has `Invalid`, `not found`, `circular`, or `descendant` and nothing moved. `moveFolder` reports failures as result strings, not typed errors. + +--- + ## Static File Serving The Express server that backs NestJS is configured before NestJS routes are registered. The middleware registration order in `main.ts` is intentional: diff --git a/architecture-docs/cli.md b/architecture-docs/cli.md index af22a0ce..d4e4af81 100644 --- a/architecture-docs/cli.md +++ b/architecture-docs/cli.md @@ -12,6 +12,7 @@ Return to [architecture README](README.md). - [Interactive vs Non-Interactive Mode](#interactive-vs-non-interactive-mode) - [TTY Detection](#tty-detection) - [Interactive Mode Flowchart](#interactive-mode-flowchart) +- [Errors and Exit Codes](#errors-and-exit-codes) - [Config Loading and Collection Resolution](#config-loading-and-collection-resolution) - [Config Loading](#config-loading) - [Collection Resolution](#collection-resolution) @@ -65,7 +66,7 @@ Both scopes read through the same core helpers. The command itself parses no ter `--list` on a collection prints three lists: the global terms, the collection's terms, and `effectiveProtectedTerms()` of the two. It names the resolved file behind each list. Paths inside the project root print as relative paths. -The core layer raises errors for a malformed file, for a collection with no file, and for a missing parent directory. The command catches each one, reports it through `ConsoleFormatter.error`, and calls `process.exit(1)`. It writes no partial result. +The core layer raises errors for a malformed file, for a collection with no file, and for a missing parent directory. The command catches each one and calls `exitWithError` (prints `❌ `, exits 1). It writes no partial result. ### `glossary` pipeline @@ -144,7 +145,7 @@ flowchart TD CHECK_TTY_MAIN -- Yes --> INTERACTIVE["Interactive path:\nprompts() for each\nmissing required field"] INTERACTIVE --> USER_INPUT{"User completes\nall fields?"} - USER_INPUT -- "Ctrl+C / cancel" --> EXIT_CANCEL(["Throw:\n❌ Operation cancelled"]) + USER_INPUT -- "Ctrl+C / cancel" --> EXIT_CANCEL(["Throw PromptCancelledError:\n❌ Operation cancelled"]) USER_INPUT -- Completes --> CALL_CORE CALL_CORE["Call @simoncodes-ca/core function\ne.g. addResource() / normalize() / validateResources()"] @@ -163,6 +164,30 @@ flowchart TD --- +## Errors and Exit Codes + +Core raises [typed errors](glossary.md#typed-errors) whose message is already the user-facing text, so the CLI prints the message and does not branch on the class, except in the resolvers below. Helpers in `utils/report-error.ts`: + +- **`exitWithError(error, prefix?)`** — prints `❌ ` through `ConsoleFormatter.error` and calls `process.exit(1)`. Used where a failure ends the command: `export` (invalid `--base-property-name`, unwritable output directory, a run that cannot start), `glossary` (unknown extractor), `protected-terms` (file errors), and `translate-locale` (prefix `Translation failed: `). +- **`PromptCancelledError(operation)`** — thrown from a prompt's `onCancel` (`executePromptsWithFallback`, `add-resource`, `export`, `import`, `normalize`, `bundle`). The message is ` cancelled`. `export` and `import` catch it with `instanceof` and print `❌ ❌ cancelled.` (exit code 0). + +Exit codes: + +| Situation | Exit code | +|---|---| +| Success | 0 | +| Config file missing or unreadable (`loadConfiguration`) | 1 | +| Read-only collection on a mutating command (`resolveWritableCollection`, `normalize`) | 1 (`process.exitCode`) | +| `exitWithError` sites above; missing or conflicting flags in `edit-collection`, `find-similar`, `install-skill`, `protected-terms`, `preferred-terminology` | 1 | +| `validate` failed, or had nothing to validate; `translate-locale` with failed entries; `export` with errors or hierarchical conflicts (not with `--dry-run`); `import` with errors | 1 | +| Unknown collection: `resolveCollection` prints `❌ Collection "x" not found.` and the command returns | 0 (`glossary` exits 1) | +| Core error in `add-collection`, `delete-collection`, `add-resource`, `edit-resource`, `delete-resource`, `move`, `add-locale`, `remove-locale` | 0 (prints `❌ `) | +| Prompt cancelled | 0 | + +The last three rows are kept as they were: those commands report a failure but do not set an exit code. + +--- + ## Config Loading and Collection Resolution ### Config Loading @@ -218,7 +243,7 @@ All shared utilities live in `apps/cli/src/utils/` and are re-exported from `app `executePromptsWithFallback(params)` is the primary entry point for commands that have multiple optional fields. It accepts a `questions` array (prompts definitions), `currentValues` (the parsed CLI options), and `requiredFields` (field names that must be present in non-interactive mode). -- In TTY mode: runs `prompts(questions, { onCancel })`, merges results with `currentValues`, and throws `"Operation cancelled"` on Ctrl+C. +- In TTY mode: runs `prompts(questions, { onCancel })`, merges results with `currentValues`, and throws `PromptCancelledError` (message `" cancelled"`, default `"Operation cancelled"`) on Ctrl+C. - In non-TTY mode: skips all prompts, checks that every `requiredField` is non-null in `currentValues`, and throws a `Missing required options: --field1, --field2` error if any are absent. `processMultiselectWithAll(selectedValues, allAvailableItems)` handles multiselect prompts that include an "All" option. If the sentinel `__ALL__` is among the selected values, it returns `undefined` (meaning "process everything"), otherwise returns the selected subset. diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index a4e2af3a..b9a7a538 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -10,6 +10,7 @@ Return to [architecture README](README.md). - [Module Map](#module-map) - [Config and Collection Resolution](#config-and-collection-resolution) +- [Error Model](#error-model) - [Resource CRUD Flows](#resource-crud-flows) - [add-resource](#add-resource) - [edit-resource](#edit-resource) @@ -127,7 +128,7 @@ libs/core/src/ │ └── errors/ # Error messages and typed errors ├── error-messages.ts # ErrorMessages: static error string builders - └── lingo-tracker-error.ts # LingoTrackerError and its subclasses (config / collection errors) + └── lingo-tracker-error.ts # LingoTrackerError and its typed subclasses (see Error Model) ``` @@ -235,7 +236,39 @@ Core owns the config file and the rule that turns a collection's config entry in - **`loadConfig({ cwd? })`** is the only reader of `.lingo-tracker.json`. It returns the file as written, with no validation and no fallbacks. It throws `ConfigNotFoundError` when the file does not exist and `ConfigParseError` when the file is not a JSON object; other I/O errors pass through. The CLI passes its `INIT_CWD`-aware directory, the API passes `process.cwd()`, and `createConfigFileOperations().read()` (used by the config writers) reads through it too. - **`openCollection(config, name, { cwd?, writable? })`** returns a `Collection`: `name`, the absolute `translationsFolder` (resolved against `cwd`), `baseLocale` (collection, else global, else `en`; an empty string counts as unset), `locales` (collection, else global, else `[]`), `targetLocales` (`locales` without `baseLocale`), `translationConfig` (collection, else global; not merged), normalized `tags`, `readOnly`, and the raw entry as `config`. It throws `CollectionNotFoundError` for an unknown name and, when `writable` is set, `ReadOnlyCollectionError` for a read-only collection. -The fallback rule lives only in `openCollection`. The collection operations in `collections-manager/` (`addLocaleToCollection`, `removeLocaleFromCollection`, `updateCollection`) use it for their locale checks. The [Import run](glossary.md#import-run) and the [Export run](glossary.md#export-run) take `Collection` objects, so they read the base locale and locales from there and never read the config file. Per-resource operations keep their `(translationsFolder, …, baseLocale, allLocales, translationConfig)` parameters; callers fill them from the `Collection`. The typed errors extend `LingoTrackerError` (`lib/errors/lingo-tracker-error.ts`), so an adapter maps them with `instanceof` instead of matching message text. +The fallback rule lives only in `openCollection`. The collection operations in `collections-manager/` (`addLocaleToCollection`, `removeLocaleFromCollection`, `updateCollection`) use it for their locale checks. The [Import run](glossary.md#import-run) and the [Export run](glossary.md#export-run) take `Collection` objects, so they read the base locale and locales from there and never read the config file. Per-resource operations keep their `(translationsFolder, …, baseLocale, allLocales, translationConfig)` parameters; callers fill them from the `Collection`. The typed errors extend `LingoTrackerError`; see [Error Model](#error-model). + +--- + +## Error Model + +Core raises a [typed error](glossary.md#typed-errors) for every failure that an adapter must tell apart. Each class extends `LingoTrackerError` (`lib/errors/lingo-tracker-error.ts`), has a stable `code`, and keeps its payload in typed fields. The message text comes from `ErrorMessages` (`lib/errors/error-messages.ts`), so it did not change when the types were added. The CLI prints the message; the API maps the class to an HTTP status (see [api.md — Error Mapping](api.md#error-mapping)). Neither adapter reads the message to decide what happened. + +| Class | `code` | Payload | Thrown by | +|---|---|---|---| +| `ConfigNotFoundError` | `CONFIG_NOT_FOUND` | `configPath` | `loadConfig` | +| `ConfigParseError` | `CONFIG_PARSE_FAILED` | `configPath`, `reason` | `loadConfig` | +| `CollectionNotFoundError` | `COLLECTION_NOT_FOUND` | `collectionName` | `openCollection`, `deleteCollectionByName`, `updateCollection`, `setCollectionProtectedTerms`, `setCollectionProtectedTermsFile` | +| `CollectionAlreadyExistsError` | `COLLECTION_ALREADY_EXISTS` | `collectionName` | `addCollection`, `updateCollection` (rename) | +| `ReadOnlyCollectionError` | `COLLECTION_READ_ONLY` | `collectionName` | `openCollection` with `{ writable: true }` | +| `InvalidLocaleError` | `INVALID_LOCALE` | `locale` | `addLocaleToCollection`, `removeLocaleFromCollection` | +| `LocaleNotFoundError` | `LOCALE_NOT_FOUND` | `locale`, `collectionName` | `removeLocaleFromCollection` | +| `LocaleAlreadyExistsError` | `LOCALE_ALREADY_EXISTS` | `locale`, `collectionName` | `addLocaleToCollection` | +| `BaseLocaleImmutableError` | `BASE_LOCALE_IMMUTABLE` | `locale` | `addLocaleToCollection`, `removeLocaleFromCollection` | +| `InvalidResourceKeyError` | `INVALID_RESOURCE_KEY` | `key` | `validateAndResolvePaths` (so `addResource`, `editResource`, `translateExistingResource`) | +| `ResourceNotFoundError` | `RESOURCE_NOT_FOUND` | `key` | `editResource`, `translateExistingResource` | +| `InvalidFolderPathError` | `INVALID_FOLDER_PATH` | `part`, `segment` | `createFolder` (`deleteFolder` and `moveFolder` report it in their result) | +| `BundleNotFoundError` | `BUNDLE_NOT_FOUND` | `bundleName` | `updateBundleDefinition`, `deleteBundleDefinition` | +| `BundleAlreadyExistsError` | `BUNDLE_ALREADY_EXISTS` | `bundleName` | `addBundleDefinition`, `updateBundleDefinition` (rename) | +| `InvalidBundleDefinitionError` | `INVALID_BUNDLE_DEFINITION` | `errors[]` | bundle definition add / update | +| `TranslationError` | provider code (`MISSING_API_KEY`, `RATE_LIMIT`, `INVALID_REQUEST`, …) | `retryable`, `providerErrorCode` | translation providers, `autoTranslateResource` | +| `PreferredTerminologyValidationError` | `INVALID_PREFERRED_TERMINOLOGY` | `errors[]` | `writePreferredTerminology` | + +Rules: + +- **Domain validators stay untyped.** `@simoncodes-ca/domain` has no error classes. `validateKey`, `validateTargetFolder`, and `validateLocale` throw a plain `Error`. Core wraps each call in one place and throws the typed error with the same message: `validateAndResolvePaths` for keys and target folders, and `assertValidLocale` (`collections-manager/assert-valid-locale.ts`) for locales. +- **Batch operations report, not throw.** `deleteResource`, `moveResource`, `deleteFolder`, and `moveFolder` put per-item failures into their result (`errors`, `error`) as strings. +- **Unexpected failures stay `Error`.** File I/O errors, invariant breaks (for example `ResourceFolder`'s "Resource entry not found"), and parser errors for import files are not typed. An adapter treats them as "something went wrong" and shows the message. --- diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 10341616..dac92faa 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -306,6 +306,14 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [` --- +### Typed Errors + +The errors core raises on purpose. Each is a subclass of `LingoTrackerError` (`libs/core/src/lib/errors/lingo-tracker-error.ts`) with a stable `code` (for example `RESOURCE_NOT_FOUND`) and typed payload fields (for example `key`). The message text comes from `ErrorMessages`. Adapters decide with `instanceof`, never by matching the message: the API maps each class to one HTTP status in `LingoTrackerExceptionFilter`, and the CLI prints the message. Domain validators throw plain `Error`; core converts them to typed errors in one place. + +Explained in context: [`core-library.md`](core-library.md#error-model), [`api.md`](api.md#error-mapping), [`cli.md`](cli.md#errors-and-exit-codes) + +--- + ### Translation Status An enum (`TranslationStatus` in `@simoncodes-ca/domain`) that tracks the review lifecycle of a non-base locale translation. Four possible values: diff --git a/libs/core/src/collections-manager/add-collection.ts b/libs/core/src/collections-manager/add-collection.ts index fd22162e..9a024b35 100644 --- a/libs/core/src/collections-manager/add-collection.ts +++ b/libs/core/src/collections-manager/add-collection.ts @@ -1,7 +1,7 @@ import { normalizeTags } from '@simoncodes-ca/domain'; import type { LingoTrackerCollection } from '../config/lingo-tracker-collection'; import { updateConfig } from '../lib/config/config-file-operations'; -import { ErrorMessages } from '../lib/errors/error-messages'; +import { CollectionAlreadyExistsError } from '../lib/errors/lingo-tracker-error'; export interface AddCollectionOptions { cwd?: string; @@ -24,7 +24,7 @@ export function addCollection( } if (config.collections[collectionName]) { - throw new Error(ErrorMessages.collectionAlreadyExists(collectionName)); + throw new CollectionAlreadyExistsError(collectionName); } const minimalCollection: LingoTrackerCollection = { diff --git a/libs/core/src/collections-manager/add-locale-to-collection.ts b/libs/core/src/collections-manager/add-locale-to-collection.ts index cb835492..de99c3be 100644 --- a/libs/core/src/collections-manager/add-locale-to-collection.ts +++ b/libs/core/src/collections-manager/add-locale-to-collection.ts @@ -1,12 +1,12 @@ import * as path from 'node:path'; import { existsSync } from 'node:fs'; -import { validateLocale } from '@simoncodes-ca/domain'; import { updateConfig } from '../lib/config/config-file-operations'; import { openCollection } from '../lib/config/open-collection'; import { walkFolders } from '../lib/normalize/iterative-folder-walker'; import { openResourceFolder } from '../lib/resource/resource-folder'; import { reindexMutation, type ResourceMutation } from '../lib/resource/resource-mutation'; -import { ErrorMessages } from '../lib/errors/error-messages'; +import { BaseLocaleImmutableError, LocaleAlreadyExistsError } from '../lib/errors/lingo-tracker-error'; +import { assertValidLocale } from './assert-valid-locale'; import { RESOURCE_ENTRIES_FILENAME } from '../constants'; export interface AddLocaleToCollectionOptions { @@ -28,7 +28,7 @@ export async function addLocaleToCollection( ): Promise { const cwd = options.cwd ?? process.cwd(); - validateLocale(locale); + assertValidLocale(locale); const updatedConfig = updateConfig((config) => { // `writable` throws inside the updater, so nothing is written for a read-only collection. @@ -39,11 +39,11 @@ export async function addLocaleToCollection( } = openCollection(config, collectionName, { cwd, writable: true }); if (locale === baseLocale) { - throw new Error(ErrorMessages.cannotModifyBaseLocale(locale)); + throw new BaseLocaleImmutableError(locale); } if (effectiveLocales.includes(locale)) { - throw new Error(ErrorMessages.localeAlreadyExists(locale, collectionName)); + throw new LocaleAlreadyExistsError(locale, collectionName); } const newLocales = [...effectiveLocales, locale]; diff --git a/libs/core/src/collections-manager/assert-valid-locale.ts b/libs/core/src/collections-manager/assert-valid-locale.ts new file mode 100644 index 00000000..08be640b --- /dev/null +++ b/libs/core/src/collections-manager/assert-valid-locale.ts @@ -0,0 +1,11 @@ +import { validateLocale } from '@simoncodes-ca/domain'; +import { InvalidLocaleError } from '../lib/errors/lingo-tracker-error'; + +/** Domain `validateLocale`, with its failure raised as a typed `InvalidLocaleError` (same message). */ +export function assertValidLocale(locale: string): void { + try { + validateLocale(locale); + } catch (error: unknown) { + throw new InvalidLocaleError(locale, error instanceof Error ? error.message : String(error)); + } +} diff --git a/libs/core/src/collections-manager/delete-collection-by-name.ts b/libs/core/src/collections-manager/delete-collection-by-name.ts index 7d9f3ead..41d8b701 100644 --- a/libs/core/src/collections-manager/delete-collection-by-name.ts +++ b/libs/core/src/collections-manager/delete-collection-by-name.ts @@ -1,5 +1,5 @@ import { updateConfig } from '../lib/config/config-file-operations'; -import { ErrorMessages } from '../lib/errors/error-messages'; +import { CollectionNotFoundError } from '../lib/errors/lingo-tracker-error'; export interface DeleteCollectionOptions { cwd?: string; @@ -11,7 +11,7 @@ export function deleteCollectionByName( ): { message: string } { updateConfig((config) => { if (!config.collections || !config.collections[collectionName]) { - throw new Error(ErrorMessages.collectionNotFound(collectionName)); + throw new CollectionNotFoundError(collectionName); } delete config.collections[collectionName]; diff --git a/libs/core/src/collections-manager/remove-locale-from-collection.ts b/libs/core/src/collections-manager/remove-locale-from-collection.ts index 5cacbd5a..958860d3 100644 --- a/libs/core/src/collections-manager/remove-locale-from-collection.ts +++ b/libs/core/src/collections-manager/remove-locale-from-collection.ts @@ -1,12 +1,12 @@ import * as path from 'node:path'; import { existsSync } from 'node:fs'; -import { validateLocale } from '@simoncodes-ca/domain'; import { updateConfig } from '../lib/config/config-file-operations'; import { openCollection } from '../lib/config/open-collection'; import { walkFolders } from '../lib/normalize/iterative-folder-walker'; import { openResourceFolder } from '../lib/resource/resource-folder'; import { reindexMutation, type ResourceMutation } from '../lib/resource/resource-mutation'; -import { ErrorMessages } from '../lib/errors/error-messages'; +import { BaseLocaleImmutableError, LocaleNotFoundError } from '../lib/errors/lingo-tracker-error'; +import { assertValidLocale } from './assert-valid-locale'; import { RESOURCE_ENTRIES_FILENAME } from '../constants'; export interface RemoveLocaleFromCollectionOptions { @@ -28,7 +28,7 @@ export async function removeLocaleFromCollection( ): Promise { const cwd = options.cwd ?? process.cwd(); - validateLocale(locale); + assertValidLocale(locale); const updatedConfig = updateConfig((config) => { // `writable` throws inside the updater, so nothing is written for a read-only collection. @@ -39,11 +39,11 @@ export async function removeLocaleFromCollection( } = openCollection(config, collectionName, { cwd, writable: true }); if (locale === baseLocale) { - throw new Error(ErrorMessages.cannotModifyBaseLocale(locale)); + throw new BaseLocaleImmutableError(locale); } if (!effectiveLocales.includes(locale)) { - throw new Error(ErrorMessages.localeNotFound(locale, collectionName)); + throw new LocaleNotFoundError(locale, collectionName); } const newLocales = effectiveLocales.filter((l) => l !== locale); diff --git a/libs/core/src/collections-manager/set-protected-terms.ts b/libs/core/src/collections-manager/set-protected-terms.ts index b926500f..df5b1f06 100644 --- a/libs/core/src/collections-manager/set-protected-terms.ts +++ b/libs/core/src/collections-manager/set-protected-terms.ts @@ -8,7 +8,7 @@ import { resolveProtectedTermsFilePath, writeProtectedTermsFile, } from '../lib/config/protected-terms-file'; -import { ErrorMessages } from '../lib/errors/error-messages'; +import { CollectionNotFoundError } from '../lib/errors/lingo-tracker-error'; import { updateCollection } from './update-collection'; export interface SetProtectedTermsOptions { @@ -53,7 +53,7 @@ export function setCollectionProtectedTerms( const collection = config.collections?.[collectionName]; if (!collection) { - throw new Error(ErrorMessages.collectionNotFound(collectionName)); + throw new CollectionNotFoundError(collectionName); } const filePath = resolveCollectionProtectedTermsFilePath(collection, cwd); @@ -114,7 +114,7 @@ export async function setCollectionProtectedTermsFile( const collection = config.collections?.[collectionName]; if (!collection) { - throw new Error(ErrorMessages.collectionNotFound(collectionName)); + throw new CollectionNotFoundError(collectionName); } const carried = readCollectionProtectedTerms(collection, cwd); diff --git a/libs/core/src/collections-manager/update-collection.ts b/libs/core/src/collections-manager/update-collection.ts index 2bfeb1ae..f4297a48 100644 --- a/libs/core/src/collections-manager/update-collection.ts +++ b/libs/core/src/collections-manager/update-collection.ts @@ -2,7 +2,7 @@ import { normalizeTags } from '@simoncodes-ca/domain'; import type { LingoTrackerCollection } from '../config/lingo-tracker-collection'; import { createConfigFileOperations, updateConfig } from '../lib/config/config-file-operations'; import { openCollection } from '../lib/config/open-collection'; -import { ErrorMessages } from '../lib/errors/error-messages'; +import { CollectionAlreadyExistsError, CollectionNotFoundError } from '../lib/errors/lingo-tracker-error'; import type { ResourceMutation } from '../lib/resource/resource-mutation'; import { addLocaleToCollection } from './add-locale-to-collection'; import { removeLocaleFromCollection } from './remove-locale-from-collection'; @@ -65,11 +65,11 @@ export async function updateCollection( updateConfig((config) => { if (!config.collections || !config.collections[collectionName]) { - throw new Error(ErrorMessages.collectionNotFound(collectionName)); + throw new CollectionNotFoundError(collectionName); } if (isRename && config.collections[targetName]) { - throw new Error(ErrorMessages.collectionAlreadyExists(targetName)); + throw new CollectionAlreadyExistsError(targetName); } const minimalCollection: LingoTrackerCollection = { diff --git a/libs/core/src/lib/bundle/bundle-definition-operations.ts b/libs/core/src/lib/bundle/bundle-definition-operations.ts index b9a9d1fc..c6d94e30 100644 --- a/libs/core/src/lib/bundle/bundle-definition-operations.ts +++ b/libs/core/src/lib/bundle/bundle-definition-operations.ts @@ -9,7 +9,11 @@ import type { BundleDefinition, CollectionBundleDefinition, EntrySelectionRule } from '../../config/bundle-definition'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; import { updateConfig } from '../config/config-file-operations'; -import { ErrorMessages } from '../errors/error-messages'; +import { + BundleAlreadyExistsError, + BundleNotFoundError, + InvalidBundleDefinitionError, +} from '../errors/lingo-tracker-error'; import { validateBundleDefinition, validateBundleKey } from './validate-bundle-definition'; export interface BundleDefinitionOperationOptions { @@ -30,7 +34,7 @@ export function addBundleDefinition( updateConfig((config) => { if (config.bundles?.[bundleKey]) { - throw new Error(ErrorMessages.bundleAlreadyExists(bundleKey)); + throw new BundleAlreadyExistsError(bundleKey); } const cleaned = assertValidDefinition(definition, config); @@ -60,11 +64,11 @@ export function updateBundleDefinition( const bundles = config.bundles ?? {}; if (!bundles[bundleKey]) { - throw new Error(ErrorMessages.bundleNotFound(bundleKey)); + throw new BundleNotFoundError(bundleKey); } if (isRename && bundles[targetKey]) { - throw new Error(ErrorMessages.bundleAlreadyExists(targetKey)); + throw new BundleAlreadyExistsError(targetKey); } const cleaned = assertValidDefinition(definition, config); @@ -97,7 +101,7 @@ export function deleteBundleDefinition( const bundles = config.bundles ?? {}; if (!bundles[bundleKey]) { - throw new Error(ErrorMessages.bundleNotFound(bundleKey)); + throw new BundleNotFoundError(bundleKey); } const { [bundleKey]: _removed, ...remaining } = bundles; @@ -118,7 +122,7 @@ function assertValidKey(key: string): string { const trimmed = key?.trim() ?? ''; const errors = validateBundleKey(trimmed); if (errors.length > 0) { - throw new Error(ErrorMessages.invalidBundleDefinition(errors)); + throw new InvalidBundleDefinitionError(errors); } return trimmed; } @@ -127,7 +131,7 @@ function assertValidDefinition(definition: BundleDefinition, config: LingoTracke const cleaned = stripUndefined(definition); const errors = validateBundleDefinition(cleaned, config); if (errors.length > 0) { - throw new Error(ErrorMessages.invalidBundleDefinition(errors)); + throw new InvalidBundleDefinitionError(errors); } return cleaned; } diff --git a/libs/core/src/lib/config/preferred-terminology-file.ts b/libs/core/src/lib/config/preferred-terminology-file.ts index a0dca341..6a00d2d5 100644 --- a/libs/core/src/lib/config/preferred-terminology-file.ts +++ b/libs/core/src/lib/config/preferred-terminology-file.ts @@ -8,6 +8,7 @@ import { validatePreferredTermRules, } from '@simoncodes-ca/domain'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { LingoTrackerError } from '../errors/lingo-tracker-error'; /** * Default location of the preferred-terminology file, resolved against the directory @@ -30,12 +31,11 @@ export interface LoadPreferredTerminologyResult { } /** Thrown by `writePreferredTerminology` when the rule list fails validation. The file is left untouched. */ -export class PreferredTerminologyValidationError extends Error { +export class PreferredTerminologyValidationError extends LingoTrackerError { readonly errors: PreferredTermRuleError[]; constructor(errors: PreferredTermRuleError[]) { - super(`Invalid preferred terminology rules: ${formatRuleErrors(errors)}`); - this.name = 'PreferredTerminologyValidationError'; + super(`Invalid preferred terminology rules: ${formatRuleErrors(errors)}`, 'INVALID_PREFERRED_TERMINOLOGY'); this.errors = errors; } } diff --git a/libs/core/src/lib/errors/error-messages.ts b/libs/core/src/lib/errors/error-messages.ts index 8c9b505f..fee84852 100644 --- a/libs/core/src/lib/errors/error-messages.ts +++ b/libs/core/src/lib/errors/error-messages.ts @@ -39,5 +39,16 @@ export const ErrorMessages = { bundleAlreadyExists: (name: string) => `Bundle "${name}" already exists`, - invalidBundleDefinition: (errors: string[]) => `Invalid bundle definition: ${errors.join('; ')}`, + invalidBundleDefinition: (errors: readonly string[]) => `Invalid bundle definition: ${errors.join('; ')}`, + + invalidFolderSegment: (part: FolderPathPart, segment: string) => + `Invalid ${part} segment "${segment}". Segments must match pattern [A-Za-z0-9_-]+`, } as const; + +/** Which part of a folder operation's input a malformed segment came from. */ +export type FolderPathPart = + | 'folder name' + | 'parent path' + | 'folder path' + | 'source folder path' + | 'destination folder path'; diff --git a/libs/core/src/lib/errors/lingo-tracker-error.spec.ts b/libs/core/src/lib/errors/lingo-tracker-error.spec.ts new file mode 100644 index 00000000..074db401 --- /dev/null +++ b/libs/core/src/lib/errors/lingo-tracker-error.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { PreferredTerminologyValidationError } from '../config/preferred-terminology-file'; +import { TranslationError } from '../translation/translation-provider'; +import { ErrorMessages } from './error-messages'; +import { + BaseLocaleImmutableError, + BundleAlreadyExistsError, + BundleNotFoundError, + CollectionAlreadyExistsError, + CollectionNotFoundError, + ConfigNotFoundError, + ConfigParseError, + InvalidBundleDefinitionError, + InvalidFolderPathError, + InvalidLocaleError, + InvalidResourceKeyError, + LingoTrackerError, + LocaleAlreadyExistsError, + LocaleNotFoundError, + ReadOnlyCollectionError, + ResourceNotFoundError, +} from './lingo-tracker-error'; + +describe('LingoTrackerError subclasses', () => { + const cases: ReadonlyArray<{ error: LingoTrackerError; name: string; code: string; message: string }> = [ + { + error: new ConfigNotFoundError('/w/.lingo-tracker.json'), + name: 'ConfigNotFoundError', + code: 'CONFIG_NOT_FOUND', + message: `${ErrorMessages.configNotFound()}: /w/.lingo-tracker.json`, + }, + { + error: new ConfigParseError('/w/.lingo-tracker.json', 'Unexpected token'), + name: 'ConfigParseError', + code: 'CONFIG_PARSE_FAILED', + message: ErrorMessages.jsonParseFailed('/w/.lingo-tracker.json', 'Unexpected token'), + }, + { + error: new CollectionNotFoundError('app'), + name: 'CollectionNotFoundError', + code: 'COLLECTION_NOT_FOUND', + message: ErrorMessages.collectionNotFound('app'), + }, + { + error: new CollectionAlreadyExistsError('app'), + name: 'CollectionAlreadyExistsError', + code: 'COLLECTION_ALREADY_EXISTS', + message: ErrorMessages.collectionAlreadyExists('app'), + }, + { + error: new ReadOnlyCollectionError('vendor'), + name: 'ReadOnlyCollectionError', + code: 'COLLECTION_READ_ONLY', + message: ErrorMessages.collectionReadOnly('vendor'), + }, + { + error: new InvalidLocaleError('x!', 'Invalid locale format: "x!"'), + name: 'InvalidLocaleError', + code: 'INVALID_LOCALE', + message: 'Invalid locale format: "x!"', + }, + { + error: new LocaleNotFoundError('ja', 'app'), + name: 'LocaleNotFoundError', + code: 'LOCALE_NOT_FOUND', + message: ErrorMessages.localeNotFound('ja', 'app'), + }, + { + error: new LocaleAlreadyExistsError('fr', 'app'), + name: 'LocaleAlreadyExistsError', + code: 'LOCALE_ALREADY_EXISTS', + message: ErrorMessages.localeAlreadyExists('fr', 'app'), + }, + { + error: new BaseLocaleImmutableError('en'), + name: 'BaseLocaleImmutableError', + code: 'BASE_LOCALE_IMMUTABLE', + message: ErrorMessages.cannotModifyBaseLocale('en'), + }, + { + error: new InvalidResourceKeyError('a..b', 'Key validation: Invalid key format "a..b"'), + name: 'InvalidResourceKeyError', + code: 'INVALID_RESOURCE_KEY', + message: 'Key validation: Invalid key format "a..b"', + }, + { + error: new ResourceNotFoundError('common.ok'), + name: 'ResourceNotFoundError', + code: 'RESOURCE_NOT_FOUND', + message: ErrorMessages.resourceNotFound('common.ok'), + }, + { + error: new InvalidFolderPathError('folder name', 'a b'), + name: 'InvalidFolderPathError', + code: 'INVALID_FOLDER_PATH', + message: 'Invalid folder name segment "a b". Segments must match pattern [A-Za-z0-9_-]+', + }, + { + error: new BundleNotFoundError('main'), + name: 'BundleNotFoundError', + code: 'BUNDLE_NOT_FOUND', + message: ErrorMessages.bundleNotFound('main'), + }, + { + error: new BundleAlreadyExistsError('main'), + name: 'BundleAlreadyExistsError', + code: 'BUNDLE_ALREADY_EXISTS', + message: ErrorMessages.bundleAlreadyExists('main'), + }, + { + error: new InvalidBundleDefinitionError(['a', 'b']), + name: 'InvalidBundleDefinitionError', + code: 'INVALID_BUNDLE_DEFINITION', + message: ErrorMessages.invalidBundleDefinition(['a', 'b']), + }, + { + error: new TranslationError('Rate limit exceeded', 'RATE_LIMIT', true), + name: 'TranslationError', + code: 'RATE_LIMIT', + message: 'Rate limit exceeded', + }, + { + error: new PreferredTerminologyValidationError([]), + name: 'PreferredTerminologyValidationError', + code: 'INVALID_PREFERRED_TERMINOLOGY', + message: 'Invalid preferred terminology rules: ', + }, + ]; + + it.each(cases)('$name has its name, code and message, and is a LingoTrackerError', ({ + error, + name, + code, + message, + }) => { + expect(error).toBeInstanceOf(LingoTrackerError); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe(name); + expect(error.code).toBe(code); + expect(error.message).toBe(message); + }); + + it('keeps the typed payload of each error', () => { + expect(new ResourceNotFoundError('common.ok').key).toBe('common.ok'); + expect(new LocaleNotFoundError('ja', 'app')).toMatchObject({ locale: 'ja', collectionName: 'app' }); + expect(new InvalidFolderPathError('parent path', 'x y')).toMatchObject({ part: 'parent path', segment: 'x y' }); + expect(new InvalidBundleDefinitionError(['a']).errors).toEqual(['a']); + }); +}); diff --git a/libs/core/src/lib/errors/lingo-tracker-error.ts b/libs/core/src/lib/errors/lingo-tracker-error.ts index 3b490fd7..b2c63895 100644 --- a/libs/core/src/lib/errors/lingo-tracker-error.ts +++ b/libs/core/src/lib/errors/lingo-tracker-error.ts @@ -1,23 +1,31 @@ -import { ErrorMessages } from './error-messages'; +import { ErrorMessages, type FolderPathPart } from './error-messages'; /** - * Base class for errors LingoTracker raises on purpose. Adapters (CLI, API) can test - * `instanceof` on a subclass to choose an exit code or HTTP status, instead of matching - * message text. + * Base class for errors LingoTracker raises on purpose. Adapters (CLI, API) test + * `instanceof` on a subclass, or read `code`, to choose an exit code or HTTP status, + * instead of matching message text. + * + * `code` is stable and machine-readable (for example `RESOURCE_NOT_FOUND`); the message + * is for people and may change. */ export class LingoTrackerError extends Error { - constructor(message: string) { + readonly code: string; + + constructor(message: string, code: string) { super(message); this.name = new.target.name; + this.code = code; } } +// --- Config ------------------------------------------------------------------ + /** `.lingo-tracker.json` does not exist in the directory that was searched. */ export class ConfigNotFoundError extends LingoTrackerError { readonly configPath: string; constructor(configPath: string) { - super(`${ErrorMessages.configNotFound()}: ${configPath}`); + super(`${ErrorMessages.configNotFound()}: ${configPath}`, 'CONFIG_NOT_FOUND'); this.configPath = configPath; } } @@ -28,18 +36,30 @@ export class ConfigParseError extends LingoTrackerError { readonly reason: string; constructor(configPath: string, reason: string) { - super(ErrorMessages.jsonParseFailed(configPath, reason)); + super(ErrorMessages.jsonParseFailed(configPath, reason), 'CONFIG_PARSE_FAILED'); this.configPath = configPath; this.reason = reason; } } +// --- Collections ------------------------------------------------------------- + /** The config has no collection with this name. */ export class CollectionNotFoundError extends LingoTrackerError { readonly collectionName: string; constructor(collectionName: string) { - super(ErrorMessages.collectionNotFound(collectionName)); + super(ErrorMessages.collectionNotFound(collectionName), 'COLLECTION_NOT_FOUND'); + this.collectionName = collectionName; + } +} + +/** A collection with this name is already registered (add, or rename onto it). */ +export class CollectionAlreadyExistsError extends LingoTrackerError { + readonly collectionName: string; + + constructor(collectionName: string) { + super(ErrorMessages.collectionAlreadyExists(collectionName), 'COLLECTION_ALREADY_EXISTS'); this.collectionName = collectionName; } } @@ -49,7 +69,119 @@ export class ReadOnlyCollectionError extends LingoTrackerError { readonly collectionName: string; constructor(collectionName: string) { - super(ErrorMessages.collectionReadOnly(collectionName)); + super(ErrorMessages.collectionReadOnly(collectionName), 'COLLECTION_READ_ONLY'); this.collectionName = collectionName; } } + +// --- Locales ----------------------------------------------------------------- + +/** The locale string is malformed. The message is the domain validator's text. */ +export class InvalidLocaleError extends LingoTrackerError { + readonly locale: string; + + constructor(locale: string, message: string) { + super(message, 'INVALID_LOCALE'); + this.locale = locale; + } +} + +/** The collection does not list this locale. */ +export class LocaleNotFoundError extends LingoTrackerError { + readonly locale: string; + readonly collectionName: string; + + constructor(locale: string, collectionName: string) { + super(ErrorMessages.localeNotFound(locale, collectionName), 'LOCALE_NOT_FOUND'); + this.locale = locale; + this.collectionName = collectionName; + } +} + +/** The collection already lists this locale. */ +export class LocaleAlreadyExistsError extends LingoTrackerError { + readonly locale: string; + readonly collectionName: string; + + constructor(locale: string, collectionName: string) { + super(ErrorMessages.localeAlreadyExists(locale, collectionName), 'LOCALE_ALREADY_EXISTS'); + this.locale = locale; + this.collectionName = collectionName; + } +} + +/** The base locale cannot be added to or removed from a collection. */ +export class BaseLocaleImmutableError extends LingoTrackerError { + readonly locale: string; + + constructor(locale: string) { + super(ErrorMessages.cannotModifyBaseLocale(locale), 'BASE_LOCALE_IMMUTABLE'); + this.locale = locale; + } +} + +// --- Resources and folders --------------------------------------------------- + +/** The resource key (or its target folder) is malformed. The message is the domain validator's text. */ +export class InvalidResourceKeyError extends LingoTrackerError { + readonly key: string; + + constructor(key: string, message: string) { + super(message, 'INVALID_RESOURCE_KEY'); + this.key = key; + } +} + +/** No resource exists at this (resolved) key. */ +export class ResourceNotFoundError extends LingoTrackerError { + readonly key: string; + + constructor(key: string) { + super(ErrorMessages.resourceNotFound(key), 'RESOURCE_NOT_FOUND'); + this.key = key; + } +} + +/** A segment of a dot-delimited folder path is malformed. */ +export class InvalidFolderPathError extends LingoTrackerError { + readonly segment: string; + readonly part: FolderPathPart; + + constructor(part: FolderPathPart, segment: string) { + super(ErrorMessages.invalidFolderSegment(part, segment), 'INVALID_FOLDER_PATH'); + this.part = part; + this.segment = segment; + } +} + +// --- Bundles ----------------------------------------------------------------- + +/** The config has no bundle with this name. */ +export class BundleNotFoundError extends LingoTrackerError { + readonly bundleName: string; + + constructor(bundleName: string) { + super(ErrorMessages.bundleNotFound(bundleName), 'BUNDLE_NOT_FOUND'); + this.bundleName = bundleName; + } +} + +/** A bundle with this name already exists (add, or rename onto it). */ +export class BundleAlreadyExistsError extends LingoTrackerError { + readonly bundleName: string; + + constructor(bundleName: string) { + super(ErrorMessages.bundleAlreadyExists(bundleName), 'BUNDLE_ALREADY_EXISTS'); + this.bundleName = bundleName; + } +} + +/** A bundle key or definition failed validation. `errors` holds every problem found. */ +export class InvalidBundleDefinitionError extends LingoTrackerError { + readonly errors: readonly string[]; + + constructor(errors: readonly string[]) { + super(ErrorMessages.invalidBundleDefinition(errors), 'INVALID_BUNDLE_DEFINITION'); + this.errors = errors; + } +} diff --git a/libs/core/src/lib/folder/create-folder.ts b/libs/core/src/lib/folder/create-folder.ts index 91d793ae..0daf6202 100644 --- a/libs/core/src/lib/folder/create-folder.ts +++ b/libs/core/src/lib/folder/create-folder.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { isValidSegment } from '@simoncodes-ca/domain'; +import { InvalidFolderPathError } from '../errors/lingo-tracker-error'; import { ensureDirectoryExists } from '../file-io/directory-operations'; import { folderMutation, type ResourceMutation } from '../resource/resource-mutation'; @@ -64,7 +65,7 @@ export function createFolder(translationsFolder: string, params: CreateFolderPar const folderSegments = folderName.split('.'); for (const segment of folderSegments) { if (!isValidSegment(segment)) { - throw new Error(`Invalid folder name segment "${segment}". Segments must match pattern [A-Za-z0-9_-]+`); + throw new InvalidFolderPathError('folder name', segment); } } @@ -73,7 +74,7 @@ export function createFolder(translationsFolder: string, params: CreateFolderPar const parentSegments = parentPath.split('.'); for (const segment of parentSegments) { if (!isValidSegment(segment)) { - throw new Error(`Invalid parent path segment "${segment}". Segments must match pattern [A-Za-z0-9_-]+`); + throw new InvalidFolderPathError('parent path', segment); } } } diff --git a/libs/core/src/lib/folder/delete-folder.ts b/libs/core/src/lib/folder/delete-folder.ts index 7af9b08b..a2a0278a 100644 --- a/libs/core/src/lib/folder/delete-folder.ts +++ b/libs/core/src/lib/folder/delete-folder.ts @@ -2,6 +2,7 @@ import { existsSync, rmSync, statSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { walkFolders } from '../normalize/iterative-folder-walker'; import { isValidSegment } from '@simoncodes-ca/domain'; +import { InvalidFolderPathError } from '../errors/lingo-tracker-error'; import { openResourceFolder } from '../resource/resource-folder'; import { folderMutation, type ResourceMutation } from '../resource/resource-mutation'; @@ -59,7 +60,7 @@ export function deleteFolder(translationsFolder: string, params: DeleteFolderPar const pathSegments = folderPath.split('.'); for (const segment of pathSegments) { if (!isValidSegment(segment)) { - throw new Error(`Invalid folder path segment "${segment}". Segments must match pattern [A-Za-z0-9_-]+`); + throw new InvalidFolderPathError('folder path', segment); } } diff --git a/libs/core/src/lib/folder/move-folder.ts b/libs/core/src/lib/folder/move-folder.ts index fc445aa5..6363e6c1 100644 --- a/libs/core/src/lib/folder/move-folder.ts +++ b/libs/core/src/lib/folder/move-folder.ts @@ -2,6 +2,7 @@ import { existsSync, statSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { walkFolders } from '../normalize/iterative-folder-walker'; import { isValidSegment } from '@simoncodes-ca/domain'; +import { InvalidFolderPathError } from '../errors/lingo-tracker-error'; import { moveResource, type MoveResourceResult } from '../../resource/move-resource'; import { deleteFolder, type DeleteFolderResult } from './delete-folder'; import { openResourceFolder } from '../resource/resource-folder'; @@ -94,7 +95,7 @@ export async function moveFolder(translationsFolder: string, params: MoveFolderP for (const segment of sourceFolderSegments) { if (!isValidSegment(segment)) { - throw new Error(`Invalid source folder path segment "${segment}". Segments must match pattern [A-Za-z0-9_-]+`); + throw new InvalidFolderPathError('source folder path', segment); } } @@ -102,9 +103,7 @@ export async function moveFolder(translationsFolder: string, params: MoveFolderP if (destinationFolderPath !== '') { for (const segment of destinationFolderSegments) { if (!isValidSegment(segment)) { - throw new Error( - `Invalid destination folder path segment "${segment}". Segments must match pattern [A-Za-z0-9_-]+`, - ); + throw new InvalidFolderPathError('destination folder path', segment); } } } diff --git a/libs/core/src/lib/resource/resource-file-paths.ts b/libs/core/src/lib/resource/resource-file-paths.ts index e85ab8b9..424c64a3 100644 --- a/libs/core/src/lib/resource/resource-file-paths.ts +++ b/libs/core/src/lib/resource/resource-file-paths.ts @@ -1,6 +1,7 @@ import { resolve, join } from 'node:path'; import { validateKey, validateTargetFolder, resolveResourceKey, splitResolvedKey } from '@simoncodes-ca/domain'; import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; +import { InvalidResourceKeyError } from '../errors/lingo-tracker-error'; export interface ResolvedResourcePaths { /** The fully resolved key (targetFolder.key) */ @@ -83,13 +84,18 @@ export function resolveResourcePaths(params: ResourcePathResolutionParams): Reso /** * Validates a key and resolves all paths in one operation. - * Throws if key or targetFolder are invalid. + * Throws `InvalidResourceKeyError` (with the domain validator's message) if the key or + * targetFolder is invalid. */ export function validateAndResolvePaths(params: ResourcePathResolutionParams): ResolvedResourcePaths { - validateKey(params.key); + try { + validateKey(params.key); - if (params.targetFolder) { - validateTargetFolder(params.targetFolder); + if (params.targetFolder) { + validateTargetFolder(params.targetFolder); + } + } catch (error: unknown) { + throw new InvalidResourceKeyError(params.key, error instanceof Error ? error.message : String(error)); } return resolveResourcePaths(params); diff --git a/libs/core/src/lib/translation/translate-existing-resource.ts b/libs/core/src/lib/translation/translate-existing-resource.ts index 9e8ac27b..46122bdf 100644 --- a/libs/core/src/lib/translation/translate-existing-resource.ts +++ b/libs/core/src/lib/translation/translate-existing-resource.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path'; import { needsTranslation } from '@simoncodes-ca/domain'; import type { TranslationConfig } from '../../config/translation-config'; import type { ResourceTreeEntry } from '../resource/load-resource-tree'; +import { ResourceNotFoundError } from '../errors/lingo-tracker-error'; import { validateAndResolvePaths } from '../resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../resource/resource-folder'; import { type ResourceMutation, upsertMutation } from '../resource/resource-mutation'; @@ -51,7 +52,7 @@ export async function translateExistingResource( const current = folder.get(paths.entryKey); if (!current?.meta) { - throw new Error(`Resource not found: ${paths.resolvedKey}`); + throw new ResourceNotFoundError(paths.resolvedKey); } const { entry, meta } = current; @@ -97,7 +98,7 @@ export async function translateExistingResource( function requireTreeEntry(folder: ResourceFolder, entryKey: string, resolvedKey: string): ResourceTreeEntry { const treeEntry = folder.treeEntry(entryKey); if (!treeEntry) { - throw new Error(`Resource not found: ${resolvedKey}`); + throw new ResourceNotFoundError(resolvedKey); } return treeEntry; } diff --git a/libs/core/src/lib/translation/translation-provider.ts b/libs/core/src/lib/translation/translation-provider.ts index 5d1bbd35..6fb70ac5 100644 --- a/libs/core/src/lib/translation/translation-provider.ts +++ b/libs/core/src/lib/translation/translation-provider.ts @@ -6,6 +6,8 @@ * the rest of the codebase to remain provider-agnostic. */ +import { LingoTrackerError } from '../errors/lingo-tracker-error'; + export interface TranslateRequest { readonly text: string; readonly sourceLocale: string; @@ -36,15 +38,13 @@ export interface TranslationProvider { * retry the operation (e.g. transient server errors) or whether retrying * would be pointless (e.g. invalid API key, malformed request). */ -export class TranslationError extends Error { - readonly code: string; +export class TranslationError extends LingoTrackerError { readonly retryable: boolean; readonly providerErrorCode: string | undefined; + /** `code` names the failure kind, e.g. `MISSING_API_KEY`, `RATE_LIMIT`, `INVALID_REQUEST`. */ constructor(message: string, code: string, retryable: boolean, providerErrorCode?: string) { - super(message); - this.name = 'TranslationError'; - this.code = code; + super(message, code); this.retryable = retryable; this.providerErrorCode = providerErrorCode; } diff --git a/libs/core/src/resource/edit-resource.ts b/libs/core/src/resource/edit-resource.ts index f52c0026..e28b5ed2 100644 --- a/libs/core/src/resource/edit-resource.ts +++ b/libs/core/src/resource/edit-resource.ts @@ -1,4 +1,5 @@ import { resolve } from 'node:path'; +import { ResourceNotFoundError } from '../lib/errors/lingo-tracker-error'; import { validateAndResolvePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../lib/resource/resource-folder'; import type { ResourceTreeEntry } from '../lib/resource/load-resource-tree'; @@ -64,7 +65,7 @@ export async function editResource( const current = folder.get(paths.entryKey); if (!current?.meta) { - throw new Error(`Resource not found: ${paths.resolvedKey}`); + throw new ResourceNotFoundError(paths.resolvedKey); } const key = paths.entryKey; @@ -149,7 +150,7 @@ export async function editResource( const updatedEntry = folder.treeEntry(key); if (!updatedEntry) { - throw new Error(`Resource not found: ${paths.resolvedKey}`); + throw new ResourceNotFoundError(paths.resolvedKey); } return { From 2e289b92495c6e3af2445efaf899522f3c40e132 Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 01:14:41 -0700 Subject: [PATCH 06/20] refactor(tracker): lift ResourceEntryDraft out of the translation editor The editor's rules (dotted-key absorption, key collision, "where it lands" context tree, tag edits, create/update DTO building, dirty-locale diff, unsaved-changes check) now live in a framework-free resource-entry-draft module with table tests. A shared segmentValidator built on the domain isValidSegment replaces three regex copies, the popover and the store share filterFolderTree, and the duplicate splitResolvedKey is gone. Every UI write of a resource entry (create, update, delete, translate) goes through a new BrowserStore entry-writes feature that owns cache patching and key rewriting in one place. Co-Authored-By: Claude Fable 5.1 --- .../resource-entry-draft.spec.ts | 460 +++++++++++++++ .../resource-entry-draft.ts | 368 ++++++++++++ .../translation-editor-dialog.spec.ts | 401 +------------- .../translation-editor-dialog.ts | 523 ++++-------------- .../translation-editor-launcher.spec.ts | 48 +- .../services/translation-editor-launcher.ts | 24 +- .../inline-folder-input.ts | 3 +- .../app/browser/store/browser.store.spec.ts | 130 ----- .../src/app/browser/store/browser.store.ts | 4 +- .../with-entry-writes.feature.spec.ts | 281 ++++++++++ .../features/with-entry-writes.feature.ts | 131 +++++ .../features/with-folder-tree.feature.ts | 26 +- .../features/with-translations.feature.ts | 33 -- .../browser/store/folder-tree.utils.spec.ts | 35 ++ .../app/browser/store/folder-tree.utils.ts | 24 +- .../header/translation-main-header.spec.ts | 7 +- .../header/translation-main-header.ts | 2 +- .../list/store/with-item-actions.feature.ts | 14 +- .../list/translation-list.spec.ts | 36 +- .../app/browser/utils/folder-path.utils.ts | 16 - .../bundle-form-dialog/bundle-form-dialog.ts | 4 +- .../validators/segment.validator.spec.ts | 23 + .../shared/validators/segment.validator.ts | 18 + architecture-docs/frontend.md | 60 +- architecture-docs/glossary.md | 8 + architecture-docs/user-flows.md | 21 +- 26 files changed, 1620 insertions(+), 1080 deletions(-) create mode 100644 apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts create mode 100644 apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts create mode 100644 apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts create mode 100644 apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts create mode 100644 apps/tracker/src/app/shared/validators/segment.validator.spec.ts create mode 100644 apps/tracker/src/app/shared/validators/segment.validator.ts diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts new file mode 100644 index 00000000..120036d7 --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts @@ -0,0 +1,460 @@ +import type { FolderNodeDto, ResourceSummaryDto, TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { describe, expect, it } from 'vitest'; +import { + absorbDottedKey, + addTag, + CONTEXT_TREE_ENTRY_LIMIT, + type ContextTreeInput, + collisionFor, + contextTree, + editedLocales, + folderEntryKeys, + hasUnsavedChanges, + type KeyAbsorption, + type KnownEntries, + type LocaleDraft, + type OriginalEntry, + type ResourceEntryDraft, + removeTag, + toCreateDto, + toUpdateDto, +} from './resource-entry-draft'; + +const entry = (key: string): ResourceSummaryDto => ({ key, translations: { en: key }, status: {} }); + +const folder = (fullPath: string, tree?: FolderNodeDto['tree']): FolderNodeDto => ({ + name: fullPath.split('.').at(-1) ?? fullPath, + fullPath, + loaded: tree !== undefined, + tree, +}); + +const known = (overrides: Partial = {}): KnownEntries => ({ + rootFolders: [], + browserFolderPath: '', + browserEntries: [], + fetched: new Map(), + ...overrides, +}); + +const draft = (overrides: Partial = {}): ResourceEntryDraft => ({ + key: 'ok', + folderPath: 'common.buttons', + baseValue: 'OK', + comment: 'The affirmative button', + tags: [], + translations: [ + { locale: 'fr', value: '', status: 'new' }, + { locale: 'de', value: '', status: 'new' }, + ], + ...overrides, +}); + +describe('absorbDottedKey', () => { + it.each<[string, string, string, string | null, KeyAbsorption | null]>([ + ['a key without dots', 'ok', 'common.buttons', null, null], + [ + 'a pasted full key', + 'apps.common.buttons.ok', + 'common.buttons', + null, + { leaf: 'ok', folder: 'apps.common.buttons' }, + ], + ['a folder typed with its trailing dot', 'apps.', 'common.buttons', null, { leaf: '', folder: 'apps' }], + ['consecutive dots', 'apps..common...ok', 'common.buttons', null, { leaf: 'ok', folder: 'apps.common' }], + ['a leading dot', '.ok', 'common.buttons', null, { leaf: 'ok' }], + ['a lone dot', '.', 'common.buttons', null, { leaf: '' }], + [ + 'a prefix pasted before an existing leaf', + 'apps.common.ok', + 'common.buttons', + null, + { leaf: 'ok', folder: 'apps.common' }, + ], + ['an invalid folder segment', 'apps.bad key.ok', 'common.buttons', null, null], + [ + 'an invalid leaf, left for the validator', + 'apps.bad key', + 'common.buttons', + null, + { leaf: 'bad key', folder: 'apps' }, + ], + ])('%s', (_case, rawKey, currentFolder, folderFromKey, expected) => { + expect(absorbDottedKey(rawKey, currentFolder, folderFromKey)).toEqual(expected); + }); + + describe('continuation rule', () => { + it('should extend the folder the last absorption produced', () => { + expect(absorbDottedKey('common.', 'apps', 'apps')).toEqual({ leaf: '', folder: 'apps.common' }); + }); + + it('should re-anchor once the user has picked a different folder', () => { + expect(absorbDottedKey('other.ok', 'picked.folder', 'apps')).toEqual({ leaf: 'ok', folder: 'other' }); + }); + + it('should re-anchor when nothing was absorbed before', () => { + expect(absorbDottedKey('apps.ok', 'apps', null)).toEqual({ leaf: 'ok', folder: 'apps' }); + }); + }); +}); + +describe('folderEntryKeys', () => { + it('should know nothing about a folder no source has loaded', () => { + expect(folderEntryKeys('common.errors', known())).toBeUndefined(); + }); + + it('should leave out the nested resources a folder listing folds in', () => { + const keys = folderEntryKeys( + 'common', + known({ browserFolderPath: 'common', browserEntries: [entry('ok'), entry('dialog.title')] }), + ); + expect(keys && [...keys]).toEqual(['ok']); + }); + + it('should prefer the expanded tree over the browser list', () => { + const keys = folderEntryKeys( + 'common', + known({ + rootFolders: [folder('common', { path: 'common', resources: [entry('fromTree')], children: [] })], + browserFolderPath: 'common', + browserEntries: [entry('fromList')], + }), + ); + expect(keys && [...keys]).toEqual(['fromTree']); + }); +}); + +describe('collisionFor', () => { + const tree = [ + folder('common', { + path: 'common', + resources: [], + children: [folder('common.buttons', { path: 'common.buttons', resources: [entry('save')], children: [] })], + }), + ]; + const listing = (path: string, ...keys: string[]): KnownEntries => + known({ browserFolderPath: path, browserEntries: keys.map(entry) }); + const buttons = listing('common.buttons', 'ok'); + + it.each<[string, string, string, KnownEntries, string | undefined, boolean]>([ + ['an empty key', '', 'common.buttons', buttons, undefined, false], + ['a blank key', ' ', 'common.buttons', buttons, undefined, false], + [ + 'source 1: an expanded folder in the tree', + 'save', + 'common.buttons', + known({ rootFolders: tree }), + undefined, + true, + ], + ['source 2: the folder the browser shows', 'ok', 'common.buttons', buttons, undefined, true], + ['source 2 at the collection root', 'ok', '', listing('', 'ok'), undefined, true], + [ + 'source 3: a folder the editor fetched', + 'x', + 'common.errors', + known({ fetched: new Map([['common.errors', ['x']]]) }), + undefined, + true, + ], + ['a folder no source knows yet', 'ok', 'common.errors', buttons, undefined, false], + [ + 'a nested resource sharing the first segment', + 'confirm', + 'common.buttons', + listing('common.buttons', 'confirm.title'), + undefined, + false, + ], + ['a key differing only by case', 'OK', 'common.buttons', buttons, undefined, false], + ['a key with surrounding spaces', ' ok ', 'common.buttons', buttons, undefined, true], + ['the entry being edited', 'ok', 'common.buttons', buttons, 'ok', false], + ])('%s', (_case, key, folderPath, knownEntries, ownKey, expected) => { + expect(collisionFor(key, folderPath, knownEntries, ownKey)).toBe(expected); + }); +}); + +describe('contextTree', () => { + const moreLabel = (hidden: number): string => `+${hidden} more`; + const input = (overrides: Partial = {}): ContextTreeInput => ({ + folderPath: 'common.buttons', + key: '', + known: known(), + loadingFolders: new Set(), + ...overrides, + }); + const entryNames = (nodes: ReturnType): string[] => + nodes.filter((node) => node.kind === 'entry').map((node) => node.name); + + const siblingsTree = [ + folder('common', { + path: 'common', + resources: [], + children: [folder('common.buttons'), folder('common.errors')], + }), + ]; + + describe('shape', () => { + it('should list the root folders, then the root entries, for the collection root', () => { + const nodes = contextTree( + input({ + folderPath: '', + key: 'ok', + known: known({ + rootFolders: [folder('common'), folder('errors')], + browserFolderPath: '', + browserEntries: [entry('cancel')], + }), + }), + moreLabel, + ); + + expect(nodes.map((node) => [node.kind, node.name, node.depth])).toEqual([ + ['folder', 'common', 0], + ['folder', 'errors', 0], + ['entry', 'cancel', 0], + ['entry', 'ok', 0], + ]); + }); + + it('should show the target among its siblings under their expanded parent', () => { + const nodes = contextTree(input({ key: 'ok', known: known({ rootFolders: siblingsTree }) }), moreLabel); + + expect( + nodes.map((node) => [node.kind, node.path, node.depth, node.here ?? false, node.expanded ?? false]), + ).toEqual([ + ['folder', 'common', 0, false, true], + ['folder', 'common.buttons', 1, true, true], + ['entry', 'common.buttons.ok', 2, false, false], + ['folder', 'common.errors', 1, false, false], + ]); + }); + + it('should still place a target folder the tree does not hold yet', () => { + const nodes = contextTree( + input({ folderPath: 'common.fresh', key: 'ok', known: known({ rootFolders: siblingsTree }) }), + moreLabel, + ); + + const target = nodes.find((node) => node.here); + expect(target).toMatchObject({ kind: 'folder', name: 'fresh', path: 'common.fresh', depth: 1, expanded: true }); + expect(nodes.at(-1)).toMatchObject({ kind: 'entry', name: 'ok', depth: 2, mark: 'new' }); + }); + + it('should show a folder still loading as a folder and nothing more', () => { + const nodes = contextTree(input({ key: 'ok', loadingFolders: new Set(['common.buttons']) }), moreLabel); + + expect(entryNames(nodes)).toEqual([]); + expect(nodes.some((node) => node.here)).toBe(true); + }); + + it('should leave nested resources out of the folder row', () => { + const nodes = contextTree( + input({ + known: known({ + browserFolderPath: 'common.buttons', + browserEntries: [entry('ok'), entry('confirm.dialog.title')], + }), + }), + moreLabel, + ); + + expect(entryNames(nodes)).toEqual(['ok']); + }); + }); + + describe('marks', () => { + const holdingOk = known({ browserFolderPath: 'common.buttons', browserEntries: [entry('ok'), entry('cancel')] }); + + it.each<[string, Partial, 'new' | 'exists' | 'editing' | undefined]>([ + ['a free key is new', { key: 'save', known: holdingOk }, 'new'], + ['a taken key exists', { key: 'ok', known: holdingOk }, 'exists'], + ['the entry being edited is editing', { key: 'ok', known: holdingOk, ownKey: 'ok' }, 'editing'], + ])('%s', (_case, overrides, mark) => { + const nodes = contextTree(input(overrides), moreLabel); + const written = nodes.find((node) => node.kind === 'entry' && node.name === overrides.key); + expect(written?.mark).toBe(mark); + }); + + it('should mark nothing before a key is typed', () => { + const nodes = contextTree(input({ known: holdingOk }), moreLabel); + expect(nodes.filter((node) => node.mark !== undefined)).toEqual([]); + }); + }); + + describe(`the ${CONTEXT_TREE_ENTRY_LIMIT}-entry window`, () => { + // k01 … k20, already sorted. + const twenty = Array.from({ length: 20 }, (_, i) => entry(`k${String(i + 1).padStart(2, '0')}`)); + const full = known({ browserFolderPath: 'common.buttons', browserEntries: twenty }); + const range = (from: number, to: number): string[] => + Array.from({ length: to - from + 1 }, (_, i) => `k${String(from + i).padStart(2, '0')}`); + + it.each<[string, string, string[], number]>([ + ['no key: the first entries', '', range(1, 8), 12], + ['a key sorting first: anchored at the start', 'a', ['a', ...range(1, 7)], 13], + ['an existing key in the middle: centred on it', 'k10', range(6, 13), 12], + ['a new key in the middle: centred on where it lands', 'k10a', [...range(7, 10), 'k10a', ...range(11, 13)], 13], + ['a key sorting last: anchored at the end', 'z', [...range(14, 20), 'z'], 13], + ])('%s', (_case, key, shown, hidden) => { + const nodes = contextTree(input({ key, known: full }), moreLabel); + + expect(entryNames(nodes)).toEqual(shown); + expect(nodes.at(-1)).toEqual({ + kind: 'more', + name: `+${hidden} more`, + path: 'common.buttons::more', + depth: 2, + }); + }); + + it('should list a folder that fits the window without a count', () => { + const nodes = contextTree( + input({ key: 'ok', known: known({ browserFolderPath: 'common.buttons', browserEntries: twenty.slice(0, 7) }) }), + moreLabel, + ); + + expect(entryNames(nodes)).toHaveLength(CONTEXT_TREE_ENTRY_LIMIT); + expect(nodes.some((node) => node.kind === 'more')).toBe(false); + }); + }); +}); + +describe('tags', () => { + it('should add a tag in normalized form', () => { + expect(addTag(['browser'], ' New Tag ')).toEqual(['browser', 'new-tag']); + }); + + it.each([ + ['a tag already present', 'Browser'], + ['input that normalizes to nothing', ' !! '], + ])('should return the same list for %s', (_case, raw) => { + const tags = ['browser']; + expect(addTag(tags, raw)).toBe(tags); + }); + + it('should remove an own tag', () => { + expect(removeTag(['browser', 'dialog'], 'browser', [])).toEqual(['dialog']); + }); + + it('should keep a tag the folder passes down', () => { + const tags = ['browser']; + expect(removeTag(tags, 'browser', ['browser'])).toBe(tags); + }); +}); + +describe('toCreateDto', () => { + it('should resolve the full key and send only the translations that have a value, all as new', () => { + const dto = toCreateDto( + draft({ + translations: [ + { locale: 'fr', value: 'Valeur', status: 'translated' }, + { locale: 'de', value: ' ', status: 'verified' }, + ], + tags: ['browser'], + }), + 'en', + ); + + expect(dto).toEqual({ + key: 'common.buttons.ok', + baseValue: 'OK', + comment: 'The affirmative button', + tags: ['browser'], + baseLocale: 'en', + translations: [{ locale: 'fr', value: 'Valeur', status: 'new' }], + }); + }); + + it.each<[string, Partial, Record]>([ + ['a root-level entry keeps its bare key', { folderPath: '' }, { key: 'ok' }], + ['a blank comment is omitted', { comment: ' ' }, { comment: undefined }], + ['a comment is trimmed', { comment: ' Why ' }, { comment: 'Why' }], + ['no tags are omitted', { tags: [] }, { tags: undefined }], + ['no filled translations are omitted', {}, { translations: undefined }], + ])('%s', (_case, overrides, expected) => { + expect(toCreateDto(draft(overrides), 'en')).toMatchObject(expected); + }); +}); + +describe('toUpdateDto and editedLocales', () => { + const original: OriginalEntry = { + resource: { + key: 'ok', + translations: { en: 'OK', fr: 'Oui', de: 'Ja' }, + status: { fr: 'translated', de: 'verified' }, + }, + folderPath: 'common.buttons', + }; + + const locales = (fr: [string, TranslationStatus], de: [string, TranslationStatus]): LocaleDraft[] => [ + { locale: 'fr', value: fr[0], status: fr[1] }, + { locale: 'de', value: de[0], status: de[1] }, + ]; + /** The locales exactly as the original holds them, minus the values: nothing edited. */ + const untouched = locales(['', 'translated'], ['', 'verified']); + + it.each<[string, LocaleDraft[], Record | undefined]>([ + [ + 'a locale with a value is written with its status', + locales(['Oui', 'translated'], ['', 'verified']), + { fr: { value: 'Oui', status: 'translated' } }, + ], + [ + 'a status-only change is written even without a value', + locales(['', 'stale'], ['', 'verified']), + { fr: { value: '', status: 'stale' } }, + ], + [ + 'an emptied locale with its status untouched is left alone', + locales(['', 'translated'], ['', 'verified']), + undefined, + ], + ['a locale with no original status counts from new', [{ locale: 'es', value: '', status: 'new' }], undefined], + [ + 'every edited locale is written', + locales(['Oui!', 'verified'], ['Ja!', 'verified']), + { fr: { value: 'Oui!', status: 'verified' }, de: { value: 'Ja!', status: 'verified' } }, + ], + ])('%s', (_case, translations, expected) => { + const edited = draft({ translations }); + + expect(toUpdateDto(edited, original).locales).toEqual(expected); + expect(editedLocales(edited, original.resource).map((t) => t.locale)).toEqual(Object.keys(expected ?? {})); + }); + + it('should name the entry by its original full key and always send the tags', () => { + const dto = toUpdateDto(draft({ comment: ' Why ', translations: untouched }), original); + + expect(dto).toEqual({ key: 'common.buttons.ok', baseValue: 'OK', comment: 'Why', tags: [] }); + expect('targetFolder' in dto).toBe(false); + }); + + it('should keep a root-level entry on its bare key', () => { + expect(toUpdateDto(draft({ folderPath: '' }), { ...original, folderPath: '' }).key).toBe('ok'); + }); + + it('should send a move to another folder as targetFolder', () => { + expect(toUpdateDto(draft({ folderPath: 'common.dialogs' }), original).targetFolder).toBe('common.dialogs'); + }); + + it('should omit targetFolder entirely for a move to the collection root', () => { + expect('targetFolder' in toUpdateDto(draft({ folderPath: '' }), original)).toBe(false); + }); +}); + +describe('hasUnsavedChanges', () => { + const initial = draft({ tags: ['browser', 'dialog'] }); + + it.each<[string, Partial, boolean, boolean]>([ + ['nothing touched', {}, false, false], + ['a field edited, even back to its value', {}, true, true], + ['the folder moved', { folderPath: 'common.dialogs' }, false, true], + ['a tag added', { tags: ['browser', 'dialog', 'footer'] }, false, true], + ['a tag removed', { tags: ['browser'] }, false, true], + ['the same tags in a new list', { tags: ['browser', 'dialog'] }, false, false], + ['the tags reordered', { tags: ['dialog', 'browser'] }, false, true], + ])('%s', (_case, overrides, fieldsEdited, expected) => { + const current = draft({ tags: ['browser', 'dialog'], ...overrides }); + expect(hasUnsavedChanges(current, initial, fieldsEdited)).toBe(expected); + }); +}); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts new file mode 100644 index 00000000..1536d2b2 --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts @@ -0,0 +1,368 @@ +import type { + CreateResourceDto, + FolderNodeDto, + ResourceSummaryDto, + TranslationStatus, + UpdateResourceDto, +} from '@simoncodes-ca/data-transfer'; +import { isValidSegment, normalizeTag, resolveResourceKey } from '@simoncodes-ca/domain'; +import { findFolderInTree } from '../../store/folder-tree.utils'; + +/* + * Resource Entry Draft: the translation editor's rules, as plain data and functions. + * + * The dialog owns the forms, focus, popovers and timers. Everything it decides + * about the entry itself lives here, with no Angular dependency: where a dotted + * key lands, whether the key is taken, what "Where it lands" shows, what a save + * sends, and whether closing would lose work. + */ + +/** One non-base locale as the editor holds it. */ +export interface LocaleDraft { + locale: string; + value: string; + status: TranslationStatus; +} + +/** The entry the editor is writing, as plain data. */ +export interface ResourceEntryDraft { + /** The entry key: a single segment inside `folderPath`. */ + key: string; + /** Dot-delimited folder the entry lands in; '' for the collection root. */ + folderPath: string; + baseValue: string; + comment: string; + tags: readonly string[]; + /** Every non-base locale, in the editor's order. */ + translations: readonly LocaleDraft[]; +} + +/** The entry an edit started from. */ +export interface OriginalEntry { + resource: ResourceSummaryDto; + /** The folder it lives in; '' for the collection root. */ + folderPath: string; +} + +// ── Dotted-key absorption ──────────────────────────────────────────────────── + +export interface KeyAbsorption { + /** What stays in the key field. */ + leaf: string; + /** The folder the key's prefix names; absent when the key carried no folder. */ + folder?: string; +} + +/** + * The primary user arrives holding a full dotted key (`apps.common.buttons.ok`), + * and the key field accepts one segment. Rather than reject the one string they + * have, the dotted prefix becomes the folder and the leaf stays in the field. + * + * Empty segments cover leading, trailing and consecutive dots in one pass; a + * trailing dot means a folder is finished but no leaf started. A prefix with an + * invalid segment is not absorbed (returns null), so the validator names the + * real problem instead of a silently mangled key. + * + * Continuation: while the target folder is still the one the last absorption + * produced (`folderFromKey`), typing `a.` then `b.` extends `a` to `a.b`. A + * folder the user picked is never extended, only replaced. + */ +export function absorbDottedKey( + rawKey: string, + currentFolder: string, + folderFromKey: string | null, +): KeyAbsorption | null { + if (!rawKey.includes('.')) { + return null; + } + + const segments = rawKey.split('.').filter((segment) => segment.length > 0); + const leaf = rawKey.endsWith('.') ? '' : (segments.pop() ?? ''); + + if (segments.some((segment) => !isValidSegment(segment))) { + return null; + } + if (segments.length === 0) { + return { leaf }; + } + + const prefix = segments.join('.'); + const continues = folderFromKey !== null && currentFolder === folderFromKey; + return { leaf, folder: continues ? `${folderFromKey}.${prefix}` : prefix }; +} + +// ── Key collision ──────────────────────────────────────────────────────────── + +/** Where the editor can learn which entries a folder already holds. */ +export interface KnownEntries { + /** The browser's folder tree. A folder the tree has expanded carries its resources. */ + rootFolders: readonly FolderNodeDto[]; + /** The folder the browser list is showing. */ + browserFolderPath: string; + /** What the browser list holds for that folder, nested resources included. */ + browserEntries: readonly ResourceSummaryDto[]; + /** Entry keys the editor fetched itself, per folder path. */ + fetched: ReadonlyMap; +} + +/** + * The entry keys known for a folder, or undefined when they are not known at all. + * Three sources, cheapest first: a folder already expanded in the tree, the folder + * the browser is showing, then anything the editor fetched. + * + * An entry key is a single segment. The browser lists a folder with its nested + * resources folded in, under keys relative to the folder (`dialog.title`, not + * `title`). Those live in another folder, so they are left out. + */ +export function folderEntryKeys(folderPath: string, known: KnownEntries): ReadonlySet | undefined { + const expanded = folderPath ? findFolderInTree(known.rootFolders, folderPath)?.tree?.resources : undefined; + const listed = expanded ?? (known.browserFolderPath === folderPath ? known.browserEntries : undefined); + const keys = listed?.map((resource) => resource.key) ?? known.fetched.get(folderPath); + return keys ? new Set(keys.filter((key) => !key.includes('.'))) : undefined; +} + +/** + * Whether `key` is already taken in `folderPath`. + * + * The same rule as the writer's `addResource`: the entry key is an exact, + * case-sensitive property of the folder's `resource_entries.json`. A folder whose + * entries are not known yet claims nothing. `ownKey` is the entry being edited: + * its key is locked, and it never collides with itself. + */ +export function collisionFor(key: string, folderPath: string, known: KnownEntries, ownKey?: string): boolean { + const entryKey = key.trim(); + if (!entryKey || entryKey === ownKey) { + return false; + } + return folderEntryKeys(folderPath, known)?.has(entryKey) === true; +} + +// ── "Where it lands" context tree ──────────────────────────────────────────── + +/** How many sibling entries the context tree lists before it counts the rest. */ +export const CONTEXT_TREE_ENTRY_LIMIT = 8; + +/** One row of the context column's "Where it lands" tree. */ +export interface ContextTreeNode { + kind: 'folder' | 'entry' | 'more'; + name: string; + path: string; + depth: number; + /** The folder the entry lands in. */ + here?: boolean; + expanded?: boolean; + /** The entry this dialog is writing, and what it is doing to it. */ + mark?: 'new' | 'editing' | 'exists'; +} + +export interface ContextTreeInput { + folderPath: string; + key: string; + known: KnownEntries; + /** Folders whose entries are in flight. They show as a folder and nothing more. */ + loadingFolders: ReadonlySet; + /** The entry being edited, marked `editing` rather than `new` or `exists`. */ + ownKey?: string; +} + +/** + * The mini tree in "Where it lands": the target folder's siblings under their + * shared parent, the target expanded over the entries it holds, and the entry + * being written marked. Entries are only known for folders something has loaded; + * an unloaded folder shows as a folder node and nothing more. + * + * `moreLabel` names the row that counts the entries outside the window. + */ +export function contextTree(input: ContextTreeInput, moreLabel: (hidden: number) => string): ContextTreeNode[] { + const roots = input.known.rootFolders; + const targetPath = input.folderPath; + const segments = targetPath.split('.').filter((segment) => segment.length > 0); + const nodes: ContextTreeNode[] = []; + + if (segments.length === 0) { + for (const folder of roots) { + nodes.push({ kind: 'folder', name: folder.name, path: folder.fullPath, depth: 0 }); + } + nodes.push(...entryNodes(input, 0, moreLabel)); + return nodes; + } + + const parentPath = segments.slice(0, -1).join('.'); + const siblings = parentPath ? (findFolderInTree(roots, parentPath)?.tree?.children ?? []) : roots; + let depth = 0; + + if (parentPath) { + nodes.push({ kind: 'folder', name: segments[segments.length - 2], path: parentPath, depth: 0, expanded: true }); + depth = 1; + } + + let placed = false; + for (const sibling of siblings) { + const here = sibling.fullPath === targetPath; + placed = placed || here; + nodes.push({ kind: 'folder', name: sibling.name, path: sibling.fullPath, depth, here, expanded: here }); + if (here) { + nodes.push(...entryNodes(input, depth + 1, moreLabel)); + } + } + + // The folder may not be in the tree yet: a path absorbed from a dotted key, or + // one the user has not expanded. It is still where the entry lands. + if (!placed) { + nodes.push({ + kind: 'folder', + name: segments[segments.length - 1], + path: targetPath, + depth, + here: true, + expanded: true, + }); + nodes.push(...entryNodes(input, depth + 1, moreLabel)); + } + + return nodes; +} + +/** + * The entries already in the target folder, plus the one being written. A folder + * can hold hundreds of keys and this is a glance, not a browser, so the list is a + * window around the entry being written; the rest is one count. + */ +function entryNodes(input: ContextTreeInput, depth: number, moreLabel: (hidden: number) => string): ContextTreeNode[] { + const { folderPath, known, loadingFolders, ownKey } = input; + const key = input.key.trim(); + const loaded = folderEntryKeys(folderPath, known); + + // Listing the new entry alone would claim the folder is empty before we know it. + if (!loaded && loadingFolders.has(folderPath)) { + return []; + } + + const names = new Set(loaded ?? []); + const taken = key.length > 0 && names.has(key); + if (key) { + names.add(key); + } + + const sorted = [...names].sort((a, b) => a.localeCompare(b)); + const limit = CONTEXT_TREE_ENTRY_LIMIT; + let shown = sorted; + if (sorted.length > limit) { + const anchor = key ? Math.max(0, sorted.indexOf(key)) : 0; + const start = Math.min(Math.max(0, anchor - Math.floor(limit / 2)), sorted.length - limit); + shown = sorted.slice(start, start + limit); + } + + const markFor = (name: string): ContextTreeNode['mark'] => { + if (!key || name !== key) return undefined; + if (key === ownKey) return 'editing'; + return taken ? 'exists' : 'new'; + }; + + const nodes: ContextTreeNode[] = shown.map((name) => ({ + kind: 'entry', + name, + path: resolveResourceKey(name, folderPath), + depth, + mark: markFor(name), + })); + + const hidden = sorted.length - shown.length; + if (hidden > 0) { + nodes.push({ kind: 'more', name: moreLabel(hidden), path: `${folderPath}::more`, depth }); + } + + return nodes; +} + +// ── Tags ───────────────────────────────────────────────────────────────────── + +/** The tag list with `raw` added in normalized form; the same list when it adds nothing new. */ +export function addTag(tags: readonly string[], raw: string): readonly string[] { + const tag = normalizeTag(raw); + return tag && !tags.includes(tag) ? [...tags, tag] : tags; +} + +/** The tag list without `tag`. An inherited tag belongs to a folder and stays. */ +export function removeTag(tags: readonly string[], tag: string, inherited: readonly string[]): readonly string[] { + return inherited.includes(tag) ? tags : tags.filter((existing) => existing !== tag); +} + +// ── Saving ─────────────────────────────────────────────────────────────────── + +/** + * The create request. Every translation typed alongside the base value goes in + * as `new`, whatever its status pill says: nothing has been reviewed yet. + */ +export function toCreateDto(draft: ResourceEntryDraft, baseLocale: string): CreateResourceDto { + const translations = draft.translations + .filter((translation) => translation.value.trim().length > 0) + .map((translation) => ({ locale: translation.locale, value: translation.value, status: 'new' as const })); + + return { + key: resolveResourceKey(draft.key, draft.folderPath), + baseValue: draft.baseValue, + comment: draft.comment.trim() || undefined, + tags: draft.tags.length > 0 ? [...draft.tags] : undefined, + baseLocale, + translations: translations.length > 0 ? translations : undefined, + }; +} + +/** + * The locales an edit writes: those with a value, and those whose status moved. + * An emptied locale with its status untouched is left alone. + */ +export function editedLocales(draft: ResourceEntryDraft, original: ResourceSummaryDto): LocaleDraft[] { + return draft.translations.filter((translation) => { + const hasValue = translation.value.trim().length > 0; + const statusChanged = translation.status !== (original.status[translation.locale] ?? 'new'); + return hasValue || statusChanged; + }); +} + +/** + * The update request. The key names the entry where it lives now. A change to a + * non-root folder travels as `targetFolder`. A change to the collection root is + * sent without `targetFolder`, as it always has been, so the server edits the + * entry where it is. Tags are always sent, so removing the last one clears them. + */ +export function toUpdateDto(draft: ResourceEntryDraft, original: OriginalEntry): UpdateResourceDto { + const dto: UpdateResourceDto = { + key: resolveResourceKey(original.resource.key, original.folderPath), + baseValue: draft.baseValue, + comment: draft.comment.trim() || undefined, + tags: [...draft.tags], + }; + + if (draft.folderPath && draft.folderPath !== original.folderPath) { + dto.targetFolder = draft.folderPath; + } + + const locales = editedLocales(draft, original.resource); + if (locales.length > 0) { + dto.locales = Object.fromEntries( + locales.map((translation) => [translation.locale, { value: translation.value, status: translation.status }]), + ); + } + + return dto; +} + +/** + * True when closing now would throw away work. + * + * `fieldsEdited` is the form's dirty flag: a field the user typed in counts even + * when typed back to what it was. The folder and the tags are not form fields, so + * they count only when they differ from where the editor started. + */ +export function hasUnsavedChanges( + draft: ResourceEntryDraft, + initial: ResourceEntryDraft, + fieldsEdited: boolean, +): boolean { + if (fieldsEdited || draft.folderPath !== initial.folderPath) { + return true; + } + return draft.tags.length !== initial.tags.length || draft.tags.some((tag, i) => tag !== initial.tags[i]); +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts index 918a4f4f..9bb5d29e 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts @@ -56,11 +56,15 @@ describe('TranslationEditorDialog', () => { const createDialog = createComponentFactory({ component: TranslationEditorDialog, imports: [BrowserAnimationsModule, getTranslocoTestingModule()], - providers: [provideHttpClient(), provideHttpClientTesting()], + // Root-level, so the BrowserStore the dialog writes through sees the same mock. + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: BrowserApiService, useFactory: () => mockBrowserApi }, + ], componentProviders: [ { provide: MatDialogRef, useFactory: () => dialogRef }, { provide: MatDialog, useFactory: () => mockDialog }, - { provide: BrowserApiService, useFactory: () => mockBrowserApi }, { provide: NotificationService, useFactory: () => mockNotifications }, { provide: CollectionsStore, useFactory: () => ({ config: mockConfig }) }, { provide: MAT_DIALOG_DATA, useValue: dialogData }, @@ -152,39 +156,6 @@ describe('TranslationEditorDialog', () => { }); describe('Form Validation - Key Field', () => { - it('should accept alphanumeric characters', () => { - component.form.controls.key.setValue('test123'); - expect(component.form.controls.key.valid).toBe(true); - }); - - it('should accept underscores', () => { - component.form.controls.key.setValue('test_key_name'); - expect(component.form.controls.key.valid).toBe(true); - }); - - it('should accept hyphens', () => { - component.form.controls.key.setValue('test-key-name'); - expect(component.form.controls.key.valid).toBe(true); - }); - - it('should reject slashes', () => { - component.form.controls.key.setValue('test/key'); - expect(component.form.controls.key.hasError('pattern')).toBe(true); - }); - - it('should reject special characters', () => { - const specialChars = ['@', '#', '$', '%', '^', '&', '*', '(', ')']; - specialChars.forEach((char) => { - component.form.controls.key.setValue(`test${char}key`); - expect(component.form.controls.key.hasError('pattern')).toBe(true); - }); - }); - - it('should reject spaces', () => { - component.form.controls.key.setValue('test key'); - expect(component.form.controls.key.hasError('pattern')).toBe(true); - }); - it('should require key field', () => { component.form.controls.key.setValue(''); expect(component.form.controls.key.hasError('required')).toBe(true); @@ -212,14 +183,6 @@ describe('TranslationEditorDialog', () => { expect(component.form.controls.key.valid).toBe(true); }); - it('should replace the folder the dialog opened on', () => { - expect(component.selectedFolderPath()).toBe('common.buttons'); - - component.form.controls.key.setValue('apps.header.title'); - - expect(component.selectedFolderPath()).toBe('apps.header'); - }); - it('should extend the derived folder while the user keeps typing dots', () => { component.form.controls.key.setValue('apps.'); expect(component.selectedFolderPath()).toBe('apps'); @@ -233,53 +196,6 @@ describe('TranslationEditorDialog', () => { expect(component.form.controls.key.value).toBe('ok'); }); - it('should re-anchor on a folder the user picked instead of extending it', () => { - component.form.controls.key.setValue('apps.'); - component.onFolderConfirmed('picked.folder'); - - component.form.controls.key.setValue('other.ok'); - - expect(component.selectedFolderPath()).toBe('other'); - }); - - it('should collapse consecutive dots', () => { - component.form.controls.key.setValue('apps..common...ok'); - - expect(component.form.controls.key.value).toBe('ok'); - expect(component.selectedFolderPath()).toBe('apps.common'); - }); - - it('should strip a leading dot without touching the folder', () => { - component.form.controls.key.setValue('.ok'); - - expect(component.form.controls.key.value).toBe('ok'); - expect(component.selectedFolderPath()).toBe('common.buttons'); - }); - - it('should leave the folder alone when the value is only a dot', () => { - component.form.controls.key.setValue('.'); - - expect(component.form.controls.key.value).toBe(''); - expect(component.selectedFolderPath()).toBe('common.buttons'); - }); - - it('should absorb a dotted key pasted into a partially filled field', () => { - component.form.controls.key.setValue('ok'); - // What the DOM reports after pasting `apps.common.` before an existing `ok`. - component.form.controls.key.setValue('apps.common.ok'); - - expect(component.form.controls.key.value).toBe('ok'); - expect(component.selectedFolderPath()).toBe('apps.common'); - }); - - it('should leave an invalid segment in the field for the pattern validator', () => { - component.form.controls.key.setValue('apps.bad key.ok'); - - expect(component.form.controls.key.value).toBe('apps.bad key.ok'); - expect(component.form.controls.key.hasError('pattern')).toBe(true); - expect(component.selectedFolderPath()).toBe('common.buttons'); - }); - it('should submit the absorbed folder as part of the full key', async () => { mockBrowserApi.createResource.mockReturnValue(of({ entriesCreated: 1, created: true })); @@ -397,25 +313,6 @@ describe('TranslationEditorDialog', () => { it('should use empty string for folderPath when not provided', async () => { const dataWithoutFolder = createMockData('create'); dataWithoutFolder.folderPath = undefined; - dialogRef = { - close: vi.fn(), - afterOpened: vi.fn().mockReturnValue(of(undefined)), - keydownEvents: vi.fn().mockReturnValue(of()), - backdropClick: vi.fn().mockReturnValue(of()), - disableClose: false, - }; - mockDialog = { - open: vi.fn().mockReturnValue({ - afterClosed: () => of(true), - }), - }; - mockBrowserApi = { - createResource: vi.fn().mockReturnValue(of({})), - updateResource: vi.fn().mockReturnValue(of({})), - searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), - getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), - }; - mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; renderDialog(dataWithoutFolder); component.form.controls.key.setValue('test_key'); @@ -510,85 +407,6 @@ describe('TranslationEditorDialog', () => { }); }); - describe('Form Submission with Other Locales', () => { - it('should include filled translations in create mode with status "new"', async () => { - component.form.controls.key.setValue('test_key'); - component.form.controls.baseValue.setValue('Test Value'); - component.form.controls.comment.setValue('Test comment'); // Skip confirmation - - const translationsArray = component.form.controls.translations; - translationsArray.at(0).patchValue({ - locale: 'fr', - value: 'Valeur de test', - status: 'translated', - }); - - await component.onSubmit(); - - const result = dialogRef.close.mock.calls.at(-1)?.[0] as TranslationEditorResult; - expect(result.translations).toBeDefined(); - expect(result.translations?.length).toBe(1); - expect(result.translations?.[0].locale).toBe('fr'); - expect(result.translations?.[0].value).toBe('Valeur de test'); - expect(result.translations?.[0].status).toBe('new'); - }); - - it('should exclude empty translations from result', async () => { - component.form.controls.key.setValue('test_key'); - component.form.controls.baseValue.setValue('Test Value'); - component.form.controls.comment.setValue('Test comment'); // Skip confirmation - - await component.onSubmit(); - - const result = dialogRef.close.mock.calls.at(-1)?.[0] as TranslationEditorResult; - expect(result.translations).toBeUndefined(); - }); - - it('should respect status dropdown values in edit mode', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value', fr: 'Valeur existante' }, - status: { fr: 'translated' }, - }; - - const editData = createMockData('edit', mockResource); - dialogRef = { - close: vi.fn(), - afterOpened: vi.fn().mockReturnValue(of(undefined)), - keydownEvents: vi.fn().mockReturnValue(of()), - backdropClick: vi.fn().mockReturnValue(of()), - disableClose: false, - }; - mockDialog = { - open: vi.fn().mockReturnValue({ - afterClosed: () => of(true), - }), - }; - mockBrowserApi = { - createResource: vi.fn().mockReturnValue(of({})), - updateResource: vi.fn().mockReturnValue(of({})), - searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), - getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), - }; - mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - renderDialog(editData); - - const translationsArray = component.form.controls.translations; - const frControl = translationsArray.controls.find((c) => c.value.locale === 'fr'); - - frControl?.patchValue({ - status: 'verified', - }); - - await component.onSubmit(); - - const result = dialogRef.close.mock.calls.at(-1)?.[0] as TranslationEditorResult; - expect(result.translations).toBeDefined(); - const frTranslation = result.translations?.find((t) => t.locale === 'fr'); - expect(frTranslation?.status).toBe('verified'); - }); - }); - describe('Edit Mode', () => { it('should pre-populate form with resource data', async () => { const mockResource: ResourceSummaryDto = { @@ -678,29 +496,6 @@ describe('TranslationEditorDialog', () => { }); describe('Comment Confirmation Flow', () => { - beforeEach(async () => { - dialogRef = { - close: vi.fn(), - afterOpened: vi.fn().mockReturnValue(of(undefined)), - keydownEvents: vi.fn().mockReturnValue(of()), - backdropClick: vi.fn().mockReturnValue(of()), - disableClose: false, - }; - mockDialog = { - open: vi.fn().mockReturnValue({ - afterClosed: () => of(true), - }), - }; - mockBrowserApi = { - createResource: vi.fn().mockReturnValue(of({})), - updateResource: vi.fn().mockReturnValue(of({})), - searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), - getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), - }; - mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - renderDialog(createMockData('create')); - }); - it('should save directly when comment is present', async () => { component.form.controls.key.setValue('test_key'); component.form.controls.baseValue.setValue('Test Value'); @@ -964,25 +759,9 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - dialogRef = { - close: vi.fn(), - afterOpened: vi.fn().mockReturnValue(of(undefined)), - keydownEvents: vi.fn().mockReturnValue(of()), - backdropClick: vi.fn().mockReturnValue(of()), - disableClose: false, - }; - mockBrowserApi = { - createResource: vi.fn().mockReturnValue(of({})), - updateResource: vi - .fn() - .mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true, skippedLocales: ['es'] })), - searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), - getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), - }; - mockDialog = { - open: vi.fn().mockReturnValue({ afterClosed: () => of(true) }), - }; - mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; + mockBrowserApi.updateResource.mockReturnValue( + of({ resolvedKey: 'common.buttons.existing_key', updated: true, skippedLocales: ['es'] }), + ); renderDialog(editData); component.form.controls.baseValue.setValue('Updated Value'); @@ -1003,25 +782,9 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - dialogRef = { - close: vi.fn(), - afterOpened: vi.fn().mockReturnValue(of(undefined)), - keydownEvents: vi.fn().mockReturnValue(of()), - backdropClick: vi.fn().mockReturnValue(of()), - disableClose: false, - }; - mockBrowserApi = { - createResource: vi.fn().mockReturnValue(of({})), - updateResource: vi - .fn() - .mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true, skippedLocales: [] })), - searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), - getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), - }; - mockDialog = { - open: vi.fn().mockReturnValue({ afterClosed: () => of(true) }), - }; - mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; + mockBrowserApi.updateResource.mockReturnValue( + of({ resolvedKey: 'common.buttons.existing_key', updated: true, skippedLocales: [] }), + ); renderDialog(editData); component.form.controls.baseValue.setValue('Updated Value'); @@ -1162,18 +925,6 @@ describe('TranslationEditorDialog', () => { expect(component.folderSegments()).toEqual(['common', 'buttons']); }); - it('should mark the entry being created in the context tree', () => { - component.form.controls.key.setValue('ok'); - spectator.detectChanges(); - - const entry = component.contextTree().find((node) => node.kind === 'entry' && node.name === 'ok'); - expect(entry?.mark).toBe('new'); - }); - - it('should mark the target folder as the one the entry lands in', () => { - expect(component.contextTree().some((node) => node.here === true)).toBe(true); - }); - it('should highlight the row of the entry being created, not just pill it', () => { component.form.controls.key.setValue('ok'); spectator.detectChanges(); @@ -1193,10 +944,6 @@ describe('TranslationEditorDialog', () => { expect(rows[0]).not.toHaveClass('ftree-n--taken'); }); - it('should not claim a collision before a key is typed', () => { - expect(component.keyCollision()).toBe(false); - }); - it('should not repeat the full key, which the footer already carries', () => { expect(spectator.query('[data-testid="context-full-key"]')).toBeNull(); expect(spectator.query('[data-testid="footer-key"]')).not.toBeNull(); @@ -1256,25 +1003,6 @@ describe('TranslationEditorDialog', () => { expect(spectator.query('[data-testid="key-collision-error"]')).not.toBeNull(); }); - it('should ignore nested resources the browser folds into the folder listing', () => { - seedBrowserFolder('common.buttons', ['ok', 'confirm.dialog.title']); - - component.form.controls.key.setValue('confirm'); - spectator.detectChanges(); - - expect(component.keyCollision()).toBe(false); - expect(component.contextTree().some((node) => node.name === 'confirm.dialog.title')).toBe(false); - }); - - it('should compare keys exactly, so case alone is not a collision', () => { - seedBrowserFolder('common.buttons', ['ok']); - - component.form.controls.key.setValue('OK'); - spectator.detectChanges(); - - expect(component.keyCollision()).toBe(false); - }); - it('should detect a collision in a folder chosen from the popover', () => { mockBrowserApi.getResourceTree.mockImplementation((_collection: string, path: string) => of({ path, resources: path === 'common.errors' ? [entry('notFound')] : [], children: [] }), @@ -1320,15 +1048,6 @@ describe('TranslationEditorDialog', () => { expect(component.keyCollision()).toBe(true); }); - it('should never collide in edit mode, where the key is locked', () => { - renderDialog(createMockData('edit', entry('ok'))); - seedBrowserFolder('common.buttons', ['ok']); - spectator.detectChanges(); - - expect(component.keyCollision()).toBe(false); - expect(spectator.query('[data-testid="key-collision-error"]')).toBeNull(); - }); - it('should mark the colliding leaf as an existing entry in the context tree', () => { seedBrowserFolder('common.buttons', ['ok']); @@ -1641,25 +1360,7 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - dialogRef = { - close: vi.fn(), - afterOpened: vi.fn().mockReturnValue(of(undefined)), - keydownEvents: vi.fn().mockReturnValue(of()), - backdropClick: vi.fn().mockReturnValue(of()), - disableClose: false, - }; - mockBrowserApi = { - createResource: vi.fn().mockReturnValue(of({})), - updateResource: vi.fn().mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })), - searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), - getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), - }; - mockDialog = { - open: vi.fn().mockReturnValue({ - afterClosed: () => of(true), - }), - }; - mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; + mockBrowserApi.updateResource.mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })); renderDialog(editData); component.form.controls.baseValue.setValue('Updated Value'); @@ -1686,25 +1387,7 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - dialogRef = { - close: vi.fn(), - afterOpened: vi.fn().mockReturnValue(of(undefined)), - keydownEvents: vi.fn().mockReturnValue(of()), - backdropClick: vi.fn().mockReturnValue(of()), - disableClose: false, - }; - mockBrowserApi = { - createResource: vi.fn().mockReturnValue(of({})), - updateResource: vi.fn().mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })), - searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), - getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), - }; - mockDialog = { - open: vi.fn().mockReturnValue({ - afterClosed: () => of(true), - }), - }; - mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; + mockBrowserApi.updateResource.mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })); renderDialog(editData); const translationsArray = component.form.controls.translations; @@ -1731,34 +1414,16 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - dialogRef = { - close: vi.fn(), - afterOpened: vi.fn().mockReturnValue(of(undefined)), - keydownEvents: vi.fn().mockReturnValue(of()), - backdropClick: vi.fn().mockReturnValue(of()), - disableClose: false, - }; - mockBrowserApi = { - createResource: vi.fn().mockReturnValue(of({})), - updateResource: vi.fn().mockReturnValue( - throwError( - () => - new HttpErrorResponse({ - status: 404, - statusText: 'Not Found', - error: { message: 'Resource not found' }, - }), - ), + mockBrowserApi.updateResource.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 404, + statusText: 'Not Found', + error: { message: 'Resource not found' }, + }), ), - searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), - getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), - }; - mockDialog = { - open: vi.fn().mockReturnValue({ - afterClosed: () => of(true), - }), - }; - mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; + ); renderDialog(editData); await component.onSubmit(); @@ -1776,25 +1441,7 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - dialogRef = { - close: vi.fn(), - afterOpened: vi.fn().mockReturnValue(of(undefined)), - keydownEvents: vi.fn().mockReturnValue(of()), - backdropClick: vi.fn().mockReturnValue(of()), - disableClose: false, - }; - mockBrowserApi = { - createResource: vi.fn().mockReturnValue(of({})), - updateResource: vi.fn().mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })), - searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), - getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), - }; - mockDialog = { - open: vi.fn().mockReturnValue({ - afterClosed: () => of(true), - }), - }; - mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; + mockBrowserApi.updateResource.mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })); renderDialog(editData); component.form.controls.baseValue.setValue('Updated Value'); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts index 1c285b76..180c0558 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts @@ -25,21 +25,18 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import type { - CreateResourceDto, CreateResourceResponseDto, FolderNodeDto, ResourceSummaryDto, SearchResultDto, TranslationStatus, - UpdateResourceDto, UpdateResourceResponseDto, } from '@simoncodes-ca/data-transfer'; import { applyPreferredTerm, findPreferredTermFindings, - isValidSegment, - normalizeTag, type PreferredTermRule, + resolveResourceKey, } from '@simoncodes-ca/domain'; import { of, Subject } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil, tap } from 'rxjs/operators'; @@ -48,10 +45,29 @@ import { CollectionsStore } from '../../../collections/store/collections.store'; import { ConfirmationDialog } from '../../../shared/components/confirmation-dialog/confirmation-dialog'; import type { ConfirmationDialogData } from '../../../shared/components/confirmation-dialog/confirmation-dialog-data'; import { NotificationService } from '../../../shared/notification'; +import { segmentValidator } from '../../../shared/validators/segment.validator'; import { BrowserApiService } from '../../services/browser-api.service'; import { BrowserStore } from '../../store/browser.store'; +import { filterFolderTree } from '../../store/folder-tree.utils'; import { FolderPicker } from './folder-picker/folder-picker'; import { PreferredTermAdvisories } from './preferred-term-advisories/preferred-term-advisories'; +import { + absorbDottedKey, + addTag, + type ContextTreeNode, + collisionFor, + contextTree, + editedLocales, + folderEntryKeys, + hasUnsavedChanges, + type KnownEntries, + type LocaleDraft, + type OriginalEntry, + removeTag, + type ResourceEntryDraft, + toCreateDto, + toUpdateDto, +} from './resource-entry-draft'; import { SimilarTranslations } from './similar-translations'; import { filterSimilarByValue, SIMILAR_SEARCH_MAX_RESULTS } from './similar-value-filter'; @@ -79,38 +95,12 @@ export interface TranslationEditorDialogData { readOnly?: boolean; } -interface TranslationFormValue { - key: string; - baseValue: string; - comment: string; - translations: LocaleTranslation[]; -} - -interface LocaleTranslation { - locale: string; - value: string; - status: TranslationStatus; -} - -/** One row of the context column's "Where it lands" tree. */ -export interface ContextTreeNode { - kind: 'folder' | 'entry' | 'more'; - name: string; - path: string; - depth: number; - /** The folder the entry lands in. */ - here?: boolean; - expanded?: boolean; - /** The entry this dialog is writing, and what it is doing to it. */ - mark?: 'new' | 'editing' | 'exists'; -} - export interface TranslationEditorResult { key: string; baseValue: string; comment?: string; folderPath: string; - translations?: LocaleTranslation[]; + translations?: LocaleDraft[]; success?: boolean; shouldOpenEdit?: boolean; existingResourceKey?: string; @@ -144,9 +134,6 @@ export interface TranslationEditorResult { ], }) export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit { - /** How many sibling entries the context tree lists before it counts the rest. */ - private static readonly CONTEXT_TREE_ENTRY_LIMIT = 8; - private readonly dialogRef = inject(MatDialogRef); private readonly dialog = inject(MatDialog); private readonly browserApi = inject(BrowserApiService); @@ -175,9 +162,8 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit @ViewChild('drawerFirstControl') drawerFirstControl?: ElementRef; #commentConfirmationShown = false; - #originalBaseValue = ''; - #originalTags: string[] = []; - #originalFolderPath = ''; + /** The draft as the dialog opened, for the unsaved-work check and the similar search. */ + #initialDraft: ResourceEntryDraft | undefined; /** * The folder path this dialog last derived from a dotted key. Typing `a.` then * `b.` has to extend `a`, not re-anchor on `b`; a folder the user picked on the @@ -262,12 +248,12 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit }); readonly tagInputText = signal(''); - readonly tagsList = signal([]); + readonly tagsList = signal([]); readonly inheritedTagsList = computed(() => this.data.resource?.inheritedTags ?? []); readonly form = new FormGroup({ key: new FormControl('', { - validators: [Validators.required, Validators.pattern(/^[a-zA-Z0-9_-]+$/)], + validators: [Validators.required, segmentValidator], nonNullable: true, }), baseValue: new FormControl('', { @@ -323,25 +309,23 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit readonly formRevision = signal(0); /** - * Live "this key is already taken in the target folder" state. - * - * Matches the rule the writer uses: `addResource` resolves the key to a folder - * and a single entry key, then asks whether that entry key is already a - * property of the folder's `resource_entries.json` — an exact, case-sensitive - * string match. So does this. In edit mode the key is locked, so there is - * nothing to collide with; while a folder's entries are still loading nothing - * is claimed either way. + * The entry being edited, by its own key. Edit mode locks the key, so the + * draft module never lets it collide with itself and marks it `editing`. */ + readonly #ownKey = this.data.mode === 'edit' ? this.data.resource?.key : undefined; + + /** Everything the draft module needs to know which entries a folder holds. */ + readonly #knownEntries = computed(() => ({ + rootFolders: this.rootFolders(), + browserFolderPath: this.browserStore.currentFolderPath(), + browserEntries: this.browserStore.translations(), + fetched: this.#loadedFolderEntries(), + })); + + /** Live "this key is already taken in the target folder" state; see `collisionFor`. */ readonly keyCollision = computed(() => { this.formRevision(); - if (this.isEditMode()) { - return false; - } - const key = this.form.controls.key.value.trim(); - if (!key) { - return false; - } - return this.#folderEntryKeys(this.selectedFolderPath())?.has(key) === true; + return collisionFor(this.form.controls.key.value, this.selectedFolderPath(), this.#knownEntries(), this.#ownKey); }); /** Live form validity, for the footer's earned check glyph. */ @@ -387,10 +371,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit this.formRevision(); const folder = this.selectedFolderPath(); const key = this.form.controls.key.value.trim(); - if (!key) { - return folder; - } - return folder ? `${folder}.${key}` : key; + return key ? resolveResourceKey(key, folder) : folder; }); /** The base locale under a name a reader recognises ("English"), for the value label. */ @@ -404,7 +385,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit ); /** Every non-base locale with the value and status the form currently holds. */ - readonly localeSummaries = computed(() => { + readonly localeSummaries = computed(() => { this.formRevision(); return this.form.controls.translations.controls.map((group) => group.getRawValue()); }); @@ -413,7 +394,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit * The locales a reviewer still owes work on. The context column lists these * alone: a locale that is already translated or verified is not news. */ - readonly localesNeedingWork = computed(() => + readonly localesNeedingWork = computed(() => this.localeSummaries().filter((locale) => locale.status === 'new' || locale.status === 'stale'), ); @@ -474,77 +455,23 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit return parts.join(' · '); }); - /** - * The mini tree in "Where it lands": the target folder's siblings under their - * shared parent, with the target expanded over the entries it already holds and - * the entry being written marked. Entries are only known for folders the tree - * has loaded; an unloaded folder shows as a folder node and nothing more. - */ + /** The mini tree in "Where it lands"; see `contextTree` in the draft module. */ readonly contextTree = computed(() => { this.formRevision(); - const roots = this.rootFolders(); - const segments = this.folderSegments(); - const targetPath = this.selectedFolderPath(); - const nodes: ContextTreeNode[] = []; - - if (segments.length === 0) { - roots.forEach((folder) => { - nodes.push({ kind: 'folder', name: folder.name, path: folder.fullPath, depth: 0 }); - }); - nodes.push(...this.#entryNodes(targetPath, 0)); - return nodes; - } - - const parentPath = segments.slice(0, -1).join('.'); - const siblings = parentPath ? (this.#findFolder(roots, parentPath)?.tree?.children ?? []) : roots; - let depth = 0; - - if (parentPath) { - nodes.push({ - kind: 'folder', - name: segments[segments.length - 2], - path: parentPath, - depth: 0, - expanded: true, - }); - depth = 1; - } - - let placed = false; - for (const sibling of siblings) { - const here = sibling.fullPath === targetPath; - placed = placed || here; - nodes.push({ kind: 'folder', name: sibling.name, path: sibling.fullPath, depth, here, expanded: here }); - if (here) { - nodes.push(...this.#entryNodes(targetPath, depth + 1)); - } - } - - // The folder may not be in the tree yet — a path absorbed from a dotted key, - // or one the user has not expanded. It is still where the entry lands. - if (!placed) { - nodes.push({ - kind: 'folder', - name: segments[segments.length - 1], - path: targetPath, - depth, - here: true, - expanded: true, - }); - nodes.push(...this.#entryNodes(targetPath, depth + 1)); - } - - return nodes; + return contextTree( + { + folderPath: this.selectedFolderPath(), + key: this.form.controls.key.value, + known: this.#knownEntries(), + loadingFolders: this.#loadingFolders(), + ownKey: this.#ownKey, + }, + (count) => this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.MOREENTRIESX, { count }), + ); }); /** Root folders narrowed by the popover's filter, pruned to the matching subtrees. */ - readonly filteredRootFolders = computed(() => { - const filter = this.folderFilter().trim().toLowerCase(); - if (!filter) { - return this.rootFolders(); - } - return this.#filterFolders(this.rootFolders(), filter); - }); + readonly filteredRootFolders = computed(() => filterFolderTree(this.rootFolders(), this.folderFilter())); /** The folder the popover's primary button would commit. */ readonly popoverFolderPath = computed(() => this.stagedFolderPath() ?? this.selectedFolderPath()); @@ -589,13 +516,10 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit this.tagsList.set(this.data.resource.tags ?? []); - this.#originalBaseValue = baseValue; - this.#populateOtherLocaleTranslations(); } - this.#originalTags = [...this.tagsList()]; - this.#originalFolderPath = this.selectedFolderPath(); + this.#initialDraft = this.#draft(); this.#setupSimilarResourcesSearch(); this.#setupPreferredTermCheck(); @@ -646,20 +570,23 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit /** True when closing now would throw away work the user has done. */ hasUnsavedChanges(): boolean { - if (this.isReadOnly() || this.isSubmitting()) { + if (this.isReadOnly() || this.isSubmitting() || !this.#initialDraft) { return false; } + return hasUnsavedChanges(this.#draft(), this.#initialDraft, this.form.dirty); + } - if (this.form.dirty) { - return true; - } - - if (this.selectedFolderPath() !== this.#originalFolderPath) { - return true; - } + /** The form, the target folder and the tags as one plain draft. */ + #draft(): ResourceEntryDraft { + const { key, baseValue, comment, translations } = this.form.getRawValue(); + return { key, baseValue, comment, translations, folderPath: this.selectedFolderPath(), tags: this.tagsList() }; + } - const tags = this.tagsList(); - return tags.length !== this.#originalTags.length || tags.some((tag, i) => tag !== this.#originalTags[i]); + /** The entry an edit started from, or undefined in create mode. */ + #originalEntry(): OriginalEntry | undefined { + return this.isEditMode() && this.data.resource + ? { resource: this.data.resource, folderPath: this.data.folderPath || '' } + : undefined; } ngOnDestroy(): void { @@ -763,10 +690,11 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit ) .subscribe((searchResults) => { // Filter out current resource in edit mode - const withoutSelf = - this.isEditMode() && this.data.resource - ? searchResults.results.filter((r) => r.key !== this.#buildOriginalFullKey()) - : searchResults.results; + const original = this.#originalEntry(); + const ownFullKey = original ? resolveResourceKey(original.resource.key, original.folderPath) : undefined; + const withoutSelf = ownFullKey + ? searchResults.results.filter((r) => r.key !== ownFullKey) + : searchResults.results; // The API matches keys too, and reports a key match ahead of a value one. // Everything downstream — the count, the exact-duplicate caption, what @@ -811,49 +739,28 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit } /** - * The primary user arrives holding a full dotted key — `apps.common.buttons.ok` — - * and the key control only accepts a single segment. Rather than rejecting the - * one string they have, take the dotted prefix as the folder and keep the leaf. + * A dotted key typed into the single-segment key field moves its prefix into the + * location pill; `absorbDottedKey` holds the rule. * * Listening on `valueChanges` covers every way text arrives: typed, pasted, - * dropped, or completed by the browser. The pattern validator stays on as the + * dropped, or completed by the browser. The segment validator stays on as the * backstop for characters that are invalid in any position. */ #setupDottedKeyAbsorption(): void { this.form.controls.key.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { - this.#absorbDottedKey(value); - }); - } - - #absorbDottedKey(rawValue: string): void { - if (!rawValue.includes('.')) { - return; - } - - // Empty segments cover leading, trailing and consecutive dots in one pass; - // a trailing dot means the user has finished a folder but not started a leaf. - const segments = rawValue.split('.').filter((segment) => segment.length > 0); - const leaf = rawValue.endsWith('.') ? '' : (segments.pop() ?? ''); - - // Anything the pattern validator would reject is left in the field verbatim, - // so the error names the real problem instead of a silently mangled key. - if (segments.some((segment) => !isValidSegment(segment))) { - return; - } - - this.#setKeyControl(leaf); - - if (segments.length === 0) { - return; - } + const absorbed = absorbDottedKey(value, this.selectedFolderPath(), this.#folderFromKey); + if (!absorbed) { + return; + } - const prefix = segments.join('.'); - const isContinuation = this.#folderFromKey !== null && this.selectedFolderPath() === this.#folderFromKey; - const nextFolder = isContinuation ? `${this.#folderFromKey}.${prefix}` : prefix; + this.#setKeyControl(absorbed.leaf); - this.#folderFromKey = nextFolder; - this.#setSelectedFolder(nextFolder); - this.#announceLocationAbsorbed(nextFolder); + if (absorbed.folder !== undefined) { + this.#folderFromKey = absorbed.folder; + this.#setSelectedFolder(absorbed.folder); + this.#announceLocationAbsorbed(absorbed.folder); + } + }); } /** Writes the leaf back without re-entering the subscription that produced it. */ @@ -887,7 +794,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit } if (this.isEditMode()) { - return currentValue !== this.#originalBaseValue; + return currentValue !== this.#initialDraft?.baseValue; } return true; @@ -918,7 +825,11 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit * `selectFolder` would navigate the list behind the dialog. */ #ensureFolderEntries(folderPath: string): void { - if (this.isEditMode() || this.#folderEntryKeys(folderPath) || this.#loadingFolders().has(folderPath)) { + if ( + this.isEditMode() || + folderEntryKeys(folderPath, this.#knownEntries()) || + this.#loadingFolders().has(folderPath) + ) { return; } @@ -949,124 +860,6 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit this.formRevision.update((revision) => revision + 1); } - /** - * The entry keys known for a folder, or undefined when they are not known at - * all. Three sources, cheapest first: a folder already expanded in the tree, - * the folder the browser is showing, then anything this dialog fetched. - */ - #folderEntryKeys(folderPath: string): ReadonlySet | undefined { - const expanded = folderPath ? this.#findFolder(this.rootFolders(), folderPath)?.tree?.resources : undefined; - const known = - expanded ?? (this.browserStore.currentFolderPath() === folderPath ? this.browserStore.translations() : undefined); - - if (known) { - return this.#ownEntryKeys(known.map((resource) => resource.key)); - } - - const loaded = this.#loadedFolderEntries().get(folderPath); - return loaded ? this.#ownEntryKeys(loaded) : undefined; - } - - /** - * An entry key is a single segment. The browser lists a folder with its nested - * resources folded in, and those arrive under keys relative to the folder — - * `translationEditor.saveButton`, not `saveButton`. They live somewhere else, - * so they neither collide with this key nor belong in the folder's own row. - */ - #ownEntryKeys(keys: readonly string[]): ReadonlySet { - return new Set(keys.filter((key) => !key.includes('.'))); - } - - #findFolder(folders: FolderNodeDto[], path: string): FolderNodeDto | undefined { - for (const folder of folders) { - if (folder.fullPath === path) { - return folder; - } - if (path.startsWith(`${folder.fullPath}.`) && folder.tree?.children) { - const found = this.#findFolder(folder.tree.children, path); - if (found) { - return found; - } - } - } - return undefined; - } - - /** - * The entries already in a folder, plus the one this dialog is about to write. - * A folder can hold hundreds of keys and this is a glance, not a browser, so - * the list is a window around the entry being written; the rest is one count. - */ - #entryNodes(folderPath: string, depth: number): ContextTreeNode[] { - const key = this.form.controls.key.value.trim(); - const loaded = this.#folderEntryKeys(folderPath); - - // A folder still loading shows as a folder and nothing else: listing the new - // entry alone would claim the folder is empty before we know that it is. - if (!loaded && this.#loadingFolders().has(folderPath)) { - return []; - } - - const names = new Set(loaded ?? []); - const taken = key.length > 0 && names.has(key); - if (key) { - names.add(key); - } - - const sorted = [...names].sort((a, b) => a.localeCompare(b)); - const window = TranslationEditorDialog.CONTEXT_TREE_ENTRY_LIMIT; - let shown = sorted; - if (sorted.length > window) { - const anchor = key ? Math.max(0, sorted.indexOf(key)) : 0; - const start = Math.min(Math.max(0, anchor - Math.floor(window / 2)), sorted.length - window); - shown = sorted.slice(start, start + window); - } - - const nodes: ContextTreeNode[] = shown.map((name) => ({ - kind: 'entry' as const, - name, - path: folderPath ? `${folderPath}.${name}` : name, - depth, - mark: - name === key && key.length > 0 - ? this.isEditMode() - ? ('editing' as const) - : taken || this.keyCollision() - ? ('exists' as const) - : ('new' as const) - : undefined, - })); - - const hidden = sorted.length - shown.length; - if (hidden > 0) { - nodes.push({ - kind: 'more', - name: this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.MOREENTRIESX, { - count: hidden, - }), - path: `${folderPath}::more`, - depth, - }); - } - - return nodes; - } - - /** Keeps a folder when it or any loaded descendant matches, and prunes the rest. */ - #filterFolders(folders: FolderNodeDto[], filter: string): FolderNodeDto[] { - const kept: FolderNodeDto[] = []; - for (const folder of folders) { - const children = folder.tree?.children ? this.#filterFolders(folder.tree.children, filter) : []; - const selfMatches = folder.fullPath.toLowerCase().includes(filter); - if (selfMatches) { - kept.push(folder); - } else if (children.length > 0 && folder.tree) { - kept.push({ ...folder, tree: { ...folder.tree, children } }); - } - } - return kept; - } - // ── Location popover ────────────────────────────────────────────────────── toggleFolderPopover(): void { @@ -1242,10 +1035,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit } addTagValue(rawValue: string): void { - const normalized = normalizeTag(rawValue); - if (normalized && !this.tagsList().includes(normalized)) { - this.tagsList.update((tags) => [...tags, normalized]); - } + this.tagsList.update((tags) => addTag(tags, rawValue)); this.tagInputText.set(''); } @@ -1255,8 +1045,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit } removeTag(tag: string): void { - if (this.inheritedTagsList().includes(tag)) return; - this.tagsList.update((tags) => tags.filter((t) => t !== tag)); + this.tagsList.update((tags) => removeTag(tags, tag, this.inheritedTagsList())); } onTagInputChange(event: Event): void { @@ -1269,7 +1058,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit * other way out of the dialog. */ async openExistingResource(): Promise { - const existingKey = this.#buildFullKey(this.form.controls.key.value.trim()); + const existingKey = resolveResourceKey(this.form.controls.key.value.trim(), this.selectedFolderPath()); if (this.hasUnsavedChanges() && !(await this.#confirmDiscard())) { return; @@ -1344,14 +1133,11 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit // than refuse it. Stop before the network and offer the same two ways out // the save-time conflict offers, so both routes end in the same place. if (this.keyCollision()) { - this.#showKeyConflictDialog(this.#buildFullKey(this.form.controls.key.value.trim())); + this.#showKeyConflictDialog(resolveResourceKey(this.form.controls.key.value.trim(), this.selectedFolderPath())); return; } - const formValue = this.form.getRawValue() as TranslationFormValue; - const commentValue = formValue.comment.trim(); - - if (!commentValue && !this.#commentConfirmationShown) { + if (!this.form.controls.comment.value.trim() && !this.#commentConfirmationShown) { const shouldProceed = await this.#showCommentConfirmation(); if (!shouldProceed) { @@ -1359,10 +1145,11 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit } } + const draft = this.#draft(); if (this.isEditMode()) { - this.#handleEditSubmit(formValue, commentValue); + this.#handleEditSubmit(draft); } else { - this.#handleCreateSubmit(formValue, commentValue); + this.#handleCreateSubmit(draft); } } @@ -1386,8 +1173,9 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit }); } - #handleEditSubmit(formValue: TranslationFormValue, commentValue: string): void { - if (!this.data.resource) { + #handleEditSubmit(draft: ResourceEntryDraft): void { + const original = this.#originalEntry(); + if (!original) { this.errorMessage.set(this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.ERROR.MISSINGRESOURCE)); return; } @@ -1395,50 +1183,18 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit this.isSubmitting.set(true); this.errorMessage.set(null); - const originalKey = this.#buildOriginalFullKey(); - const newKey = formValue.key; - const newFolderPath = this.selectedFolderPath(); - const originalFolderPath = this.data.folderPath || ''; - - // The key control is readonly in edit mode (`html`), so `newKey` can only + // The key control is readonly in edit mode (`html`), so `draft.key` can only // ever equal the original; renaming is a move, handled by the CLI. - const hasFolderChanged = newFolderPath !== originalFolderPath; - - const filledTranslations = formValue.translations.filter((translation) => { - const hasValue = translation.value.trim().length > 0; - const originalStatus = this.data.resource?.status[translation.locale] ?? 'new'; - const hasStatusChange = translation.status !== originalStatus; - return hasValue || hasStatusChange; - }); - - const locales: Record = {}; - filledTranslations.forEach((translation) => { - locales[translation.locale] = { value: translation.value, status: translation.status }; - }); - - const updateDto: UpdateResourceDto = { - key: originalKey, - baseValue: formValue.baseValue, - comment: commentValue || undefined, - tags: this.tagsList(), - }; + const edited = editedLocales(draft, original.resource); - if (hasFolderChanged) { - updateDto.targetFolder = newFolderPath || undefined; - } - - if (Object.keys(locales).length > 0) { - updateDto.locales = locales; - } - - this.browserApi.updateResource(this.data.collectionName, updateDto).subscribe({ + this.browserStore.updateResource(this.data.collectionName, toUpdateDto(draft, original)).subscribe({ next: (response: UpdateResourceResponseDto) => { this.dialogRef.close({ - key: newKey, - baseValue: formValue.baseValue, - comment: commentValue || undefined, - folderPath: newFolderPath, - translations: filledTranslations.length > 0 ? filledTranslations : undefined, + key: draft.key, + baseValue: draft.baseValue, + comment: draft.comment.trim() || undefined, + folderPath: draft.folderPath, + translations: edited.length > 0 ? edited : undefined, success: true, resource: response.resource, skippedLocales: response.skippedLocales?.length ? response.skippedLocales : undefined, @@ -1451,68 +1207,31 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit }); } - #handleCreateSubmit(formValue: TranslationFormValue, commentValue: string): void { + #handleCreateSubmit(draft: ResourceEntryDraft): void { this.isSubmitting.set(true); this.errorMessage.set(null); - const fullKey = this.#buildFullKey(formValue.key); - - const filledTranslations = formValue.translations - .filter((translation) => translation.value.trim().length > 0) - .map((translation) => ({ - locale: translation.locale, - value: translation.value, - status: 'new' as TranslationStatus, - })); - - const createDto: CreateResourceDto = { - key: fullKey, - baseValue: formValue.baseValue, - comment: commentValue || undefined, - tags: this.tagsList().length > 0 ? this.tagsList() : undefined, - baseLocale: this.data.baseLocale, - translations: filledTranslations.length > 0 ? filledTranslations : undefined, - }; + const createDto = toCreateDto(draft, this.data.baseLocale); - this.browserApi.createResource(this.data.collectionName, createDto).subscribe({ + this.browserStore.createResource(this.data.collectionName, createDto).subscribe({ next: (response: CreateResourceResponseDto) => { this.dialogRef.close({ - key: formValue.key, - baseValue: formValue.baseValue, - comment: commentValue || undefined, - folderPath: this.selectedFolderPath(), - translations: filledTranslations.length > 0 ? filledTranslations : undefined, + key: draft.key, + baseValue: draft.baseValue, + comment: createDto.comment, + folderPath: draft.folderPath, + translations: createDto.translations, success: true, skippedLocales: response.skippedLocales?.length ? response.skippedLocales : undefined, }); }, error: (error: unknown) => { this.isSubmitting.set(false); - this.#handleCreateError(error, fullKey); + this.#handleCreateError(error, createDto.key); }, }); } - #buildFullKey(key: string): string { - const folderPath = this.selectedFolderPath(); - if (!folderPath) { - return key; - } - return `${folderPath}.${key}`; - } - - #buildOriginalFullKey(): string { - if (!this.data.resource) { - return ''; - } - const folderPath = this.data.folderPath || ''; - const key = this.data.resource.key; - if (!folderPath) { - return key; - } - return `${folderPath}.${key}`; - } - #handleCreateError(error: unknown, fullKey: string): void { if (error instanceof HttpErrorResponse) { if (error.status === 409) { diff --git a/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts b/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts index 09e117d1..7af71fd1 100644 --- a/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts +++ b/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts @@ -101,19 +101,23 @@ describe('TranslationEditorLauncher', () => { }); describe('openEditor', () => { - it('should update the cache under the store key and flash the row after a save', () => { - patchState(store, { translations: [{ ...resource, key: 'backButton' }] }); + const savedInto = (folderPath: string) => ({ + afterClosed: () => + of({ + key: 'backButton', + baseValue: 'Back', + folderPath, + success: true, + resource: { ...resource, translations: { en: 'Go back' } }, + }), + }); + + // The save itself, and the cache patch that follows it, belong to + // BrowserStore.updateResource; the launcher only reports on it. + it('should flash the row under its store key and confirm the save', () => { + patchState(store, { translations: [resource] }); const onUpdated = vi.fn(); - mockDialog.open.mockReturnValue({ - afterClosed: () => - of({ - key: 'backButton', - baseValue: 'Back', - folderPath: 'browser.header', - success: true, - resource: { ...resource, translations: { en: 'Go back' } }, - }), - }); + mockDialog.open.mockReturnValue(savedInto('browser.header')); launcher.openEditor({ resource, @@ -123,32 +127,24 @@ describe('TranslationEditorLauncher', () => { onUpdated, }); - expect(store.translations()[0].translations['en']).toBe('Go back'); expect(onUpdated).toHaveBeenCalledWith('backButton'); expect(notifications.success).toHaveBeenCalled(); + expect(store.translations()).toEqual([resource]); }); - it('should drop the entry from the cache when it was saved into another folder', () => { - patchState(store, { translations: [{ ...resource, key: 'backButton' }] }); - mockDialog.open.mockReturnValue({ - afterClosed: () => - of({ - key: 'backButton', - baseValue: 'Back', - folderPath: 'browser.footer', - success: true, - resource, - }), - }); + it('should stay quiet when the entry was saved into another folder', () => { + const onUpdated = vi.fn(); + mockDialog.open.mockReturnValue(savedInto('browser.footer')); launcher.openEditor({ resource, collectionName: 'test-collection', folderPath: 'browser.header', storeKey: 'backButton', + onUpdated, }); - expect(store.translations()).toEqual([]); + expect(onUpdated).not.toHaveBeenCalled(); expect(notifications.success).not.toHaveBeenCalled(); }); }); diff --git a/apps/tracker/src/app/browser/services/translation-editor-launcher.ts b/apps/tracker/src/app/browser/services/translation-editor-launcher.ts index ac78416b..70d1c9cd 100644 --- a/apps/tracker/src/app/browser/services/translation-editor-launcher.ts +++ b/apps/tracker/src/app/browser/services/translation-editor-launcher.ts @@ -22,15 +22,10 @@ export interface OpenEditorParams { /** Dot-delimited folder the entry lives in; '' for the collection root. */ folderPath: string; /** - * The key the browser store files this resource under — the list caches by the - * key it renders, which is relative in folder mode and full in search mode. + * The key the list renders this resource under — relative in folder mode, full + * in search mode — handed back through `onUpdated` for the row's flash. */ storeKey: string; - /** - * The key to drop from the cache when the entry moves out of `folderPath`. - * Defaults to the saved key, which is what a same-folder rename produces. - */ - originalKey?: string; /** Called with the store key after an in-place update, for the row's flash. */ onUpdated?: (storeKey: string) => void; } @@ -39,7 +34,7 @@ export interface OpenEditorParams { * Opens the translation editor in edit mode, from wherever the request came. * * The list's row menu and the create dialog's "Open existing" both need the same - * dialog with the same post-save bookkeeping, and they sit in different injector + * dialog with the same post-save feedback, and they sit in different injector * branches — the list store is component-scoped, the header is its sibling. The * launcher is the one place that knows the dialog's configuration, so neither * call site carries a copy of it. @@ -54,7 +49,7 @@ export class TranslationEditorLauncher { /** Opens the editor for a resource the caller already holds. */ openEditor(params: OpenEditorParams): void { - const { resource, collectionName, folderPath, storeKey, originalKey, onUpdated } = params; + const { resource, collectionName, folderPath, storeKey, onUpdated } = params; const dialogData: TranslationEditorDialogData = { mode: 'edit', @@ -80,17 +75,14 @@ export class TranslationEditorLauncher { restoreFocus: false, }); + // The save went through `BrowserStore.updateResource`, which has already + // brought the list in line; what is left here is telling the user. dialogRef.afterClosed().subscribe((result: TranslationEditorResult | undefined) => { if (!result?.success) return; if (!result.resource) return; + // Saved into another folder: the entry has left this list, so there is no row to flash. + if (result.folderPath !== folderPath) return; - const cacheKey = originalKey ?? result.key; - if (result.folderPath !== folderPath) { - this.#browserStore.removeResourceFromCache(cacheKey); - return; - } - - this.#browserStore.updateTranslationInCache({ ...result.resource, key: storeKey }); onUpdated?.(storeKey); this.#notifications.success(this.#transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.TRANSLATIONUPDATED)); diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/inline-folder-input/inline-folder-input.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/inline-folder-input/inline-folder-input.ts index 5b27b53f..087f98a7 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/inline-folder-input/inline-folder-input.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/inline-folder-input/inline-folder-input.ts @@ -6,6 +6,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatIconModule } from '@angular/material/icon'; import { TranslocoPipe } from '@jsverse/transloco'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; +import { segmentValidator } from '../../../../shared/validators/segment.validator'; /** * Inline input component for creating new folders in the folder tree. @@ -42,7 +43,7 @@ export class InlineFolderInput { /** Form control for folder name with validation */ readonly folderNameControl = new FormControl('', { nonNullable: true, - validators: [Validators.required, Validators.pattern(/^[A-Za-z0-9_-]+$/)], + validators: [Validators.required, segmentValidator], }); /** Track if confirm was already emitted to prevent blur from canceling */ diff --git a/apps/tracker/src/app/browser/store/browser.store.spec.ts b/apps/tracker/src/app/browser/store/browser.store.spec.ts index 9d331317..1266fb98 100644 --- a/apps/tracker/src/app/browser/store/browser.store.spec.ts +++ b/apps/tracker/src/app/browser/store/browser.store.spec.ts @@ -1362,134 +1362,4 @@ describe('BrowserStore', () => { expect(store.needsWorkCount()).toBe(0); }); }); - - describe('Resource Deletion', () => { - it('should remove resource from translations cache', async () => { - vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); - vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeRoot)); - - store.setSelectedCollection({ - collectionName: 'test', - locales: ['en', 'es'], - }); - - await waitForSignals(); - - expect(store.translations().length).toBe(1); - expect(store.translations()[0].key).toBe('welcome'); - - store.removeResourceFromCache('welcome'); - - expect(store.translations().length).toBe(0); - }); - - it('should remove resource from search results when in search mode', async () => { - vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); - vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeRoot)); - - store.setSelectedCollection({ - collectionName: 'test', - locales: ['en', 'es'], - }); - - await waitForSignals(); - - const mockSearchResults = [ - { - key: 'common.save', - translations: { en: 'Save', es: 'Guardar' }, - status: { es: 'verified' as const }, - matchType: 'partial-key' as const, - }, - { - key: 'common.cancel', - translations: { en: 'Cancel', es: 'Cancelar' }, - status: { es: 'verified' as const }, - matchType: 'partial-key' as const, - }, - ]; - - vi.spyOn(apiService, 'searchTranslations').mockReturnValue( - of({ - query: 'common', - results: mockSearchResults, - totalFound: 2, - limited: false, - }), - ); - - store.setSearchQuery('common'); - store.searchTranslations('common'); - - await waitForSignals(); - - expect(store.searchResults().length).toBe(2); - expect(store.isSearchMode()).toBe(true); - - store.removeResourceFromCache('common.save'); - - expect(store.searchResults().length).toBe(1); - expect(store.searchResults()[0].key).toBe('common.cancel'); - }); - - it('should not affect other resources when removing one', async () => { - const mockMultipleResources: ResourceTreeDto = { - path: '', - resources: [ - { - key: 'first', - translations: { en: 'First' }, - status: {}, - }, - { - key: 'second', - translations: { en: 'Second' }, - status: {}, - }, - { - key: 'third', - translations: { en: 'Third' }, - status: {}, - }, - ], - children: [], - }; - - vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); - vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockMultipleResources)); - - store.setSelectedCollection({ - collectionName: 'test', - locales: ['en', 'es'], - }); - - await waitForSignals(); - - expect(store.translations().length).toBe(3); - - store.removeResourceFromCache('second'); - - expect(store.translations().length).toBe(2); - expect(store.translations()[0].key).toBe('first'); - expect(store.translations()[1].key).toBe('third'); - }); - - it('should handle removal of non-existent resource gracefully', async () => { - vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); - vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeRoot)); - - store.setSelectedCollection({ - collectionName: 'test', - locales: ['en', 'es'], - }); - - await waitForSignals(); - - const initialCount = store.translations().length; - - store.removeResourceFromCache('nonexistent-key'); - - expect(store.translations().length).toBe(initialCount); - }); - }); }); diff --git a/apps/tracker/src/app/browser/store/browser.store.ts b/apps/tracker/src/app/browser/store/browser.store.ts index c03e0c17..6942e8fd 100644 --- a/apps/tracker/src/app/browser/store/browser.store.ts +++ b/apps/tracker/src/app/browser/store/browser.store.ts @@ -6,7 +6,6 @@ import { TranslocoService } from '@jsverse/transloco'; import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; import { NotificationService } from '../../shared/notification'; import { BrowserApiService } from '../services/browser-api.service'; -import { splitResolvedKey } from '../utils/folder-path.utils'; import { toErrorMessage } from './async-error.utils'; import { resolveCompactLocale } from './density-mode.utils'; import { withSearchFeature } from './features/with-search.feature'; @@ -15,7 +14,9 @@ import { withFilterFeature } from './features/with-filter.feature'; import { withViewPreferencesFeature } from './features/with-view-preferences.feature'; import { withTranslationsFeature } from './features/with-translations.feature'; import { withFolderTreeFeature } from './features/with-folder-tree.feature'; +import { withEntryWritesFeature } from './features/with-entry-writes.feature'; import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { splitResolvedKey } from '@simoncodes-ca/domain'; import type { DensityMode } from '../types/density-mode'; /** @@ -63,6 +64,7 @@ export const BrowserStore = signalStore( withSearchFeature(), withFilterFeature(), withTranslationsFeature(), + withEntryWritesFeature(), withFolderTreeFeature(), withCacheStatusFeature(), withViewPreferencesFeature(), diff --git a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts new file mode 100644 index 00000000..a46f90cd --- /dev/null +++ b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts @@ -0,0 +1,281 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { patchState } from '@ngrx/signals'; +import type { ResourceSummaryDto, SearchResultDto } from '@simoncodes-ca/data-transfer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; +import { BrowserStore } from '../browser.store'; +import { listKeyFor } from './with-entry-writes.feature'; + +const RESOURCES_URL = '/api/collections/my-collection/resources'; + +const entry = (key: string, en = key): ResourceSummaryDto => ({ key, translations: { en }, status: {} }); +const hit = (key: string, en = key): SearchResultDto => ({ ...entry(key, en), matchType: 'value' }); + +describe('listKeyFor', () => { + it.each<[string, string, string | undefined]>([ + ['common.save', '', 'common.save'], + ['common.save', 'common', 'save'], + ['common.dialog.title', 'common', 'dialog.title'], + ['commonly.save', 'common', undefined], + ['errors.save', 'common', undefined], + ])('%s in the list of "%s" is %s', (fullKey, listFolderPath, expected) => { + expect(listKeyFor(fullKey, listFolderPath)).toBe(expected); + }); +}); + +describe('BrowserStore entry writes', () => { + let store: InstanceType; + let http: HttpTestingController; + + /** The folder list as the browser shows `common` with nested resources folded in. */ + const folderMode = (): void => { + patchState(store, { + selectedCollection: 'my-collection', + currentFolderPath: 'common', + translations: [entry('save', 'Save'), entry('dialog.title', 'Title')], + }); + }; + + /** A search over the same collection, with the `common` folder list still behind it. */ + const searchMode = (): void => { + folderMode(); + patchState(store, { + isSearchMode: true, + searchQuery: 'sa', + searchResults: [hit('common.save', 'Save'), hit('errors.save', 'Save')], + }); + }; + + const englishOf = (items: readonly ResourceSummaryDto[], key: string): string | undefined => + items.find((item) => item.key === key)?.translations['en']; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [getTranslocoTestingModule()], + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + store = TestBed.inject(BrowserStore); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + http.verify(); + }); + + describe('createResource', () => { + it('should post the DTO and reload the current folder so the list shows the new entry', () => { + folderMode(); + const next = vi.fn(); + + store.createResource('my-collection', { key: 'common.ok', baseValue: 'OK' }).subscribe(next); + + const post = http.expectOne({ method: 'POST', url: RESOURCES_URL }); + expect(post.request.body).toEqual({ key: 'common.ok', baseValue: 'OK' }); + post.flush({ entriesCreated: 1, created: true }); + + const reload = http.expectOne((req) => req.url === `${RESOURCES_URL}/tree`); + expect(reload.request.params.get('path')).toBe('common'); + reload.flush({ path: 'common', resources: [entry('ok'), entry('save')], children: [] }); + + expect(next).toHaveBeenCalledWith({ entriesCreated: 1, created: true }); + expect(store.translations().map((item) => item.key)).toEqual(['ok', 'save']); + }); + + it('should cancel a folder load already in flight, so only the reload lands', () => { + folderMode(); + const treeRequests = () => http.match((req) => req.url === `${RESOURCES_URL}/tree`); + + store.selectFolder('common'); + store.createResource('my-collection', { key: 'common.ok', baseValue: 'OK' }).subscribe(); + http.expectOne({ method: 'POST', url: RESOURCES_URL }).flush({ entriesCreated: 1, created: true }); + + const [stale, reload] = treeRequests(); + expect(stale.cancelled).toBe(true); + reload.flush({ path: 'common', resources: [entry('ok'), entry('save')], children: [] }); + + expect(store.translations().map((item) => item.key)).toEqual(['ok', 'save']); + }); + + it('should hand a failure to the caller without reloading', () => { + folderMode(); + const error = vi.fn(); + + store.createResource('my-collection', { key: 'common.save', baseValue: 'Save' }).subscribe({ error }); + http + .expectOne({ method: 'POST', url: RESOURCES_URL }) + .flush({ message: 'exists' }, { status: 409, statusText: 'Conflict' }); + + expect(error).toHaveBeenCalledWith(expect.objectContaining({ status: 409 })); + http.expectNone((req) => req.url === `${RESOURCES_URL}/tree`); + }); + }); + + describe('updateResource', () => { + const update = (key: string, response: object, targetFolder?: string): void => { + store + .updateResource('my-collection', { key, baseValue: 'x', ...(targetFolder ? { targetFolder } : {}) }) + .subscribe(); + const patch = http.expectOne({ method: 'PATCH', url: RESOURCES_URL }); + expect(patch.request.body.key).toBe(key); + patch.flush(response); + }; + + it('should patch the folder list under the key relative to its folder', () => { + folderMode(); + + update('common.save', { resolvedKey: 'common.save', updated: true, resource: entry('save', 'Save now') }); + + expect(englishOf(store.translations(), 'save')).toBe('Save now'); + expect(store.translations().map((item) => item.key)).toEqual(['save', 'dialog.title']); + }); + + it('should keep the sub-path of a nested entry, which the API reports by its bare key', () => { + folderMode(); + + update('common.dialog.title', { + resolvedKey: 'common.dialog.title', + updated: true, + resource: entry('title', 'New'), + }); + + expect(englishOf(store.translations(), 'dialog.title')).toBe('New'); + }); + + it('should patch a search result under its full key and the folder list under its relative key', () => { + searchMode(); + + update('common.save', { resolvedKey: 'common.save', updated: true, resource: entry('save', 'Save now') }); + + const result = store.searchResults().find((item) => item.key === 'common.save'); + expect(result?.translations['en']).toBe('Save now'); + expect(result?.matchType).toBe('value'); + expect(englishOf(store.searchResults(), 'errors.save')).toBe('Save'); + expect(englishOf(store.translations(), 'save')).toBe('Save now'); + }); + + it('should patch a search result outside the folder list without touching the list', () => { + searchMode(); + const listBefore = store.translations(); + + update('errors.save', { resolvedKey: 'errors.save', updated: true, resource: entry('save', 'Retry') }); + + expect(englishOf(store.searchResults(), 'errors.save')).toBe('Retry'); + expect(store.translations()).toBe(listBefore); + }); + + it('should drop an entry sent to another folder from both caches', () => { + searchMode(); + + update('common.save', { resolvedKey: 'other.common.save', updated: true, resource: entry('save') }, 'other'); + + expect(store.translations().map((item) => item.key)).toEqual(['dialog.title']); + expect(store.searchResults().map((item) => item.key)).toEqual(['errors.save']); + }); + + it('should patch in place when the DTO carries no targetFolder, as a move to the root does', () => { + folderMode(); + + store.updateResource('my-collection', { key: 'common.save', baseValue: 'Save now' }).subscribe(); + const patch = http.expectOne({ method: 'PATCH', url: RESOURCES_URL }); + expect('targetFolder' in patch.request.body).toBe(false); + patch.flush({ resolvedKey: 'common.save', updated: true, resource: entry('save', 'Save now') }); + + expect(store.translations().map((item) => item.key)).toEqual(['save', 'dialog.title']); + expect(englishOf(store.translations(), 'save')).toBe('Save now'); + }); + + it('should leave the caches alone when the response carries no resource', () => { + folderMode(); + const before = store.translations(); + + update('common.save', { resolvedKey: 'common.save', updated: false }); + + expect(store.translations()).toBe(before); + }); + + it('should hand a failure to the caller and leave the caches alone', () => { + folderMode(); + const before = store.translations(); + const error = vi.fn(); + + store.updateResource('my-collection', { key: 'common.save', baseValue: 'x' }).subscribe({ error }); + http + .expectOne({ method: 'PATCH', url: RESOURCES_URL }) + .flush({ message: 'gone' }, { status: 404, statusText: 'Not Found' }); + + expect(error).toHaveBeenCalledWith(expect.objectContaining({ status: 404 })); + expect(store.translations()).toBe(before); + }); + }); + + describe('deleteResource', () => { + const remove = (fullKey: string, entriesDeleted: number): void => { + store.deleteResource('my-collection', fullKey).subscribe(); + const request = http.expectOne({ method: 'DELETE', url: RESOURCES_URL }); + expect(request.request.body).toEqual({ keys: [fullKey] }); + request.flush({ entriesDeleted }); + }; + + it('should drop the entry from the folder list under its relative key', () => { + folderMode(); + + remove('common.dialog.title', 1); + + expect(store.translations().map((item) => item.key)).toEqual(['save']); + }); + + it('should drop a search result under its full key, and the folder row with it', () => { + searchMode(); + + remove('common.save', 1); + + expect(store.searchResults().map((item) => item.key)).toEqual(['errors.save']); + expect(store.translations().map((item) => item.key)).toEqual(['dialog.title']); + }); + + it('should keep everything when the server deleted nothing', () => { + folderMode(); + + remove('common.save', 0); + + expect(store.translations().map((item) => item.key)).toEqual(['save', 'dialog.title']); + }); + + it('should ignore a key that is not cached', () => { + folderMode(); + + remove('common.missing', 1); + + expect(store.translations().map((item) => item.key)).toEqual(['save', 'dialog.title']); + }); + }); + + describe('translateResource', () => { + const translate = (fullKey: string, resource: ResourceSummaryDto): void => { + store.translateResource('my-collection', fullKey).subscribe(); + const request = http.expectOne({ method: 'POST', url: `${RESOURCES_URL}/translate` }); + expect(request.request.body).toEqual({ key: fullKey }); + request.flush({ resource, translatedCount: 1, skippedLocales: [] }); + }; + + it('should patch the folder list, rewriting the bare API key to the relative one', () => { + folderMode(); + + translate('common.dialog.title', { key: 'title', translations: { en: 'Title', fr: 'Titre' }, status: {} }); + + const row = store.translations().find((item) => item.key === 'dialog.title'); + expect(row?.translations['fr']).toBe('Titre'); + }); + + it('should patch a search result under its full key', () => { + searchMode(); + + translate('common.save', { key: 'save', translations: { en: 'Save', fr: 'Enregistrer' }, status: {} }); + + expect(store.searchResults().find((item) => item.key === 'common.save')?.translations['fr']).toBe('Enregistrer'); + expect(store.translations().find((item) => item.key === 'save')?.translations['fr']).toBe('Enregistrer'); + }); + }); +}); diff --git a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts new file mode 100644 index 00000000..afb45d3f --- /dev/null +++ b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts @@ -0,0 +1,131 @@ +import { inject } from '@angular/core'; +import { patchState, signalStoreFeature, type, withMethods } from '@ngrx/signals'; +import type { + CreateResourceDto, + CreateResourceResponseDto, + DeleteResourceResponseDto, + ResourceSummaryDto, + SearchResultDto, + TranslateResourceResponseDto, + UpdateResourceDto, + UpdateResourceResponseDto, +} from '@simoncodes-ca/data-transfer'; +import { type Observable, tap } from 'rxjs'; +import { BrowserApiService } from '../../services/browser-api.service'; + +/** + * The key the folder list files an entry under: relative to the folder the list + * shows (nested entries keep their sub-path), or undefined when the entry lies + * outside that folder and so cannot be in the list. + */ +export function listKeyFor(fullKey: string, listFolderPath: string): string | undefined { + if (!listFolderPath) { + return fullKey; + } + const prefix = `${listFolderPath}.`; + return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : undefined; +} + +/** + * How a Resource entry is written from the UI. + * + * Every method takes the entry's full dot-delimited key (or a DTO carrying it) + * and returns the API call, so the caller still owns its own error handling — + * the editor's 409 conflict dialog, a failure toast. On success the store brings + * its caches in line before the caller hears back. + * + * The caches are keyed by what they render: the folder list by the key relative + * to its folder, search results by the full key. Callers never rewrite keys; + * that happens here, once. + */ +export function withEntryWritesFeature<_>() { + return signalStoreFeature( + { + state: type<{ + currentFolderPath: string; + isSearchMode: boolean; + translations: ResourceSummaryDto[]; + searchResults: SearchResultDto[]; + }>(), + // Provided by withTranslationsFeature, which composes before this feature. + methods: type<{ selectFolder(path: string): void }>(), + }, + withMethods((store) => { + const api = inject(BrowserApiService); + + /** Replaces the cached entry with what the server now holds, in both caches. */ + function patchEntry(fullKey: string, resource: ResourceSummaryDto): void { + const listKey = listKeyFor(fullKey, store.currentFolderPath()); + if (listKey !== undefined) { + patchState(store, { + translations: store + .translations() + .map((entry) => (entry.key === listKey ? { ...resource, key: listKey } : entry)), + }); + } + + if (store.isSearchMode()) { + patchState(store, { + searchResults: store + .searchResults() + .map((result) => (result.key === fullKey ? { ...result, ...resource, key: fullKey } : result)), + }); + } + } + + /** Drops an entry that no longer lives where the caches show it. */ + function dropEntry(fullKey: string): void { + const listKey = listKeyFor(fullKey, store.currentFolderPath()); + if (listKey !== undefined) { + patchState(store, { translations: store.translations().filter((entry) => entry.key !== listKey) }); + } + + if (store.isSearchMode()) { + patchState(store, { searchResults: store.searchResults().filter((result) => result.key !== fullKey) }); + } + } + + return { + /** Creates an entry, then reloads the current folder so the list shows it in place. */ + createResource(collectionName: string, dto: CreateResourceDto): Observable { + return api.createResource(collectionName, dto).pipe(tap(() => store.selectFolder(store.currentFolderPath()))); + }, + + /** + * Updates the entry `dto.key` names. A DTO with a `targetFolder` moves the + * entry, so it leaves the caches. A DTO without one, including a move to + * the collection root (see `toUpdateDto`), is patched in place. + */ + updateResource(collectionName: string, dto: UpdateResourceDto): Observable { + return api.updateResource(collectionName, dto).pipe( + tap((response) => { + if (dto.targetFolder !== undefined) { + dropEntry(dto.key); + } else if (response.resource) { + patchEntry(dto.key, response.resource); + } + }), + ); + }, + + /** Deletes one entry and drops it from the caches once the server confirms it. */ + deleteResource(collectionName: string, fullKey: string): Observable { + return api.deleteResource(collectionName, [fullKey]).pipe( + tap((response) => { + if (response.entriesDeleted > 0) { + dropEntry(fullKey); + } + }), + ); + }, + + /** Auto-translates one entry and patches the caches with the result. */ + translateResource(collectionName: string, fullKey: string): Observable { + return api + .translateResource(collectionName, fullKey) + .pipe(tap((response) => patchEntry(fullKey, response.resource))); + }, + }; + }), + ); +} diff --git a/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts b/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts index aaa20573..75cbdd0b 100644 --- a/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts @@ -11,6 +11,7 @@ import { insertFolderIntoTree, removeFolderFromTree, findFolderInTree, + filterFolderTree, rebaseFolderPaths, collectExpandablePaths, collectAncestorPaths, @@ -70,30 +71,7 @@ export function withFolderTreeFeature<_>() { withState(initialFolderTreeState), withComputed( ({ rootFolders, folderTreeFilter, currentFolderPath, isFolderTreeLoading, isTranslationsLoading }) => ({ - filteredFolders: computed(() => { - const filter = folderTreeFilter().toLowerCase().trim(); - if (!filter) return rootFolders(); - - const matchesFilter = (folder: FolderNodeDto): boolean => - folder.name.toLowerCase().includes(filter) || folder.fullPath.toLowerCase().includes(filter); - - const filterTree = (folders: FolderNodeDto[]): FolderNodeDto[] => - folders.reduce((acc, folder) => { - const folderMatches = matchesFilter(folder); - const childrenMatch = folder.tree?.children ? filterTree(folder.tree.children) : []; - - if (folderMatches || childrenMatch.length > 0) { - acc.push({ - ...folder, - tree: folder.tree ? { ...folder.tree, children: childrenMatch } : undefined, - }); - } - - return acc; - }, []); - - return filterTree(rootFolders()); - }), + filteredFolders: computed(() => filterFolderTree(rootFolders(), folderTreeFilter())), breadcrumbs: computed(() => { const path = currentFolderPath(); diff --git a/apps/tracker/src/app/browser/store/features/with-translations.feature.ts b/apps/tracker/src/app/browser/store/features/with-translations.feature.ts index 98a48715..b2b3baa6 100644 --- a/apps/tracker/src/app/browser/store/features/with-translations.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-translations.feature.ts @@ -190,39 +190,6 @@ export function withTranslationsFeature<_>() { patchState(store, { showNestedResources: value }); this.selectFolder(store.currentFolderPath()); }, - - removeResourceFromCache(resourceKey: string): void { - const updatedTranslations = store.translations().filter((resource) => resource.key !== resourceKey); - patchState(store, { translations: updatedTranslations }); - - if (store.isSearchMode()) { - const updatedSearchResults = store.searchResults().filter((resource) => resource.key !== resourceKey); - patchState(store, { searchResults: updatedSearchResults }); - } - }, - - updateTranslationInCache(resource: ResourceSummaryDto): void { - const currentTranslations = store.translations(); - const translationIndex = currentTranslations.findIndex((t) => t.key === resource.key); - if (translationIndex !== -1) { - const updatedTranslations = [...currentTranslations]; - updatedTranslations[translationIndex] = resource; - patchState(store, { translations: updatedTranslations }); - } - - if (store.isSearchMode()) { - const currentSearchResults = store.searchResults(); - const searchIndex = currentSearchResults.findIndex((t) => t.key === resource.key); - if (searchIndex !== -1) { - const updatedSearchResults = [...currentSearchResults]; - updatedSearchResults[searchIndex] = { - ...currentSearchResults[searchIndex], - ...resource, - }; - patchState(store, { searchResults: updatedSearchResults }); - } - } - }, }; }), ); diff --git a/apps/tracker/src/app/browser/store/folder-tree.utils.spec.ts b/apps/tracker/src/app/browser/store/folder-tree.utils.spec.ts index 80a14a01..e2307637 100644 --- a/apps/tracker/src/app/browser/store/folder-tree.utils.spec.ts +++ b/apps/tracker/src/app/browser/store/folder-tree.utils.spec.ts @@ -3,6 +3,7 @@ import { insertFolderIntoTree, removeFolderFromTree, findFolderInTree, + filterFolderTree, rebaseFolderPaths, collectExpandablePaths, collectAncestorPaths, @@ -307,3 +308,37 @@ describe('rebaseExpandedPaths', () => { expect([...rebaseExpandedPaths(paths, 'apps', '')]).toEqual(['apps', 'apps.common']); }); }); + +describe('filterFolderTree', () => { + const buttons = leaf('buttons', 'common.buttons'); + const errors = leaf('errors', 'common.errors'); + const common = withChildren(leaf('common', 'common'), [buttons, errors]); + const http = leaf('http', 'errors.http'); + const rootErrors = withChildren(leaf('errors', 'errors'), [http]); + const tree = [common, rootErrors]; + + it('should return the tree itself for an empty or blank filter', () => { + expect(filterFolderTree(tree, '')).toBe(tree); + expect(filterFolderTree(tree, ' ')).toBe(tree); + }); + + it('should keep a matching folder whole, descendants included', () => { + expect(filterFolderTree(tree, 'common')).toEqual([common]); + }); + + it('should keep an unmatched ancestor pruned to its matching children', () => { + expect(filterFolderTree(tree, 'buttons')).toEqual([withChildren(leaf('common', 'common'), [buttons])]); + }); + + it('should match on the full path, trimmed and case-insensitive', () => { + expect(filterFolderTree(tree, ' COMMON.ERR ')).toEqual([withChildren(leaf('common', 'common'), [errors])]); + }); + + it('should keep every branch that holds a match', () => { + expect(filterFolderTree(tree, 'errors')).toEqual([withChildren(leaf('common', 'common'), [errors]), rootErrors]); + }); + + it('should return nothing when no folder matches', () => { + expect(filterFolderTree(tree, 'missing')).toEqual([]); + }); +}); diff --git a/apps/tracker/src/app/browser/store/folder-tree.utils.ts b/apps/tracker/src/app/browser/store/folder-tree.utils.ts index e925b943..d3733023 100644 --- a/apps/tracker/src/app/browser/store/folder-tree.utils.ts +++ b/apps/tracker/src/app/browser/store/folder-tree.utils.ts @@ -68,7 +68,7 @@ export function removeFolderFromTree(folders: FolderNodeDto[], pathToRemove: str * Finds a folder node in the tree by its full path. * Returns the folder node or undefined if not found. */ -export function findFolderInTree(folders: FolderNodeDto[], fullPath: string): FolderNodeDto | undefined { +export function findFolderInTree(folders: readonly FolderNodeDto[], fullPath: string): FolderNodeDto | undefined { for (const folder of folders) { if (folder.fullPath === fullPath) return folder; if (folder.tree?.children) { @@ -79,6 +79,28 @@ export function findFolderInTree(folders: FolderNodeDto[], fullPath: string): Fo return undefined; } +/** + * Narrows a folder tree to the folders whose path contains `filter` (trimmed, + * case-insensitive), keeping an unmatched folder only as the ancestor of a match. + * + * A matching folder is kept whole: its descendants' paths start with its own, so + * they match too. An unmatched ancestor is copied with its children pruned. An + * empty filter returns the tree as it is. + */ +export function filterFolderTree(folders: FolderNodeDto[], filter: string): FolderNodeDto[] { + const needle = filter.trim().toLowerCase(); + if (!needle) return folders; + + const prune = (nodes: readonly FolderNodeDto[]): FolderNodeDto[] => + nodes.flatMap((folder) => { + if (folder.fullPath.toLowerCase().includes(needle)) return [folder]; + const children = folder.tree ? prune(folder.tree.children) : []; + return folder.tree && children.length > 0 ? [{ ...folder, tree: { ...folder.tree, children } }] : []; + }); + + return prune(folders); +} + /** * Creates a deep copy of a folder with all paths updated to reflect a new parent location. * For example, moving folder "common" (fullPath "common") into "apps" updates: diff --git a/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts b/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts index 14c7eeac..d3e26afa 100644 --- a/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts +++ b/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts @@ -198,7 +198,9 @@ describe('TranslationMainHeader', () => { expect(notificationsSpy.warning).not.toHaveBeenCalled(); }); - it('should reload the current folder before showing snackbars', async () => { + // BrowserStore.createResource reloads the folder as the save succeeds + // (with-entry-writes.feature.spec.ts), so the header must not do it twice. + it('should leave reloading the folder to the store', async () => { const store = spectator.inject(BrowserStore); const selectFolderSpy = vi.spyOn(store, 'selectFolder'); @@ -214,7 +216,8 @@ describe('TranslationMainHeader', () => { component.handleAddTranslation(); await vi.advanceTimersByTimeAsync(3200); - expect(selectFolderSpy).toHaveBeenCalled(); + expect(selectFolderSpy).not.toHaveBeenCalled(); + expect(notificationsSpy.success).toHaveBeenCalled(); }); }); diff --git a/apps/tracker/src/app/browser/translations/header/translation-main-header.ts b/apps/tracker/src/app/browser/translations/header/translation-main-header.ts index 83ad6774..a074407f 100644 --- a/apps/tracker/src/app/browser/translations/header/translation-main-header.ts +++ b/apps/tracker/src/app/browser/translations/header/translation-main-header.ts @@ -120,9 +120,9 @@ export class TranslationMainHeader { return; } + // `BrowserStore.createResource` has already reloaded the folder. if (!result?.success) return; - this.store.selectFolder(this.store.currentFolderPath()); this.#notifications.success(this.#transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.RESOURCECREATED)); if (result.skippedLocales?.length) { diff --git a/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts b/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts index da65457c..ac83cd9f 100644 --- a/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts +++ b/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts @@ -4,7 +4,6 @@ import { signalStoreFeature, type, withMethods } from '@ngrx/signals'; import { MatDialog } from '@angular/material/dialog'; import { TranslocoService } from '@jsverse/transloco'; import { NotificationService } from '../../../../shared/notification'; -import { BrowserApiService } from '../../../services/browser-api.service'; import { BrowserStore } from '../../../store/browser.store'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { TranslationEditorLauncher } from '../../../services/translation-editor-launcher'; @@ -24,7 +23,6 @@ export function withItemActions() { }>(), }, withMethods((store) => { - const api = inject(BrowserApiService); const browserStore = inject(BrowserStore); const dialog = inject(MatDialog); const launcher = inject(TranslationEditorLauncher); @@ -61,7 +59,6 @@ export function withItemActions() { collectionName, folderPath, storeKey: translation.key, - originalKey: browserStore.isSearchMode() ? translation.key : undefined, onUpdated: (key) => store.flashRecentlyUpdated(key), }); }, @@ -107,14 +104,12 @@ export function withItemActions() { .pipe(takeUntilDestroyed(destroyRef)) .subscribe((confirmed: boolean | undefined) => { if (!confirmed) return; - api - .deleteResource(collectionName, [fullKey]) + browserStore + .deleteResource(collectionName, fullKey) .pipe(takeUntilDestroyed(destroyRef)) .subscribe({ next: (response) => { if (response.entriesDeleted > 0) { - // Cache is indexed by the relative key, not the full key used for the API call. - browserStore.removeResourceFromCache(translation.key); notifications.success(transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.RESOURCEDELETED)); } else { notifications.error(transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.DELETEFAILED)); @@ -139,15 +134,12 @@ export function withItemActions() { ); store.addTranslatingKey(translation.key); - api + browserStore .translateResource(collectionName, fullKey) .pipe(takeUntilDestroyed(destroyRef)) .subscribe({ next: (response: TranslateResourceResponseDto) => { store.removeTranslatingKey(translation.key); - // Cache uses the relative key; rewrite from the bare API key before updating. - const storeResource = { ...response.resource, key: translation.key }; - browserStore.updateTranslationInCache(storeResource); store.flashRecentlyUpdated(translation.key); const { translatedCount, skippedLocales } = response; diff --git a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts index e5e5cc06..c3547dea 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts @@ -4,6 +4,7 @@ import type { ComponentFixture } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { TranslocoService } from '@jsverse/transloco'; import { createComponentFactory } from '@ngneat/spectator/vitest'; +import { patchState } from '@ngrx/signals'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; import { of, throwError } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -392,10 +393,7 @@ describe('TranslationList - skippedLocales warning snackbar', () => { expect(notificationsSpy.error).not.toHaveBeenCalled(); }); - it('should update the store cache when the edit result contains skippedLocales', () => { - const store = fixture.debugElement.injector.get(BrowserStore); - const updateCacheSpy = vi.spyOn(store, 'updateTranslationInCache'); - + it('should flash the edited row when the edit result contains skippedLocales', () => { const result: TranslationEditorResult = { key: 'common.test', baseValue: 'Test Value', @@ -409,7 +407,7 @@ describe('TranslationList - skippedLocales warning snackbar', () => { const listStore = fixture.debugElement.injector.get(TranslationListStore); listStore.editTranslation(mockResource, 'test-collection'); - expect(updateCacheSpy).toHaveBeenCalledWith({ ...mockResource, key: mockResource.key }); + expect(listStore.recentlyUpdatedKey()).toBe(mockResource.key); }); }); @@ -437,9 +435,10 @@ describe('TranslationList - handleEdit key rewrite', () => { fixture.detectChanges(); }); - it('should rewrite key to the store key when calling updateTranslationInCache on edit success', () => { + // The cache itself is patched by BrowserStore.updateResource (see + // with-entry-writes.feature.spec.ts); the list only has to flash the right row. + it('should flash the row under the key the list renders, not the bare API key', () => { const store = fixture.debugElement.injector.get(BrowserStore); - const updateCacheSpy = vi.spyOn(store, 'updateTranslationInCache'); // Activate search mode so the store key contains the full path ("buttons.save") // while the API returns only the bare entry key ("save"). @@ -474,9 +473,7 @@ describe('TranslationList - handleEdit key rewrite', () => { const listStore = fixture.debugElement.injector.get(TranslationListStore); listStore.editTranslation(storeResource, 'test-collection'); - // updateTranslationInCache must be called with the full-path key that the - // store uses ("buttons.save"), not the bare API key ("save"). - expect(updateCacheSpy).toHaveBeenCalledWith({ ...apiResource, key: storeResource.key }); + expect(listStore.recentlyUpdatedKey()).toBe(storeResource.key); }); }); @@ -556,14 +553,13 @@ describe('TranslationList - deleteTranslation', () => { it('should call API and show success notification when dialog is confirmed', () => { mockDialogRef.afterClosed.mockReturnValue(of(true)); mockBrowserApi.deleteResource.mockReturnValue(of({ entriesDeleted: 1 })); - - const removeFromCacheSpy = vi.spyOn(store, 'removeResourceFromCache'); + patchState(store, { translations: [mockResource] }); const listStore = fixture.debugElement.injector.get(TranslationListStore); listStore.deleteTranslation(mockResource, 'my-collection'); expect(mockBrowserApi.deleteResource).toHaveBeenCalledWith('my-collection', ['button.delete']); - expect(removeFromCacheSpy).toHaveBeenCalledWith('button.delete'); + expect(store.translations()).toEqual([]); expect(notificationsSpy.success).toHaveBeenCalled(); expect(notificationsSpy.error).not.toHaveBeenCalled(); }); @@ -571,13 +567,12 @@ describe('TranslationList - deleteTranslation', () => { it('should show error notification when API throws', () => { mockDialogRef.afterClosed.mockReturnValue(of(true)); mockBrowserApi.deleteResource.mockReturnValue(throwError(() => new Error('Network failure'))); - - const removeFromCacheSpy = vi.spyOn(store, 'removeResourceFromCache'); + patchState(store, { translations: [mockResource] }); const listStore = fixture.debugElement.injector.get(TranslationListStore); listStore.deleteTranslation(mockResource, 'my-collection'); - expect(removeFromCacheSpy).not.toHaveBeenCalled(); + expect(store.translations()).toEqual([mockResource]); expect(notificationsSpy.error).toHaveBeenCalledWith('Network failure'); }); @@ -611,8 +606,7 @@ describe('TranslationList - handleTranslate', () => { }; // The API returns only the bare entry key ("save"), not the relative-path key - // ("button.save") that the store uses. The store must rewrite it before - // passing the resource to updateTranslationInCache. + // ("button.save") the list renders. BrowserStore rewrites it when it patches. const mockUpdatedResource: ResourceSummaryDto = { key: 'save', translations: { en: 'Save', fr: 'Enregistrer' }, @@ -642,7 +636,7 @@ describe('TranslationList - handleTranslate', () => { vi.useRealTimers(); }); - it('should add the key to translatingKeys during the request and call store.updateTranslationInCache on success', () => { + it('should add the key to translatingKeys during the request and patch the store on success', () => { vi.useFakeTimers(); mockBrowserApi.translateResource.mockReturnValue( @@ -653,7 +647,7 @@ describe('TranslationList - handleTranslate', () => { }), ); - const updateCacheSpy = vi.spyOn(store, 'updateTranslationInCache'); + patchState(store, { translations: [mockResource] }); const listStore = fixture.debugElement.injector.get(TranslationListStore); listStore.translateResource(mockResource, 'my-collection'); @@ -663,7 +657,7 @@ describe('TranslationList - handleTranslate', () => { // Store was updated with the key rewritten from the bare API key ("save") // back to the relative-path key that the store indexes by ("button.save"). - expect(updateCacheSpy).toHaveBeenCalledWith({ ...mockUpdatedResource, key: mockResource.key }); + expect(store.translations()).toEqual([{ ...mockUpdatedResource, key: mockResource.key }]); // Success notification shown expect(notificationsSpy.success).toHaveBeenCalledWith('1 locale translated successfully'); diff --git a/apps/tracker/src/app/browser/utils/folder-path.utils.ts b/apps/tracker/src/app/browser/utils/folder-path.utils.ts index a1e0390f..09539438 100644 --- a/apps/tracker/src/app/browser/utils/folder-path.utils.ts +++ b/apps/tracker/src/app/browser/utils/folder-path.utils.ts @@ -15,19 +15,3 @@ export function extractParentFolderPath(folderPath: string): string { const parts = folderPath.split('.'); return parts.length > 1 ? parts.slice(0, -1).join('.') : ''; } - -/** - * Splits a resolved resource key into its components. - * For example: "apps.common.buttons.cancel" -> - * { segments: ['apps','common','buttons','cancel'], folderPath: ['apps','common','buttons'], entryKey: 'cancel' } - */ -export function splitResolvedKey(resolvedKey: string): { - segments: string[]; - folderPath: string[]; - entryKey: string; -} { - const segments = resolvedKey.split('.'); - const entryKey = segments[segments.length - 1]; - const folderPath = segments.slice(0, -1); - return { segments, folderPath, entryKey }; -} diff --git a/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.ts b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.ts index 26a62d9d..cd39d2a0 100644 --- a/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.ts +++ b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.ts @@ -29,6 +29,7 @@ import type { TokenCasingDto, } from '@simoncodes-ca/data-transfer'; import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; +import { segmentValidator } from '../../shared/validators/segment.validator'; import { CollectionsApiService } from '../services/collections-api.service'; import { CollectionsStore } from '../store/collections.store'; import type { BundleFormDialogData, BundleFormResult } from './bundle-form-dialog-data'; @@ -74,7 +75,6 @@ export interface PreviewFile { export type PreviewStatus = 'waiting' | 'loading' | 'ready' | 'error'; const LOCALE_PLACEHOLDER = '{locale}'; -const NAME_PATTERN = /^[A-Za-z0-9_-]+$/; const escapeHtml = (value: string): string => value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); @@ -146,7 +146,7 @@ export class BundleFormDialog { readonly form = new FormGroup({ name: new FormControl('', { nonNullable: true, - validators: [Validators.required, Validators.pattern(NAME_PATTERN), this.#uniqueNameValidator()], + validators: [Validators.required, segmentValidator, this.#uniqueNameValidator()], }), dist: new FormControl('', { nonNullable: true, validators: [Validators.required] }), bundleName: new FormControl('', { diff --git a/apps/tracker/src/app/shared/validators/segment.validator.spec.ts b/apps/tracker/src/app/shared/validators/segment.validator.spec.ts new file mode 100644 index 00000000..18859fbe --- /dev/null +++ b/apps/tracker/src/app/shared/validators/segment.validator.spec.ts @@ -0,0 +1,23 @@ +import { FormControl } from '@angular/forms'; +import { describe, expect, it } from 'vitest'; +import { segmentValidator } from './segment.validator'; + +describe('segmentValidator', () => { + it.each(['test123', 'test_key_name', 'test-key-name', 'ABC'])('should accept %s', (value) => { + expect(segmentValidator(new FormControl(value))).toBeNull(); + }); + + it.each([ + 'test/key', + 'test key', + 'a.b', + ...['@', '#', '$', '%', '^', '&', '*', '(', ')'].map((c) => `test${c}key`), + ])('should reject %s under the pattern error key', (value) => { + expect(segmentValidator(new FormControl(value))).toEqual({ pattern: { actualValue: value } }); + }); + + it('should leave an empty value to Validators.required', () => { + expect(segmentValidator(new FormControl(''))).toBeNull(); + expect(segmentValidator(new FormControl(null))).toBeNull(); + }); +}); diff --git a/apps/tracker/src/app/shared/validators/segment.validator.ts b/apps/tracker/src/app/shared/validators/segment.validator.ts new file mode 100644 index 00000000..e3a1a4a8 --- /dev/null +++ b/apps/tracker/src/app/shared/validators/segment.validator.ts @@ -0,0 +1,18 @@ +import type { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { isValidSegment } from '@simoncodes-ca/domain'; + +/** + * One segment of a dot-delimited key, by the domain's rule (`isValidSegment`): + * letters, digits, `_` and `-`. + * + * It reports under `pattern`, the key `Validators.pattern` used, so error + * messages keyed on it keep working. Like `Validators.pattern`, it leaves an + * empty value to `Validators.required`. + */ +export const segmentValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (typeof value !== 'string' || value.length === 0) { + return null; + } + return isValidSegment(value) ? null : { pattern: { actualValue: value } }; +}; diff --git a/architecture-docs/frontend.md b/architecture-docs/frontend.md index c44dbbdb..01cb6a70 100644 --- a/architecture-docs/frontend.md +++ b/architecture-docs/frontend.md @@ -23,6 +23,8 @@ Return to [architecture README](README.md). - [Optimistic Updates with Rollback](#optimistic-updates-with-rollback) - [Drag-and-Drop — Move Resource and Folder](#drag-and-drop--move-resource-and-folder) - [Lazy-Loaded Dialogs](#lazy-loaded-dialogs) + - [Translation Editor and the Resource Entry Draft](#translation-editor-and-the-resource-entry-draft) + - [Writing a Resource Entry](#writing-a-resource-entry) - [Theming System](#theming-system) - [i18n — Transloco Integration](#i18n--transloco-integration) - [Cross-Links](#cross-links) @@ -109,7 +111,7 @@ flowchart TD TranslationItem -. "lazy on edit (double-click / E key)" .-> TranslationEditorDialog TranslationItem -. "lazy on delete (Del key)" .-> ConfirmationDialog2["ConfirmationDialog\n(shared/components/confirmation-dialog)"] - TranslationEditorDialog["TranslationEditorDialog\n(browser/dialogs/translation-editor)\nCreate / edit resource. Tabbed locale\nfields, similar-translation sidebar,\nfolder picker, status controls.\nChip input for tag editing with\nper-collection autocomplete."] + TranslationEditorDialog["TranslationEditorDialog\n(browser/dialogs/translation-editor)\nCreate / edit resource. Tabbed locale\nfields, similar-translation sidebar,\nfolder picker, status controls.\nChip input for tag editing with\nper-collection autocomplete.\nRules: resource-entry-draft.ts"] TranslationEditorDialog --> SimilarTranslations["SimilarTranslations\n(dialogs/translation-editor/similar-translations.ts)\nLive similarity search as user types"] TranslationEditorDialog --> FolderPicker["FolderPicker\n(dialogs/translation-editor/folder-picker)\nTree picker for changing resource folder"] @@ -125,7 +127,7 @@ flowchart TD ### BrowserStore — Feature Composition -`BrowserStore` is a single `signalStore` provided in root. Its state is split across six `signalStoreFeature` functions that compose sequentially. Cross-cutting state (the fields shared between multiple features) lives in the root `withState()` call; each feature adds its own slice. +`BrowserStore` is a single `signalStore` provided in root. Its state is split across seven `signalStoreFeature` functions that compose sequentially. Cross-cutting state (the fields shared between multiple features) lives in the root `withState()` call; each feature adds its own slice. @@ -137,7 +139,9 @@ flowchart TD Root --> WF["withFilterFeature\n(with-filter.feature.ts)\nAdds: selectedLocales, selectedStatuses,\nsortField, sortDirection\nComputed: filteredLocales, filterableLocales,\nlocaleFilterText, statusFilterText\nMethods: toggleLocale, setSortField,\ntoggleStatus, selectNeedsWorkStatuses, …"] - Root --> WT["withTranslationsFeature\n(with-translations.feature.ts)\nAdds: translations, isTranslationsLoading,\nshowNestedResources\nComputed: sortedTranslations, displayedTranslations,\nisEmpty, translationCount\nMethods: selectFolder (rxMethod),\nupdateTranslationInCache, removeResourceFromCache,\nsetNestedResources"] + Root --> WT["withTranslationsFeature\n(with-translations.feature.ts)\nAdds: translations, isTranslationsLoading,\nshowNestedResources\nComputed: sortedTranslations, displayedTranslations,\nisEmpty, translationCount\nMethods: selectFolder (rxMethod),\nsetNestedResources"] + + Root --> WEW["withEntryWritesFeature\n(with-entry-writes.feature.ts)\nNo new state\nMethods: createResource, updateResource,\ndeleteResource, translateResource\n(return the API Observable; patch\ntranslations / searchResults on success)"] Root --> WFT["withFolderTreeFeature\n(with-folder-tree.feature.ts)\nAdds: rootFolders, expandedFolders,\nfolderTreeFilter, isFolderTreeLoading,\nisAddingFolder, addFolderParentPath,\nnewlyCreatedFolderPath, isDeletingFolder\nComputed: filteredFolders, breadcrumbs, isLoading\nMethods: loadRootFolders, loadFolderChildren,\ncreateFolder, deleteFolder, moveFolder (rxMethods)"] @@ -148,10 +152,11 @@ flowchart TD WS -.->|"isSearchMode, searchResults\nread by"| WT WF -.->|"selectedLocales, selectedStatuses\nread by"| WT WFT -.->|"calls selectFolder\nprovided by"| WT + WEW -.->|"calls selectFolder,\npatches translations"| WT WCS -.->|"calls loadRootFolders\nprovided by"| WFT ``` -**Composition order matters.** `withFolderTreeFeature` requires `selectFolder` and `setTranslationsLoading` from `withTranslationsFeature`, so `withTranslationsFeature` must appear first. `withCacheStatusFeature` requires `loadRootFolders` from `withFolderTreeFeature`, so it follows. `withViewPreferencesFeature` reads from every other feature's state and is last. +**Composition order matters.** `withEntryWritesFeature` and `withFolderTreeFeature` require `selectFolder` (and the folder tree also `setTranslationsLoading`) from `withTranslationsFeature`, so `withTranslationsFeature` must appear first. `withCacheStatusFeature` requires `loadRootFolders` from `withFolderTreeFeature`, so it follows. `withViewPreferencesFeature` reads from every other feature's state and is last. --- @@ -161,7 +166,8 @@ flowchart TD |---|---|---|---| | `with-search.feature.ts` | `searchQuery`, `isSearchMode`, `searchResults`, `isSearchLoading`, `searchError` | — | `setSearchQuery`, `clearSearch`, `searchTranslations` | | `with-filter.feature.ts` | `selectedLocales`, `selectedStatuses`, `sortField`, `sortDirection` | `filteredLocales`, `filterableLocales`, `localeFilterText`, `statusFilterText`, `isShowingAllLocales`, `isShowingAllStatuses` | `toggleLocale`, `setSelectedLocales`, `setSortField`, `toggleSortDirection`, `toggleStatus`, `selectNeedsWorkStatuses` | -| `with-translations.feature.ts` | `translations`, `isTranslationsLoading`, `showNestedResources` | `sortedTranslations`, `displayedTranslations`, `isEmpty`, `translationCount`, `hasTranslations` | `selectFolder`, `setTranslationsLoading`, `setNestedResources`, `updateTranslationInCache`, `removeResourceFromCache` | +| `with-translations.feature.ts` | `translations`, `isTranslationsLoading`, `showNestedResources` | `sortedTranslations`, `displayedTranslations`, `isEmpty`, `translationCount`, `hasTranslations` | `selectFolder`, `setTranslationsLoading`, `setNestedResources` | +| `with-entry-writes.feature.ts` | (no new state) | — | `createResource`, `updateResource`, `deleteResource`, `translateResource` — see [Writing a Resource Entry](#writing-a-resource-entry) | | `with-folder-tree.feature.ts` | `rootFolders`, `expandedFolders`, `folderTreeFilter`, `isFolderTreeLoading`, `isAddingFolder`, `addFolderParentPath`, `newlyCreatedFolderPath`, `isDeletingFolder`, `deletingFolderPath` | `filteredFolders`, `breadcrumbs`, `isLoading` | `loadRootFolders`, `loadFolderChildren`, `createFolder`, `createFolderAt`, `deleteFolder`, `moveFolder`, `toggleFolderExpanded`, `startAddingFolder`, `cancelAddingFolder` | | `with-cache-status.feature.ts` | `cacheStatus`, `cacheError`, `collectionStats` | `isCacheReady`, `isCacheIndexing`, `collectionTotalKeys`, `collectionLocaleCount`, `hasCollectionStats` | `checkCacheStatus` (polls every 2 s via `interval`, stops when `status === 'ready'`) | | `with-view-preferences.feature.ts` | (no new state) | `canShowMultipleLocales` | `setDensityMode`, `loadViewPreferences` (reads `localStorage`) | @@ -182,7 +188,7 @@ Root-level methods on `BrowserStore` (not in a feature): `TranslationListStore` is a lightweight store provided at the `TranslationList` component level (not root). It composes two features: - **`withItemUiState`** — tracks `translatingKeys: Set` (in-progress auto-translate calls) and `recentlyUpdatedKey: string | undefined` (drives the 1.5 s flash highlight after a save). Exposes `addTranslatingKey`, `removeTranslatingKey`, `flashRecentlyUpdated`, `isTranslating(key)`, `isRecentlyUpdated(key)`. Cleans up the flash timer `onDestroy`. -- **`withItemActions`** — exposes `editTranslation`, `deleteTranslation`, `translateResource`, `copyKey`. Opens `TranslationEditorDialog` or `ConfirmationDialog` via `MatDialog`. On edit success, delegates cache updates to `BrowserStore.updateTranslationInCache()`. +- **`withItemActions`** — exposes `editTranslation`, `deleteTranslation`, `translateResource`, `copyKey`. Edit goes through `TranslationEditorLauncher`; delete opens `ConfirmationDialog`. Delete and translate resolve the row's full key and call `BrowserStore.deleteResource` / `BrowserStore.translateResource`, which update the caches. The feature keeps the per-row feedback: the translating spinner, the flash, and the toasts. Because `TranslationListStore` is component-provided, each `TranslationList` instance gets its own store. `TranslationItem` injects it via `inject(TranslationListStore)` — no prop drilling needed. @@ -247,7 +253,7 @@ The viewport recalculates its size via `viewport.checkViewportSize()` inside `re **Edit translation** (via `TranslationListStore.withItemActions`): -Editing happens inside the dialog. On dialog close with `result.success`, `BrowserStore.updateTranslationInCache` replaces the stale entry in the `translations` array in-place. There is no server round-trip for the cache update — the API response from the save is embedded in `result.resource`. `TranslationListStore.flashRecentlyUpdated` then sets `recentlyUpdatedKey` for 1.5 s to drive the highlight animation. +Editing happens inside the dialog, which saves through `BrowserStore.updateResource`. When the `PATCH` succeeds, the store replaces the stale entry in `translations` (and in `searchResults` during a search) with the resource in the response. There is no second request. The dialog then closes with `result.success`, and `TranslationListStore.flashRecentlyUpdated` sets `recentlyUpdatedKey` for 1.5 s to drive the highlight animation. If the `PATCH` fails, the caches do not change and the dialog shows the error. --- @@ -291,6 +297,46 @@ This keeps dialog modules out of the initial bundle entirely. The pattern is use The dialog also includes a tag chip input (Material `mat-chip-grid` + `mat-autocomplete`) in the Base Info tab. Autocomplete suggestions are derived client-side as a `computed()` over `BrowserStore.translations()`, scoped to the current collection. Tags are normalized on chip commit (`normalizeTag` from `@simoncodes-ca/domain`) and sent as `tags: string[]` on the existing `PATCH /collections/:name/resources` endpoint. +### Translation Editor and the Resource Entry Draft + +`TranslationEditorDialog` (`browser/dialogs/translation-editor/`) creates and edits one resource entry. It has two parts: + +- **The component** owns the Angular parts: the reactive form, focus choreography, the location popover and the other-locales drawer, clipboard, flash timers, the confirmation dialogs, and the RxJS wiring for the similar-values search. +- **`resource-entry-draft.ts`** owns the rules. It is a pure module with no Angular imports. The dialog turns its form, target folder and tags into a plain `ResourceEntryDraft` and asks the module for every decision. + +| Function | Rule | +|---|---| +| `absorbDottedKey(rawKey, currentFolder, folderFromKey)` | A dotted key typed in the key field moves its prefix to the folder and keeps the leaf. The next dotted key extends the folder only while the folder is still the one the last absorption set. | +| `folderEntryKeys(folderPath, known)` / `collisionFor(key, folderPath, known, ownKey?)` | Which entry keys a folder holds, from three sources in order: the expanded folder tree, the folder the browser shows, then folders the dialog fetched. Nested keys (with a dot) are not entries of the folder. The match is exact and case-sensitive, the same as `addResource`. The entry being edited never collides with itself. | +| `contextTree(input, moreLabel)` | The "Where it lands" tree: the target folder among its siblings, and an 8-entry window of its entries around the key. The remaining entries are one "more" row. | +| `addTag` / `removeTag` | Tag list operations. Tags are normalized with `normalizeTag`. Inherited tags cannot be removed. | +| `toCreateDto(draft, baseLocale)` | The create request. Every typed translation is sent with status `new`. | +| `toUpdateDto(draft, original)` / `editedLocales` | The update request. A locale is sent when it has a value or when its status changed. | +| `hasUnsavedChanges(draft, initial, fieldsEdited)` | Closing loses work when a form field was edited, the folder moved, or the tags changed. | + +The key field validator is `segmentValidator` (`shared/validators/segment.validator.ts`). It uses the domain `isValidSegment` rule and reports under the `pattern` error key. The bundle name and the inline new-folder name use the same validator. The folder filter in the location popover uses `filterFolderTree` from `browser/store/folder-tree.utils.ts`, the same function as `BrowserStore.filteredFolders`. + +The dialog reads two things directly from `BrowserApiService`: `searchTranslations` for similar values, and `getResourceTree` for the entries of a folder picked in the popover. Both are dialog-local reads. The store's `selectFolder` would move the browser list behind the dialog, so the dialog does not use it. + +### Writing a Resource Entry + +All UI writes of a resource entry go through `withEntryWritesFeature` on `BrowserStore`: + +| Method | Caller | After a successful write | +|---|---|---| +| `createResource(collectionName, dto)` | `TranslationEditorDialog` (create) | Reloads the current folder with `selectFolder`. | +| `updateResource(collectionName, dto)` | `TranslationEditorDialog` (edit) | Patches the entry in place. If the DTO has a `targetFolder` property, removes the entry instead. | +| `deleteResource(collectionName, fullKey)` | `withItemActions.deleteTranslation` | Removes the entry when `entriesDeleted > 0`. | +| `translateResource(collectionName, fullKey)` | `withItemActions.translateResource` | Patches the entry in place. | + +Each method takes the full dot-delimited key and returns the API `Observable`. The caller subscribes and keeps its own error handling, for example the dialog's 409 conflict dialog and its 400 and 404 messages. The store changes its caches only on success. + +`toUpdateDto` includes `targetFolder` only when the entry moves to a different non-root folder. A move to the collection root is sent without `targetFolder`, as before this change. The server then edits the entry in its current folder, so the store patches the row in place. The store rule and the DTO rule use the same test: the `targetFolder` property is present or absent. + +The two caches use different keys. `translations` uses the key relative to `currentFolderPath`, so a nested entry keeps its sub-path (`dialog.title`). `searchResults` uses the full key. The store converts the key with `listKeyFor` in one place. The API returns a bare entry key, so the store also replaces the key of the returned resource. Callers do not convert keys. + +`TranslationEditorLauncher` and `TranslationMainHeader` only give feedback after the dialog closes: the row flash and the toasts. + --- ## Theming System diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index dac92faa..3056abfe 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -181,6 +181,14 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md) --- +### Resource Entry Draft + +The translation editor's view of the [resource entry](#resource-entry) it is writing, as plain data: entry key, target folder, base value, comment, tags, and one value and status for each non-base locale. The pure module `apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts` holds the editor's rules for a draft: dotted-key absorption, key collision, the "Where it lands" tree, tag edits, the create and update DTOs, and the unsaved-work check. The module has no Angular dependency. + +Explained in context: [`frontend.md`](frontend.md#translation-editor-and-the-resource-entry-draft) + +--- + ### Resource Folder One folder of the translation hierarchy, seen as a unit: its `resource_entries.json` ([resource entries](#resource-entry)) and `tracker_meta.json` ([tracker metadata](#tracker-metadata)) are always read and written together. In code, `openResourceFolder()` returns a `ResourceFolder` (`libs/core/src/lib/resource/resource-folder.ts`), and every core operation that changes resources goes through it. It computes checksums and applies the [staleness rule](#staleness-rule). diff --git a/architecture-docs/user-flows.md b/architecture-docs/user-flows.md index 411195e9..a0572600 100644 --- a/architecture-docs/user-flows.md +++ b/architecture-docs/user-flows.md @@ -284,19 +284,22 @@ sequenceDiagram Note over Dev,Dialog: E. Edit a resource Dev->>TB: double-click TranslationItem (or press E) TB->>TLS: editTranslation(translation, collectionName) - TLS->>Dialog: MatDialog.open(TranslationEditorDialog, data) + TLS->>Dialog: MatDialog.open(TranslationEditorDialog, data) [via TranslationEditorLauncher] Dev->>Dialog: edit values, click Save - Dialog->>API: PATCH /api/collections/{name}/resources - API-->>Dialog: UpdateResourceResponseDto { resource: ResourceSummaryDto } + Dialog->>Dialog: toUpdateDto(draft, original) [resource-entry-draft.ts] + Dialog->>BS: updateResource(collectionName, dto) [withEntryWritesFeature] + BS->>API: PATCH /api/collections/{name}/resources + API-->>BS: UpdateResourceResponseDto { resource: ResourceSummaryDto } + + Note over BS: F. Cache patch (no re-fetch) + BS->>BS: patch translations[] (relative key) and searchResults[] (full key) + Note right of BS: Uses the API response payload.
No second HTTP request. + BS-->>Dialog: response Dialog-->>TLS: afterClosed() → { success: true, resource, folderPath } - - Note over TLS,BS: F. Optimistic cache update (no re-fetch) - TLS->>BS: updateTranslationInCache(resource) - Note right of BS: Replaces the stale entry in translations[]
in-place using the API response payload.
No second HTTP request. TLS->>TLS: flashRecentlyUpdated(key) — 1.5 s highlight - Note over TLS,BS: G. Rollback path (API error) - Note right of TLS: If PATCH fails, Dialog closes
with result.success = false.
translations[] is never mutated —
no rollback needed for edit. + Note over BS,Dialog: G. Error path + Note right of BS: If PATCH fails, the store does not
change the caches. The error reaches
the dialog, which shows it and stays open.
No rollback is needed. ``` --- From 38b0de1f952f3f2511fa92f7d110e6539d12b326 Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 01:30:45 -0700 Subject: [PATCH 07/20] refactor(tracker): one translation status summary for roll-ups Add a pure domain module (STATUS_PRECEDENCE, countByStatus, worstStatus) and one Tracker presentation table (icons, label tokens, display order, rollupCenter). The three roll-up computations and seven status tables in the browser list, rollup ring, locale column, status filter, sort and breakdown text now use them. Rendering is unchanged; both existing list orders (worst-first and new-first) are kept and named. Co-Authored-By: Claude Fable 5.1 --- .../features/with-translations.feature.ts | 12 +- .../header/status-filter/status-filter.ts | 21 ++- .../list/translation-item/item-header.ts | 17 +-- .../list/translation-item/item-locales.ts | 74 +++------- .../translation-item/translation-item.spec.ts | 34 ----- .../list/translation-item/translation-item.ts | 74 +++------- .../translation-item/translation-rollup.ts | 126 ++++++------------ .../translations/utils/sort-translations.ts | 40 ++---- .../src/app/shared/i18n/status-breakdown.ts | 24 +--- .../translation-status-presentation.spec.ts | 54 ++++++++ .../translation-status-presentation.ts | 89 +++++++++++++ architecture-docs/frontend.md | 5 + architecture-docs/glossary.md | 8 ++ architecture-docs/monorepo-structure.md | 2 + libs/domain/src/index.ts | 1 + .../lib/translation-status-summary.spec.ts | 60 +++++++++ .../src/lib/translation-status-summary.ts | 44 ++++++ 17 files changed, 374 insertions(+), 311 deletions(-) create mode 100644 apps/tracker/src/app/shared/translation-status/translation-status-presentation.spec.ts create mode 100644 apps/tracker/src/app/shared/translation-status/translation-status-presentation.ts create mode 100644 libs/domain/src/lib/translation-status-summary.spec.ts create mode 100644 libs/domain/src/lib/translation-status-summary.ts diff --git a/apps/tracker/src/app/browser/store/features/with-translations.feature.ts b/apps/tracker/src/app/browser/store/features/with-translations.feature.ts index b2b3baa6..1decd6a3 100644 --- a/apps/tracker/src/app/browser/store/features/with-translations.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-translations.feature.ts @@ -5,7 +5,8 @@ import { pipe, tap, switchMap, catchError, of } from 'rxjs'; import { TranslocoService } from '@jsverse/transloco'; import { BrowserApiService } from '../../services/browser-api.service'; import { sortTranslations } from '../../translations/utils/sort-translations'; -import type { ResourceSummaryDto, SearchResultDto, TranslationStatus } from '@simoncodes-ca/data-transfer'; +import type { ResourceSummaryDto, SearchResultDto } from '@simoncodes-ca/data-transfer'; +import { countByStatus, STATUS_PRECEDENCE, type TranslationStatus } from '@simoncodes-ca/domain'; import { toErrorMessage } from '../async-error.utils'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; @@ -21,7 +22,6 @@ const initialTranslationsState: TranslationsState = { showNestedResources: true, }; -const ALL_STATUSES: readonly TranslationStatus[] = ['new', 'stale', 'translated', 'verified']; const NEEDS_WORK_STATUSES: readonly TranslationStatus[] = ['new', 'stale']; /** @@ -36,10 +36,8 @@ function matchesAnyStatus( locales: readonly string[], statuses: readonly TranslationStatus[], ): boolean { - return locales.some((locale) => { - const localeStatus = item.status?.[locale]; - return !!localeStatus && statuses.includes(localeStatus); - }); + const counts = countByStatus(locales.map((locale) => item.status?.[locale])); + return statuses.some((status) => counts[status] > 0); } export function withTranslationsFeature<_>() { @@ -113,7 +111,7 @@ export function withTranslationsFeature<_>() { const counts: Record = { new: 0, stale: 0, translated: 0, verified: 0 }; for (const item of items) { - for (const status of ALL_STATUSES) { + for (const status of STATUS_PRECEDENCE) { if (matchesAnyStatus(item, locales, [status])) counts[status]++; } } diff --git a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.ts b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.ts index 336cce5e..1bb0b636 100644 --- a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.ts +++ b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.ts @@ -1,9 +1,13 @@ import { Component, ChangeDetectionStrategy, computed, inject } from '@angular/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslocoPipe } from '@jsverse/transloco'; -import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +import type { TranslationStatus } from '@simoncodes-ca/domain'; import { BrowserStore } from '../../../store/browser.store'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; +import { + STATUS_DISPLAY_ORDER, + STATUS_PRESENTATION, +} from '../../../../shared/translation-status/translation-status-presentation'; /** One toggle in the rail. `statuses` is what it selects, not what it is called. */ interface StatusToggle { @@ -42,16 +46,6 @@ export class StatusFilter { readonly store = inject(BrowserStore); readonly TOKENS = TRACKER_TOKENS; - readonly statuses: readonly TranslationStatus[] = ['new', 'stale', 'translated', 'verified']; - - /** Reads the shared status color spine, so a status means the same thing here as on the rows it filters. */ - readonly statusLabels: Record = { - new: TRACKER_TOKENS.BROWSER.STATUS.NEW, - stale: TRACKER_TOKENS.BROWSER.STATUS.STALE, - translated: TRACKER_TOKENS.BROWSER.STATUS.TRANSLATED, - verified: TRACKER_TOKENS.BROWSER.STATUS.VERIFIED, - }; - /** * "Needs work" is selected only when it is exactly what is selected. A user who * ticked `new` alone has not asked for the shortcut, and lighting it up would @@ -74,9 +68,10 @@ export class StatusFilter { count: this.store.needsWorkCount(), selected: this.isNeedsWorkSelected(), }, - ...this.statuses.map((status) => ({ + // The shared status labels, so a status reads the same here as on the rows it filters. + ...STATUS_DISPLAY_ORDER.map((status) => ({ id: status, - label: this.statusLabels[status], + label: STATUS_PRESENTATION[status].labelToken, statuses: [status] as const, count: counts[status], selected: selected.includes(status), diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/item-header.ts b/apps/tracker/src/app/browser/translations/list/translation-item/item-header.ts index 38c606bf..aea5f7a4 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/item-header.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/item-header.ts @@ -10,7 +10,7 @@ import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { KeyMarkupPipe, hasKeyLeaf } from '../../../../shared/pipes/key-markup.pipe'; import { TagList } from '../../../../shared/tag-list/tag-list.component'; import { TranslationRollup, type LocaleState } from './translation-rollup'; -import type { ResourceSummaryDto, TranslationStatus } from '@simoncodes-ca/data-transfer'; +import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; import { BrowserStore } from '../../../store/browser.store'; import { TranslationListStore } from '../store/translation-list.store'; @@ -83,17 +83,12 @@ export class TranslationItemHeader { readonly TOKENS = TRACKER_TOKENS; - /** Locale states for the rollup component — derived from the translation status map */ + /** Non-base locales that carry a status — the rollup's input, derived from the translation status map. */ readonly localeStates = computed(() => { const statusMap = this.translation().status || {}; const base = this.#browserStore.baseLocale(); - return Object.entries(statusMap) - .filter(([locale, status]) => locale !== base && status) - .map(([locale, status]) => ({ - code: locale, - status: status as TranslationStatus, - })); + return Object.entries(statusMap).flatMap(([code, status]) => (code !== base && status ? [{ code, status }] : [])); }); /** Base locale code from the browser store */ @@ -163,11 +158,7 @@ export class TranslationItemHeader { * indicating there is work for the auto-translator to do. */ readonly hasTranslatableLocales = computed(() => { - const statusMap = this.translation().status || {}; - const base = this.#browserStore.baseLocale(); - return Object.entries(statusMap) - .filter(([locale]) => locale !== base) - .some(([, status]) => status === 'new' || status === 'stale'); + return this.localeStates().some(({ status }) => status === 'new' || status === 'stale'); }); /** Whether the translate action is disabled */ diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.ts b/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.ts index b2cd955e..d4b5b4fd 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.ts @@ -3,22 +3,19 @@ import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; import { HighlightPipe } from '../../../../shared/pipes/highlight.pipe'; import { TranslocoPipe } from '@jsverse/transloco'; -import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { countByStatus, STATUS_PRECEDENCE, type StatusCounts, type TranslationStatus } from '@simoncodes-ca/domain'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; -import { injectStatusBreakdown, type StatusCounts } from '../../../../shared/i18n/status-breakdown'; +import { injectStatusBreakdown } from '../../../../shared/i18n/status-breakdown'; +import { + statusIconFor, + statusLabelTokenFor, +} from '../../../../shared/translation-status/translation-status-presentation'; import type { DensityMode } from '../../../types/density-mode'; -/** The statuses a locale row can carry, as a runtime guard over the string field. */ -const TRANSLATION_STATUSES: readonly TranslationStatus[] = ['new', 'stale', 'translated', 'verified']; - -function asTranslationStatus(status: string | undefined): TranslationStatus | undefined { - return TRANSLATION_STATUSES.find((s) => s === status); -} - export type LocaleTranslation = { locale: string; value: string; - status?: string; + status?: TranslationStatus; /** * The stored value is byte-identical to the base locale's. The status metadata * still says `translated` — a checksum cannot tell a deliberate loanword from a @@ -41,38 +38,6 @@ export type BaseTranslation = { value: string; }; -/** Material icon name for a translation status. Shared with the compact item row. */ -export function statusIconFor(status: string | undefined): string { - switch (status) { - case 'verified': - return 'check_circle'; - case 'translated': - return 'language'; - case 'stale': - return 'warning'; - case 'new': - return 'add_circle'; - default: - return 'help_outline'; - } -} - -/** Transloco token for a translation status label. Shared with the compact item row. */ -export function statusLabelTokenFor(status: string | undefined): string { - switch (status) { - case 'verified': - return TRACKER_TOKENS.BROWSER.STATUS.VERIFIED; - case 'translated': - return TRACKER_TOKENS.BROWSER.STATUS.TRANSLATED; - case 'stale': - return TRACKER_TOKENS.BROWSER.STATUS.STALE; - case 'new': - return TRACKER_TOKENS.BROWSER.STATUS.NEW; - default: - return ''; - } -} - /** * Displays locale translations in a grid layout. * Supports different density modes with appropriate styling. @@ -110,14 +75,9 @@ export class TranslationItemLocales { readonly TOKENS = TRACKER_TOKENS; /** Locale rows per status, over the rows actually rendered. */ - private readonly statusCounts = computed(() => { - const counts: StatusCounts = { new: 0, stale: 0, translated: 0, verified: 0 }; - for (const lt of this.localeTranslations()) { - const status = asTranslationStatus(lt.status); - if (status) counts[status]++; - } - return counts; - }); + private readonly statusCounts = computed(() => + countByStatus(this.localeTranslations().map((lt) => lt.status)), + ); /** Localized "{n} translated" for the rendered rows. */ private readonly breakdown = injectStatusBreakdown(this.statusCounts); @@ -132,13 +92,11 @@ export class TranslationItemLocales { * which is the case where the column is worth reading. */ readonly uniformStatus = computed(() => { - const rows = this.localeTranslations(); - if (rows.length < 2) return undefined; - - const first = asTranslationStatus(rows[0].status); - if (!first) return undefined; + const rowCount = this.localeTranslations().length; + if (rowCount < 2) return undefined; - return rows.every((lt) => lt.status === first) ? first : undefined; + const counts = this.statusCounts(); + return STATUS_PRECEDENCE.find((status) => counts[status] === rowCount); }); /** The collapsed chip: the shared status, labelled with its count. */ @@ -147,11 +105,11 @@ export class TranslationItemLocales { return status ? { status, label: this.breakdown() } : undefined; }); - getStatusIcon(status: string | undefined): string { + getStatusIcon(status: TranslationStatus | undefined): string { return statusIconFor(status); } - getStatusLabel(status: string | undefined): string { + getStatusLabel(status: TranslationStatus | undefined): string { return statusLabelTokenFor(status); } } diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts index 027aaa43..747b3540 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts @@ -224,22 +224,6 @@ describe('TranslationItem', () => { }); }); - it('rollupStatus should reflect all verified state as verified', () => { - const t: ResourceSummaryDto = { - key: 'k-all-verified', - translations: { en: 'a', es: 'b' }, - status: { en: 'verified', es: 'verified' }, - } as any; - - store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); - fixture.componentRef.setInput('translation', t); - fixture.detectChanges(); - - const roll = component.rollupStatus(); - expect(roll[0]).toBe('verified'); - expect(roll[1]).toBe(2); - }); - it('should use filteredLocales from store (replaces locales input)', () => { store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); store.setDensityMode('full'); @@ -295,24 +279,6 @@ describe('TranslationItem - Compact helpers', () => { expect(component.compactDisplay().value).toBe('Save'); }); - it('rollupStatus should calculate worst status across all locales', () => { - const t: ResourceSummaryDto = { - key: 'k', - translations: { en: 'a', es: 'b', fr: 'c' }, - status: { en: 'verified', es: 'translated', fr: 'stale' }, - }; - - store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); - store.setDensityMode('full'); - store.clearAllLocales(); - fixture.componentRef.setInput('translation', t); - fixture.detectChanges(); - - const roll = component.rollupStatus(); - expect(roll[0]).toBe('stale'); - expect(roll[1]).toBe(1); - }); - it('statusBreakdown should return human readable counts in priority order', () => { const t: ResourceSummaryDto = { key: 'k3', diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts index a9537030..8a57a0f4 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts @@ -2,15 +2,20 @@ import { Component, ChangeDetectionStrategy, input, output, computed, effect, in import { MatIconModule } from '@angular/material/icon'; import { CdkDrag, CdkDragPlaceholder } from '@angular/cdk/drag-drop'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; +import { countByStatus, STATUS_PRECEDENCE, type TranslationStatus } from '@simoncodes-ca/domain'; import { BrowserStore } from '../../../store/browser.store'; import { TranslocoPipe } from '@jsverse/transloco'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { TranslationItemHeader } from './item-header'; -import { TranslationItemLocales, statusIconFor, statusLabelTokenFor, type BaseTranslation } from './item-locales'; +import { TranslationItemLocales, type BaseTranslation } from './item-locales'; import { HighlightPipe } from '../../../../shared/pipes/highlight.pipe'; import type { DragData } from '../../../types/drag-data'; import { TranslationListStore } from '../store/translation-list.store'; import { injectStatusBreakdown } from '../../../../shared/i18n/status-breakdown'; +import { + statusIconFor, + statusLabelTokenFor, +} from '../../../../shared/translation-status/translation-status-presentation'; const EXPAND_THRESHOLD = 200; @@ -35,13 +40,11 @@ function isIdenticalToBase(value: string, baseValue: string): boolean { return trimmed.length > 0 && trimmed === baseValue.trim(); } -const STATUS_SORT_PRIORITY: Record = { - stale: 0, - new: 1, - translated: 2, - verified: 3, - missing: 4, -}; +/** Locale row order: worst status first; a row with no known status goes last. */ +function statusRank(status: TranslationStatus | undefined): number { + const rank = status ? STATUS_PRECEDENCE.indexOf(status) : -1; + return rank === -1 ? STATUS_PRECEDENCE.length : rank; +} /** * Displays a single translation entry with key, base value, locale translations, @@ -153,9 +156,8 @@ export class TranslationItem { }; }) .sort((a, b) => { - const priorityA = a.status ? (STATUS_SORT_PRIORITY[a.status] ?? 4) : 4; - const priorityB = b.status ? (STATUS_SORT_PRIORITY[b.status] ?? 4) : 4; - if (priorityA !== priorityB) return priorityA - priorityB; + const rankDiff = statusRank(a.status) - statusRank(b.status); + if (rankDiff !== 0) return rankDiff; return a.locale.localeCompare(b.locale); }); }); @@ -352,30 +354,15 @@ export class TranslationItem { } } - /** - * Computes counts of each status and total known statuses. - * Used by rollupStatus and statusBreakdown to avoid duplicated logic. - */ + /** Status counts across every non-base locale of the entry, whatever the locale filter shows. */ readonly #statusCounts = computed(() => { const statusMap = this.translation().status || {}; - - const counts: Record<'stale' | 'new' | 'translated' | 'verified', number> = { - stale: 0, - new: 0, - translated: 0, - verified: 0, - }; - - const total = Object.values(statusMap).reduce((acc, s) => { - if (!s) return acc; - if (s in counts) { - counts[s as keyof typeof counts] += 1; - return acc + 1; - } - return acc; - }, 0); - - return { counts, total } as const; + const base = this.#store.baseLocale(); + return countByStatus( + Object.entries(statusMap) + .filter(([locale]) => locale !== base) + .map(([, status]) => status), + ); }); constructor() { @@ -410,26 +397,7 @@ export class TranslationItem { * Localized breakdown of statuses across all locales, announced to screen * readers. Example: "2 stale, 3 verified, 1 new". */ - readonly statusBreakdown = injectStatusBreakdown(computed(() => this.#statusCounts().counts)); - - /** - * Roll-up status across ALL locales. Priority (worst first): stale > new > translated > verified - * Returns tuple: [status, count] - */ - readonly rollupStatus = computed(() => { - const { counts, total } = this.#statusCounts(); - - if (total === 0) return ['new', 0] as const; - - const priority: Array = ['stale', 'new', 'translated', 'verified']; - - for (const p of priority) { - const c = counts[p]; - if (c > 0) return [p, c] as const; - } - - return ['new', 0] as const; - }); + readonly statusBreakdown = injectStatusBreakdown(this.#statusCounts); /** * Handles drag started event. diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-rollup.ts b/apps/tracker/src/app/browser/translations/list/translation-item/translation-rollup.ts index 78ec156f..c31ba7c7 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-rollup.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-rollup.ts @@ -16,9 +16,14 @@ import { Overlay, OverlayModule, type OverlayRef } from '@angular/cdk/overlay'; import { TemplatePortal, PortalModule } from '@angular/cdk/portal'; import { ViewContainerRef, type TemplateRef } from '@angular/core'; import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; -import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { countByStatus, type TranslationStatus } from '@simoncodes-ca/domain'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { injectActiveLang, injectStatusBreakdown } from '../../../../shared/i18n/status-breakdown'; +import { + rollupCenter, + STATUS_DISPLAY_ORDER, + STATUS_PRESENTATION, +} from '../../../../shared/translation-status/translation-status-presentation'; /** Locale state for rollup display */ export interface LocaleState { @@ -26,15 +31,12 @@ export interface LocaleState { status: TranslationStatus; } -/** Per-status display configuration. Color lives in CSS so both themes can move it. */ -interface StatusConfig { - labelToken: string; - icon: string; -} - /** Close delay in ms */ const CLOSE_DELAY = 120; +/** The ring draws its arcs in the reverse of the display order, starting at 12 o'clock. */ +const RING_ORDER: readonly TranslationStatus[] = [...STATUS_DISPLAY_ORDER].reverse(); + /** * Displays a visual rollup of translation statuses across locales. * Shows a segmented ring with status colors and a tooltip with details. @@ -348,26 +350,6 @@ export class TranslationRollup implements OnDestroy { return 2 * Math.PI * this.radius; } - /** Status configuration */ - private readonly statusConfig: Record = { - new: { - labelToken: TRACKER_TOKENS.BROWSER.STATUS.NEW, - icon: 'add_circle', - }, - stale: { - labelToken: TRACKER_TOKENS.BROWSER.STATUS.STALE, - icon: 'warning', - }, - translated: { - labelToken: TRACKER_TOKENS.BROWSER.STATUS.TRANSLATED, - icon: 'language', - }, - verified: { - labelToken: TRACKER_TOKENS.BROWSER.STATUS.VERIFIED, - icon: 'check_circle', - }, - }; - ngOnDestroy(): void { this.close(); } @@ -378,53 +360,26 @@ export class TranslationRollup implements OnDestroy { return (this.locales() ?? []).filter((l) => (l?.code ?? '').toLowerCase() !== base); }); - /** Get locale codes by status */ - private codesBy(status: TranslationStatus): string[] { - return this.effectiveLocales() - .filter((l) => l.status === status) - .map((l) => l.code) - .sort((a, b) => a.localeCompare(b)); - } - /** Status counts */ - readonly counts = computed(() => ({ - new: this.codesBy('new').length, - stale: this.codesBy('stale').length, - translated: this.codesBy('translated').length, - verified: this.codesBy('verified').length, - })); + readonly counts = computed(() => countByStatus(this.effectiveLocales().map((l) => l.status))); /** Total non-base locales */ readonly total = computed(() => this.effectiveLocales().length); - /** Whether all locales are verified */ - readonly isAllVerified = computed(() => this.total() > 0 && this.counts().verified === this.total()); + /** What the centre reports: state and glyph, from `rollupCenter`. */ + private readonly center = computed(() => rollupCenter(this.counts())); /** - * The single state the centre reports. `new` and `stale` are the two states a - * translator triages differently, so they stay separate here; `mixed` is the - * only case that merges them, and only because both are genuinely present. + * The single state the centre reports: the worst status, or `mixed` when new + * and stale are both present — the two states a translator triages differently. */ - readonly centerState = computed<'new' | 'stale' | 'mixed' | 'verified' | 'translated'>(() => { - const { new: isNew, stale } = this.counts(); - if (isNew > 0 && stale > 0) return 'mixed'; - if (stale > 0) return 'stale'; - if (isNew > 0) return 'new'; - return this.isAllVerified() ? 'verified' : 'translated'; - }); + readonly centerState = computed(() => this.center().state); /** * Center icon. Shape carries the state, so the two issue kinds stay legible to - * a reader who cannot separate the arcs by hue. The icons match the tooltip - * rows for the same status. + * a reader who cannot separate the arcs by hue. */ - readonly centerIcon = computed(() => { - const state = this.centerState(); - if (state === 'mixed') return 'priority_high'; - if (state === 'verified') return 'check'; - if (state === 'translated') return 'language'; - return this.statusConfig[state].icon; - }); + readonly centerIcon = computed(() => this.center().icon); /** Localized status breakdown, e.g. "2 stale, 1 new". */ readonly breakdown = injectStatusBreakdown(this.counts); @@ -451,41 +406,36 @@ export class TranslationRollup implements OnDestroy { const c = this.counts(); const circ = this.circumference; - const order: TranslationStatus[] = ['verified', 'translated', 'stale', 'new']; - let acc = 0; - return order - .map((st) => { - const count = c[st] || 0; - const frac = count / t; - const len = frac * circ; - const gap = circ - len; - const seg = { - status: st, - dashArray: `${len} ${gap}`, - dashOffset: -acc, - }; - acc += len; - return seg; - }) - .filter((s) => { - if (this.total() === 0) return false; - const len = parseFloat(s.dashArray.split(' ')[0]); - return len > 0.5; - }); + return RING_ORDER.map((st) => { + const count = c[st] || 0; + const frac = count / t; + const len = frac * circ; + const gap = circ - len; + const seg = { + status: st, + dashArray: `${len} ${gap}`, + dashOffset: -acc, + }; + acc += len; + return seg; + }).filter((s) => { + if (this.total() === 0) return false; + const len = parseFloat(s.dashArray.split(' ')[0]); + return len > 0.5; + }); }); - /** Tooltip rows - one row per locale, sorted by severity then locale code */ + /** Tooltip rows - one row per locale, in display order then by locale code */ readonly tooltipLocaleRows = computed(() => { - const order: TranslationStatus[] = ['new', 'stale', 'translated', 'verified']; - const orderIndex = (s: TranslationStatus) => order.indexOf(s); + const orderIndex = (s: TranslationStatus) => STATUS_DISPLAY_ORDER.indexOf(s); return this.effectiveLocales() .map((l) => ({ code: l.code, status: l.status, - labelToken: this.statusConfig[l.status].labelToken, - icon: this.statusConfig[l.status].icon, + labelToken: STATUS_PRESENTATION[l.status].labelToken, + icon: STATUS_PRESENTATION[l.status].icon, })) .sort((a, b) => { const orderDiff = orderIndex(a.status) - orderIndex(b.status); diff --git a/apps/tracker/src/app/browser/translations/utils/sort-translations.ts b/apps/tracker/src/app/browser/translations/utils/sort-translations.ts index 30ab4a51..eddc8f5b 100644 --- a/apps/tracker/src/app/browser/translations/utils/sort-translations.ts +++ b/apps/tracker/src/app/browser/translations/utils/sort-translations.ts @@ -1,33 +1,19 @@ -import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { countByStatus, type TranslationStatus } from '@simoncodes-ca/domain'; +import { STATUS_DISPLAY_ORDER } from '../../../shared/translation-status/translation-status-presentation'; export type SortField = 'key' | 'status'; export type SortDirection = 'asc' | 'desc'; -export const STATUS_PRIORITY: Record = { - new: 0, - stale: 1, - translated: 2, - verified: 3, - undefined: 3, -}; +const VERIFIED_RANK = STATUS_DISPLAY_ORDER.indexOf('verified'); -export function getWorstStatus(statuses: Record, locales: string[]): number { - if (locales.length === 0) { - return STATUS_PRIORITY.verified; - } - - const priorities = locales - .map((locale) => { - const status = statuses[locale]; - return STATUS_PRIORITY[status ?? 'undefined']; - }) - .filter((priority) => priority !== undefined); - - if (priorities.length === 0) { - return STATUS_PRIORITY.verified; - } - - return Math.min(...priorities); +/** + * Sort rank of an item: the position, in the display order (new first), of the + * earliest status its locales carry. Locales with no status rank as verified. + */ +function statusRank(statuses: Record, locales: string[]): number { + const counts = countByStatus(locales.map((locale) => statuses[locale])); + const rank = STATUS_DISPLAY_ORDER.findIndex((status) => counts[status] > 0); + return rank === -1 ? VERIFIED_RANK : rank; } export function sortTranslations< @@ -46,8 +32,8 @@ export function sortTranslations< } // Sort by status - const statusA = getWorstStatus(itemA.status ?? {}, selectedLocales); - const statusB = getWorstStatus(itemB.status ?? {}, selectedLocales); + const statusA = statusRank(itemA.status ?? {}, selectedLocales); + const statusB = statusRank(itemB.status ?? {}, selectedLocales); if (statusA !== statusB) { return statusA - statusB; diff --git a/apps/tracker/src/app/shared/i18n/status-breakdown.ts b/apps/tracker/src/app/shared/i18n/status-breakdown.ts index 09428f2b..6dc0f09e 100644 --- a/apps/tracker/src/app/shared/i18n/status-breakdown.ts +++ b/apps/tracker/src/app/shared/i18n/status-breakdown.ts @@ -1,22 +1,9 @@ import { computed, inject, type Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { TranslocoService } from '@jsverse/transloco'; -import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { STATUS_PRECEDENCE, type StatusCounts } from '@simoncodes-ca/domain'; import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; - -/** Number of locales in each translation status. */ -export type StatusCounts = Record; - -/** Pluralized "{n} " resource for each status. */ -const COUNT_TOKENS: Record = { - stale: TRACKER_TOKENS.BROWSER.STATUS.STALECOUNTX, - new: TRACKER_TOKENS.BROWSER.STATUS.NEWCOUNTX, - translated: TRACKER_TOKENS.BROWSER.STATUS.TRANSLATEDCOUNTX, - verified: TRACKER_TOKENS.BROWSER.STATUS.VERIFIEDCOUNTX, -}; - -/** Worst status first, so the part that needs attention is read first. */ -const BREAKDOWN_ORDER: readonly TranslationStatus[] = ['stale', 'new', 'translated', 'verified']; +import { STATUS_PRESENTATION } from '../translation-status/translation-status-presentation'; /** * Signal of the active UI language. Read it inside a `computed` that calls @@ -31,7 +18,8 @@ export function injectActiveLang(): Signal { /** * Builds a localized status breakdown such as "2 stale, 1 new" from a signal of - * counts, omitting statuses with no locales and falling back to the "no statuses" + * counts, worst status first so the part that needs attention is read first, + * omitting statuses with no locales and falling back to the "no statuses" * message when every count is zero. The list is joined with `Intl.ListFormat` so * the separator follows the reader's language rather than a hardcoded comma. * Must be called from an injection context. @@ -44,8 +32,8 @@ export function injectStatusBreakdown(counts: Signal): Signal current[status] > 0).map((status) => - transloco.translate(COUNT_TOKENS[status], { count: current[status] }), + const parts = STATUS_PRECEDENCE.filter((status) => current[status] > 0).map((status) => + transloco.translate(STATUS_PRESENTATION[status].countToken, { count: current[status] }), ); if (parts.length === 0) { diff --git a/apps/tracker/src/app/shared/translation-status/translation-status-presentation.spec.ts b/apps/tracker/src/app/shared/translation-status/translation-status-presentation.spec.ts new file mode 100644 index 00000000..253ad54b --- /dev/null +++ b/apps/tracker/src/app/shared/translation-status/translation-status-presentation.spec.ts @@ -0,0 +1,54 @@ +import { STATUS_PRECEDENCE, type StatusCounts, type TranslationStatus } from '@simoncodes-ca/domain'; +import { describe, expect, it } from 'vitest'; +import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; +import { + type RollupCenterState, + rollupCenter, + STATUS_DISPLAY_ORDER, + STATUS_PRESENTATION, + statusIconFor, + statusLabelTokenFor, +} from './translation-status-presentation'; + +const ZERO: StatusCounts = { stale: 0, new: 0, translated: 0, verified: 0 }; + +describe('STATUS_DISPLAY_ORDER', () => { + it('lists every status once, new first', () => { + expect(STATUS_DISPLAY_ORDER).toEqual(['new', 'stale', 'translated', 'verified']); + expect([...STATUS_DISPLAY_ORDER].sort()).toEqual([...STATUS_PRECEDENCE].sort()); + }); +}); + +describe('statusIconFor / statusLabelTokenFor', () => { + it.each<[TranslationStatus, string, string]>([ + ['stale', 'warning', TRACKER_TOKENS.BROWSER.STATUS.STALE], + ['new', 'add_circle', TRACKER_TOKENS.BROWSER.STATUS.NEW], + ['translated', 'language', TRACKER_TOKENS.BROWSER.STATUS.TRANSLATED], + ['verified', 'check_circle', TRACKER_TOKENS.BROWSER.STATUS.VERIFIED], + ])('%s → %s', (status, icon, labelToken) => { + expect(statusIconFor(status)).toBe(icon); + expect(statusLabelTokenFor(status)).toBe(labelToken); + expect(STATUS_PRESENTATION[status].icon).toBe(icon); + }); + + it('falls back for no status and for an unknown value', () => { + const unknown = 'missing' as TranslationStatus; + expect(statusIconFor(undefined)).toBe('help_outline'); + expect(statusIconFor(unknown)).toBe('help_outline'); + expect(statusLabelTokenFor(undefined)).toBe(''); + expect(statusLabelTokenFor(unknown)).toBe(''); + }); +}); + +describe('rollupCenter', () => { + it.each<[string, Partial, RollupCenterState, string]>([ + ['new and stale together', { new: 1, stale: 2, verified: 4 }, 'mixed', 'priority_high'], + ['stale without new', { stale: 1, translated: 3 }, 'stale', 'warning'], + ['new without stale', { new: 1, verified: 3 }, 'new', 'add_circle'], + ['translated and verified', { translated: 1, verified: 3 }, 'translated', 'language'], + ['all verified', { verified: 3 }, 'verified', 'check'], + ['no counted locale', {}, 'translated', 'language'], + ])('%s → %s', (_name, counts, state, icon) => { + expect(rollupCenter({ ...ZERO, ...counts })).toEqual({ state, icon }); + }); +}); diff --git a/apps/tracker/src/app/shared/translation-status/translation-status-presentation.ts b/apps/tracker/src/app/shared/translation-status/translation-status-presentation.ts new file mode 100644 index 00000000..cf4daf13 --- /dev/null +++ b/apps/tracker/src/app/shared/translation-status/translation-status-presentation.ts @@ -0,0 +1,89 @@ +import { type StatusCounts, type TranslationStatus, worstStatus } from '@simoncodes-ca/domain'; +import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; + +/** + * How the Tracker shows a translation status. The counting and the worst-status + * rule live in `@simoncodes-ca/domain` (translation-status-summary); this module + * only holds what is presentational: glyphs, label tokens and list order. Colour + * lives in CSS (`--color-status-*`) so both themes can move it. + */ +interface StatusPresentation { + /** Material icon on status chips and rollup tooltip rows. */ + readonly icon: string; + /** Glyph in the centre of the rollup ring. */ + readonly centerIcon: string; + /** Transloco token for the status name. */ + readonly labelToken: string; + /** Transloco token for the pluralized "{count} ". */ + readonly countToken: string; +} + +export const STATUS_PRESENTATION: Readonly> = { + stale: { + icon: 'warning', + centerIcon: 'warning', + labelToken: TRACKER_TOKENS.BROWSER.STATUS.STALE, + countToken: TRACKER_TOKENS.BROWSER.STATUS.STALECOUNTX, + }, + new: { + icon: 'add_circle', + centerIcon: 'add_circle', + labelToken: TRACKER_TOKENS.BROWSER.STATUS.NEW, + countToken: TRACKER_TOKENS.BROWSER.STATUS.NEWCOUNTX, + }, + translated: { + icon: 'language', + centerIcon: 'language', + labelToken: TRACKER_TOKENS.BROWSER.STATUS.TRANSLATED, + countToken: TRACKER_TOKENS.BROWSER.STATUS.TRANSLATEDCOUNTX, + }, + verified: { + icon: 'check_circle', + centerIcon: 'check', + labelToken: TRACKER_TOKENS.BROWSER.STATUS.VERIFIED, + countToken: TRACKER_TOKENS.BROWSER.STATUS.VERIFIEDCOUNTX, + }, +}; + +/** + * The order statuses are listed in: the status filter rail, the rollup tooltip + * rows, and sort by status. The rollup ring draws its arcs in the reverse order. + * + * This is not the worst-first `STATUS_PRECEDENCE` (stale first), which orders + * the status breakdown text and a card's locale rows. + */ +export const STATUS_DISPLAY_ORDER: readonly TranslationStatus[] = ['new', 'stale', 'translated', 'verified']; + +/** Presentation for a status, or `undefined` for no status or a value that is not a known status. */ +function presentationOf(status: TranslationStatus | undefined): StatusPresentation | undefined { + return status ? STATUS_PRESENTATION[status] : undefined; +} + +/** Material icon for a status chip; `help_outline` when the status is unknown. */ +export function statusIconFor(status: TranslationStatus | undefined): string { + return presentationOf(status)?.icon ?? 'help_outline'; +} + +/** Transloco token for a status label; `''` when the status is unknown. */ +export function statusLabelTokenFor(status: TranslationStatus | undefined): string { + return presentationOf(status)?.labelToken ?? ''; +} + +/** + * What the rollup ring's centre reports: the worst status, except that `new` + * and `stale` together are `mixed` — the two states a translator triages + * differently, merged only because both are present. + */ +export type RollupCenterState = TranslationStatus | 'mixed'; + +/** + * The rollup centre's state and glyph for an entry's counts. With no counted + * locale the centre reads `translated`; the item header does not render the + * rollup in that case. + */ +export function rollupCenter(counts: StatusCounts): { readonly state: RollupCenterState; readonly icon: string } { + if (counts.new > 0 && counts.stale > 0) return { state: 'mixed', icon: 'priority_high' }; + + const state = worstStatus(counts) ?? 'translated'; + return { state, icon: STATUS_PRESENTATION[state].centerIcon }; +} diff --git a/architecture-docs/frontend.md b/architecture-docs/frontend.md index 01cb6a70..be5d4078 100644 --- a/architecture-docs/frontend.md +++ b/architecture-docs/frontend.md @@ -24,6 +24,7 @@ Return to [architecture README](README.md). - [Drag-and-Drop — Move Resource and Folder](#drag-and-drop--move-resource-and-folder) - [Lazy-Loaded Dialogs](#lazy-loaded-dialogs) - [Translation Editor and the Resource Entry Draft](#translation-editor-and-the-resource-entry-draft) + - [Translation Status Summary](#translation-status-summary) - [Writing a Resource Entry](#writing-a-resource-entry) - [Theming System](#theming-system) - [i18n — Transloco Integration](#i18n--transloco-integration) @@ -318,6 +319,10 @@ The key field validator is `segmentValidator` (`shared/validators/segment.valida The dialog reads two things directly from `BrowserApiService`: `searchTranslations` for similar values, and `getResourceTree` for the entries of a folder picked in the popover. Both are dialog-local reads. The store's `selectFolder` would move the browser list behind the dialog, so the dialog does not use it. +### Translation Status Summary + +Each status roll-up in the browser uses the domain [translation status summary](glossary.md#translation-status-summary) (`countByStatus`, `worstStatus`, `STATUS_PRECEDENCE`). These roll-ups are the `TranslationRollup` ring and its accessible name, the item's screen-reader breakdown, the locale column's single-status chip, the `StatusFilter` counts and `matchesAnyStatus`, and sort by status. The components only render the result. The Tracker keeps the presentation in one table, `shared/translation-status/translation-status-presentation.ts`. `STATUS_PRESENTATION` gives the chip icon, the ring-centre glyph, the label token and the count token for each status. `rollupCenter(counts)` gives the ring centre: the worst status, or `mixed` when `new` and `stale` are both present. The module also has `STATUS_DISPLAY_ORDER` (`new`, `stale`, `translated`, `verified`), which the filter rail, the rollup tooltip rows and sort by status use. The ring draws its arcs in the reverse of this order. The breakdown text and a card's locale rows use the worst-first `STATUS_PRECEDENCE` instead. A per-folder roll-up can use the same functions if `FolderNodeDto` gets status data in the future. + ### Writing a Resource Entry All UI writes of a resource entry go through `withEntryWritesFeature` on `BrowserStore`: diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 3056abfe..4a1694e1 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -336,3 +336,11 @@ An enum (`TranslationStatus` in `@simoncodes-ca/domain`) that tracks the review The lifecycle flows: `new` → `translated` → `verified`. If the base value changes after `verified`, the status automatically reverts to `stale`. Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [`bundle-generation.md`](bundle-generation.md), [`domain-and-data-model.md`](domain-and-data-model.md) + +--- + +### Translation Status Summary + +The roll-up of a set of locale [translation statuses](#translation-status): the number of locales in each status (`StatusCounts`) and the worst status. The pure module `libs/domain/src/lib/translation-status-summary.ts` holds the rules. `countByStatus(statuses)` counts the statuses and ignores a locale with no status. `worstStatus(counts)` applies `STATUS_PRECEDENCE`, which is worst first: `stale` > `new` > `translated` > `verified`. Every roll-up in the Tracker UI uses this module: the rollup ring, the screen-reader breakdown, the locale column, the status filter counts, and sort by status. The glyphs, label tokens and display order are presentation. They are in one Tracker table, `shared/translation-status/translation-status-presentation.ts`. + +Explained in context: [`frontend.md`](frontend.md#translation-status-summary) diff --git a/architecture-docs/monorepo-structure.md b/architecture-docs/monorepo-structure.md index 172ea27b..c575e57a 100644 --- a/architecture-docs/monorepo-structure.md +++ b/architecture-docs/monorepo-structure.md @@ -45,6 +45,7 @@ lingo-tracker/ # Nx workspace root │ ├── domain/ # Browser-safe pure logic (zero Node.js deps) │ │ └── src/lib/ │ │ ├── translation-status.ts # TranslationStatus type +│ │ ├── translation-status-summary.ts # Status counts and worst-status precedence │ │ ├── locale-metadata.ts # LocaleMetadata interface │ │ ├── resource-key.ts # Key validation, resolve, split │ │ ├── staleness.ts # Staleness rule and status transitions @@ -140,6 +141,7 @@ graph TD | Module | What it does | |---|---| | `translation-status.ts` | Defines the `TranslationStatus` union type (`'new' \| 'translated' \| 'stale' \| 'verified'`) | +| `translation-status-summary.ts` | The [translation status summary](glossary.md#translation-status-summary): `countByStatus`, `worstStatus`, and `STATUS_PRECEDENCE` (worst first) | | `locale-metadata.ts` | Defines the `LocaleMetadata` interface (checksum, baseChecksum, status) | | `resource-key.ts` | Validates, resolves (`resolveResourceKey`), and splits (`splitResolvedKey`) dot-delimited keys | | `staleness.ts` | The staleness rule and status transitions (`applyBaseChange`, `recordTranslation`, `needsTranslation`, `resolveImportStatus`) | diff --git a/libs/domain/src/index.ts b/libs/domain/src/index.ts index a1ba1941..5a30f0b0 100644 --- a/libs/domain/src/index.ts +++ b/libs/domain/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/escape-regexp'; export * from './lib/translation-status'; +export * from './lib/translation-status-summary'; export * from './lib/locale-metadata'; export * from './lib/resource-key'; export * from './lib/staleness'; diff --git a/libs/domain/src/lib/translation-status-summary.spec.ts b/libs/domain/src/lib/translation-status-summary.spec.ts new file mode 100644 index 00000000..792d325f --- /dev/null +++ b/libs/domain/src/lib/translation-status-summary.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import type { TranslationStatus } from './translation-status'; +import { countByStatus, STATUS_PRECEDENCE, type StatusCounts, worstStatus } from './translation-status-summary'; + +const ZERO: StatusCounts = { stale: 0, new: 0, translated: 0, verified: 0 }; + +describe('STATUS_PRECEDENCE', () => { + it('lists every status once, worst first', () => { + expect(STATUS_PRECEDENCE).toEqual(['stale', 'new', 'translated', 'verified']); + }); +}); + +describe('countByStatus', () => { + it.each<[string, (TranslationStatus | undefined)[], StatusCounts]>([ + ['no statuses', [], ZERO], + ['one of each', ['stale', 'new', 'translated', 'verified'], { stale: 1, new: 1, translated: 1, verified: 1 }], + ['repeated statuses', ['verified', 'verified', 'stale'], { ...ZERO, verified: 2, stale: 1 }], + ['undefined (e.g. the base locale) is not counted', [undefined, 'new', undefined], { ...ZERO, new: 1 }], + ])('%s', (_name, statuses, expected) => { + expect(countByStatus(statuses)).toEqual(expected); + }); + + it('ignores values that are not a known status', () => { + const fromTheWire = ['missing', 'toString', 'new'] as unknown as TranslationStatus[]; + expect(countByStatus(fromTheWire)).toEqual({ ...ZERO, new: 1 }); + }); + + it('accepts the values of a status-by-locale map', () => { + const statusByLocale: Record = { + en: undefined, + es: 'translated', + fr: 'verified', + }; + expect(countByStatus(Object.values(statusByLocale))).toEqual({ ...ZERO, translated: 1, verified: 1 }); + }); +}); + +describe('worstStatus', () => { + it('is undefined when every count is zero', () => { + expect(worstStatus(ZERO)).toBeUndefined(); + }); + + it.each(STATUS_PRECEDENCE)('is %s when it is the only status', (status) => { + expect(worstStatus({ ...ZERO, [status]: 3 })).toBe(status); + }); + + // Every pair (a, b) where a is worse than b: a wins regardless of the counts. + const pairs = STATUS_PRECEDENCE.flatMap((worse, i) => + STATUS_PRECEDENCE.slice(i + 1).map((better) => [worse, better]), + ); + + it.each(pairs)('%s beats %s', (worse, better) => { + expect(worstStatus({ ...ZERO, [worse]: 1, [better]: 5 })).toBe(worse); + }); + + it('reports the worst status of a real entry', () => { + expect(worstStatus(countByStatus(['verified', 'translated', 'stale']))).toBe('stale'); + expect(worstStatus(countByStatus(['verified', 'verified']))).toBe('verified'); + }); +}); diff --git a/libs/domain/src/lib/translation-status-summary.ts b/libs/domain/src/lib/translation-status-summary.ts new file mode 100644 index 00000000..36a8f624 --- /dev/null +++ b/libs/domain/src/lib/translation-status-summary.ts @@ -0,0 +1,44 @@ +import type { TranslationStatus } from './translation-status'; + +/** + * Translation status summary — the one home of the roll-up rules for a set of + * locale statuses: how many locales are in each status, and which status is the + * worst. + * + * Every roll-up in the Tracker (an entry's rollup ring, its screen-reader + * breakdown, the locale column, the status filter counts, sort by status) is + * built from these functions, so "what does this entry need" has one answer. + * + * Pure: no Node.js dependencies. + */ + +/** + * Every status, worst first. `stale` leads because published work is now wrong; + * `new` follows because nothing has been done yet. + */ +export const STATUS_PRECEDENCE = [ + 'stale', + 'new', + 'translated', + 'verified', +] as const satisfies readonly TranslationStatus[]; + +/** Number of locales in each translation status. */ +export type StatusCounts = Readonly>; + +/** + * Counts statuses. `undefined` (a locale with no status, such as the base + * locale) and values that are not a known status are not counted. + */ +export function countByStatus(statuses: Iterable): StatusCounts { + const counts: Record = { stale: 0, new: 0, translated: 0, verified: 0 }; + for (const status of statuses) { + if (status !== undefined && STATUS_PRECEDENCE.includes(status)) counts[status] += 1; + } + return counts; +} + +/** The worst status with at least one locale, by {@link STATUS_PRECEDENCE}; `undefined` when every count is zero. */ +export function worstStatus(counts: StatusCounts): TranslationStatus | undefined { + return STATUS_PRECEDENCE.find((status) => counts[status] > 0); +} From 00cb70e93b006a6aa424f500af8c73d63e0c7ce6 Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 01:53:50 -0700 Subject: [PATCH 08/20] refactor(libs): make the domain and core barrels explicit Replace export * with named, grouped exports in @simoncodes-ca/domain and @simoncodes-ca/core (top level and sub-barrels), listing only names with a consumer outside the library. Leaked internals (SafeAny, ErrorMessages, file-io helpers, provider classes, ICU helpers) leave the public surface; findIcuArguments had no consumer and is deleted. TokenCasing moves to domain, and data-transfer's TranslationStatus and TokenCasingDto become type re-exports of the domain types instead of copies. The ICU auto-fixer tests move to domain with the code they test. Co-Authored-By: Claude Fable 5.1 --- apps/cli/src/commands/bundle.ts | 3 +- apps/cli/src/commands/import-cmd.ts | 2 +- .../commands/preferred-terminology.spec.ts | 2 - apps/cli/src/init/init.ts | 2 +- apps/cli/src/types/init-options.ts | 2 +- architecture-docs/README.md | 2 +- architecture-docs/core-library.md | 32 +- architecture-docs/glossary.md | 8 + architecture-docs/monorepo-structure.md | 24 +- libs/core/src/collections-manager/index.ts | 29 +- libs/core/src/config/bundle-definition.ts | 10 +- libs/core/src/config/lingo-tracker-config.ts | 3 +- libs/core/src/index.ts | 234 +++++- libs/core/src/lib/bundle/generate-bundle.ts | 9 +- libs/core/src/lib/bundle/index.ts | 32 +- libs/core/src/lib/bundle/plan-bundle.ts | 4 +- .../bundle/type-generation/generate-types.ts | 4 +- .../type-generation/hierarchy-builder.ts | 2 +- libs/core/src/lib/config/index.ts | 25 +- libs/core/src/lib/errors/index.ts | 23 +- libs/core/src/lib/file-io/index.ts | 2 - libs/core/src/lib/folder/index.ts | 8 +- .../import/icu-auto-fix.integration.spec.ts | 35 +- .../src/lib/import/icu-auto-fixer.spec.ts | 661 ----------------- libs/core/src/lib/import/index.ts | 1 - libs/core/src/lib/normalize/index.ts | 8 +- libs/core/src/lib/resource/index.ts | 46 +- libs/core/src/lib/translation/index.ts | 33 +- libs/core/src/lib/validate/index.ts | 12 +- libs/core/src/resource/index.ts | 17 +- .../src/lib/bundle-definition.dto.ts | 10 +- .../src/lib/translation-status.ts | 4 +- libs/data-transfer/tsconfig.lib.json | 5 + libs/domain/src/index.spec.ts | 63 ++ libs/domain/src/index.ts | 116 ++- libs/domain/src/lib/icu-arguments.spec.ts | 34 +- libs/domain/src/lib/icu-arguments.ts | 41 +- libs/domain/src/lib/icu-auto-fixer.spec.ts | 689 +++++++++++++++++- libs/domain/src/lib/js-identifier.ts | 2 +- libs/domain/src/lib/token-casing.ts | 9 + 40 files changed, 1337 insertions(+), 911 deletions(-) delete mode 100644 libs/core/src/lib/file-io/index.ts delete mode 100644 libs/core/src/lib/import/icu-auto-fixer.spec.ts create mode 100644 libs/domain/src/index.spec.ts create mode 100644 libs/domain/src/lib/token-casing.ts diff --git a/apps/cli/src/commands/bundle.ts b/apps/cli/src/commands/bundle.ts index 90d57a0a..e8ea633f 100644 --- a/apps/cli/src/commands/bundle.ts +++ b/apps/cli/src/commands/bundle.ts @@ -1,5 +1,6 @@ import prompts from 'prompts'; -import type { LingoTrackerConfig, TokenCasing } from '@simoncodes-ca/core'; +import type { LingoTrackerConfig } from '@simoncodes-ca/core'; +import type { TokenCasing } from '@simoncodes-ca/domain'; import { generateBundle, hasTypeDistConfigured } from '@simoncodes-ca/core'; import { loadConfiguration, parseCommaSeparatedList, ConsoleFormatter, ErrorMessages } from '../utils'; import { PromptCancelledError } from '../utils/report-error'; diff --git a/apps/cli/src/commands/import-cmd.ts b/apps/cli/src/commands/import-cmd.ts index d728aa6a..4995c5dc 100644 --- a/apps/cli/src/commands/import-cmd.ts +++ b/apps/cli/src/commands/import-cmd.ts @@ -4,13 +4,13 @@ import { type ImportFormat, type ImportResult, type ImportRunOptions, - type ImportStrategy, importResources, loadPreferredTerminology, parseJsonImport, parseXliffImport, readEffectiveProtectedTerms, } from '@simoncodes-ca/core'; +import type { ImportStrategy } from '@simoncodes-ca/domain'; import * as fs from 'fs'; import * as path from 'path'; import prompts from 'prompts'; diff --git a/apps/cli/src/commands/preferred-terminology.spec.ts b/apps/cli/src/commands/preferred-terminology.spec.ts index 49248d42..aa1c9640 100644 --- a/apps/cli/src/commands/preferred-terminology.spec.ts +++ b/apps/cli/src/commands/preferred-terminology.spec.ts @@ -1,7 +1,6 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { clearPreferredTerminologyCache } from '@simoncodes-ca/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { preferredTerminologyCommand } from './preferred-terminology'; @@ -34,7 +33,6 @@ describe('preferredTerminologyCommand', () => { beforeEach(() => { vi.clearAllMocks(); - clearPreferredTerminologyCache(); projectDir = mkdtempSync(join(tmpdir(), 'lingo-preferred-terminology-')); filePath = join(projectDir, FILE_NAME); config = { baseLocale: 'en', locales: ['en', 'es'], collections: {} }; diff --git a/apps/cli/src/init/init.ts b/apps/cli/src/init/init.ts index 9441cecf..1c4827f1 100644 --- a/apps/cli/src/init/init.ts +++ b/apps/cli/src/init/init.ts @@ -8,9 +8,9 @@ import { type LingoTrackerConfig, type LingoTrackerCollection, type TranslationConfig, - type TokenCasing, type BundleDefinition, } from '@simoncodes-ca/core'; +import type { TokenCasing } from '@simoncodes-ca/domain'; import { getCwd, ConsoleFormatter, executePromptsWithFallback } from '../utils'; const DEFAULT_BUNDLE_DIST = './src/assets/i18n'; diff --git a/apps/cli/src/types/init-options.ts b/apps/cli/src/types/init-options.ts index 81c48185..6fe2175c 100644 --- a/apps/cli/src/types/init-options.ts +++ b/apps/cli/src/types/init-options.ts @@ -1,4 +1,4 @@ -import type { TokenCasing } from '@simoncodes-ca/core'; +import type { TokenCasing } from '@simoncodes-ca/domain'; /** * Configuration options for the init command diff --git a/architecture-docs/README.md b/architecture-docs/README.md index aa17b0b5..58642c21 100644 --- a/architecture-docs/README.md +++ b/architecture-docs/README.md @@ -77,7 +77,7 @@ apps (cli, api, tracker) └── domain (pure logic — browser-safe, no Node.js) ``` -`domain` has zero Node.js dependencies and is safe to import in the browser. `core` depends on `domain` but never the reverse. Apps depend on both; `data-transfer` is leaf-level with no dependencies on `core` or `domain`. +`domain` has zero Node.js dependencies and is safe to import in the browser. `core` depends on `domain` but never the reverse. Apps depend on both; `data-transfer` never imports `core` and takes only two types from `domain` (`TranslationStatus`, `TokenCasing`). --- diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index b9a7a538..36661c27 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -9,6 +9,7 @@ Return to [architecture README](README.md). ## Table of Contents - [Module Map](#module-map) +- [Public Surface](#public-surface) - [Config and Collection Resolution](#config-and-collection-resolution) - [Error Model](#error-model) - [Resource CRUD Flows](#resource-crud-flows) @@ -32,8 +33,8 @@ Return to [architecture README](README.md). ``` libs/core/src/ -├── index.ts # Public barrel — re-exports all sub-modules -├── constants.ts # Shared filenames (resource_entries.json, tracker_meta.json, etc.) +├── index.ts # Public barrel — named exports grouped by role (see Public Surface) +├── constants.ts # CONFIG_FILENAME, DEFAULT_CONFIG (public); resource/meta filenames (internal) │ ├── config/ # Config types used at the root of the package │ ├── lingo-tracker-config.ts # LingoTrackerConfig interface @@ -122,12 +123,12 @@ libs/core/src/ │ ├── delete-folder.ts # deleteFolder(): recursive removal │ └── move-folder.ts # moveFolder(): rename + resource re-key │ - ├── file-io/ # Low-level JSON read/write helpers + ├── file-io/ # Low-level JSON read/write helpers (internal; no barrel) │ ├── json-file-operations.ts # readJsonFile(), writeJsonFile(), typed helpers │ └── directory-operations.ts # ensureDirectoryExists() │ └── errors/ # Error messages and typed errors - ├── error-messages.ts # ErrorMessages: static error string builders + ├── error-messages.ts # ErrorMessages: static error string builders (internal) └── lingo-tracker-error.ts # LingoTrackerError and its typed subclasses (see Error Model) ``` @@ -140,13 +141,13 @@ graph TD API["api"] end - subgraph core["@simoncodes-ca/core (public surface)"] + subgraph core["@simoncodes-ca/core root modules"] RESOURCE["resource/\nadd · edit · delete · move"] COLLECTIONS["collections-manager/\nadd · delete · update"] CONFIG_ROOT["config/\nLingoTrackerConfig\nBundleDefinition\nTranslationConfig"] end - subgraph lib["core/lib/ (internal sub-modules)"] + subgraph lib["core/lib/ sub-modules"] BUNDLE["bundle/\ngenerateBundle"] EXPORT["export/\nrunExport"] IMPORT["import/\nparseJsonImport · parseXliffImport\nimportResources"] @@ -161,7 +162,7 @@ graph TD end subgraph domain["@simoncodes-ca/domain (peer)"] - DOMAIN["validateKey · resolveResourceKey\nsplitResolvedKey · translocoToICU\nicuToTransloco · classifyICUContent\nshouldMarkStale · calculateChecksum"] + DOMAIN["validateKey · resolveResourceKey\nsplitResolvedKey · translocoToICU\nicuToTransloco · classifyICUContent\napplyBaseChange · recordTranslation"] end CLI --> RESOURCE @@ -229,6 +230,23 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that --- +## Public Surface + +`libs/core/src/index.ts` is the [public surface](glossary.md#public-surface): 172 names, listed one by one and grouped by role. It exports only what the API or CLI uses, plus the types in those names' signatures. It does not re-export `domain` names; callers import `TranslationStatus`, `TokenCasing` and `ImportStrategy` from `@simoncodes-ca/domain`. + +| Group | What it holds | +|---|---| +| Operations | The entry points the apps call. Resources: `addResource`, `editResource`, `deleteResource`, `moveResource`, `createDefaultTranslations`. Folders: `createFolder`, `deleteFolder`, `moveFolder`. Collections and locales: `addCollection`, `updateCollection`, `deleteCollectionByName`, `addLocaleToCollection`, `removeLocaleFromCollection`, `setGlobal/CollectionProtectedTerms[File]`. Bundles: `generateBundle`, `planBundle`, `add/update/deleteBundleDefinition`, `validateBundleKey`, `validateBundleDefinition`, `getBundleOutputPath`, `hasTypeDistConfigured`. Import: `importResources` and its adapters. Export: `runExport`, `exportTargetLocales`, the export argument checks, `loadResourcesFromCollections`. Also `normalize`, `translateLocale`, `translateExistingResource`, `validateResources`, `generateValidationSummary`, `describePreferredTermRule`. | +| Collection & config | `loadConfig`, `openCollection`, `Collection`, `CONFIG_FILENAME`, `DEFAULT_CONFIG`, the config types (`LingoTrackerConfig`, `LingoTrackerCollection`, `TranslationConfig`, `BundleDefinition`, ...), and the protected-terms and preferred-terminology file readers and writers. | +| ResourceFolder | `openResourceFolder`, `ResourceFolder` and the types in its methods, `resolveResourcePaths`. | +| Read models | `loadResourceTree`, `extractSubtree`, `extractResourcesRecursively`, `searchTranslations`, `searchResourceTree`, `computeTreeFingerprint`, `treeFingerprintsMatch`, `reindexMutation` and their types. The API's [Collection Index](glossary.md#collection-index) is built from these. | +| Errors | `LingoTrackerError` and every typed subclass, `TranslationError`, `PreferredTerminologyValidationError`. See [Error Model](#error-model). | +| Types | Parameter and result types for the operations above (`AddResourceParams`, `GenerateBundleResult`, `ImportResult`, ...). | + +Each sub-module with a barrel (`resource/`, `collections-manager/`, and `lib/bundle`, `config`, `errors`, `folder`, `import`, `normalize`, `resource`, `translation`, `validate`) lists its own public names the same way, and the root barrel re-exports from it. `lib/export/` has no barrel, so the root barrel imports its files directly. `lib/file-io/` is internal and has no barrel. Everything else is internal: `ErrorMessages`, `calculateChecksum`, the translation provider classes, the bundle helpers, the normalize walker, `SafeAny`, and the like. Core's specs import these by relative path. Test helpers such as `setupMockFs` (`collections-manager/locale-spec-helpers.ts`) are not in any barrel. + +--- + ## Config and Collection Resolution Core owns the config file and the rule that turns a collection's config entry into its effective settings. The adapters (CLI, API) call two functions in `lib/config/` once per command or request, then pass the results to the per-resource operations. diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 4a1694e1..c558c248 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -153,6 +153,14 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md#prot --- +### Public Surface + +The names a library's `index.ts` barrel exports — everything a caller must know to use it. The `domain` and `core` barrels list their exports by name, never with `export *`. They list only names that something outside the library uses, plus the types those names' signatures need. A helper that only its own library uses stays exported from its file but not from the barrel. `libs/domain/src/index.spec.ts` pins domain's runtime exports, so adding one is a deliberate change. + +Explained in context: [`monorepo-structure.md`](monorepo-structure.md#public-surface), [`core-library.md`](core-library.md#public-surface) + +--- + ## R ### Resource Entry diff --git a/architecture-docs/monorepo-structure.md b/architecture-docs/monorepo-structure.md index c575e57a..1f2e26e5 100644 --- a/architecture-docs/monorepo-structure.md +++ b/architecture-docs/monorepo-structure.md @@ -14,6 +14,7 @@ Return to [architecture README](README.md). - [domain — browser-safe pure logic](#domain--browser-safe-pure-logic) - [core — Node.js business logic](#core--nodejs-business-logic) - [data-transfer — API contract DTOs](#data-transfer--api-contract-dtos) + - [Public surface](#public-surface) - [Nx Workspace Configuration Highlights](#nx-workspace-configuration-highlights) - [Build targets by project](#build-targets-by-project) - [Test runner: Vitest](#test-runner-vitest) @@ -45,6 +46,7 @@ lingo-tracker/ # Nx workspace root │ ├── domain/ # Browser-safe pure logic (zero Node.js deps) │ │ └── src/lib/ │ │ ├── translation-status.ts # TranslationStatus type +│ │ ├── token-casing.ts # TokenCasing type │ │ ├── translation-status-summary.ts # Status counts and worst-status precedence │ │ ├── locale-metadata.ts # LocaleMetadata interface │ │ ├── resource-key.ts # Key validation, resolve, split @@ -119,6 +121,7 @@ graph TD Tracker --> Domain Core --> Domain + DT -.->|types only| Domain style Domain fill:#d4edda,stroke:#28a745,color:#000 style Core fill:#d1ecf1,stroke:#17a2b8,color:#000 @@ -128,7 +131,7 @@ graph TD style Tracker fill:#f8f9fa,stroke:#6c757d,color:#000 ``` -**Arrows point in the direction of the import.** No arrow ever points toward `apps`; no arrow ever points from `domain` to `core`. `data-transfer` has no dependencies on `core` or `domain` — it is a leaf library. +**Arrows point in the direction of the import.** No arrow ever points toward `apps`; no arrow ever points from `domain` to `core`. `data-transfer` never imports `core`; it imports two types from `domain` (see [data-transfer](#data-transfer--api-contract-dtos)). --- @@ -141,6 +144,7 @@ graph TD | Module | What it does | |---|---| | `translation-status.ts` | Defines the `TranslationStatus` union type (`'new' \| 'translated' \| 'stale' \| 'verified'`) | +| `token-casing.ts` | Defines the `TokenCasing` union type (`'upperCase' \| 'camelCase'`) for generated bundle tokens | | `translation-status-summary.ts` | The [translation status summary](glossary.md#translation-status-summary): `countByStatus`, `worstStatus`, and `STATUS_PRECEDENCE` (worst first) | | `locale-metadata.ts` | Defines the `LocaleMetadata` interface (checksum, baseChecksum, status) | | `resource-key.ts` | Validates, resolves (`resolveResourceKey`), and splits (`splitResolvedKey`) dot-delimited keys | @@ -182,7 +186,7 @@ See [core-library.md](core-library.md) for the full module breakdown. ### data-transfer — API contract DTOs -`@simoncodes-ca/data-transfer` is a leaf library: it imports nothing from `core` or `domain`. It contains only TypeScript interfaces and classes that define the shapes of HTTP request bodies, response payloads, and shared view models exchanged between the API, CLI, and Tracker UI. +`@simoncodes-ca/data-transfer` imports nothing from `core`. From `domain` it takes two types, with `import type`, so they are declared once: `TranslationStatus` (re-exported as is) and `TokenCasing` (aliased as `TokenCasingDto`). Both are browser-safe and erased at build time. Otherwise it contains only TypeScript interfaces and classes that define the shapes of HTTP request bodies, response payloads, and shared view models exchanged between the API, CLI, and Tracker UI. **Why isolate DTOs in their own library?** API contracts must be stable across all three consumers. Keeping DTOs in a dedicated library with no business logic means: @@ -190,6 +194,22 @@ See [core-library.md](core-library.md) for the full module breakdown. 2. Breaking changes to the API surface are localized here and immediately visible to all consumers via TypeScript compilation. 3. The library has no runtime behavior to test, so its `project.json` intentionally has an empty `targets` block. +The bundle DTOs (`BundleDefinitionDto`, `CollectionBundleDefinitionDto`, `EntrySelectionRuleDto`) still mirror core's `BundleDefinition` field for field, which is why `apps/api/src/app/mappers/bundle.mapper.ts` copies every field in both directions. + +--- + +### Public surface + +Each library's [public surface](glossary.md#public-surface) is its `src/index.ts` barrel. The `domain` and `core` barrels list every export by name and group them with a one-line comment per group. They export only what a caller outside the library uses, plus the types in those names' signatures. Everything else is a module detail: it can stay exported from its own file for the library's specs, but not from the barrel. + +| Library | Barrel | Groups | +|---|---|---| +| `domain` | `libs/domain/src/index.ts` (69 names) | shared types, keys, staleness, status summary, ICU/Transloco, validation, terminology, references, tags, utilities. `index.spec.ts` pins the runtime export list. | +| `core` | `libs/core/src/index.ts` (172 names) | operations, collection & config, `ResourceFolder`, read models, errors, operation parameter and result types. See [core-library.md](core-library.md#public-surface). | +| `data-transfer` | `libs/data-transfer/src/index.ts` | `export *` of each DTO file. The library holds only DTOs, so every export is part of the contract. | + +`core` does not re-export `domain` names. A caller that needs `TranslationStatus`, `TokenCasing` or `ImportStrategy` imports it from `@simoncodes-ca/domain`. + --- ## Nx Workspace Configuration Highlights diff --git a/libs/core/src/collections-manager/index.ts b/libs/core/src/collections-manager/index.ts index 7fc44d51..314129a7 100644 --- a/libs/core/src/collections-manager/index.ts +++ b/libs/core/src/collections-manager/index.ts @@ -1,6 +1,23 @@ -export * from './delete-collection-by-name'; -export * from './add-collection'; -export * from './update-collection'; -export * from './add-locale-to-collection'; -export * from './remove-locale-from-collection'; -export * from './set-protected-terms'; +// Collection and locale operations: each edits .lingo-tracker.json (and, for locales, the resource tree). + +export { addCollection, type AddCollectionOptions } from './add-collection'; +export { + type AddLocaleToCollectionOptions, + type AddLocaleToCollectionResult, + addLocaleToCollection, +} from './add-locale-to-collection'; +export { type DeleteCollectionOptions, deleteCollectionByName } from './delete-collection-by-name'; +export { + type RemoveLocaleFromCollectionOptions, + type RemoveLocaleFromCollectionResult, + removeLocaleFromCollection, +} from './remove-locale-from-collection'; +export { + type SetProtectedTermsOptions, + type SetProtectedTermsResult, + setCollectionProtectedTerms, + setCollectionProtectedTermsFile, + setGlobalProtectedTerms, + setGlobalProtectedTermsFile, +} from './set-protected-terms'; +export { type UpdateCollectionOptions, updateCollection } from './update-collection'; diff --git a/libs/core/src/config/bundle-definition.ts b/libs/core/src/config/bundle-definition.ts index 43bfc8a9..679778e0 100644 --- a/libs/core/src/config/bundle-definition.ts +++ b/libs/core/src/config/bundle-definition.ts @@ -2,15 +2,7 @@ * Bundle configuration interfaces for generating deployment artifacts */ -/** - * Controls the casing of generated TypeScript token property keys. - * - 'upperCase': SCREAMING_SNAKE_CASE (e.g. FILE_UPLOAD) — default, fully backward compatible - * - 'camelCase': camelCase (e.g. fileUpload) - * - * Note: The const name (e.g. TRACKER_TOKENS) and type name (e.g. TrackerTokens) - * are always SCREAMING_SNAKE_CASE and PascalCase respectively, regardless of this setting. - */ -export type TokenCasing = 'upperCase' | 'camelCase'; +import type { TokenCasing } from '@simoncodes-ca/domain'; /** * Pattern and tag-based rule for selecting which entries to include in a bundle diff --git a/libs/core/src/config/lingo-tracker-config.ts b/libs/core/src/config/lingo-tracker-config.ts index 1a09d027..11bd9525 100644 --- a/libs/core/src/config/lingo-tracker-config.ts +++ b/libs/core/src/config/lingo-tracker-config.ts @@ -1,4 +1,5 @@ -import type { BundleDefinition, TokenCasing } from './bundle-definition'; +import type { TokenCasing } from '@simoncodes-ca/domain'; +import type { BundleDefinition } from './bundle-definition'; import type { LingoTrackerCollection } from './lingo-tracker-collection'; import type { TranslationConfig } from './translation-config'; diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 3a94d7bb..180fe9e1 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,24 +1,210 @@ -export * from './collections-manager'; -export * from './config/bundle-definition'; -export * from './config/lingo-tracker-collection'; -export * from './config/lingo-tracker-config'; -export * from './config/translation-config'; -export * from './constants'; -export * from './lib/bundle'; -export * from './lib/config'; -export * from './lib/errors'; -export * from './lib/export/export-common'; -export * from './lib/export/export-summary'; -export * from './lib/export/export-to-json'; -export * from './lib/export/export-to-xliff'; -export * from './lib/export/run-export'; -export * from './lib/export/types'; -// Export new utilities -export * from './lib/file-io'; -export * from './lib/folder'; -export * from './lib/import'; -export * from './lib/normalize'; -export * from './lib/resource'; -export * from './lib/translation'; -export * from './lib/validate'; -export * from './resource'; +// The public surface of @simoncodes-ca/core: the Node-side operations the API and CLI call. +// Only names with a consumer outside this library are listed, plus the types their signatures use. +// Domain rules and types (TranslationStatus, TokenCasing, ImportStrategy, ...) come from @simoncodes-ca/domain. + +// Operations: resources +export { addResource, createDefaultTranslations, deleteResource, editResource, moveResource } from './resource'; + +// Operations: folders +export { createFolder, deleteFolder, moveFolder } from './lib/folder'; + +// Operations: collections and locales +export { + addCollection, + addLocaleToCollection, + deleteCollectionByName, + removeLocaleFromCollection, + setCollectionProtectedTerms, + setCollectionProtectedTermsFile, + setGlobalProtectedTerms, + setGlobalProtectedTermsFile, + updateCollection, +} from './collections-manager'; + +// Operations: bundles +export { hasTypeDistConfigured } from './config/bundle-definition'; +export { + addBundleDefinition, + deleteBundleDefinition, + generateBundle, + getBundleOutputPath, + planBundle, + updateBundleDefinition, + validateBundleDefinition, + validateBundleKey, +} from './lib/bundle'; + +// Operations: import +export { + detectImportFormat, + generateImportSummary, + importResources, + parseJsonImport, + parseXliffImport, +} from './lib/import'; + +// Operations: export +export { + loadResourcesFromCollections, + validateBasePropertyName, + validateOutputDirectory, +} from './lib/export/export-common'; +export { exportTargetLocales, runExport } from './lib/export/run-export'; + +// Operations: normalize, translate, validate +export { normalize } from './lib/normalize'; +export { translateExistingResource, translateLocale } from './lib/translation'; +export { describePreferredTermRule, generateValidationSummary, validateResources } from './lib/validate'; + +// Collection & config +export type { BundleDefinition, CollectionBundleDefinition, EntrySelectionRule } from './config/bundle-definition'; +export type { LingoTrackerCollection } from './config/lingo-tracker-collection'; +export type { LingoTrackerConfig } from './config/lingo-tracker-config'; +export type { TranslationConfig } from './config/translation-config'; +export { CONFIG_FILENAME, DEFAULT_CONFIG } from './constants'; +export { + type Collection, + type LoadPreferredTerminologyResult, + loadConfig, + loadPreferredTerminology, + openCollection, + type ResolvedProtectedTerms, + readCollectionProtectedTerms, + readEffectiveProtectedTerms, + readGlobalProtectedTerms, + resolveCollectionProtectedTermsFilePath, + resolveGlobalProtectedTermsFilePath, + resolvePreferredTerminologyFilePath, + resolveProtectedTermsForConfig, + writePreferredTerminology, +} from './lib/config'; + +// ResourceFolder: one folder's entries and metadata, loaded and saved as a unit +export { + type EntryDetails, + openResourceFolder, + type ResolvedResourcePaths, + type ResourceFolder, + type ResourceFolderEntry, + type ResourceFolderSaveResult, + resolveResourcePaths, +} from './lib/resource'; + +// Read models: the resource tree, search and fingerprints behind the API's CollectionIndex +export { + computeTreeFingerprint, + extractResourcesRecursively, + extractSubtree, + type FolderChild, + loadResourceTree, + type MatchType, + type ResourceMutation, + type ResourceTreeEntry, + type ResourceTreeNode, + reindexMutation, + type SearchResult, + searchResourceTree, + searchTranslations, + type TreeFingerprint, + treeFingerprintsMatch, +} from './lib/resource'; + +// Errors +export { PreferredTerminologyValidationError } from './lib/config'; +export { + BaseLocaleImmutableError, + BundleAlreadyExistsError, + BundleNotFoundError, + CollectionAlreadyExistsError, + CollectionNotFoundError, + ConfigNotFoundError, + ConfigParseError, + type FolderPathPart, + InvalidBundleDefinitionError, + InvalidFolderPathError, + InvalidLocaleError, + InvalidResourceKeyError, + LingoTrackerError, + LocaleAlreadyExistsError, + LocaleNotFoundError, + ReadOnlyCollectionError, + ResourceNotFoundError, +} from './lib/errors'; +export { TranslationError } from './lib/translation'; + +// Types: operation parameters and results +export type { + AddCollectionOptions, + AddLocaleToCollectionOptions, + AddLocaleToCollectionResult, + DeleteCollectionOptions, + RemoveLocaleFromCollectionOptions, + RemoveLocaleFromCollectionResult, + SetProtectedTermsOptions, + SetProtectedTermsResult, + UpdateCollectionOptions, +} from './collections-manager'; +export type { + BundleDefinitionOperationOptions, + BundlePlan, + BundlePlanExampleKey, + BundlePlanFile, + BundleProgressEvent, + GenerateBundleParams, + GenerateBundleResult, + PlanBundleParams, + UpdateBundleDefinitionOptions, +} from './lib/bundle'; +export type { LoadConfigOptions, OpenCollectionOptions } from './lib/config'; +export type { LoadedResource } from './lib/export/export-common'; +export type { ExportLocaleResult, ExportRunOptions, ExportRunResult } from './lib/export/run-export'; +export type { ExportFormat, ExportResult } from './lib/export/types'; +export type { + CreateFolderParams, + CreateFolderResult, + DeleteFolderParams, + DeleteFolderResult, + MoveFolderParams, + MoveFolderResult, +} from './lib/folder'; +export type { + ICUAutoFix, + ICUAutoFixError, + ImportChange, + ImportChangeType, + ImportedResource, + ImportFormat, + ImportParseOptions, + ImportResult, + ImportRunOptions, + ImportSummaryOptions, + StatusTransition, +} from './lib/import'; +export type { NormalizeParams, NormalizeResult } from './lib/normalize'; +export type { + ComputeTreeFingerprintOptions, + LoadResourceTreeOptions, + OpenResourceFolderOptions, + ResourcePathResolutionParams, + SearchParams, + SearchTreeParams, +} from './lib/resource'; +export type { + TranslateExistingResourceOptions, + TranslateExistingResourceResult, + TranslateLocaleParams, + TranslateLocaleProgress, + TranslateLocaleResult, +} from './lib/translation'; +export type { ResourceValidationResult, ValidationOptions } from './lib/validate'; +export type { + AddResourceOptions, + AddResourceParams, + DeleteResourceParams, + DeleteResourceResult, + EditResourceOptions, + EditResourceResult, + MoveResourceParams, + MoveResourceResult, + ResourceEntryMetadata, +} from './resource'; diff --git a/libs/core/src/lib/bundle/generate-bundle.ts b/libs/core/src/lib/bundle/generate-bundle.ts index 643146f0..b100a449 100644 --- a/libs/core/src/lib/bundle/generate-bundle.ts +++ b/libs/core/src/lib/bundle/generate-bundle.ts @@ -4,13 +4,18 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { effectiveTags, hasUnbundlableBranchBody, icuToTransloco, validateICUSyntax } from '@simoncodes-ca/domain'; +import { + effectiveTags, + hasUnbundlableBranchBody, + icuToTransloco, + type TokenCasing, + validateICUSyntax, +} from '@simoncodes-ca/domain'; import { type BundleDefinition, type CollectionBundleDefinition, type EntrySelectionRule, hasTypeDistConfigured, - type TokenCasing, } from '../../config/bundle-definition'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; import type { ResourceEntries } from '../../resource/resource-entry'; diff --git a/libs/core/src/lib/bundle/index.ts b/libs/core/src/lib/bundle/index.ts index 1775ac57..665c476b 100644 --- a/libs/core/src/lib/bundle/index.ts +++ b/libs/core/src/lib/bundle/index.ts @@ -1,8 +1,24 @@ -export * from './generate-bundle'; -export * from './plan-bundle'; -export * from './validate-bundle-definition'; -export * from './bundle-definition-operations'; -export * from './pattern-matcher'; -export * from './tag-filter'; -export * from './hierarchy-builder'; -export * from './resource-loader'; +// The bundle module: plan or generate bundles, and edit the bundle definitions in config. + +export { + addBundleDefinition, + type BundleDefinitionOperationOptions, + deleteBundleDefinition, + type UpdateBundleDefinitionOptions, + updateBundleDefinition, +} from './bundle-definition-operations'; +export { + type BundleProgressEvent, + type GenerateBundleParams, + type GenerateBundleResult, + generateBundle, + getBundleOutputPath, +} from './generate-bundle'; +export { + type BundlePlan, + type BundlePlanExampleKey, + type BundlePlanFile, + type PlanBundleParams, + planBundle, +} from './plan-bundle'; +export { validateBundleDefinition, validateBundleKey } from './validate-bundle-definition'; diff --git a/libs/core/src/lib/bundle/plan-bundle.ts b/libs/core/src/lib/bundle/plan-bundle.ts index 8362e844..84aa4161 100644 --- a/libs/core/src/lib/bundle/plan-bundle.ts +++ b/libs/core/src/lib/bundle/plan-bundle.ts @@ -9,8 +9,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { detectHierarchicalConflicts } from '@simoncodes-ca/domain'; -import { type BundleDefinition, hasTypeDistConfigured, type TokenCasing } from '../../config/bundle-definition'; +import { detectHierarchicalConflicts, type TokenCasing } from '@simoncodes-ca/domain'; +import { type BundleDefinition, hasTypeDistConfigured } from '../../config/bundle-definition'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; import type { ResourceEntries } from '../../resource/resource-entry'; import { type BundleKeyTrace, collectBundleData, getBundleOutputPath } from './generate-bundle'; diff --git a/libs/core/src/lib/bundle/type-generation/generate-types.ts b/libs/core/src/lib/bundle/type-generation/generate-types.ts index 00d9ed06..c161d85e 100644 --- a/libs/core/src/lib/bundle/type-generation/generate-types.ts +++ b/libs/core/src/lib/bundle/type-generation/generate-types.ts @@ -1,11 +1,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { LingoTrackerConfig } from '../../../config/lingo-tracker-config'; -import { type BundleDefinition, hasTypeDistConfigured, type TokenCasing } from '../../../config/bundle-definition'; +import { type BundleDefinition, hasTypeDistConfigured } from '../../../config/bundle-definition'; import { loadCollectionResources } from '../resource-loader'; import { matchesPattern } from '../pattern-matcher'; import { matchesTags } from '../tag-filter'; -import { effectiveTags } from '@simoncodes-ca/domain'; +import { effectiveTags, type TokenCasing } from '@simoncodes-ca/domain'; import { buildTypeHierarchy, serializeHierarchy } from './hierarchy-builder'; import { generateFileHeader } from './file-header'; import { bundleKeyToConstantName, validateJavaScriptIdentifier } from './key-transformer'; diff --git a/libs/core/src/lib/bundle/type-generation/hierarchy-builder.ts b/libs/core/src/lib/bundle/type-generation/hierarchy-builder.ts index 6a46aa3e..9e0b4f02 100644 --- a/libs/core/src/lib/bundle/type-generation/hierarchy-builder.ts +++ b/libs/core/src/lib/bundle/type-generation/hierarchy-builder.ts @@ -1,5 +1,5 @@ import { segmentToPropertyName, splitKeyIntoSegments, constantNameToTypeName } from './key-transformer'; -import type { TokenCasing } from '../../../config/bundle-definition'; +import type { TokenCasing } from '@simoncodes-ca/domain'; export interface TypeHierarchyNode { children: Record; diff --git a/libs/core/src/lib/config/index.ts b/libs/core/src/lib/config/index.ts index 00847e32..fb51c5c6 100644 --- a/libs/core/src/lib/config/index.ts +++ b/libs/core/src/lib/config/index.ts @@ -1,5 +1,20 @@ -export * from './config-file-operations'; -export * from './preferred-terminology-file'; -export * from './protected-terms-file'; -export * from './load-config'; -export * from './open-collection'; +// Config and collections: load .lingo-tracker.json, open a collection, and read or write its terminology files. + +export { type LoadConfigOptions, loadConfig } from './load-config'; +export { type Collection, type OpenCollectionOptions, openCollection } from './open-collection'; +export { + type LoadPreferredTerminologyResult, + loadPreferredTerminology, + PreferredTerminologyValidationError, + resolvePreferredTerminologyFilePath, + writePreferredTerminology, +} from './preferred-terminology-file'; +export { + type ResolvedProtectedTerms, + readCollectionProtectedTerms, + readEffectiveProtectedTerms, + readGlobalProtectedTerms, + resolveCollectionProtectedTermsFilePath, + resolveGlobalProtectedTermsFilePath, + resolveProtectedTermsForConfig, +} from './protected-terms-file'; diff --git a/libs/core/src/lib/errors/index.ts b/libs/core/src/lib/errors/index.ts index 50bdac62..185a231d 100644 --- a/libs/core/src/lib/errors/index.ts +++ b/libs/core/src/lib/errors/index.ts @@ -1,2 +1,21 @@ -export * from './error-messages'; -export * from './lingo-tracker-error'; +// Typed core errors. Callers map them by class (the API's exception filter, the CLI's reporter). + +export type { FolderPathPart } from './error-messages'; +export { + BaseLocaleImmutableError, + BundleAlreadyExistsError, + BundleNotFoundError, + CollectionAlreadyExistsError, + CollectionNotFoundError, + ConfigNotFoundError, + ConfigParseError, + InvalidBundleDefinitionError, + InvalidFolderPathError, + InvalidLocaleError, + InvalidResourceKeyError, + LingoTrackerError, + LocaleAlreadyExistsError, + LocaleNotFoundError, + ReadOnlyCollectionError, + ResourceNotFoundError, +} from './lingo-tracker-error'; diff --git a/libs/core/src/lib/file-io/index.ts b/libs/core/src/lib/file-io/index.ts deleted file mode 100644 index 7933d0cf..00000000 --- a/libs/core/src/lib/file-io/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './json-file-operations'; -export * from './directory-operations'; diff --git a/libs/core/src/lib/folder/index.ts b/libs/core/src/lib/folder/index.ts index 9e63dd4e..f08aed95 100644 --- a/libs/core/src/lib/folder/index.ts +++ b/libs/core/src/lib/folder/index.ts @@ -1,3 +1,5 @@ -export * from './create-folder'; -export * from './delete-folder'; -export * from './move-folder'; +// Folder operations on a collection's resource tree. + +export { type CreateFolderParams, type CreateFolderResult, createFolder } from './create-folder'; +export { type DeleteFolderParams, type DeleteFolderResult, deleteFolder } from './delete-folder'; +export { type MoveFolderParams, type MoveFolderResult, moveFolder } from './move-folder'; diff --git a/libs/core/src/lib/import/icu-auto-fix.integration.spec.ts b/libs/core/src/lib/import/icu-auto-fix.integration.spec.ts index 5ea69084..2cb19932 100644 --- a/libs/core/src/lib/import/icu-auto-fix.integration.spec.ts +++ b/libs/core/src/lib/import/icu-auto-fix.integration.spec.ts @@ -1,10 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { - autoFixICUPlaceholders, - hasICUPlaceholders, - extractICUPlaceholders, - validateICUSyntax, -} from '@simoncodes-ca/domain'; +import { autoFixICUPlaceholders } from '@simoncodes-ca/domain'; import { applyICUAutoFixToResources } from './apply-icu-auto-fix'; import type { ImportedResource } from './types'; @@ -229,34 +224,6 @@ describe('ICU Auto-Fix Integration', () => { }); }); - describe('Utility Functions', () => { - it('hasICUPlaceholders should detect various placeholder types', () => { - expect(hasICUPlaceholders('Hello {name}')).toBe(true); - expect(hasICUPlaceholders('{count, plural, one {#} other {#}}')).toBe(true); - expect(hasICUPlaceholders('{gender, select, male {he} female {she}}')).toBe(true); - expect(hasICUPlaceholders('No placeholders')).toBe(false); - expect(hasICUPlaceholders("'{escaped}'")).toBe(false); - }); - - it('extractICUPlaceholders should extract all placeholder types', () => { - const result = extractICUPlaceholders('Hello {name}, you have {count, plural, one {# item} other {# items}}'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(2); - expect(result.placeholders[0].name).toBe('name'); - expect(result.placeholders[0].type).toBe('simple'); - expect(result.placeholders[1].name).toBe('count'); - expect(result.placeholders[1].type).toBe('plural'); - }); - - it('validateICUSyntax should validate syntax', () => { - expect(validateICUSyntax('Hello {name}')).toBe(true); - expect(validateICUSyntax('{count, plural, one {#} other {#}}')).toBe(true); - expect(validateICUSyntax('Hello {name')).toBe(false); - expect(validateICUSyntax('Hello name}')).toBe(false); - }); - }); - describe('Performance and Scale', () => { it('should handle large number of resources efficiently', () => { const resources: ImportedResource[] = []; diff --git a/libs/core/src/lib/import/icu-auto-fixer.spec.ts b/libs/core/src/lib/import/icu-auto-fixer.spec.ts deleted file mode 100644 index 663646cc..00000000 --- a/libs/core/src/lib/import/icu-auto-fixer.spec.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - extractICUPlaceholders, - hasICUPlaceholders, - autoFixICUPlaceholders, - validateICUSyntax, - hasTranslocoPlaceholders, - extractTranslocoPlaceholders, - autoFixTranslocoPlaceholders, -} from '@simoncodes-ca/domain'; - -describe('icu-auto-fixer', () => { - describe('hasICUPlaceholders', () => { - it('should detect simple placeholders', () => { - expect(hasICUPlaceholders('Hello {name}')).toBe(true); - expect(hasICUPlaceholders('You have {count} items')).toBe(true); - expect(hasICUPlaceholders('{0} of {1}')).toBe(true); - }); - - it('should detect plural placeholders', () => { - expect(hasICUPlaceholders('{count, plural, one {# item} other {# items}}')).toBe(true); - }); - - it('should detect select placeholders', () => { - expect(hasICUPlaceholders('{gender, select, male {he} female {she} other {they}}')).toBe(true); - }); - - it('should return false for strings without placeholders', () => { - expect(hasICUPlaceholders('Hello world')).toBe(false); - expect(hasICUPlaceholders('No placeholders here')).toBe(false); - expect(hasICUPlaceholders('')).toBe(false); - }); - - it('should ignore escaped braces', () => { - expect(hasICUPlaceholders("This is '{not a placeholder}'")).toBe(false); - expect(hasICUPlaceholders("'{literal braces}' are ignored")).toBe(false); - }); - - it('should detect mixed escaped and real placeholders', () => { - expect(hasICUPlaceholders("'{escaped}' and {real}")).toBe(true); - }); - }); - - describe('extractICUPlaceholders', () => { - describe('simple placeholders', () => { - it('should extract single placeholder', () => { - const result = extractICUPlaceholders('Hello {name}'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(1); - expect(result.placeholders[0].name).toBe('name'); - expect(result.placeholders[0].type).toBe('simple'); - expect(result.placeholders[0].fullText).toBe('{name}'); - expect(result.textSegments).toEqual(['Hello ', '']); - }); - - it('should extract multiple placeholders', () => { - const result = extractICUPlaceholders('Hello {firstName} {lastName}'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(2); - expect(result.placeholders[0].name).toBe('firstName'); - expect(result.placeholders[1].name).toBe('lastName'); - expect(result.textSegments).toEqual(['Hello ', ' ', '']); - }); - - it('should extract numeric placeholders', () => { - const result = extractICUPlaceholders('{0} of {1}'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(2); - expect(result.placeholders[0].name).toBe('0'); - expect(result.placeholders[1].name).toBe('1'); - }); - - it('should handle placeholder at start', () => { - const result = extractICUPlaceholders('{name} is here'); - - expect(result.success).toBe(true); - expect(result.textSegments[0]).toBe(''); - expect(result.textSegments[1]).toBe(' is here'); - }); - - it('should handle placeholder at end', () => { - const result = extractICUPlaceholders('Hello {name}'); - - expect(result.success).toBe(true); - expect(result.textSegments[0]).toBe('Hello '); - expect(result.textSegments[1]).toBe(''); - }); - - it('should handle consecutive placeholders', () => { - const result = extractICUPlaceholders('{firstName}{lastName}'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(2); - expect(result.textSegments).toEqual(['', '', '']); - }); - }); - - describe('plural placeholders', () => { - it('should extract plural placeholder', () => { - const result = extractICUPlaceholders('{count, plural, one {# item} other {# items}}'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(1); - expect(result.placeholders[0].name).toBe('count'); - expect(result.placeholders[0].type).toBe('plural'); - expect(result.placeholders[0].fullText).toBe('{count, plural, one {# item} other {# items}}'); - }); - - it('should extract plural with surrounding text', () => { - const result = extractICUPlaceholders('You have {count, plural, one {# item} other {# items}} in cart'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(1); - expect(result.textSegments).toEqual(['You have ', ' in cart']); - }); - - it('should extract complex plural with zero case', () => { - const result = extractICUPlaceholders('{count, plural, =0 {no items} one {# item} other {# items}}'); - - expect(result.success).toBe(true); - expect(result.placeholders[0].type).toBe('plural'); - }); - }); - - describe('select placeholders', () => { - it('should extract select placeholder', () => { - const result = extractICUPlaceholders('{gender, select, male {he} female {she} other {they}}'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(1); - expect(result.placeholders[0].name).toBe('gender'); - expect(result.placeholders[0].type).toBe('select'); - }); - - it('should extract select with surrounding text', () => { - const result = extractICUPlaceholders( - 'The user said {gender, select, male {he is} female {she is} other {they are}} happy', - ); - - expect(result.success).toBe(true); - expect(result.textSegments).toEqual(['The user said ', ' happy']); - }); - }); - - describe('number/date/time formatters', () => { - it('should extract number formatter', () => { - const result = extractICUPlaceholders('Price: {price, number, currency}'); - - expect(result.success).toBe(true); - expect(result.placeholders[0].name).toBe('price'); - expect(result.placeholders[0].type).toBe('number'); - }); - - it('should extract date formatter', () => { - const result = extractICUPlaceholders('Date: {today, date, short}'); - - expect(result.success).toBe(true); - expect(result.placeholders[0].type).toBe('date'); - }); - - it('should extract time formatter', () => { - const result = extractICUPlaceholders('Time: {now, time, medium}'); - - expect(result.success).toBe(true); - expect(result.placeholders[0].type).toBe('time'); - }); - }); - - describe('escaped braces', () => { - it('should ignore escaped braces in text', () => { - const result = extractICUPlaceholders("This is '{not a placeholder}'"); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(0); - expect(result.textSegments).toEqual(["This is '{not a placeholder}'"]); - }); - - it('should handle mixed escaped and real placeholders', () => { - const result = extractICUPlaceholders("'{escaped}' and {real}"); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(1); - expect(result.placeholders[0].name).toBe('real'); - }); - }); - - describe('nested patterns', () => { - it('should handle nested plural with placeholders inside', () => { - const result = extractICUPlaceholders( - '{count, plural, one {You have {count} item} other {You have {count} items}}', - ); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(1); - expect(result.placeholders[0].type).toBe('plural'); - }); - }); - - describe('edge cases', () => { - it('should handle empty string', () => { - const result = extractICUPlaceholders(''); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(0); - expect(result.textSegments).toEqual(['']); - }); - - it('should handle string with no placeholders', () => { - const result = extractICUPlaceholders('Just plain text'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(0); - expect(result.textSegments).toEqual(['Just plain text']); - }); - - it('should handle whitespace in placeholders', () => { - const result = extractICUPlaceholders('Hello { name }'); - - expect(result.success).toBe(true); - expect(result.placeholders[0].name).toBe('name'); - }); - - it('should detect unmatched opening brace', () => { - const result = extractICUPlaceholders('Hello {name'); - - expect(result.success).toBe(false); - expect(result.error).toContain('Unclosed placeholder'); - }); - - it('should detect unmatched closing brace', () => { - const result = extractICUPlaceholders('Hello name}'); - - expect(result.success).toBe(false); - expect(result.error).toContain('Unmatched closing brace'); - }); - }); - }); - - describe('validateICUSyntax', () => { - it('should validate correct ICU syntax', () => { - expect(validateICUSyntax('Hello {name}')).toBe(true); - expect(validateICUSyntax('{count, plural, one {# item} other {# items}}')).toBe(true); - expect(validateICUSyntax('No placeholders')).toBe(true); - }); - - it('should reject invalid ICU syntax', () => { - expect(validateICUSyntax('Hello {name')).toBe(false); - expect(validateICUSyntax('Hello name}')).toBe(false); - expect(validateICUSyntax('Hello {{{name}}}')).toBe(false); - }); - }); - - describe('autoFixICUPlaceholders', () => { - describe('no fix needed', () => { - it('should return unchanged if base has no placeholders', () => { - const result = autoFixICUPlaceholders('Hello world', 'Hola mundo'); - - expect(result.wasFixed).toBe(false); - expect(result.value).toBe('Hola mundo'); - }); - - it('should return unchanged if placeholders already match', () => { - const result = autoFixICUPlaceholders('Hello {name}', 'Hola {name}'); - - expect(result.wasFixed).toBe(false); - expect(result.value).toBe('Hola {name}'); - }); - - it('should return unchanged if plural placeholders match', () => { - const result = autoFixICUPlaceholders( - '{count, plural, one {# item} other {# items}}', - '{count, plural, one {# elemento} other {# elementos}}', - ); - - expect(result.wasFixed).toBe(false); - expect(result.value).toBe('{count, plural, one {# elemento} other {# elementos}}'); - }); - }); - - describe('simple placeholder replacement', () => { - it('should fix renamed placeholder', () => { - const result = autoFixICUPlaceholders('Hello {name}', 'Hola {nombre}'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Hola {name}'); - expect(result.description).toContain('{nombre} → {name}'); - expect(result.originalPlaceholders).toEqual(['{nombre}']); - expect(result.fixedPlaceholders).toEqual(['{name}']); - }); - - it('should fix multiple renamed placeholders', () => { - const result = autoFixICUPlaceholders('Hello {firstName} {lastName}', 'Hola {nombre} {apellido}'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Hola {firstName} {lastName}'); - expect(result.originalPlaceholders).toHaveLength(2); - expect(result.fixedPlaceholders).toHaveLength(2); - }); - - it('should preserve translated text while fixing placeholders', () => { - const result = autoFixICUPlaceholders('You have {count} items', 'Tienes {numero} elementos'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Tienes {count} elementos'); - }); - - it('should fix placeholder with different position in translation', () => { - const result = autoFixICUPlaceholders('{count} items in cart', 'Hay {cantidad} elementos en el carrito'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Hay {count} elementos en el carrito'); - }); - }); - - describe('plural and select placeholder fixing', () => { - it('should fix renamed plural placeholder', () => { - const result = autoFixICUPlaceholders( - '{count, plural, one {# item} other {# items}}', - '{numero, plural, one {# elemento} other {# elementos}}', - ); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('{count, plural, one {# elemento} other {# elementos}}'); - expect(result.description).toContain('{numero, plural'); - }); - - it('should fix renamed select placeholder', () => { - const result = autoFixICUPlaceholders( - '{gender, select, male {he} female {she} other {they}}', - '{genero, select, male {él} female {ella} other {ellos}}', - ); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('{gender, select, male {él} female {ella} other {ellos}}'); - }); - - it('should fix plural with surrounding text', () => { - const result = autoFixICUPlaceholders( - 'You have {count, plural, one {# item} other {# items}} in cart', - 'Tienes {numero, plural, one {# elemento} other {# elementos}} en carrito', - ); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Tienes {count, plural, one {# elemento} other {# elementos}} en carrito'); - }); - }); - - describe('missing placeholders', () => { - it('should insert single missing placeholder at end', () => { - const result = autoFixICUPlaceholders('You have {count} items', 'Tienes items'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toContain('{count}'); - expect(result.description).toContain('Inserted missing placeholder'); - }); - - it('should error on multiple missing placeholders', () => { - const result = autoFixICUPlaceholders('Hello {firstName} {lastName}', 'Hola'); - - expect(result.wasFixed).toBe(false); - expect(result.error).toContain('missing 2 placeholders'); - }); - }); - - describe('extra placeholders', () => { - it('should error on extra placeholders', () => { - const result = autoFixICUPlaceholders('Hello {name}', 'Hola {name} {extra}'); - - expect(result.wasFixed).toBe(false); - expect(result.error).toContain('extra placeholders'); - }); - }); - - describe('malformed ICU syntax', () => { - it('should error on malformed base value', () => { - const result = autoFixICUPlaceholders('Hello {name', 'Hola {nombre}'); - - expect(result.wasFixed).toBe(false); - expect(result.error).toContain('Failed to parse base value'); - }); - - it('should error on malformed translation value', () => { - const result = autoFixICUPlaceholders('Hello {name}', 'Hola {nombre'); - - expect(result.wasFixed).toBe(false); - expect(result.error).toContain('Failed to parse translation value'); - }); - }); - - describe('number/date/time formatters', () => { - it('should fix renamed number formatter placeholder', () => { - const result = autoFixICUPlaceholders('Price: {price, number, currency}', 'Precio: {precio, number, currency}'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Precio: {price, number, currency}'); - }); - - it('should fix date formatter placeholder', () => { - const result = autoFixICUPlaceholders('Date: {today, date, short}', 'Fecha: {hoy, date, short}'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Fecha: {today, date, short}'); - }); - }); - - describe('edge cases', () => { - it('should handle empty translation', () => { - const result = autoFixICUPlaceholders('Hello {name}', ''); - - expect(result.wasFixed).toBe(true); - expect(result.value).toContain('{name}'); - }); - - it('should handle consecutive placeholders', () => { - const result = autoFixICUPlaceholders('{firstName}{lastName}', '{nombre}{apellido}'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('{firstName}{lastName}'); - }); - - it('should preserve whitespace in text segments', () => { - const result = autoFixICUPlaceholders('Hello {name} world', 'Hola {nombre} mundo'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Hola {name} mundo'); - }); - }); - }); - - describe('hasICUPlaceholders — Transloco exclusion', () => { - it('should return false for a Transloco double-brace pattern', () => { - expect(hasICUPlaceholders('Create {{ itemName }}?')).toBe(false); - expect(hasICUPlaceholders('Hello {{ name }}')).toBe(false); - expect(hasICUPlaceholders('{{ count }} items selected')).toBe(false); - }); - - it('should return true for a single-brace ICU pattern', () => { - expect(hasICUPlaceholders('Create {itemName}?')).toBe(true); - expect(hasICUPlaceholders('Hello {name}')).toBe(true); - }); - - it('should return false for plain text with no braces', () => { - expect(hasICUPlaceholders('No placeholders here')).toBe(false); - }); - - it('should return false for empty double-brace {{}}', () => { - expect(hasICUPlaceholders('text {{}}')).toBe(false); - }); - }); - - describe('hasTranslocoPlaceholders', () => { - it('should detect single Transloco placeholder', () => { - expect(hasTranslocoPlaceholders('Create {{ itemName }}?')).toBe(true); - }); - - it('should detect multiple Transloco placeholders', () => { - expect(hasTranslocoPlaceholders('Hello {{ firstName }} {{ lastName }}')).toBe(true); - }); - - it('should detect placeholder with no spaces inside braces', () => { - expect(hasTranslocoPlaceholders('{{count}} items')).toBe(true); - }); - - it('should return false for ICU single-brace patterns', () => { - expect(hasTranslocoPlaceholders('Hello {name}')).toBe(false); - }); - - it('should return false for plain text', () => { - expect(hasTranslocoPlaceholders('No placeholders')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(hasTranslocoPlaceholders('')).toBe(false); - }); - }); - - describe('extractTranslocoPlaceholders', () => { - it('should extract a single placeholder with surrounding text', () => { - const result = extractTranslocoPlaceholders('Create {{ itemName }}?'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(1); - expect(result.placeholders[0].name).toBe('itemName'); - expect(result.placeholders[0].fullText).toBe('{{ itemName }}'); - expect(result.placeholders[0].startPosition).toBe(7); - expect(result.placeholders[0].endPosition).toBe(21); - expect(result.textSegments).toEqual(['Create ', '?']); - }); - - it('should extract multiple placeholders', () => { - const result = extractTranslocoPlaceholders('Hello {{ firstName }} {{ lastName }}'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(2); - expect(result.placeholders[0].name).toBe('firstName'); - expect(result.placeholders[1].name).toBe('lastName'); - expect(result.textSegments).toEqual(['Hello ', ' ', '']); - }); - - it('should extract placeholder at start', () => { - const result = extractTranslocoPlaceholders('{{ count }} items selected'); - - expect(result.success).toBe(true); - expect(result.placeholders[0].name).toBe('count'); - expect(result.textSegments[0]).toBe(''); - expect(result.textSegments[1]).toBe(' items selected'); - }); - - it('should extract placeholder at end', () => { - const result = extractTranslocoPlaceholders('Welcome, {{ name }}'); - - expect(result.success).toBe(true); - expect(result.placeholders[0].name).toBe('name'); - expect(result.textSegments).toEqual(['Welcome, ', '']); - }); - - it('should return empty arrays for plain text', () => { - const result = extractTranslocoPlaceholders('No placeholders'); - - expect(result.success).toBe(true); - expect(result.placeholders).toHaveLength(0); - expect(result.textSegments).toEqual(['No placeholders']); - }); - - it('should handle placeholder without spaces inside braces', () => { - const result = extractTranslocoPlaceholders('{{count}} items'); - - expect(result.success).toBe(true); - expect(result.placeholders[0].name).toBe('count'); - }); - }); - - describe('autoFixTranslocoPlaceholders', () => { - describe('no fix needed', () => { - it('should return unchanged when base has no Transloco placeholders', () => { - const result = autoFixTranslocoPlaceholders('Hello world', 'Hola mundo'); - - expect(result.wasFixed).toBe(false); - expect(result.value).toBe('Hola mundo'); - }); - - it('should return unchanged when placeholders already match', () => { - const result = autoFixTranslocoPlaceholders('Create {{ itemName }}?', 'Créer {{ itemName }} ?'); - - expect(result.wasFixed).toBe(false); - expect(result.value).toBe('Créer {{ itemName }} ?'); - }); - - it('should return unchanged when multiple placeholders already match', () => { - const result = autoFixTranslocoPlaceholders( - 'Hello {{ firstName }} {{ lastName }}', - 'Hola {{ firstName }} {{ lastName }}', - ); - - expect(result.wasFixed).toBe(false); - expect(result.value).toBe('Hola {{ firstName }} {{ lastName }}'); - }); - - it('should not fix when spacing differs but names match', () => { - const result = autoFixTranslocoPlaceholders('Hello {{ name }}', 'Hola {{name}}'); - expect(result.wasFixed).toBe(false); - expect(result.value).toBe('Hola {{name}}'); - }); - }); - - describe('renamed placeholders', () => { - it('should fix a single renamed placeholder', () => { - const result = autoFixTranslocoPlaceholders('Create {{ itemName }}?', 'Créer {{ nomElement }} ?'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Créer {{ itemName }} ?'); - expect(result.originalPlaceholders).toEqual(['{{ nomElement }}']); - expect(result.fixedPlaceholders).toEqual(['{{ itemName }}']); - }); - - it('should fix multiple renamed placeholders', () => { - const result = autoFixTranslocoPlaceholders( - 'Hello {{ firstName }} {{ lastName }}', - 'Hola {{ nombre }} {{ apellido }}', - ); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Hola {{ firstName }} {{ lastName }}'); - expect(result.originalPlaceholders).toHaveLength(2); - expect(result.fixedPlaceholders).toHaveLength(2); - }); - - it('should preserve translated text segments while replacing placeholder names', () => { - const result = autoFixTranslocoPlaceholders( - 'You have {{ count }} unread messages', - 'Sie haben {{ anzahl }} ungelesene Nachrichten', - ); - - expect(result.wasFixed).toBe(true); - expect(result.value).toBe('Sie haben {{ count }} ungelesene Nachrichten'); - }); - }); - - describe('missing placeholders', () => { - it('should append a single missing placeholder at the end', () => { - const result = autoFixTranslocoPlaceholders('Hello {{ name }}', 'Hola'); - - expect(result.wasFixed).toBe(true); - expect(result.value).toContain('{{ name }}'); - expect(result.description).toContain('Inserted missing placeholder'); - }); - - it('should error when multiple placeholders are missing', () => { - const result = autoFixTranslocoPlaceholders('Hello {{ firstName }} {{ lastName }}', 'Hola'); - - expect(result.wasFixed).toBe(false); - expect(result.error).toContain('missing 2 placeholders'); - }); - - it('should error when translation has fewer placeholders than base', () => { - const result = autoFixTranslocoPlaceholders('Hello {{ firstName }} {{ lastName }}', 'Hola {{ nombre }}'); - - expect(result.wasFixed).toBe(false); - expect(result.error).toBeDefined(); - }); - }); - - describe('count mismatch', () => { - it('should error when translation has extra placeholders', () => { - const result = autoFixTranslocoPlaceholders('Hello {{ name }}', 'Hola {{ name }} {{ extra }}'); - - expect(result.wasFixed).toBe(false); - expect(result.error).toContain('extra placeholders'); - }); - }); - }); - - describe('autoFixICUPlaceholders — Transloco pass-through', () => { - it('should not error on Transloco values when base has no ICU placeholders', () => { - // "Create {{ itemName }}?" has no ICU single-braces, so autoFixICUPlaceholders - // should treat it as having no placeholders and return as-is. - const result = autoFixICUPlaceholders('Create {{ itemName }}?', 'Créer {{ nomElement }} ?'); - - expect(result.wasFixed).toBe(false); - expect(result.error).toBeUndefined(); - expect(result.value).toBe('Créer {{ nomElement }} ?'); - }); - - it('should not error on plain Transloco value with no ICU braces', () => { - const result = autoFixICUPlaceholders('{{ count }} items', '{{ anzahl }} Elemente'); - - expect(result.wasFixed).toBe(false); - expect(result.error).toBeUndefined(); - }); - - it('should pass through Transloco values via the hasTranslocoPlaceholders guard', () => { - const result = autoFixICUPlaceholders('Create {{ itemName }}?', 'إنشاء {{ itemName }}؟'); - expect(result.wasFixed).toBe(false); - expect(result.error).toBeUndefined(); - expect(result.value).toBe('إنشاء {{ itemName }}؟'); - }); - }); -}); diff --git a/libs/core/src/lib/import/index.ts b/libs/core/src/lib/import/index.ts index b8bb901a..e5b94d9a 100644 --- a/libs/core/src/lib/import/index.ts +++ b/libs/core/src/lib/import/index.ts @@ -15,7 +15,6 @@ export type { ImportParseOptions, ImportResult, ImportRunOptions, - ImportStrategy, ImportSummaryOptions, StatusTransition, } from './types'; diff --git a/libs/core/src/lib/normalize/index.ts b/libs/core/src/lib/normalize/index.ts index dbdbe426..20a9525d 100644 --- a/libs/core/src/lib/normalize/index.ts +++ b/libs/core/src/lib/normalize/index.ts @@ -1,5 +1,3 @@ -export * from './folder-utils'; -export * from './cleanup-empty-folders'; -export * from './normalize-entry'; -export * from './normalize'; -export * from './iterative-folder-walker'; +// The normalize module: one operation that repairs entries, metadata and empty folders across a collection. + +export { type NormalizeParams, type NormalizeResult, normalize } from './normalize'; diff --git a/libs/core/src/lib/resource/index.ts b/libs/core/src/lib/resource/index.ts index 46b843d9..d6fcd0ad 100644 --- a/libs/core/src/lib/resource/index.ts +++ b/libs/core/src/lib/resource/index.ts @@ -1,8 +1,38 @@ -export * from './resource-file-paths'; -export * from './load-resource-tree'; -export * from './load-full-resource-tree'; -export * from './extract-subtree'; -export * from './search'; -export * from './tree-fingerprint'; -export * from './resource-folder'; -export * from './resource-mutation'; +// Resource folders on disk, and the read models (tree, search, fingerprint) built from them. + +export { extractResourcesRecursively, extractSubtree } from './extract-subtree'; +export { + type FolderChild, + type LoadResourceTreeOptions, + loadResourceTree, + type ResourceTreeEntry, + type ResourceTreeNode, +} from './load-resource-tree'; +export { + type ResolvedResourcePaths, + type ResourcePathResolutionParams, + resolveResourcePaths, +} from './resource-file-paths'; +export { + type EntryDetails, + type OpenResourceFolderOptions, + openResourceFolder, + type ResourceFolder, + type ResourceFolderEntry, + type ResourceFolderSaveResult, +} from './resource-folder'; +export { type ResourceMutation, reindexMutation } from './resource-mutation'; +export { + type MatchType, + type SearchParams, + type SearchResult, + type SearchTreeParams, + searchResourceTree, + searchTranslations, +} from './search'; +export { + type ComputeTreeFingerprintOptions, + computeTreeFingerprint, + type TreeFingerprint, + treeFingerprintsMatch, +} from './tree-fingerprint'; diff --git a/libs/core/src/lib/translation/index.ts b/libs/core/src/lib/translation/index.ts index c0065e6c..3a2cb811 100644 --- a/libs/core/src/lib/translation/index.ts +++ b/libs/core/src/lib/translation/index.ts @@ -1,21 +1,14 @@ -export type { - TranslateRequest, - TranslateResult, - ProviderCapabilities, - TranslationProvider, -} from './translation-provider'; +// The translation module: machine-translate one resource or a whole locale through the configured provider. + +export { + type TranslateExistingResourceOptions, + type TranslateExistingResourceResult, + translateExistingResource, +} from './translate-existing-resource'; +export { + type TranslateLocaleParams, + type TranslateLocaleProgress, + type TranslateLocaleResult, + translateLocale, +} from './translate-locale'; export { TranslationError } from './translation-provider'; -export { GoogleTranslateV2Provider } from './google-translate-v2.provider'; -export { createTranslationProvider } from './translation-provider-factory'; -export { TranslationOrchestrator } from './translation-orchestrator'; -export type { TranslateTextResult } from './translation-orchestrator'; -export { classifyICUContent } from './icu-classifier'; -export type { ICUClassification } from './icu-classifier'; -export { protectPlaceholders, restorePlaceholders } from './placeholder-protector'; -export type { ExtractedPlaceholder, ProtectedText, RestoreResult } from './placeholder-protector'; -export { autoTranslateResource } from './auto-translate-resources'; -export type { AutoTranslateParams, AutoTranslatedEntry, AutoTranslateResult } from './auto-translate-resources'; -export { translateExistingResource } from './translate-existing-resource'; -export type { TranslateExistingResourceOptions, TranslateExistingResourceResult } from './translate-existing-resource'; -export { translateLocale } from './translate-locale'; -export type { TranslateLocaleParams, TranslateLocaleProgress, TranslateLocaleResult } from './translate-locale'; diff --git a/libs/core/src/lib/validate/index.ts b/libs/core/src/lib/validate/index.ts index edc0e34a..f40c942b 100644 --- a/libs/core/src/lib/validate/index.ts +++ b/libs/core/src/lib/validate/index.ts @@ -1,6 +1,6 @@ -export * from './generate-validation-summary'; -export * from './types'; -export * from './validate-icu'; -export * from './validate-placeholders'; -export * from './validate-resources'; -export * from './validate-terminology'; +// The validate module: check a collection's resources and summarise the result. + +export { generateValidationSummary } from './generate-validation-summary'; +export type { ResourceValidationResult, ValidationOptions } from './types'; +export { validateResources } from './validate-resources'; +export { describePreferredTermRule } from './validate-terminology'; diff --git a/libs/core/src/resource/index.ts b/libs/core/src/resource/index.ts index a9528140..571dca3b 100644 --- a/libs/core/src/resource/index.ts +++ b/libs/core/src/resource/index.ts @@ -1,9 +1,8 @@ -export * from './resource-entry'; -export * from './resource-entry-metadata'; -export * from './tracker-metadata'; -export * from './checksum'; -export * from './translation-helpers'; -export * from './add-resource'; -export * from './delete-resource'; -export * from './move-resource'; -export * from './edit-resource'; +// Resource operations: add, edit, delete and move one entry. + +export { type AddResourceOptions, type AddResourceParams, addResource } from './add-resource'; +export { type DeleteResourceParams, type DeleteResourceResult, deleteResource } from './delete-resource'; +export { type EditResourceOptions, type EditResourceResult, editResource } from './edit-resource'; +export { type MoveResourceParams, type MoveResourceResult, moveResource } from './move-resource'; +export type { ResourceEntryMetadata } from './resource-entry-metadata'; +export { createDefaultTranslations } from './translation-helpers'; diff --git a/libs/data-transfer/src/lib/bundle-definition.dto.ts b/libs/data-transfer/src/lib/bundle-definition.dto.ts index cd1b9c43..74f8e527 100644 --- a/libs/data-transfer/src/lib/bundle-definition.dto.ts +++ b/libs/data-transfer/src/lib/bundle-definition.dto.ts @@ -3,12 +3,10 @@ * so they are safe to use from the browser-based Tracker UI. */ -/** - * Casing of generated TypeScript token property keys. - * - 'upperCase': SCREAMING_SNAKE_CASE (e.g. FILE_UPLOAD) — default - * - 'camelCase': camelCase (e.g. fileUpload) - */ -export type TokenCasingDto = 'upperCase' | 'camelCase'; +import type { TokenCasing } from '@simoncodes-ca/domain'; + +/** Casing of generated TypeScript token property keys. Declared once, in domain. */ +export type TokenCasingDto = TokenCasing; /** Pattern and tag-based rule selecting which entries of a collection are bundled. */ export interface EntrySelectionRuleDto { diff --git a/libs/data-transfer/src/lib/translation-status.ts b/libs/data-transfer/src/lib/translation-status.ts index 904c6b95..9fb0a62a 100644 --- a/libs/data-transfer/src/lib/translation-status.ts +++ b/libs/data-transfer/src/lib/translation-status.ts @@ -1,4 +1,4 @@ /** - * Translation status for a resource in a specific locale. + * Translation status for a resource in a specific locale. Declared once, in domain. */ -export type TranslationStatus = 'new' | 'translated' | 'stale' | 'verified'; +export type { TranslationStatus } from '@simoncodes-ca/domain'; diff --git a/libs/data-transfer/tsconfig.lib.json b/libs/data-transfer/tsconfig.lib.json index 9650004d..00531bd5 100644 --- a/libs/data-transfer/tsconfig.lib.json +++ b/libs/data-transfer/tsconfig.lib.json @@ -20,5 +20,10 @@ "src/**/*.spec.js", "src/**/*.test.jsx", "src/**/*.spec.jsx" + ], + "references": [ + { + "path": "../domain/tsconfig.lib.json" + } ] } diff --git a/libs/domain/src/index.spec.ts b/libs/domain/src/index.spec.ts new file mode 100644 index 00000000..aeb41cf1 --- /dev/null +++ b/libs/domain/src/index.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import * as domain from './index'; + +// The barrel is the library's interface. Adding a name here is a deliberate API change: +// export it only when something outside libs/domain needs it. Types are erased, so only values appear. +describe('domain public surface', () => { + it('exports exactly the listed runtime values', () => { + expect(Object.keys(domain).sort()).toEqual([ + 'JS_IDENTIFIER_PATTERN', + 'STATUS_PRECEDENCE', + 'applyBaseChange', + 'applyPreferredTerm', + 'autoFixICUPlaceholders', + 'autoFixTranslocoPlaceholders', + 'classifyICUContent', + 'compareIcuArguments', + 'countByStatus', + 'detectDuplicateKeys', + 'detectHierarchicalConflicts', + 'effectiveProtectedTerms', + 'effectiveTags', + 'escapeRegExp', + 'findIcuCompileError', + 'findPreferredTermFindings', + 'findProtectedTermViolations', + 'findProtectedTerms', + 'findUnportablePluralCases', + 'hasICUPlaceholders', + 'hasTranslocoPlaceholders', + 'hasUnbundlableBranchBody', + 'icuToTransloco', + 'isEmptyValue', + 'isIcuLocaleSupported', + 'isJavaScriptReservedWord', + 'isKeyTooLong', + 'isUnderNodeModules', + 'isUntranslatedCopy', + 'isValidJavaScriptIdentifier', + 'isValidSegment', + 'needsTranslation', + 'normalizePreferredTermRules', + 'normalizeProtectedTerms', + 'normalizeTag', + 'normalizeTags', + 'normalizeTranslocoSyntax', + 'normalizedLevenshtein', + 'recordTranslation', + 'resolveAllReferences', + 'resolveImportStatus', + 'resolveResourceKey', + 'sortPreferredTermRules', + 'splitResolvedKey', + 'translocoToICU', + 'validateICUSyntax', + 'validateImportKey', + 'validateKey', + 'validateLocale', + 'validatePreferredTermRules', + 'validateTargetFolder', + 'worstStatus', + ]); + }); +}); diff --git a/libs/domain/src/index.ts b/libs/domain/src/index.ts index 5a30f0b0..e00b41b6 100644 --- a/libs/domain/src/index.ts +++ b/libs/domain/src/index.ts @@ -1,24 +1,92 @@ -export * from './lib/escape-regexp'; -export * from './lib/translation-status'; -export * from './lib/translation-status-summary'; -export * from './lib/locale-metadata'; -export * from './lib/resource-key'; -export * from './lib/staleness'; -export * from './lib/icu-auto-fixer'; -export * from './lib/icu-to-transloco'; -export * from './lib/transloco-to-icu'; -export * from './lib/transloco-brace-scan'; -export * from './lib/validation-utils'; -export * from './lib/normalize-transloco-syntax'; -export * from './lib/icu-classifier'; -export * from './lib/node-modules'; -export * from './lib/normalize-tags'; -export * from './lib/effective-tags'; -export * from './lib/protected-terms'; -export * from './lib/normalized-levenshtein'; -export * from './lib/icu-locale-validation'; -export * from './lib/portable-plural-categories'; -export * from './lib/icu-arguments'; -export * from './lib/js-identifier'; -export * from './lib/preferred-terminology'; -export * from './lib/reference-resolver'; +// The public surface of @simoncodes-ca/domain: browser-safe rules shared by core, the API, the CLI and the Tracker. +// Only names with a consumer outside this library are listed; everything else is a module-level detail. + +// Shared types +export type { LocaleMetadata } from './lib/locale-metadata'; +export type { TokenCasing } from './lib/token-casing'; +export type { TranslationStatus } from './lib/translation-status'; + +// Keys: resource keys and generated-token identifiers +export { isJavaScriptReservedWord, isValidJavaScriptIdentifier, JS_IDENTIFIER_PATTERN } from './lib/js-identifier'; +export { + isValidSegment, + type KeyValidationOptions, + resolveResourceKey, + splitResolvedKey, + validateKey, + validateTargetFolder, +} from './lib/resource-key'; + +// Staleness: how edits and imports move a locale's status +export { + applyBaseChange, + type EntryLocaleMetadata, + type ImportStrategy, + isUntranslatedCopy, + needsTranslation, + recordTranslation, + type ResolveImportStatusParams, + resolveImportStatus, +} from './lib/staleness'; + +// Status summary: roll-ups over many statuses +export { countByStatus, STATUS_PRECEDENCE, type StatusCounts, worstStatus } from './lib/translation-status-summary'; + +// ICU/Transloco: conversion, classification, placeholder repair and ICU checks +export { compareIcuArguments, type ArgumentMismatch } from './lib/icu-arguments'; +export { + autoFixICUPlaceholders, + autoFixTranslocoPlaceholders, + hasICUPlaceholders, + hasTranslocoPlaceholders, + type ICUAutoFixResult, + validateICUSyntax, +} from './lib/icu-auto-fixer'; +export { classifyICUContent, type ICUClassification } from './lib/icu-classifier'; +export { findIcuCompileError, isIcuLocaleSupported } from './lib/icu-locale-validation'; +export { icuToTransloco } from './lib/icu-to-transloco'; +export { normalizeTranslocoSyntax } from './lib/normalize-transloco-syntax'; +export { findUnportablePluralCases, type UnportablePluralCase } from './lib/portable-plural-categories'; +export { hasUnbundlableBranchBody } from './lib/transloco-brace-scan'; +export { translocoToICU } from './lib/transloco-to-icu'; + +// Validation: import keys, locales, values and key-set conflicts +export { + detectDuplicateKeys, + detectHierarchicalConflicts, + isEmptyValue, + isKeyTooLong, + validateImportKey, + validateLocale, +} from './lib/validation-utils'; + +// Terminology: protected terms, preferred terminology and similarity +export { normalizedLevenshtein } from './lib/normalized-levenshtein'; +export { + applyPreferredTerm, + findPreferredTermFindings, + normalizePreferredTermRules, + type PreferredTermFinding, + type PreferredTermRange, + type PreferredTermRule, + type PreferredTermRuleError, + sortPreferredTermRules, + validatePreferredTermRules, +} from './lib/preferred-terminology'; +export { + effectiveProtectedTerms, + findProtectedTerms, + findProtectedTermViolations, + normalizeProtectedTerms, +} from './lib/protected-terms'; + +// References: resolving `{{t('other.key')}}` references across a key set +export { type KeyedValue, resolveAllReferences } from './lib/reference-resolver'; + +// Tags +export { effectiveTags } from './lib/effective-tags'; +export { normalizeTag, normalizeTags } from './lib/normalize-tags'; + +// Utilities +export { escapeRegExp } from './lib/escape-regexp'; +export { isUnderNodeModules } from './lib/node-modules'; diff --git a/libs/domain/src/lib/icu-arguments.spec.ts b/libs/domain/src/lib/icu-arguments.spec.ts index 64184baa..ba391866 100644 --- a/libs/domain/src/lib/icu-arguments.spec.ts +++ b/libs/domain/src/lib/icu-arguments.spec.ts @@ -1,60 +1,64 @@ import { describe, it, expect } from 'vitest'; -import { findIcuArguments, compareIcuArguments } from './icu-arguments'; +import { compareIcuArguments } from './icu-arguments'; -describe('findIcuArguments', () => { +/** The arguments a value interpolates, read back as the `missing` side of a comparison against plain text. */ +const argumentsOf = (value: string): ReadonlySet => + new Set(compareIcuArguments(value, 'plain text')?.missing ?? []); + +describe('compareIcuArguments — which arguments count', () => { it('returns an empty set for a value with no arguments', () => { - expect(findIcuArguments('No placeholders here')).toEqual(new Set()); + expect(argumentsOf('No placeholders here')).toEqual(new Set()); }); it('finds a plain argument', () => { - expect(findIcuArguments('Folder {name}')).toEqual(new Set(['name'])); + expect(argumentsOf('Folder {name}')).toEqual(new Set(['name'])); }); it('accepts Transloco syntax', () => { - expect(findIcuArguments('Folder {{ name }}')).toEqual(new Set(['name'])); + expect(argumentsOf('Folder {{ name }}')).toEqual(new Set(['name'])); }); it('gives the same answer in either syntax', () => { - expect(findIcuArguments('Folder {{ name }}')).toEqual(findIcuArguments('Folder {name}')); + expect(argumentsOf('Folder {{ name }}')).toEqual(argumentsOf('Folder {name}')); }); it('finds a formatted argument', () => { - expect(findIcuArguments('{count, number} files')).toEqual(new Set(['count'])); + expect(argumentsOf('{count, number} files')).toEqual(new Set(['count'])); }); it('finds the argument a plural switches on', () => { - expect(findIcuArguments('{count, plural, =1 {1 file} other {# files}}')).toEqual(new Set(['count'])); + expect(argumentsOf('{count, plural, =1 {1 file} other {# files}}')).toEqual(new Set(['count'])); }); it('finds arguments used only inside a branch', () => { const value = '{count, plural, =1 {1 file in {dir}} other {# files in {dir}}}'; - expect(findIcuArguments(value)).toEqual(new Set(['count', 'dir'])); + expect(argumentsOf(value)).toEqual(new Set(['count', 'dir'])); }); it('finds arguments nested through a select inside a plural', () => { const value = '{n, plural, =1 {{kind, select, a {{x}} other {{y}}}} other {#}}'; - expect(findIcuArguments(value)).toEqual(new Set(['n', 'kind', 'x', 'y'])); + expect(argumentsOf(value)).toEqual(new Set(['n', 'kind', 'x', 'y'])); }); it('excludes branch selectors', () => { - const args = findIcuArguments('{count, plural, =1 {one} one {one} few {few} other {many}}'); + const args = argumentsOf('{count, plural, =1 {one} one {one} few {few} other {many}}'); expect(args).toEqual(new Set(['count'])); }); it('excludes the octothorpe', () => { - expect(findIcuArguments('{count, plural, other {# items}}')).toEqual(new Set(['count'])); + expect(argumentsOf('{count, plural, other {# items}}')).toEqual(new Set(['count'])); }); it('collapses a repeated argument', () => { - expect(findIcuArguments('{a} and {a} and {a}')).toEqual(new Set(['a'])); + expect(argumentsOf('{a} and {a} and {a}')).toEqual(new Set(['a'])); }); it('returns an empty set for an unparseable value', () => { - expect(findIcuArguments('{unbalanced')).toEqual(new Set()); + expect(argumentsOf('{unbalanced')).toEqual(new Set()); }); it('is case-sensitive', () => { - expect(findIcuArguments('Ordner {Name}')).toEqual(new Set(['Name'])); + expect(argumentsOf('Ordner {Name}')).toEqual(new Set(['Name'])); }); }); diff --git a/libs/domain/src/lib/icu-arguments.ts b/libs/domain/src/lib/icu-arguments.ts index 5171d017..d8199f2b 100644 --- a/libs/domain/src/lib/icu-arguments.ts +++ b/libs/domain/src/lib/icu-arguments.ts @@ -19,7 +19,8 @@ import { normalizeTranslocoSyntax } from './normalize-transloco-syntax'; */ /** - * Collects the names of every argument a message interpolates. + * Collects the names of every argument a message interpolates, or reports that + * it could not be parsed. * * Counts plain arguments (`{name}`), formatted ones (`{count, number}`), and * the argument a `plural`, `selectordinal` or `select` switches on — each of @@ -36,43 +37,13 @@ import { normalizeTranslocoSyntax } from './normalize-transloco-syntax'; * translation may legitimately mention an argument more times than the base * value does, or fewer, provided it mentions the same ones. * - * Values that do not parse yield an empty set. An unparseable value is the ICU - * compile check's business, and reporting one defect as two helps nobody. Use - * `compareIcuArguments` rather than comparing two of these sets by hand: it - * tells an unparseable value apart from one that genuinely has no arguments. - * * Accepts either syntax: Transloco's `{{ name }}` is normalized to `{name}` * first, so stored values and bundled values give the same answer. * - * @param value - The translation string, in ICU or Transloco syntax. - * @returns The distinct argument names the value interpolates. - * - * @example - * ```typescript - * findIcuArguments('Folder {name}'); - * // → Set { 'name' } - * - * findIcuArguments('Folder {{ name }}'); - * // → Set { 'name' } — same answer in either syntax - * - * findIcuArguments('{count, plural, one {1 file in {dir}} other {# files in {dir}}}'); - * // → Set { 'count', 'dir' } - * - * findIcuArguments('No placeholders here'); - * // → Set {} - * ``` - */ -export function findIcuArguments(value: string): ReadonlySet { - return parseArguments(value) ?? new Set(); -} - -/** - * Collects a value's arguments, or reports that it could not be parsed. - * - * The distinction matters to `compareIcuArguments` and nowhere else: a value - * that does not parse has no arguments to speak of, which is not the same as a - * value that parses and interpolates none. Treating the two alike makes every - * malformed translation look as though it had dropped every placeholder. + * Returns null for a value that does not parse. A value that does not parse has + * no arguments to speak of, which is not the same as a value that parses and + * interpolates none. Treating the two alike makes every malformed translation + * look as though it had dropped every placeholder. * * @internal */ diff --git a/libs/domain/src/lib/icu-auto-fixer.spec.ts b/libs/domain/src/lib/icu-auto-fixer.spec.ts index f05101c6..77a66b8e 100644 --- a/libs/domain/src/lib/icu-auto-fixer.spec.ts +++ b/libs/domain/src/lib/icu-auto-fixer.spec.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { autoFixICUPlaceholders, extractICUPlaceholders, hasICUPlaceholders } from './icu-auto-fixer'; +import { + autoFixICUPlaceholders, + autoFixTranslocoPlaceholders, + extractICUPlaceholders, + extractTranslocoPlaceholders, + hasICUPlaceholders, + hasTranslocoPlaceholders, + validateICUSyntax, +} from './icu-auto-fixer'; describe('extractICUPlaceholders \u2014 ICU quote escaping', () => { it('extracts a placeholder from a string containing a natural apostrophe', () => { @@ -153,3 +161,682 @@ describe('autoFixICUPlaceholders — sub-message format rewriting', () => { expect(result.value).toBe('{rank, selectordinal, one {#er} other {place {rank}}}'); }); }); + +describe('icu-auto-fixer', () => { + describe('hasICUPlaceholders', () => { + it('should detect simple placeholders', () => { + expect(hasICUPlaceholders('Hello {name}')).toBe(true); + expect(hasICUPlaceholders('You have {count} items')).toBe(true); + expect(hasICUPlaceholders('{0} of {1}')).toBe(true); + }); + + it('should detect plural placeholders', () => { + expect(hasICUPlaceholders('{count, plural, one {# item} other {# items}}')).toBe(true); + }); + + it('should detect select placeholders', () => { + expect(hasICUPlaceholders('{gender, select, male {he} female {she} other {they}}')).toBe(true); + }); + + it('should return false for strings without placeholders', () => { + expect(hasICUPlaceholders('Hello world')).toBe(false); + expect(hasICUPlaceholders('No placeholders here')).toBe(false); + expect(hasICUPlaceholders('')).toBe(false); + }); + + it('should ignore escaped braces', () => { + expect(hasICUPlaceholders("This is '{not a placeholder}'")).toBe(false); + expect(hasICUPlaceholders("'{literal braces}' are ignored")).toBe(false); + }); + + it('should detect mixed escaped and real placeholders', () => { + expect(hasICUPlaceholders("'{escaped}' and {real}")).toBe(true); + }); + }); + + describe('extractICUPlaceholders', () => { + describe('simple placeholders', () => { + it('should extract single placeholder', () => { + const result = extractICUPlaceholders('Hello {name}'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(1); + expect(result.placeholders[0].name).toBe('name'); + expect(result.placeholders[0].type).toBe('simple'); + expect(result.placeholders[0].fullText).toBe('{name}'); + expect(result.textSegments).toEqual(['Hello ', '']); + }); + + it('should extract multiple placeholders', () => { + const result = extractICUPlaceholders('Hello {firstName} {lastName}'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(2); + expect(result.placeholders[0].name).toBe('firstName'); + expect(result.placeholders[1].name).toBe('lastName'); + expect(result.textSegments).toEqual(['Hello ', ' ', '']); + }); + + it('should extract numeric placeholders', () => { + const result = extractICUPlaceholders('{0} of {1}'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(2); + expect(result.placeholders[0].name).toBe('0'); + expect(result.placeholders[1].name).toBe('1'); + }); + + it('should handle placeholder at start', () => { + const result = extractICUPlaceholders('{name} is here'); + + expect(result.success).toBe(true); + expect(result.textSegments[0]).toBe(''); + expect(result.textSegments[1]).toBe(' is here'); + }); + + it('should handle placeholder at end', () => { + const result = extractICUPlaceholders('Hello {name}'); + + expect(result.success).toBe(true); + expect(result.textSegments[0]).toBe('Hello '); + expect(result.textSegments[1]).toBe(''); + }); + + it('should handle consecutive placeholders', () => { + const result = extractICUPlaceholders('{firstName}{lastName}'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(2); + expect(result.textSegments).toEqual(['', '', '']); + }); + }); + + describe('plural placeholders', () => { + it('should extract plural placeholder', () => { + const result = extractICUPlaceholders('{count, plural, one {# item} other {# items}}'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(1); + expect(result.placeholders[0].name).toBe('count'); + expect(result.placeholders[0].type).toBe('plural'); + expect(result.placeholders[0].fullText).toBe('{count, plural, one {# item} other {# items}}'); + }); + + it('should extract plural with surrounding text', () => { + const result = extractICUPlaceholders('You have {count, plural, one {# item} other {# items}} in cart'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(1); + expect(result.textSegments).toEqual(['You have ', ' in cart']); + }); + + it('should extract complex plural with zero case', () => { + const result = extractICUPlaceholders('{count, plural, =0 {no items} one {# item} other {# items}}'); + + expect(result.success).toBe(true); + expect(result.placeholders[0].type).toBe('plural'); + }); + }); + + describe('select placeholders', () => { + it('should extract select placeholder', () => { + const result = extractICUPlaceholders('{gender, select, male {he} female {she} other {they}}'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(1); + expect(result.placeholders[0].name).toBe('gender'); + expect(result.placeholders[0].type).toBe('select'); + }); + + it('should extract select with surrounding text', () => { + const result = extractICUPlaceholders( + 'The user said {gender, select, male {he is} female {she is} other {they are}} happy', + ); + + expect(result.success).toBe(true); + expect(result.textSegments).toEqual(['The user said ', ' happy']); + }); + }); + + describe('number/date/time formatters', () => { + it('should extract number formatter', () => { + const result = extractICUPlaceholders('Price: {price, number, currency}'); + + expect(result.success).toBe(true); + expect(result.placeholders[0].name).toBe('price'); + expect(result.placeholders[0].type).toBe('number'); + }); + + it('should extract date formatter', () => { + const result = extractICUPlaceholders('Date: {today, date, short}'); + + expect(result.success).toBe(true); + expect(result.placeholders[0].type).toBe('date'); + }); + + it('should extract time formatter', () => { + const result = extractICUPlaceholders('Time: {now, time, medium}'); + + expect(result.success).toBe(true); + expect(result.placeholders[0].type).toBe('time'); + }); + }); + + describe('escaped braces', () => { + it('should ignore escaped braces in text', () => { + const result = extractICUPlaceholders("This is '{not a placeholder}'"); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(0); + expect(result.textSegments).toEqual(["This is '{not a placeholder}'"]); + }); + + it('should handle mixed escaped and real placeholders', () => { + const result = extractICUPlaceholders("'{escaped}' and {real}"); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(1); + expect(result.placeholders[0].name).toBe('real'); + }); + }); + + describe('nested patterns', () => { + it('should handle nested plural with placeholders inside', () => { + const result = extractICUPlaceholders( + '{count, plural, one {You have {count} item} other {You have {count} items}}', + ); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(1); + expect(result.placeholders[0].type).toBe('plural'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + const result = extractICUPlaceholders(''); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(0); + expect(result.textSegments).toEqual(['']); + }); + + it('should handle string with no placeholders', () => { + const result = extractICUPlaceholders('Just plain text'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(0); + expect(result.textSegments).toEqual(['Just plain text']); + }); + + it('should handle whitespace in placeholders', () => { + const result = extractICUPlaceholders('Hello { name }'); + + expect(result.success).toBe(true); + expect(result.placeholders[0].name).toBe('name'); + }); + + it('should detect unmatched opening brace', () => { + const result = extractICUPlaceholders('Hello {name'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unclosed placeholder'); + }); + + it('should detect unmatched closing brace', () => { + const result = extractICUPlaceholders('Hello name}'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unmatched closing brace'); + }); + }); + }); + + describe('validateICUSyntax', () => { + it('should validate correct ICU syntax', () => { + expect(validateICUSyntax('Hello {name}')).toBe(true); + expect(validateICUSyntax('{count, plural, one {# item} other {# items}}')).toBe(true); + expect(validateICUSyntax('No placeholders')).toBe(true); + }); + + it('should reject invalid ICU syntax', () => { + expect(validateICUSyntax('Hello {name')).toBe(false); + expect(validateICUSyntax('Hello name}')).toBe(false); + expect(validateICUSyntax('Hello {{{name}}}')).toBe(false); + }); + }); + + describe('autoFixICUPlaceholders', () => { + describe('no fix needed', () => { + it('should return unchanged if base has no placeholders', () => { + const result = autoFixICUPlaceholders('Hello world', 'Hola mundo'); + + expect(result.wasFixed).toBe(false); + expect(result.value).toBe('Hola mundo'); + }); + + it('should return unchanged if placeholders already match', () => { + const result = autoFixICUPlaceholders('Hello {name}', 'Hola {name}'); + + expect(result.wasFixed).toBe(false); + expect(result.value).toBe('Hola {name}'); + }); + + it('should return unchanged if plural placeholders match', () => { + const result = autoFixICUPlaceholders( + '{count, plural, one {# item} other {# items}}', + '{count, plural, one {# elemento} other {# elementos}}', + ); + + expect(result.wasFixed).toBe(false); + expect(result.value).toBe('{count, plural, one {# elemento} other {# elementos}}'); + }); + }); + + describe('simple placeholder replacement', () => { + it('should fix renamed placeholder', () => { + const result = autoFixICUPlaceholders('Hello {name}', 'Hola {nombre}'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Hola {name}'); + expect(result.description).toContain('{nombre} → {name}'); + expect(result.originalPlaceholders).toEqual(['{nombre}']); + expect(result.fixedPlaceholders).toEqual(['{name}']); + }); + + it('should fix multiple renamed placeholders', () => { + const result = autoFixICUPlaceholders('Hello {firstName} {lastName}', 'Hola {nombre} {apellido}'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Hola {firstName} {lastName}'); + expect(result.originalPlaceholders).toHaveLength(2); + expect(result.fixedPlaceholders).toHaveLength(2); + }); + + it('should preserve translated text while fixing placeholders', () => { + const result = autoFixICUPlaceholders('You have {count} items', 'Tienes {numero} elementos'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Tienes {count} elementos'); + }); + + it('should fix placeholder with different position in translation', () => { + const result = autoFixICUPlaceholders('{count} items in cart', 'Hay {cantidad} elementos en el carrito'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Hay {count} elementos en el carrito'); + }); + }); + + describe('plural and select placeholder fixing', () => { + it('should fix renamed plural placeholder', () => { + const result = autoFixICUPlaceholders( + '{count, plural, one {# item} other {# items}}', + '{numero, plural, one {# elemento} other {# elementos}}', + ); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('{count, plural, one {# elemento} other {# elementos}}'); + expect(result.description).toContain('{numero, plural'); + }); + + it('should fix renamed select placeholder', () => { + const result = autoFixICUPlaceholders( + '{gender, select, male {he} female {she} other {they}}', + '{genero, select, male {él} female {ella} other {ellos}}', + ); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('{gender, select, male {él} female {ella} other {ellos}}'); + }); + + it('should fix plural with surrounding text', () => { + const result = autoFixICUPlaceholders( + 'You have {count, plural, one {# item} other {# items}} in cart', + 'Tienes {numero, plural, one {# elemento} other {# elementos}} en carrito', + ); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Tienes {count, plural, one {# elemento} other {# elementos}} en carrito'); + }); + }); + + describe('missing placeholders', () => { + it('should insert single missing placeholder at end', () => { + const result = autoFixICUPlaceholders('You have {count} items', 'Tienes items'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toContain('{count}'); + expect(result.description).toContain('Inserted missing placeholder'); + }); + + it('should error on multiple missing placeholders', () => { + const result = autoFixICUPlaceholders('Hello {firstName} {lastName}', 'Hola'); + + expect(result.wasFixed).toBe(false); + expect(result.error).toContain('missing 2 placeholders'); + }); + }); + + describe('extra placeholders', () => { + it('should error on extra placeholders', () => { + const result = autoFixICUPlaceholders('Hello {name}', 'Hola {name} {extra}'); + + expect(result.wasFixed).toBe(false); + expect(result.error).toContain('extra placeholders'); + }); + }); + + describe('malformed ICU syntax', () => { + it('should error on malformed base value', () => { + const result = autoFixICUPlaceholders('Hello {name', 'Hola {nombre}'); + + expect(result.wasFixed).toBe(false); + expect(result.error).toContain('Failed to parse base value'); + }); + + it('should error on malformed translation value', () => { + const result = autoFixICUPlaceholders('Hello {name}', 'Hola {nombre'); + + expect(result.wasFixed).toBe(false); + expect(result.error).toContain('Failed to parse translation value'); + }); + }); + + describe('number/date/time formatters', () => { + it('should fix renamed number formatter placeholder', () => { + const result = autoFixICUPlaceholders('Price: {price, number, currency}', 'Precio: {precio, number, currency}'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Precio: {price, number, currency}'); + }); + + it('should fix date formatter placeholder', () => { + const result = autoFixICUPlaceholders('Date: {today, date, short}', 'Fecha: {hoy, date, short}'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Fecha: {today, date, short}'); + }); + }); + + describe('edge cases', () => { + it('should handle empty translation', () => { + const result = autoFixICUPlaceholders('Hello {name}', ''); + + expect(result.wasFixed).toBe(true); + expect(result.value).toContain('{name}'); + }); + + it('should handle consecutive placeholders', () => { + const result = autoFixICUPlaceholders('{firstName}{lastName}', '{nombre}{apellido}'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('{firstName}{lastName}'); + }); + + it('should preserve whitespace in text segments', () => { + const result = autoFixICUPlaceholders('Hello {name} world', 'Hola {nombre} mundo'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Hola {name} mundo'); + }); + }); + }); + + describe('hasICUPlaceholders — Transloco exclusion', () => { + it('should return false for a Transloco double-brace pattern', () => { + expect(hasICUPlaceholders('Create {{ itemName }}?')).toBe(false); + expect(hasICUPlaceholders('Hello {{ name }}')).toBe(false); + expect(hasICUPlaceholders('{{ count }} items selected')).toBe(false); + }); + + it('should return true for a single-brace ICU pattern', () => { + expect(hasICUPlaceholders('Create {itemName}?')).toBe(true); + expect(hasICUPlaceholders('Hello {name}')).toBe(true); + }); + + it('should return false for plain text with no braces', () => { + expect(hasICUPlaceholders('No placeholders here')).toBe(false); + }); + + it('should return false for empty double-brace {{}}', () => { + expect(hasICUPlaceholders('text {{}}')).toBe(false); + }); + }); + + describe('hasTranslocoPlaceholders', () => { + it('should detect single Transloco placeholder', () => { + expect(hasTranslocoPlaceholders('Create {{ itemName }}?')).toBe(true); + }); + + it('should detect multiple Transloco placeholders', () => { + expect(hasTranslocoPlaceholders('Hello {{ firstName }} {{ lastName }}')).toBe(true); + }); + + it('should detect placeholder with no spaces inside braces', () => { + expect(hasTranslocoPlaceholders('{{count}} items')).toBe(true); + }); + + it('should return false for ICU single-brace patterns', () => { + expect(hasTranslocoPlaceholders('Hello {name}')).toBe(false); + }); + + it('should return false for plain text', () => { + expect(hasTranslocoPlaceholders('No placeholders')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(hasTranslocoPlaceholders('')).toBe(false); + }); + }); + + describe('extractTranslocoPlaceholders', () => { + it('should extract a single placeholder with surrounding text', () => { + const result = extractTranslocoPlaceholders('Create {{ itemName }}?'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(1); + expect(result.placeholders[0].name).toBe('itemName'); + expect(result.placeholders[0].fullText).toBe('{{ itemName }}'); + expect(result.placeholders[0].startPosition).toBe(7); + expect(result.placeholders[0].endPosition).toBe(21); + expect(result.textSegments).toEqual(['Create ', '?']); + }); + + it('should extract multiple placeholders', () => { + const result = extractTranslocoPlaceholders('Hello {{ firstName }} {{ lastName }}'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(2); + expect(result.placeholders[0].name).toBe('firstName'); + expect(result.placeholders[1].name).toBe('lastName'); + expect(result.textSegments).toEqual(['Hello ', ' ', '']); + }); + + it('should extract placeholder at start', () => { + const result = extractTranslocoPlaceholders('{{ count }} items selected'); + + expect(result.success).toBe(true); + expect(result.placeholders[0].name).toBe('count'); + expect(result.textSegments[0]).toBe(''); + expect(result.textSegments[1]).toBe(' items selected'); + }); + + it('should extract placeholder at end', () => { + const result = extractTranslocoPlaceholders('Welcome, {{ name }}'); + + expect(result.success).toBe(true); + expect(result.placeholders[0].name).toBe('name'); + expect(result.textSegments).toEqual(['Welcome, ', '']); + }); + + it('should return empty arrays for plain text', () => { + const result = extractTranslocoPlaceholders('No placeholders'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(0); + expect(result.textSegments).toEqual(['No placeholders']); + }); + + it('should handle placeholder without spaces inside braces', () => { + const result = extractTranslocoPlaceholders('{{count}} items'); + + expect(result.success).toBe(true); + expect(result.placeholders[0].name).toBe('count'); + }); + }); + + describe('autoFixTranslocoPlaceholders', () => { + describe('no fix needed', () => { + it('should return unchanged when base has no Transloco placeholders', () => { + const result = autoFixTranslocoPlaceholders('Hello world', 'Hola mundo'); + + expect(result.wasFixed).toBe(false); + expect(result.value).toBe('Hola mundo'); + }); + + it('should return unchanged when placeholders already match', () => { + const result = autoFixTranslocoPlaceholders('Create {{ itemName }}?', 'Créer {{ itemName }} ?'); + + expect(result.wasFixed).toBe(false); + expect(result.value).toBe('Créer {{ itemName }} ?'); + }); + + it('should return unchanged when multiple placeholders already match', () => { + const result = autoFixTranslocoPlaceholders( + 'Hello {{ firstName }} {{ lastName }}', + 'Hola {{ firstName }} {{ lastName }}', + ); + + expect(result.wasFixed).toBe(false); + expect(result.value).toBe('Hola {{ firstName }} {{ lastName }}'); + }); + + it('should not fix when spacing differs but names match', () => { + const result = autoFixTranslocoPlaceholders('Hello {{ name }}', 'Hola {{name}}'); + expect(result.wasFixed).toBe(false); + expect(result.value).toBe('Hola {{name}}'); + }); + }); + + describe('renamed placeholders', () => { + it('should fix a single renamed placeholder', () => { + const result = autoFixTranslocoPlaceholders('Create {{ itemName }}?', 'Créer {{ nomElement }} ?'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Créer {{ itemName }} ?'); + expect(result.originalPlaceholders).toEqual(['{{ nomElement }}']); + expect(result.fixedPlaceholders).toEqual(['{{ itemName }}']); + }); + + it('should fix multiple renamed placeholders', () => { + const result = autoFixTranslocoPlaceholders( + 'Hello {{ firstName }} {{ lastName }}', + 'Hola {{ nombre }} {{ apellido }}', + ); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Hola {{ firstName }} {{ lastName }}'); + expect(result.originalPlaceholders).toHaveLength(2); + expect(result.fixedPlaceholders).toHaveLength(2); + }); + + it('should preserve translated text segments while replacing placeholder names', () => { + const result = autoFixTranslocoPlaceholders( + 'You have {{ count }} unread messages', + 'Sie haben {{ anzahl }} ungelesene Nachrichten', + ); + + expect(result.wasFixed).toBe(true); + expect(result.value).toBe('Sie haben {{ count }} ungelesene Nachrichten'); + }); + }); + + describe('missing placeholders', () => { + it('should append a single missing placeholder at the end', () => { + const result = autoFixTranslocoPlaceholders('Hello {{ name }}', 'Hola'); + + expect(result.wasFixed).toBe(true); + expect(result.value).toContain('{{ name }}'); + expect(result.description).toContain('Inserted missing placeholder'); + }); + + it('should error when multiple placeholders are missing', () => { + const result = autoFixTranslocoPlaceholders('Hello {{ firstName }} {{ lastName }}', 'Hola'); + + expect(result.wasFixed).toBe(false); + expect(result.error).toContain('missing 2 placeholders'); + }); + + it('should error when translation has fewer placeholders than base', () => { + const result = autoFixTranslocoPlaceholders('Hello {{ firstName }} {{ lastName }}', 'Hola {{ nombre }}'); + + expect(result.wasFixed).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('count mismatch', () => { + it('should error when translation has extra placeholders', () => { + const result = autoFixTranslocoPlaceholders('Hello {{ name }}', 'Hola {{ name }} {{ extra }}'); + + expect(result.wasFixed).toBe(false); + expect(result.error).toContain('extra placeholders'); + }); + }); + }); + + describe('autoFixICUPlaceholders — Transloco pass-through', () => { + it('should not error on Transloco values when base has no ICU placeholders', () => { + // "Create {{ itemName }}?" has no ICU single-braces, so autoFixICUPlaceholders + // should treat it as having no placeholders and return as-is. + const result = autoFixICUPlaceholders('Create {{ itemName }}?', 'Créer {{ nomElement }} ?'); + + expect(result.wasFixed).toBe(false); + expect(result.error).toBeUndefined(); + expect(result.value).toBe('Créer {{ nomElement }} ?'); + }); + + it('should not error on plain Transloco value with no ICU braces', () => { + const result = autoFixICUPlaceholders('{{ count }} items', '{{ anzahl }} Elemente'); + + expect(result.wasFixed).toBe(false); + expect(result.error).toBeUndefined(); + }); + + it('should pass through Transloco values via the hasTranslocoPlaceholders guard', () => { + const result = autoFixICUPlaceholders('Create {{ itemName }}?', 'إنشاء {{ itemName }}؟'); + expect(result.wasFixed).toBe(false); + expect(result.error).toBeUndefined(); + expect(result.value).toBe('إنشاء {{ itemName }}؟'); + }); + }); +}); + +describe('icu-auto-fixer utility functions', () => { + it('hasICUPlaceholders should detect various placeholder types', () => { + expect(hasICUPlaceholders('Hello {name}')).toBe(true); + expect(hasICUPlaceholders('{count, plural, one {#} other {#}}')).toBe(true); + expect(hasICUPlaceholders('{gender, select, male {he} female {she}}')).toBe(true); + expect(hasICUPlaceholders('No placeholders')).toBe(false); + expect(hasICUPlaceholders("'{escaped}'")).toBe(false); + }); + + it('extractICUPlaceholders should extract all placeholder types', () => { + const result = extractICUPlaceholders('Hello {name}, you have {count, plural, one {# item} other {# items}}'); + + expect(result.success).toBe(true); + expect(result.placeholders).toHaveLength(2); + expect(result.placeholders[0].name).toBe('name'); + expect(result.placeholders[0].type).toBe('simple'); + expect(result.placeholders[1].name).toBe('count'); + expect(result.placeholders[1].type).toBe('plural'); + }); + + it('validateICUSyntax should validate syntax', () => { + expect(validateICUSyntax('Hello {name}')).toBe(true); + expect(validateICUSyntax('{count, plural, one {#} other {#}}')).toBe(true); + expect(validateICUSyntax('Hello {name')).toBe(false); + expect(validateICUSyntax('Hello name}')).toBe(false); + }); +}); diff --git a/libs/domain/src/lib/js-identifier.ts b/libs/domain/src/lib/js-identifier.ts index 1389804c..1b34b2d1 100644 --- a/libs/domain/src/lib/js-identifier.ts +++ b/libs/domain/src/lib/js-identifier.ts @@ -10,7 +10,7 @@ export const JS_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; /** ES2022 + TypeScript contextual keywords that cannot be used as bare identifiers in a const declaration. */ -export const JS_RESERVED_WORDS: ReadonlySet = new Set([ +const JS_RESERVED_WORDS: ReadonlySet = new Set([ // ES2022 reserved words 'break', 'case', diff --git a/libs/domain/src/lib/token-casing.ts b/libs/domain/src/lib/token-casing.ts new file mode 100644 index 00000000..94d375e8 --- /dev/null +++ b/libs/domain/src/lib/token-casing.ts @@ -0,0 +1,9 @@ +/** + * Controls the casing of generated TypeScript token property keys. + * - 'upperCase': SCREAMING_SNAKE_CASE (e.g. FILE_UPLOAD) — default, fully backward compatible + * - 'camelCase': camelCase (e.g. fileUpload) + * + * Note: The const name (e.g. TRACKER_TOKENS) and type name (e.g. TrackerTokens) + * are always SCREAMING_SNAKE_CASE and PascalCase respectively, regardless of this setting. + */ +export type TokenCasing = 'upperCase' | 'camelCase'; From 650d968bc3ee375a6b4c720db0a62cbe294f7281 Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 11:53:02 -0700 Subject: [PATCH 09/20] refactor(core): bind resource and folder operations to an opened Collection addResource, editResource, deleteResource, moveResource, translateExistingResource, createFolder, deleteFolder and moveFolder take the opened Collection as their first parameter, like importResources. Base locale, locales and translation config come only from it: no 'en' fallbacks, no allLocales, no cwd. Core owns the locale seeding rule in resource/locale-seeding.ts (seedLocales): a supplied translation wins, else auto-translate when the collection enables it, else a copy of the base value as `new`. On edit it runs after a base-value change for the locales the staleness rule marks as needing work, and never replaces a real translation with a copy. editResource(collection, key, changes) takes the full existing key and an explicit `moveTo` folder ('' for the root); the destination is re-read and re-checked for a collision right before the move. Behaviour changes: - POST resources: `CreateResourceDto.baseLocale` removed; partial translations are seeded; an unknown locale answers 400. - PATCH resources: `targetFolder` replaced by `moveTo`; a folder change moves the entry (was 404); a destination collision answers 409. - DELETE folders: missing folder 404, malformed path 400; `DeleteFolderResponseDto.error` removed. - POST folders/move: typed errors replace message sniffing (400 for a malformed path or move into a descendant, 404 for a missing source). - New typed errors: ResourceAlreadyExistsError, FolderNotFoundError, FolderMoveIntoDescendantError, AutoTranslationDisabledError. - CLI edit-resource --target-folder moves the entry instead of being prepended to --key. - Tracker sends the full key plus moveTo; toCreateDto drops baseLocale. Deleted dead code: verifyChecksum, loadFullResourceTree, createDefaultTranslations, resource.mapper.ts. Co-Authored-By: Claude Fable 5.1 --- .../cache/collection-index.service.spec.ts | 28 +- .../folders/folders.controller.spec.ts | 158 ++-- .../collections/folders/folders.controller.ts | 39 +- .../resources/resources.controller.spec.ts | 337 +++----- .../resources/resources.controller.ts | 78 +- .../lingo-tracker-exception.filter.spec.ts | 32 + .../errors/lingo-tracker-exception.filter.ts | 13 +- apps/api/src/app/mappers/resource.mapper.ts | 17 - .../cli/src/add-resource/add-resource.test.ts | 101 +-- apps/cli/src/add-resource/add-resource.ts | 36 +- apps/cli/src/commands/delete-resource.test.ts | 41 +- apps/cli/src/commands/delete-resource.ts | 2 +- apps/cli/src/commands/edit-resource.test.ts | 43 +- apps/cli/src/commands/edit-resource.ts | 63 +- apps/cli/src/commands/move.ts | 4 +- apps/cli/src/main.ts | 2 +- .../resource-entry-draft.spec.ts | 24 +- .../resource-entry-draft.ts | 19 +- .../translation-editor-dialog.ts | 2 +- .../with-entry-writes.feature.spec.ts | 18 +- .../features/with-entry-writes.feature.ts | 8 +- architecture-docs/api.md | 31 +- architecture-docs/cli.md | 8 +- architecture-docs/core-library.md | 76 +- architecture-docs/domain-and-data-model.md | 2 +- architecture-docs/frontend.md | 8 +- architecture-docs/glossary.md | 16 +- architecture-docs/user-flows.md | 12 +- libs/core/src/index.ts | 12 +- libs/core/src/lib/errors/error-messages.ts | 9 + libs/core/src/lib/errors/index.ts | 4 + .../lib/errors/lingo-tracker-error.spec.ts | 28 + .../src/lib/errors/lingo-tracker-error.ts | 47 ++ .../core/src/lib/folder/create-folder.spec.ts | 65 +- libs/core/src/lib/folder/create-folder.ts | 22 +- libs/core/src/lib/folder/delete-folder.ts | 105 +-- .../lib/folder/move-folder.real-fs.spec.ts | 39 +- libs/core/src/lib/folder/move-folder.spec.ts | 176 ++-- libs/core/src/lib/folder/move-folder.ts | 305 ++++--- .../resource/load-full-resource-tree.spec.ts | 762 ------------------ .../lib/resource/load-full-resource-tree.ts | 33 - .../resource-mutation.real-fs.spec.ts | 51 +- libs/core/src/lib/translation/index.ts | 1 - .../translate-existing-resource.spec.ts | 129 +-- .../translate-existing-resource.ts | 48 +- libs/core/src/resource/add-resource.spec.ts | 595 +++++--------- libs/core/src/resource/add-resource.ts | 248 ++---- libs/core/src/resource/checksum.spec.ts | 23 +- libs/core/src/resource/checksum.ts | 10 - .../core/src/resource/delete-resource.spec.ts | 49 +- libs/core/src/resource/delete-resource.ts | 11 +- libs/core/src/resource/edit-resource.spec.ts | 654 ++++++--------- libs/core/src/resource/edit-resource.ts | 326 ++++---- libs/core/src/resource/index.ts | 8 +- libs/core/src/resource/locale-seeding.ts | 93 +++ .../resource/move-resource.real-fs.spec.ts | 32 +- libs/core/src/resource/move-resource.spec.ts | 41 +- libs/core/src/resource/move-resource.ts | 55 +- .../src/resource/translation-helpers.spec.ts | 65 -- libs/core/src/resource/translation-helpers.ts | 27 - .../src/lib/create-resource.dto.ts | 9 +- .../src/lib/delete-folder.dto.ts | 5 +- .../src/lib/update-resource.dto.ts | 7 +- 63 files changed, 1999 insertions(+), 3313 deletions(-) delete mode 100644 apps/api/src/app/mappers/resource.mapper.ts delete mode 100644 libs/core/src/lib/resource/load-full-resource-tree.spec.ts delete mode 100644 libs/core/src/lib/resource/load-full-resource-tree.ts create mode 100644 libs/core/src/resource/locale-seeding.ts delete mode 100644 libs/core/src/resource/translation-helpers.spec.ts delete mode 100644 libs/core/src/resource/translation-helpers.ts diff --git a/apps/api/src/app/cache/collection-index.service.spec.ts b/apps/api/src/app/cache/collection-index.service.spec.ts index 7cc0ea9a..05c3933b 100644 --- a/apps/api/src/app/cache/collection-index.service.spec.ts +++ b/apps/api/src/app/cache/collection-index.service.spec.ts @@ -153,7 +153,7 @@ describe('CollectionIndex', () => { }); it('adds a resource, creating its folders', async () => { - const result = await addResource(collection().translationsFolder, { + const result = await addResource(collection(), { key: 'apps.dialogs.confirm.yes', baseValue: 'Yes', }); @@ -164,7 +164,7 @@ describe('CollectionIndex', () => { }); it('replaces an edited resource in place', async () => { - const result = await editResource(collection().translationsFolder, { key: 'common.ok', baseValue: 'Okay' }); + const result = await editResource(collection(), 'common.ok', { baseValue: 'Okay' }); index.apply(result.mutations); expect(readyTree(collection(), 'common')?.resources.find((r) => r.key === 'ok')?.source).toBe('Okay'); @@ -172,14 +172,14 @@ describe('CollectionIndex', () => { }); it('removes deleted resources', () => { - index.apply(deleteResource(collection().translationsFolder, { keys: ['common.ok', 'apps.title'] }).mutations); + index.apply(deleteResource(collection(), { keys: ['common.ok', 'apps.title'] }).mutations); expect(keysOf(readyTree(collection(), 'common'))).toEqual(['cancel']); expectIndexMatchesDisk(); }); it('moves resources by pattern', async () => { - const result = await moveResource(collection().translationsFolder, { source: 'common.*', destination: 'shared' }); + const result = await moveResource(collection(), { source: 'common.*', destination: 'shared' }); index.apply(result.mutations); expect(keysOf(readyTree(collection(), 'shared'))).toEqual(['cancel', 'ok']); @@ -192,10 +192,10 @@ describe('CollectionIndex', () => { const other = openCollection(config('other'), 'other'); readyTree(other); - const result = await moveResource(collection().translationsFolder, { + const result = await moveResource(collection(), { source: 'common.ok', destination: 'imported.ok', - destinationTranslationsFolder: other.translationsFolder, + destinationCollection: other, }); index.apply(result.mutations); @@ -206,10 +206,10 @@ describe('CollectionIndex', () => { }); it('creates, moves and deletes folders', async () => { - index.apply(createFolder(collection().translationsFolder, { folderName: 'empty', parentPath: 'apps' }).mutations); + index.apply(createFolder(collection(), { folderName: 'empty', parentPath: 'apps' }).mutations); expect(readyTree(collection(), 'apps.empty')).not.toBeNull(); - const moved = await moveFolder(collection().translationsFolder, { + const moved = await moveFolder(collection(), { sourceFolderPath: 'common', destinationFolderPath: 'apps', }); @@ -217,7 +217,7 @@ describe('CollectionIndex', () => { expect(keysOf(readyTree(collection(), 'apps.common'))).toEqual(['cancel', 'ok']); expect(readyTree(collection(), 'common')).toBeNull(); - index.apply(deleteFolder(collection().translationsFolder, { folderPath: 'apps.empty' }).mutations); + index.apply(deleteFolder(collection(), { folderPath: 'apps.empty' }).mutations); expect(readyTree(collection(), 'apps.empty')).toBeNull(); expectIndexMatchesDisk(); @@ -242,7 +242,7 @@ describe('CollectionIndex', () => { }); it('ignores mutations for collections that are not indexed', async () => { - const result = await addResource(path.join(root, 'elsewhere'), { key: 'ok', baseValue: 'OK' }); + const result = await addResource(collection('elsewhere'), { key: 'ok', baseValue: 'OK' }); index.apply(result.mutations); expect(index.tree(collection()).status).toBe('ready'); @@ -273,17 +273,13 @@ describe('CollectionIndex', () => { }); it('does not read its own write as an outside change', async () => { - index.apply( - (await addResource(collection().translationsFolder, { key: 'common.yes', baseValue: 'Yes' })).mutations, - ); + index.apply((await addResource(collection(), { key: 'common.yes', baseValue: 'Yes' })).mutations); expect(index.tree(collection()).status).toBe('ready'); }); it('detects an outside change made after its own write settled', async () => { - index.apply( - (await addResource(collection().translationsFolder, { key: 'common.yes', baseValue: 'Yes' })).mutations, - ); + index.apply((await addResource(collection(), { key: 'common.yes', baseValue: 'Yes' })).mutations); // Let the deferred fingerprint refresh run. await new Promise((resolve) => setTimeout(resolve, 5)); diff --git a/apps/api/src/app/collections/folders/folders.controller.spec.ts b/apps/api/src/app/collections/folders/folders.controller.spec.ts index da76631a..a298991e 100644 --- a/apps/api/src/app/collections/folders/folders.controller.spec.ts +++ b/apps/api/src/app/collections/folders/folders.controller.spec.ts @@ -1,11 +1,16 @@ import { resolve } from 'node:path'; -import { Test, type TestingModule } from '@nestjs/testing'; import { ForbiddenException, HttpException, NotFoundException } from '@nestjs/common'; -import { FoldersController } from './folders.controller'; -import { ConfigService } from '../../config/config.service'; +import { Test, type TestingModule } from '@nestjs/testing'; +import * as core from '@simoncodes-ca/core'; import { CollectionIndex } from '../../cache/collection-index.service'; +import { ConfigService } from '../../config/config.service'; import { toHttpException } from '../../errors/lingo-tracker-exception.filter'; -import * as core from '@simoncodes-ca/core'; +import { FoldersController } from './folders.controller'; + +const httpErrorOf = (promise: Promise): Promise => + promise.then(() => { + throw new Error('expected the handler to reject'); + }, toHttpException); // Mock the core module jest.mock('@simoncodes-ca/core', () => { @@ -86,13 +91,19 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith(resolve('./translations/test'), { - sourceFolderPath: 'apps.common.buttons', - destinationFolderPath: 'apps.shared', - override: undefined, - nestUnderDestination: undefined, - destinationTranslationsFolder: undefined, - }); + expect(core.moveFolder).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-collection', + translationsFolder: resolve('./translations/test'), + }), + { + sourceFolderPath: 'apps.common.buttons', + destinationFolderPath: 'apps.shared', + override: undefined, + nestUnderDestination: undefined, + destinationCollection: undefined, + }, + ); expect(result).toEqual({ movedCount: 5, @@ -120,13 +131,19 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith(resolve('./translations/test'), { - sourceFolderPath: 'apps.buttons', - destinationFolderPath: 'apps.actions', - override: true, - nestUnderDestination: undefined, - destinationTranslationsFolder: undefined, - }); + expect(core.moveFolder).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-collection', + translationsFolder: resolve('./translations/test'), + }), + { + sourceFolderPath: 'apps.buttons', + destinationFolderPath: 'apps.actions', + override: true, + nestUnderDestination: undefined, + destinationCollection: undefined, + }, + ); expect(result.movedCount).toBe(3); expect(result.foldersDeleted).toBe(1); @@ -150,13 +167,22 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith(resolve('./translations/test'), { - sourceFolderPath: 'apps.buttons', - destinationFolderPath: 'shared.buttons', - override: undefined, - nestUnderDestination: undefined, - destinationTranslationsFolder: resolve('./translations/another'), - }); + expect(core.moveFolder).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-collection', + translationsFolder: resolve('./translations/test'), + }), + { + sourceFolderPath: 'apps.buttons', + destinationFolderPath: 'shared.buttons', + override: undefined, + nestUnderDestination: undefined, + destinationCollection: expect.objectContaining({ + name: 'another-collection', + translationsFolder: resolve('./translations/another'), + }), + }, + ); expect(result.movedCount).toBe(2); }); @@ -216,42 +242,21 @@ describe('FoldersController', () => { expect(core.moveFolder).not.toHaveBeenCalled(); }); - it('should throw HttpException for circular dependency errors', async () => { - const moveFolderDto = { - sourceFolderPath: 'apps.common', - destinationFolderPath: 'apps.common.buttons', - }; - - const mockMoveResult = { - movedCount: 0, - foldersDeleted: 0, - warnings: [], - errors: ['Cannot move folder into its own descendant'], - }; - - (core.moveFolder as jest.Mock).mockResolvedValue(mockMoveResult); - - await expect(foldersController.move('test-collection', moveFolderDto)).rejects.toThrow(HttpException); - - expect(core.moveFolder).toHaveBeenCalled(); - }); - - it('should throw HttpException for invalid path segments', async () => { - const moveFolderDto = { - sourceFolderPath: 'apps.invalid@char', - destinationFolderPath: 'apps.actions', - }; - - const mockMoveResult = { - movedCount: 0, - foldersDeleted: 0, - warnings: [], - errors: ['Invalid source folder path segment "invalid@char"'], - }; - - (core.moveFolder as jest.Mock).mockResolvedValue(mockMoveResult); + it.each([ + [new core.InvalidFolderPathError('source folder path', 'bad path'), 400], + [new core.FolderMoveIntoDescendantError('apps.common', 'apps.common.buttons'), 400], + [new core.FolderNotFoundError('apps.missing'), 404], + ])('maps a typed move error through the exception filter', async (coreError, status) => { + (core.moveFolder as jest.Mock).mockRejectedValue(coreError); + + const error = await httpErrorOf( + foldersController.move('test-collection', { + sourceFolderPath: 'apps.common', + destinationFolderPath: 'apps.shared', + }), + ); - await expect(foldersController.move('test-collection', moveFolderDto)).rejects.toThrow(HttpException); + expect(error.getStatus()).toBe(status); }); it('should report a move of an empty folder', async () => { @@ -316,7 +321,10 @@ describe('FoldersController', () => { await foldersController.move('test%2Dcollection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith(resolve('./translations/test'), expect.any(Object)); + expect(core.moveFolder).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), + expect.any(Object), + ); }); }); @@ -336,10 +344,10 @@ describe('FoldersController', () => { const result = await foldersController.create('test-collection', createFolderDto); - expect(core.createFolder).toHaveBeenCalledWith(resolve('./translations/test'), { - folderName: 'buttons', - parentPath: 'apps.common', - }); + expect(core.createFolder).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), + { folderName: 'buttons', parentPath: 'apps.common' }, + ); expect(result.created).toBe(true); expect(result.folderPath).toBe('apps.common.buttons'); @@ -371,20 +379,30 @@ describe('FoldersController', () => { const mockDeleteResult = { folderPath: 'apps.common.buttons', - deleted: true, resourcesDeleted: 5, + mutations: [], }; (core.deleteFolder as jest.Mock).mockReturnValue(mockDeleteResult); const result = await foldersController.delete('test-collection', deleteFolderDto); - expect(core.deleteFolder).toHaveBeenCalledWith(resolve('./translations/test'), { - folderPath: 'apps.common.buttons', + expect(core.deleteFolder).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), + { folderPath: 'apps.common.buttons' }, + ); + + expect(result).toEqual({ deleted: true, folderPath: 'apps.common.buttons', resourcesDeleted: 5 }); + }); + + it('answers 404 when core reports that the folder does not exist', async () => { + (core.deleteFolder as jest.Mock).mockImplementation(() => { + throw new core.FolderNotFoundError('apps.missing'); }); - expect(result.deleted).toBe(true); - expect(result.resourcesDeleted).toBe(5); + const error = await httpErrorOf(foldersController.delete('test-collection', { folderPath: 'apps.missing' })); + + expect(error.getStatus()).toBe(404); }); }); }); diff --git a/apps/api/src/app/collections/folders/folders.controller.ts b/apps/api/src/app/collections/folders/folders.controller.ts index 82ea42ee..2cd01fb1 100644 --- a/apps/api/src/app/collections/folders/folders.controller.ts +++ b/apps/api/src/app/collections/folders/folders.controller.ts @@ -27,9 +27,9 @@ export class FoldersController { @Param('collectionName') collectionName: string, @Body() createFolderDto: CreateFolderDto, ): Promise { - const { translationsFolder } = openRouteCollection(this.configService.getConfig(), collectionName); + const collection = openRouteCollection(this.configService.getConfig(), collectionName); - const result = createFolder(translationsFolder, { + const result = createFolder(collection, { folderName: createFolderDto.folderName, parentPath: createFolderDto.parentPath, }); @@ -59,35 +59,38 @@ export class FoldersController { }; } - /** Core `deleteFolder` reports every failure in `error`, so this answers 200 even then. */ + /** Failures are typed core errors: a missing folder answers 404, a malformed path 400. */ @Delete() async delete( @Param('collectionName') collectionName: string, @Body() deleteFolderDto: DeleteFolderDto, ): Promise { - const { translationsFolder } = openRouteCollection(this.configService.getConfig(), collectionName); + const collection = openRouteCollection(this.configService.getConfig(), collectionName); - const result = deleteFolder(translationsFolder, { + const result = deleteFolder(collection, { folderPath: deleteFolderDto.folderPath, }); this.index.apply(result.mutations); return { - deleted: result.deleted, + deleted: true, folderPath: result.folderPath, resourcesDeleted: result.resourcesDeleted, - error: result.error, }; } + /** + * Bad input is a typed core error (400 for a malformed path or a move into the folder's own + * descendant, 404 for a missing source folder). Per-resource failures come back in `errors`. + */ @Post('move') async move( @Param('collectionName') collectionName: string, @Body() moveFolderDto: MoveFolderDto, ): Promise { const config = this.configService.getConfig(); - const { translationsFolder } = openRouteCollection(config, collectionName); + const collection = openRouteCollection(config, collectionName); if ( !moveFolderDto.sourceFolderPath || @@ -100,32 +103,20 @@ export class FoldersController { ); } - // Handle cross-collection moves - const destinationTranslationsFolder = moveFolderDto.toCollection - ? openDestinationCollection(config, moveFolderDto.toCollection).translationsFolder + const destinationCollection = moveFolderDto.toCollection + ? openDestinationCollection(config, moveFolderDto.toCollection) : undefined; - // Perform the move. Core `moveFolder` never throws; it reports failures in `errors`. - const result = await moveFolder(translationsFolder, { + const result = await moveFolder(collection, { sourceFolderPath: moveFolderDto.sourceFolderPath, destinationFolderPath: moveFolderDto.destinationFolderPath, override: moveFolderDto.override, nestUnderDestination: moveFolderDto.nestUnderDestination, - destinationTranslationsFolder, + destinationCollection, }); this.index.apply(result.mutations); - // Check for critical errors that should return 400 - const hasCriticalError = result.errors.some( - (err) => - err.includes('Invalid') || err.includes('not found') || err.includes('circular') || err.includes('descendant'), - ); - - if (hasCriticalError && result.movedCount === 0) { - throw new HttpException(`Validation error: ${result.errors.join(', ')}`, HttpStatus.BAD_REQUEST); - } - return { movedCount: result.movedCount, foldersDeleted: result.foldersDeleted, diff --git a/apps/api/src/app/collections/resources/resources.controller.spec.ts b/apps/api/src/app/collections/resources/resources.controller.spec.ts index f1abe1a4..3c503927 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -1,15 +1,15 @@ import { resolve } from 'node:path'; -import { Test, type TestingModule } from '@nestjs/testing'; import { HttpException, NotFoundException } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import * as core from '@simoncodes-ca/core'; import { TranslationError } from '@simoncodes-ca/core'; -import type { TranslationStatus, LocaleMetadata } from '@simoncodes-ca/domain'; import type { ResourceTreeDto } from '@simoncodes-ca/data-transfer'; -import { ResourcesController } from './resources.controller'; -import { ConfigService } from '../../config/config.service'; +import type { LocaleMetadata, TranslationStatus } from '@simoncodes-ca/domain'; import { CollectionIndex } from '../../cache/collection-index.service'; -import { TranslationJobService } from '../../translation-job/translation-job.service'; +import { ConfigService } from '../../config/config.service'; import { toHttpException } from '../../errors/lingo-tracker-exception.filter'; -import * as core from '@simoncodes-ca/core'; +import { TranslationJobService } from '../../translation-job/translation-job.service'; +import { ResourcesController } from './resources.controller'; /** What the handler rejects with, as the HTTP exception the global exception filter answers with. */ const httpErrorOf = (promise: Promise): Promise => @@ -28,16 +28,10 @@ jest.mock('@simoncodes-ca/core', () => { moveResourcesByPattern: jest.fn(), editResource: jest.fn(), translateExistingResource: jest.fn(), - createDefaultTranslations: jest.fn(), extractResourcesRecursively: jest.fn(), }; }); -// Mock the mapper -jest.mock('../../mappers/resource.mapper', () => ({ - mapDtoToAddResourceParams: jest.fn((dto) => dto), -})); - // Mock the resource tree mapper jest.mock('../../mappers/resource-tree.mapper', () => ({ mapResourceEntryToSummary: jest.fn((entry) => ({ @@ -171,12 +165,19 @@ describe('ResourcesController', () => { created: true, }); expect(addResource).toHaveBeenCalledWith( - resolve('./translations/test'), expect.objectContaining({ - key: 'app.button.ok', - baseValue: 'OK', + name: 'test-collection', + translationsFolder: resolve('./translations/test'), baseLocale: 'en', }), + { + key: 'app.button.ok', + baseValue: 'OK', + comment: undefined, + tags: undefined, + targetFolder: undefined, + translations: undefined, + }, ); }); @@ -246,62 +247,6 @@ describe('ResourcesController', () => { expect(addResource).toHaveBeenCalledTimes(3); }); - it('should use collection baseLocale when provided', async () => { - const addResource = core.addResource as jest.Mock; - addResource.mockReturnValue({ - resolvedKey: 'app.button.ok', - created: true, - }); - - const configWithCustomBaseLocale = { - ...mockConfig, - collections: { - 'test-collection': { - translationsFolder: './translations/test', - baseLocale: 'fr-ca', - }, - }, - }; - jest.spyOn(configService, 'getConfig').mockReturnValue(configWithCustomBaseLocale); - - const dto = { - key: 'app.button.ok', - baseValue: 'OK', - }; - - await resourcesController.createResources('test-collection', dto); - - expect(addResource).toHaveBeenCalledWith( - resolve('./translations/test'), - expect.objectContaining({ - baseLocale: 'fr-ca', - }), - ); - }); - - it('should use DTO baseLocale when explicitly provided', async () => { - const addResource = core.addResource as jest.Mock; - addResource.mockReturnValue({ - resolvedKey: 'app.button.ok', - created: true, - }); - - const dto = { - key: 'app.button.ok', - baseValue: 'OK', - baseLocale: 'es', - }; - - await resourcesController.createResources('test-collection', dto); - - expect(addResource).toHaveBeenCalledWith( - resolve('./translations/test'), - expect.objectContaining({ - baseLocale: 'es', - }), - ); - }); - it('should URI decode collection names with special characters', async () => { const addResource = core.addResource as jest.Mock; addResource.mockReturnValue({ @@ -326,7 +271,13 @@ describe('ResourcesController', () => { await resourcesController.createResources('My%20Collection', dto); - expect(addResource).toHaveBeenCalledWith(resolve('./translations/my-collection'), expect.any(Object)); + expect(addResource).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'My Collection', + translationsFolder: resolve('./translations/my-collection'), + }), + expect.any(Object), + ); }); it('should throw NotFoundException when collection does not exist', async () => { @@ -438,7 +389,11 @@ describe('ResourcesController', () => { created: true, }); expect(addResource).toHaveBeenCalledWith( - resolve('./translations/test'), + expect.objectContaining({ + name: 'test-collection', + translationsFolder: resolve('./translations/test'), + baseLocale: 'en', + }), expect.objectContaining({ key: 'cancel', baseValue: 'Cancel', @@ -476,7 +431,7 @@ describe('ResourcesController', () => { await resourcesController.createResources('test-collection', dto); expect(addResource).toHaveBeenCalledWith( - resolve('./translations/test'), + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), expect.objectContaining({ translations: [ { locale: 'fr-ca', value: "D'accord", status: 'translated' }, @@ -485,124 +440,6 @@ describe('ResourcesController', () => { }), ); }); - - it('should automatically create entries for all non-base locales when translations are not provided', async () => { - const addResource = core.addResource as jest.Mock; - addResource.mockReturnValue({ - resolvedKey: 'app.button.ok', - created: true, - }); - - const createDefaultTranslations = core.createDefaultTranslations as jest.Mock; - createDefaultTranslations.mockReturnValue([ - { locale: 'fr-ca', value: 'OK', status: 'new' }, - { locale: 'es', value: 'OK', status: 'new' }, - ]); - - const dto = { - key: 'app.button.ok', - baseValue: 'OK', - // No translations provided - }; - - await resourcesController.createResources('test-collection', dto); - - expect(addResource).toHaveBeenCalledWith( - resolve('./translations/test'), - expect.objectContaining({ - key: 'app.button.ok', - baseValue: 'OK', - baseLocale: 'en', - translations: [ - { locale: 'fr-ca', value: 'OK', status: 'new' }, - { locale: 'es', value: 'OK', status: 'new' }, - ], - }), - ); - }); - - it('should use collection locales when available, fall back to global locales', async () => { - const addResource = core.addResource as jest.Mock; - addResource.mockReturnValue({ - resolvedKey: 'app.button.ok', - created: true, - }); - - const createDefaultTranslations = core.createDefaultTranslations as jest.Mock; - createDefaultTranslations.mockReturnValue([ - { locale: 'fr-ca', value: 'OK', status: 'new' }, - { locale: 'es', value: 'OK', status: 'new' }, - { locale: 'de', value: 'OK', status: 'new' }, - ]); - - const configWithCollectionLocales = { - ...mockConfig, - collections: { - 'test-collection': { - translationsFolder: './translations/test', - baseLocale: 'en', - locales: ['en', 'fr-ca', 'es', 'de'], - }, - }, - }; - jest.spyOn(configService, 'getConfig').mockReturnValue(configWithCollectionLocales); - - const dto = { - key: 'app.button.ok', - baseValue: 'OK', - }; - - await resourcesController.createResources('test-collection', dto); - - expect(addResource).toHaveBeenCalledWith( - resolve('./translations/test'), - expect.objectContaining({ - translations: [ - { locale: 'fr-ca', value: 'OK', status: 'new' }, - { locale: 'es', value: 'OK', status: 'new' }, - { locale: 'de', value: 'OK', status: 'new' }, - ], - }), - ); - }); - - it('should not create translations if locales array is empty', async () => { - const addResource = core.addResource as jest.Mock; - addResource.mockReturnValue({ - resolvedKey: 'app.button.ok', - created: true, - }); - - const createDefaultTranslations = core.createDefaultTranslations as jest.Mock; - createDefaultTranslations.mockReturnValue(undefined); - - const configWithNoLocales = { - ...mockConfig, - collections: { - 'test-collection': { - translationsFolder: './translations/test', - baseLocale: 'en', - // No locales property - }, - }, - locales: [], // Empty global locales - }; - jest.spyOn(configService, 'getConfig').mockReturnValue(configWithNoLocales); - - const dto = { - key: 'app.button.ok', - baseValue: 'OK', - }; - - await resourcesController.createResources('test-collection', dto); - - expect(addResource).toHaveBeenCalledWith( - resolve('./translations/test'), - expect.objectContaining({ - translations: undefined, - }), - ); - }); }); describe('delete', () => { @@ -623,9 +460,10 @@ describe('ResourcesController', () => { entriesDeleted: 1, errors: undefined, }); - expect(deleteResource).toHaveBeenCalledWith(resolve('./translations/test'), { - keys: ['app.button.ok'], - }); + expect(deleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), + { keys: ['app.button.ok'] }, + ); }); it('should successfully delete multiple resources (bulk operation)', async () => { @@ -645,9 +483,10 @@ describe('ResourcesController', () => { entriesDeleted: 3, errors: undefined, }); - expect(deleteResource).toHaveBeenCalledWith(resolve('./translations/test'), { - keys: ['app.button.ok', 'app.button.cancel', 'app.button.save'], - }); + expect(deleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), + { keys: ['app.button.ok', 'app.button.cancel', 'app.button.save'] }, + ); }); it('should handle partial failures with errors array', async () => { @@ -700,7 +539,10 @@ describe('ResourcesController', () => { await resourcesController.delete('My%20Collection', dto); - expect(deleteResource).toHaveBeenCalledWith(resolve('./translations/my-collection'), { keys: ['app.button.ok'] }); + expect(deleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'My Collection', translationsFolder: resolve('./translations/my-collection') }), + { keys: ['app.button.ok'] }, + ); }); it('should throw NotFoundException when collection does not exist', async () => { @@ -775,9 +617,10 @@ describe('ResourcesController', () => { entriesDeleted: 1, errors: undefined, }); - expect(deleteResource).toHaveBeenCalledWith(resolve('./translations/test'), { - keys: ['apps.common.buttons.ok'], - }); + expect(deleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), + { keys: ['apps.common.buttons.ok'] }, + ); }); }); @@ -802,7 +645,7 @@ describe('ResourcesController', () => { errors: [], }); expect(moveResource).toHaveBeenCalledWith( - resolve('./translations/test'), + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), expect.objectContaining({ source: 'app.button.ok', destination: 'app.actions.ok', @@ -831,7 +674,7 @@ describe('ResourcesController', () => { await resourcesController.move('test-collection', dto); expect(moveResource).toHaveBeenCalledWith( - resolve('./translations/test'), + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), expect.objectContaining({ source: 'app.button.ok', destination: 'app.actions.ok', @@ -906,11 +749,14 @@ describe('ResourcesController', () => { expect(result.movedCount).toBe(1); expect(moveResource).toHaveBeenCalledWith( - resolve('./translations/test'), + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), expect.objectContaining({ source: 'app.button.ok', destination: 'app.actions.ok', - destinationTranslationsFolder: resolve('./translations/other'), + destinationCollection: expect.objectContaining({ + name: 'other-collection', + translationsFolder: resolve('./translations/other'), + }), }), ); }); @@ -976,13 +822,47 @@ describe('ResourcesController', () => { message: undefined, }); expect(editResource).toHaveBeenCalledWith( - resolve('./translations/test'), expect.objectContaining({ - key: 'app.button.ok', - baseValue: 'OK Updated', + name: 'test-collection', + translationsFolder: resolve('./translations/test'), baseLocale: 'en', }), + 'app.button.ok', + { + baseValue: 'OK Updated', + comment: undefined, + tags: undefined, + translations: undefined, + moveTo: undefined, + }, + ); + }); + + it('should pass moveTo through to core', async () => { + const editResource = core.editResource as jest.Mock; + editResource.mockReturnValue({ resolvedKey: 'shared.ok', updated: true }); + + await resourcesController.update('test-collection', { + key: 'app.button.ok', + moveTo: 'shared', + }); + + expect(editResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection' }), + 'app.button.ok', + expect.objectContaining({ moveTo: 'shared' }), + ); + }); + + it('should answer 409 when the destination resource already exists', async () => { + const editResource = core.editResource as jest.Mock; + editResource.mockRejectedValue(new core.ResourceAlreadyExistsError('shared.ok')); + + const error = await httpErrorOf( + resourcesController.update('test-collection', { key: 'app.button.ok', moveTo: 'shared' }), ); + + expect(error.getStatus()).toBe(409); }); it('should return no-op message when no changes detected', async () => { @@ -1246,17 +1126,14 @@ describe('ResourcesController', () => { }; it('should return 422 when translation is not enabled for the collection', async () => { - // mockConfig has no translation config — auto-translation is disabled - await expect(resourcesController.translateResource('test-collection', { key: 'buttons.save' })).rejects.toThrow( - HttpException, + const translateExistingResource = core.translateExistingResource as jest.Mock; + translateExistingResource.mockRejectedValue(new core.AutoTranslationDisabledError('test-collection')); + + const error = await httpErrorOf( + resourcesController.translateResource('test-collection', { key: 'buttons.save' }), ); - try { - await resourcesController.translateResource('test-collection', { key: 'buttons.save' }); - } catch (error: unknown) { - expect(error).toBeInstanceOf(HttpException); - expect((error as HttpException).getStatus()).toBe(422); - } + expect(error.getStatus()).toBe(422); }); it('should return 404 when the collection does not exist', async () => { @@ -1311,24 +1188,8 @@ describe('ResourcesController', () => { expect(result.resource.key).toBe('save'); }); - it('should pass translation config from collection when collection overrides global', async () => { - const collectionTranslationConfig = { - enabled: true, - provider: 'google-translate', - apiKeyEnv: 'COLLECTION_API_KEY', - }; - const configWithCollectionTranslation = { - ...mockConfig, - translation: { enabled: true, provider: 'google-translate', apiKeyEnv: 'GLOBAL_API_KEY' }, - collections: { - 'test-collection': { - ...mockConfig.collections['test-collection'], - translation: collectionTranslationConfig, - }, - }, - }; - (configService.getConfig as jest.Mock).mockReturnValue(configWithCollectionTranslation); - + it('should pass the opened collection and resource key to core', async () => { + (configService.getConfig as jest.Mock).mockReturnValue(configWithTranslation); const translateExistingResource = core.translateExistingResource as jest.Mock; translateExistingResource.mockResolvedValue({ translatedCount: 1, @@ -1340,8 +1201,10 @@ describe('ResourcesController', () => { expect(translateExistingResource).toHaveBeenCalledWith( expect.objectContaining({ - translationConfig: collectionTranslationConfig, + name: 'test-collection', + translationsFolder: resolve('./translations/test'), }), + 'buttons.save', ); }); diff --git a/apps/api/src/app/collections/resources/resources.controller.ts b/apps/api/src/app/collections/resources/resources.controller.ts index 93dd7637..2e9d06b9 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -17,7 +17,6 @@ import { import type { Response } from 'express'; import { addResource, - createDefaultTranslations, deleteResource, moveResource, editResource, @@ -46,7 +45,6 @@ import type { TranslateLocaleJobDto, } from '@simoncodes-ca/data-transfer'; import { ConfigService } from '../../config/config.service'; -import { mapDtoToAddResourceParams } from '../../mappers/resource.mapper'; import { mapResourceTreeToDto, mapResourceEntryToSummary } from '../../mappers/resource-tree.mapper'; import { mapSearchResultsToDto } from '../../mappers/search-result.mapper'; import { CollectionIndex } from '../../cache/collection-index.service'; @@ -73,20 +71,7 @@ export class ResourcesController { @Body() dto: TranslateResourceDto, ): Promise { const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const { translationConfig } = collection; - - if (!translationConfig?.enabled) { - throw new HttpException('Auto-translation is not enabled for this collection', HttpStatus.UNPROCESSABLE_ENTITY); - } - - const result = await translateExistingResource({ - key: dto.key, - translationsFolder: collection.translationsFolder, - translationConfig, - allLocales: collection.locales, - baseLocale: collection.baseLocale, - cwd: process.cwd(), - }); + const result = await translateExistingResource(collection, dto.key); this.#index.apply(result.mutations); @@ -103,7 +88,6 @@ export class ResourcesController { @Body() body: CreateResourceDto | CreateResourceDto[], ): Promise { const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const { translationsFolder, baseLocale, locales, translationConfig } = collection; // Normalize to array const resources = Array.isArray(body) ? body : [body]; @@ -113,37 +97,20 @@ export class ResourcesController { } let entriesCreated = 0; - let hasCreated = false; const allSkippedLocales: string[] = []; for (const resource of resources) { - const resourceBaseLocale = resource.baseLocale || baseLocale; - const hasExplicitTranslations = resource.translations && resource.translations.length > 0; - const canAutoTranslate = translationConfig?.enabled && !hasExplicitTranslations; - - // When auto-translation is enabled and no explicit translations provided, - // let addResource handle translation via the configured provider. - // Otherwise, fall back to default translations (copies base value with 'new' status). - const translations = hasExplicitTranslations - ? resource.translations - : canAutoTranslate - ? undefined - : createDefaultTranslations(locales, resourceBaseLocale, resource.baseValue); - - const params = mapDtoToAddResourceParams({ - ...resource, - baseLocale: resourceBaseLocale, - translations, - ...(canAutoTranslate && { allLocales: locales }), + const result = await addResource(collection, { + key: resource.key, + baseValue: resource.baseValue, + comment: resource.comment, + tags: resource.tags, + targetFolder: resource.targetFolder, + translations: resource.translations, }); - const result = canAutoTranslate - ? await addResource(translationsFolder, params, { translationConfig }) - : await addResource(translationsFolder, params); - if (result.created) { entriesCreated++; - hasCreated = true; } if (result.skippedLocales?.length) { @@ -157,7 +124,7 @@ export class ResourcesController { return { entriesCreated, - created: hasCreated, + created: entriesCreated > 0, ...(uniqueSkippedLocales.length > 0 && { skippedLocales: uniqueSkippedLocales }), }; } @@ -167,13 +134,13 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Body() dto: DeleteResourceDto, ): Promise { - const { translationsFolder } = openRouteCollection(this.#configService.getConfig(), collectionName); + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); if (!dto.keys || !Array.isArray(dto.keys) || dto.keys.length === 0) { throw new HttpException('Invalid request: keys array is required and must not be empty', HttpStatus.BAD_REQUEST); } - const result = deleteResource(translationsFolder, { keys: dto.keys }); + const result = deleteResource(collection, { keys: dto.keys }); this.#index.apply(result.mutations); return { @@ -188,7 +155,7 @@ export class ResourcesController { @Body() dto: MoveResourceDto, ): Promise { const config = this.#configService.getConfig(); - const { translationsFolder } = openRouteCollection(config, collectionName); + const collection = openRouteCollection(config, collectionName); const result: MoveResourceResponseDto = { movedCount: 0, @@ -201,12 +168,11 @@ export class ResourcesController { } for (const moveOp of dto.moves) { - let destinationTranslationsFolder: string | undefined; + let destinationCollection: Collection | undefined; if (moveOp.toCollection) { - let destination: Collection; try { - destination = openDestinationCollection(config, moveOp.toCollection); + destinationCollection = openDestinationCollection(config, moveOp.toCollection); } catch (error: unknown) { if (!(error instanceof NotFoundException || error instanceof ForbiddenException)) throw error; // Missing or read-only destination: for consistency with other bulk ops, report it @@ -215,14 +181,13 @@ export class ResourcesController { result.errors.push(error.message); continue; } - destinationTranslationsFolder = destination.translationsFolder; } - const moveResult = await moveResource(translationsFolder, { + const moveResult = await moveResource(collection, { source: moveOp.source, destination: moveOp.destination, override: moveOp.override, - destinationTranslationsFolder: destinationTranslationsFolder, + destinationCollection, }); this.#index.apply(moveResult.mutations); @@ -245,11 +210,12 @@ export class ResourcesController { ): Promise { const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const result = await editResource(collection.translationsFolder, { - ...dto, - baseLocale: collection.baseLocale, - translationConfig: collection.translationConfig, - allLocales: collection.locales, + const result = await editResource(collection, dto.key, { + baseValue: dto.baseValue, + comment: dto.comment, + tags: dto.tags, + translations: dto.locales, + moveTo: dto.moveTo, }); this.#index.apply(result.mutations); diff --git a/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts b/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts index a16164fe..d0a444f4 100644 --- a/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts +++ b/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts @@ -2,10 +2,13 @@ import { Controller, Get, HttpException, type INestApplication, Logger, NotFound import { APP_FILTER } from '@nestjs/core'; import { Test } from '@nestjs/testing'; import { + AutoTranslationDisabledError, BaseLocaleImmutableError, BundleAlreadyExistsError, BundleNotFoundError, CollectionNotFoundError, + FolderMoveIntoDescendantError, + FolderNotFoundError, InvalidBundleDefinitionError, InvalidFolderPathError, InvalidLocaleError, @@ -14,6 +17,7 @@ import { LocaleAlreadyExistsError, LocaleNotFoundError, ReadOnlyCollectionError, + ResourceAlreadyExistsError, ResourceNotFoundError, TranslationError, } from '@simoncodes-ca/core'; @@ -31,6 +35,11 @@ describe('toHttpException', () => { 404, { message: 'Resource not found: a.b', error: 'Not Found', statusCode: 404 }, ], + [ + new FolderNotFoundError('apps.missing'), + 404, + { message: 'Folder not found: apps.missing', error: 'Not Found', statusCode: 404 }, + ], [new BundleNotFoundError('main'), 404, { message: 'Bundle "main" not found', error: 'Not Found', statusCode: 404 }], [ new ReadOnlyCollectionError('vendor'), @@ -46,6 +55,20 @@ describe('toHttpException', () => { 409, { message: 'Bundle "main" already exists', error: 'Conflict', statusCode: 409 }, ], + [ + new ResourceAlreadyExistsError('apps.ok'), + 409, + { message: 'Resource already exists: apps.ok', error: 'Conflict', statusCode: 409 }, + ], + [ + new AutoTranslationDisabledError('app'), + 422, + { + message: 'Auto-translation is not enabled for collection "app"', + error: 'Unprocessable Entity', + statusCode: 422, + }, + ], [ new InvalidFolderPathError('folder name', 'a b'), 400, @@ -55,6 +78,15 @@ describe('toHttpException', () => { statusCode: 400, }, ], + [ + new FolderMoveIntoDescendantError('apps.common', 'apps.common.buttons'), + 400, + { + message: 'Validation error: Cannot move folder "apps.common" into its own descendant "apps.common.buttons"', + error: 'Bad Request', + statusCode: 400, + }, + ], [ new InvalidResourceKeyError('a..b', 'Key validation: bad'), 400, diff --git a/apps/api/src/app/errors/lingo-tracker-exception.filter.ts b/apps/api/src/app/errors/lingo-tracker-exception.filter.ts index aadb5a54..2cd9b221 100644 --- a/apps/api/src/app/errors/lingo-tracker-exception.filter.ts +++ b/apps/api/src/app/errors/lingo-tracker-exception.filter.ts @@ -10,13 +10,17 @@ import { InternalServerErrorException, Logger, NotFoundException, + UnprocessableEntityException, } from '@nestjs/common'; import { BaseExceptionFilter } from '@nestjs/core'; import { + AutoTranslationDisabledError, BaseLocaleImmutableError, BundleAlreadyExistsError, BundleNotFoundError, CollectionNotFoundError, + FolderMoveIntoDescendantError, + FolderNotFoundError, InvalidBundleDefinitionError, InvalidFolderPathError, InvalidLocaleError, @@ -25,6 +29,7 @@ import { LocaleAlreadyExistsError, LocaleNotFoundError, ReadOnlyCollectionError, + ResourceAlreadyExistsError, ResourceNotFoundError, TranslationError, } from '@simoncodes-ca/core'; @@ -45,6 +50,7 @@ export function lingoTrackerErrorToHttp(error: LingoTrackerError): HttpException if ( error instanceof CollectionNotFoundError || error instanceof ResourceNotFoundError || + error instanceof FolderNotFoundError || error instanceof BundleNotFoundError ) { return new NotFoundException(message); @@ -52,10 +58,13 @@ export function lingoTrackerErrorToHttp(error: LingoTrackerError): HttpException if (error instanceof ReadOnlyCollectionError) { return new ForbiddenException(message); } - if (error instanceof BundleAlreadyExistsError) { + if (error instanceof BundleAlreadyExistsError || error instanceof ResourceAlreadyExistsError) { return new ConflictException(message); } - if (error instanceof InvalidFolderPathError) { + if (error instanceof AutoTranslationDisabledError) { + return new UnprocessableEntityException(message); + } + if (error instanceof InvalidFolderPathError || error instanceof FolderMoveIntoDescendantError) { return new BadRequestException(`Validation error: ${message}`); } if ( diff --git a/apps/api/src/app/mappers/resource.mapper.ts b/apps/api/src/app/mappers/resource.mapper.ts deleted file mode 100644 index 70cadf36..00000000 --- a/apps/api/src/app/mappers/resource.mapper.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { AddResourceParams } from '@simoncodes-ca/core'; -import type { CreateResourceDto } from '@simoncodes-ca/data-transfer'; - -export function mapDtoToAddResourceParams( - dto: CreateResourceDto & { allLocales?: readonly string[] }, -): AddResourceParams { - return { - key: dto.key, - baseValue: dto.baseValue, - comment: dto.comment, - tags: dto.tags, - targetFolder: dto.targetFolder, - baseLocale: dto.baseLocale, - translations: dto.translations, - allLocales: dto.allLocales, - }; -} diff --git a/apps/cli/src/add-resource/add-resource.test.ts b/apps/cli/src/add-resource/add-resource.test.ts index 3e830245..0ab8a514 100644 --- a/apps/cli/src/add-resource/add-resource.test.ts +++ b/apps/cli/src/add-resource/add-resource.test.ts @@ -162,7 +162,7 @@ describe('addResourceCommand', () => { expect(utils.resolveWritableCollection).toHaveBeenCalledWith('NonExistentCollection', config, '/test'); }); - it('should handle translations array format', async () => { + it('should pass the opened collection and supplied fields through to core', async () => { const config = { collections: { TestCollection: { @@ -196,30 +196,32 @@ describe('addResourceCommand', () => { collection: 'TestCollection', key: 'buttons.ok', value: 'OK', + comment: 'Primary confirmation action', + tags: 'ui, buttons', + targetFolder: 'common', translations: [ { locale: 'fr-ca', value: "D'accord", status: 'translated' }, { locale: 'es', value: 'Aceptar', status: 'verified' }, ], }); - // Should call addResource with translations array expect(core.addResource).toHaveBeenCalledWith( - '/test/translations', expect.objectContaining({ - translations: expect.arrayContaining([ - expect.objectContaining({ - locale: 'fr-ca', - value: "D'accord", - status: 'translated', - }), - expect.objectContaining({ - locale: 'es', - value: 'Aceptar', - status: 'verified', - }), - ]), + name: 'TestCollection', + translationsFolder: '/test/translations', + baseLocale: 'en', }), - expect.any(Object), + { + key: 'buttons.ok', + baseValue: 'OK', + comment: 'Primary confirmation action', + tags: ['ui', 'buttons'], + targetFolder: 'common', + translations: [ + { locale: 'fr-ca', value: "D'accord", status: 'translated' }, + { locale: 'es', value: 'Aceptar', status: 'verified' }, + ], + }, ); }); @@ -291,73 +293,6 @@ describe('addResourceCommand', () => { }); }); - it('should create entries for all locales when no translations provided', async () => { - const config = { - collections: { - TestCollection: { - translationsFolder: 'translations', - baseLocale: 'en', - locales: ['en', 'fr-ca', 'es', 'de'], - }, - }, - baseLocale: 'en', - locales: ['en', 'fr-ca', 'es', 'de'], - }; - - // Mock successful config loading - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', - }); - - // Mock promptForCollection to return the collection name - vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); - - // Mock resolveWritableCollection to return collection data - vi.mocked(utils.resolveWritableCollection).mockReturnValue( - core.openCollection(config, 'TestCollection', { cwd: '/test' }), - ); - - vi.mocked(fs.existsSync).mockReturnValue(false); - - // Mock non-interactive mode - const originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - }); - - await addResourceCommand({ - collection: 'TestCollection', - key: 'buttons.ok', - value: 'OK', - }); - - // Should call addResource with translations for all non-base locales - expect(core.addResource).toHaveBeenCalledWith( - '/test/translations', - expect.objectContaining({ - translations: expect.arrayContaining([ - expect.objectContaining({ - locale: 'fr-ca', - value: 'OK', - status: 'new', - }), - expect.objectContaining({ locale: 'es', value: 'OK', status: 'new' }), - expect.objectContaining({ locale: 'de', value: 'OK', status: 'new' }), - ]), - }), - expect.any(Object), - ); - - // Restore - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - }); - }); - describe('preferred terminology', () => { const filePath = '/test/.lingo-tracker-preferred-terminology.json'; const config = { diff --git a/apps/cli/src/add-resource/add-resource.ts b/apps/cli/src/add-resource/add-resource.ts index 41703820..9de0f20f 100644 --- a/apps/cli/src/add-resource/add-resource.ts +++ b/apps/cli/src/add-resource/add-resource.ts @@ -1,5 +1,5 @@ import type { Collection } from '@simoncodes-ca/core'; -import { addResource, createDefaultTranslations, openResourceFolder, resolveResourcePaths } from '@simoncodes-ca/core'; +import { addResource, openResourceFolder, resolveResourcePaths } from '@simoncodes-ca/core'; import { type TranslationStatus, translocoToICU } from '@simoncodes-ca/domain'; import prompts from 'prompts'; import { @@ -55,7 +55,6 @@ export async function addResourceCommand(options: AddResourceOptions): Promise 0 - ? answers.translations - : translationConfig?.enabled - ? undefined - : createDefaultTranslations(locales, baseLocale, answers.value); - - const result = await addResource( - collection.translationsFolder, - { - key: answers.key, - baseValue: answers.value, - comment: answers.comment || undefined, - tags: tagsArray.length > 0 ? tagsArray : undefined, - targetFolder: answers.targetFolder || undefined, - baseLocale, - translations: translations && translations.length > 0 ? translations : undefined, - allLocales: locales, - }, - { cwd, translationConfig }, - ); + // Locales without a supplied translation are seeded by core (auto-translated or copied as `new`). + const result = await addResource(collection, { + key: answers.key, + baseValue: answers.value, + comment: answers.comment || undefined, + tags: tagsArray.length > 0 ? tagsArray : undefined, + targetFolder: answers.targetFolder || undefined, + translations: answers.translations, + }); ConsoleFormatter.success(`Resource added: ${result.resolvedKey}`); if (result.created) { diff --git a/apps/cli/src/commands/delete-resource.test.ts b/apps/cli/src/commands/delete-resource.test.ts index 70cd4382..5d8b3e60 100644 --- a/apps/cli/src/commands/delete-resource.test.ts +++ b/apps/cli/src/commands/delete-resource.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { resolve } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; -import { deleteResourceCommand } from './delete-resource'; +import { resolve } from 'node:path'; import { deleteResource } from '@simoncodes-ca/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { deleteResourceCommand } from './delete-resource'; const fsMocks = vi.hoisted(() => ({ existsSync: vi.fn(), @@ -63,9 +63,10 @@ describe('deleteResourceCommand', () => { await deleteResourceCommand(options); - expect(mockDeleteResource).toHaveBeenCalledWith(resolve('/test/project', 'src/i18n'), { - keys: ['apps.common.buttons.ok'], - }); + expect(mockDeleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), + { keys: ['apps.common.buttons.ok'] }, + ); }); it('should delete multiple resources from comma-separated keys', async () => { @@ -83,9 +84,10 @@ describe('deleteResourceCommand', () => { await deleteResourceCommand(options); - expect(mockDeleteResource).toHaveBeenCalledWith(resolve('/test/project', 'src/i18n'), { - keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel', 'apps.common.buttons.save'], - }); + expect(mockDeleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), + { keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel', 'apps.common.buttons.save'] }, + ); }); it('should handle partial success with errors', async () => { @@ -104,9 +106,10 @@ describe('deleteResourceCommand', () => { await deleteResourceCommand(options); - expect(mockDeleteResource).toHaveBeenCalledWith(resolve('/test/project', 'src/i18n'), { - keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel', 'apps.common.invalid'], - }); + expect(mockDeleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), + { keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel', 'apps.common.invalid'] }, + ); }); it('should not delete if config does not exist', async () => { @@ -155,9 +158,10 @@ describe('deleteResourceCommand', () => { await deleteResourceCommand(options); - expect(mockDeleteResource).toHaveBeenCalledWith(resolve('/test/project', 'src/i18n'), { - keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel'], - }); + expect(mockDeleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), + { keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel'] }, + ); }); it('should handle zero deletions', async () => { @@ -176,8 +180,9 @@ describe('deleteResourceCommand', () => { await deleteResourceCommand(options); - expect(mockDeleteResource).toHaveBeenCalledWith(resolve('/test/project', 'src/i18n'), { - keys: ['apps.common.notfound'], - }); + expect(mockDeleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), + { keys: ['apps.common.notfound'] }, + ); }); }); diff --git a/apps/cli/src/commands/delete-resource.ts b/apps/cli/src/commands/delete-resource.ts index d182285e..5ee78610 100644 --- a/apps/cli/src/commands/delete-resource.ts +++ b/apps/cli/src/commands/delete-resource.ts @@ -51,7 +51,7 @@ export async function deleteResourceCommand(options: DeleteResourceOptions): Pro } try { - const result = deleteResource(collection.translationsFolder, { keys }); + const result = deleteResource(collection, { keys }); if (result.entriesDeleted === 0) { ConsoleFormatter.warning('No resources were deleted.'); diff --git a/apps/cli/src/commands/edit-resource.test.ts b/apps/cli/src/commands/edit-resource.test.ts index 057da44d..35eefd15 100644 --- a/apps/cli/src/commands/edit-resource.test.ts +++ b/apps/cli/src/commands/edit-resource.test.ts @@ -68,12 +68,15 @@ describe('editResourceCommand', () => { await editResourceCommand(options); expect(mockEditResource).toHaveBeenCalledWith( - resolve('/test/project', 'src/i18n'), expect.objectContaining({ - key: 'apps.common.buttons.ok', - baseValue: 'OK Updated', + name: 'default', + translationsFolder: resolve('/test/project', 'src/i18n'), baseLocale: 'en', }), + 'apps.common.buttons.ok', + expect.objectContaining({ + baseValue: 'OK Updated', + }), ); }); @@ -115,7 +118,8 @@ describe('editResourceCommand', () => { await editResourceCommand(options); expect(mockEditResource).toHaveBeenCalledWith( - resolve('/test/project', 'src/i18n'), + expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), + 'apps.common.buttons.ok', expect.objectContaining({ comment: 'New comment', tags: ['ui', 'buttons'], @@ -141,9 +145,10 @@ describe('editResourceCommand', () => { await editResourceCommand(options); expect(mockEditResource).toHaveBeenCalledWith( - resolve('/test/project', 'src/i18n'), + expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), + 'apps.common.buttons.ok', expect.objectContaining({ - locales: { + translations: { fr: { value: "D'accord" }, }, }), @@ -169,9 +174,10 @@ describe('editResourceCommand', () => { expect.stringContaining('Both --locale and --localeValue must be provided'), ); expect(mockEditResource).toHaveBeenCalledWith( - expect.any(String), + expect.objectContaining({ name: 'default' }), + 'apps.common.buttons.ok', expect.not.objectContaining({ - locales: expect.anything(), + translations: expect.anything(), }), ); }); @@ -260,13 +266,32 @@ describe('editResourceCommand', () => { ); expect(mockEditResource).toHaveBeenCalledWith( - resolve('/test/project', 'src/i18n'), + expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), + 'apps.common.buttons.ok', expect.objectContaining({ baseValue: 'Promped Value', }), ); }); + it('maps --target-folder to moveTo', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + mockEditResource.mockResolvedValue({ resolvedKey: 'shared.ok', updated: true }); + + await editResourceCommand({ + collection: 'default', + key: 'apps.common.buttons.ok', + targetFolder: 'shared', + }); + + expect(mockEditResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'default' }), + 'apps.common.buttons.ok', + expect.objectContaining({ moveTo: 'shared' }), + ); + }); + describe('preferred terminology', () => { const rules = [{ discouraged: 'Expenditure', preferred: 'Investment', reason: 'Finance style guide' }]; diff --git a/apps/cli/src/commands/edit-resource.ts b/apps/cli/src/commands/edit-resource.ts index 1aca827a..487e5110 100644 --- a/apps/cli/src/commands/edit-resource.ts +++ b/apps/cli/src/commands/edit-resource.ts @@ -1,6 +1,4 @@ -import { resolve } from 'node:path'; -import type { LingoTrackerConfig } from '@simoncodes-ca/core'; -import { editResource } from '@simoncodes-ca/core'; +import { type EditResourceChanges, editResource, type LingoTrackerConfig } from '@simoncodes-ca/core'; import { translocoToICU } from '@simoncodes-ca/domain'; import type prompts from 'prompts'; import { @@ -37,59 +35,30 @@ export async function editResourceCommand(options: EditResourceOptions): Promise const answers = await promptForMissing(options, config, collectionName); - // Prepare edit options - const editOptions: { - key: string; - cwd: string; - baseLocale: string; - targetFolder?: string; - baseValue?: string; - comment?: string; - tags?: string[]; - locales?: Record; - } = { - key: answers.key, - cwd: resolve(cwd), - baseLocale: collection.baseLocale, - }; - - if (options.targetFolder) { - editOptions.targetFolder = options.targetFolder; - } - - if (answers.baseValue) { - editOptions.baseValue = answers.baseValue; - } - - if (options.comment) { - editOptions.comment = options.comment; - } - - if (options.tags) { - editOptions.tags = parseCommaSeparatedList(options.tags); - } - - if (options.locale && options.localeValue) { - editOptions.locales = { - [options.locale]: { value: options.localeValue }, - }; - } else if (options.locale || options.localeValue) { + const translations = + options.locale && options.localeValue ? { [options.locale]: { value: options.localeValue } } : undefined; + if (!translations && (options.locale || options.localeValue)) { ConsoleFormatter.warning('Both --locale and --localeValue must be provided to update a translation.'); } + const changes: EditResourceChanges = { + baseValue: answers.baseValue || undefined, + comment: options.comment || undefined, + tags: options.tags ? parseCommaSeparatedList(options.tags) : undefined, + translations, + // `--target-folder` names the folder the entry moves to ('' for the collection root). + moveTo: options.targetFolder, + }; + try { - const result = await editResource(collection.translationsFolder, { - ...editOptions, - translationConfig: collection.translationConfig, - allLocales: collection.locales, - }); + const result = await editResource(collection, answers.key, changes); if (result.updated) { ConsoleFormatter.success(`Resource "${result.resolvedKey}" updated successfully.`); // Only a base value supplied in this invocation is checked; editing a comment // or a translation should not re-raise advice about untouched wording. - if (editOptions.baseValue !== undefined) { - warnAboutPreferredTerminology(config, cwd, translocoToICU(editOptions.baseValue)); + if (changes.baseValue !== undefined) { + warnAboutPreferredTerminology(config, cwd, translocoToICU(changes.baseValue)); } } else { ConsoleFormatter.info(result.message || 'No changes detected'); diff --git a/apps/cli/src/commands/move.ts b/apps/cli/src/commands/move.ts index 29e908ca..6448b68b 100644 --- a/apps/cli/src/commands/move.ts +++ b/apps/cli/src/commands/move.ts @@ -40,11 +40,11 @@ export async function moveResourceCommand(options: MoveResourceOptions): Promise } try { - const result = await moveResource(sourceCollection.translationsFolder, { + const result = await moveResource(sourceCollection, { source: answers.source, destination: answers.dest, override: options.override, - destinationTranslationsFolder: destCollection?.translationsFolder, + destinationCollection: destCollection, }); if (result.movedCount > 0) { diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 7100666d..3bb502c9 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -115,7 +115,7 @@ program .option('--base-value ', 'New base value (source text)') .option('--comment ', 'New comment') .option('--tags ', 'New tags (comma-separated)') - .option('--target-folder ', 'New target folder (dot-delimited)') + .option('--target-folder ', 'Move the resource into this folder (dot-delimited; "" for the collection root)') .option('--locale ', 'Locale to update (requires --locale-value)') .option('--locale-value ', 'New value for the specified locale') .action(async (options) => { diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts index 120036d7..cd740f59 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts @@ -352,7 +352,6 @@ describe('toCreateDto', () => { ], tags: ['browser'], }), - 'en', ); expect(dto).toEqual({ @@ -360,7 +359,6 @@ describe('toCreateDto', () => { baseValue: 'OK', comment: 'The affirmative button', tags: ['browser'], - baseLocale: 'en', translations: [{ locale: 'fr', value: 'Valeur', status: 'new' }], }); }); @@ -372,7 +370,7 @@ describe('toCreateDto', () => { ['no tags are omitted', { tags: [] }, { tags: undefined }], ['no filled translations are omitted', {}, { translations: undefined }], ])('%s', (_case, overrides, expected) => { - expect(toCreateDto(draft(overrides), 'en')).toMatchObject(expected); + expect(toCreateDto(draft(overrides))).toMatchObject(expected); }); }); @@ -426,19 +424,29 @@ describe('toUpdateDto and editedLocales', () => { const dto = toUpdateDto(draft({ comment: ' Why ', translations: untouched }), original); expect(dto).toEqual({ key: 'common.buttons.ok', baseValue: 'OK', comment: 'Why', tags: [] }); - expect('targetFolder' in dto).toBe(false); + expect('moveTo' in dto).toBe(false); }); it('should keep a root-level entry on its bare key', () => { expect(toUpdateDto(draft({ folderPath: '' }), { ...original, folderPath: '' }).key).toBe('ok'); }); - it('should send a move to another folder as targetFolder', () => { - expect(toUpdateDto(draft({ folderPath: 'common.dialogs' }), original).targetFolder).toBe('common.dialogs'); + it('should send a move to another folder as moveTo, with the full original key', () => { + const dto = toUpdateDto(draft({ folderPath: 'common.dialogs' }), original); + + expect(dto.key).toBe('common.buttons.ok'); + expect(dto.moveTo).toBe('common.dialogs'); + }); + + it('should send a move to the collection root as an empty moveTo', () => { + expect(toUpdateDto(draft({ folderPath: '' }), original).moveTo).toBe(''); }); - it('should omit targetFolder entirely for a move to the collection root', () => { - expect('targetFolder' in toUpdateDto(draft({ folderPath: '' }), original)).toBe(false); + it('should send a move out of the collection root', () => { + const dto = toUpdateDto(draft({ folderPath: 'common' }), { ...original, folderPath: '' }); + + expect(dto.key).toBe('ok'); + expect(dto.moveTo).toBe('common'); }); }); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts index 1536d2b2..45ad2265 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts @@ -292,9 +292,11 @@ export function removeTag(tags: readonly string[], tag: string, inherited: reado /** * The create request. Every translation typed alongside the base value goes in - * as `new`, whatever its status pill says: nothing has been reviewed yet. + * as `new`, whatever its status pill says: nothing has been reviewed yet. The + * server seeds the locales left empty by the collection's rule (auto-translated, + * or a copy of the base value as `new`). */ -export function toCreateDto(draft: ResourceEntryDraft, baseLocale: string): CreateResourceDto { +export function toCreateDto(draft: ResourceEntryDraft): CreateResourceDto { const translations = draft.translations .filter((translation) => translation.value.trim().length > 0) .map((translation) => ({ locale: translation.locale, value: translation.value, status: 'new' as const })); @@ -304,7 +306,6 @@ export function toCreateDto(draft: ResourceEntryDraft, baseLocale: string): Crea baseValue: draft.baseValue, comment: draft.comment.trim() || undefined, tags: draft.tags.length > 0 ? [...draft.tags] : undefined, - baseLocale, translations: translations.length > 0 ? translations : undefined, }; } @@ -322,10 +323,10 @@ export function editedLocales(draft: ResourceEntryDraft, original: ResourceSumma } /** - * The update request. The key names the entry where it lives now. A change to a - * non-root folder travels as `targetFolder`. A change to the collection root is - * sent without `targetFolder`, as it always has been, so the server edits the - * entry where it is. Tags are always sent, so removing the last one clears them. + * The update request. The key is the entry's full key where it lives now. A + * change of folder, the collection root included, travels as `moveTo` (the + * destination folder; '' for the root). Tags are always sent, so removing the + * last one clears them. */ export function toUpdateDto(draft: ResourceEntryDraft, original: OriginalEntry): UpdateResourceDto { const dto: UpdateResourceDto = { @@ -335,8 +336,8 @@ export function toUpdateDto(draft: ResourceEntryDraft, original: OriginalEntry): tags: [...draft.tags], }; - if (draft.folderPath && draft.folderPath !== original.folderPath) { - dto.targetFolder = draft.folderPath; + if (draft.folderPath !== original.folderPath) { + dto.moveTo = draft.folderPath; } const locales = editedLocales(draft, original.resource); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts index 180c0558..86866de9 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts @@ -1211,7 +1211,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit this.isSubmitting.set(true); this.errorMessage.set(null); - const createDto = toCreateDto(draft, this.data.baseLocale); + const createDto = toCreateDto(draft); this.browserStore.createResource(this.data.collectionName, createDto).subscribe({ next: (response: CreateResourceResponseDto) => { diff --git a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts index a46f90cd..409c0228 100644 --- a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts +++ b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts @@ -113,9 +113,9 @@ describe('BrowserStore entry writes', () => { }); describe('updateResource', () => { - const update = (key: string, response: object, targetFolder?: string): void => { + const update = (key: string, response: object, moveTo?: string): void => { store - .updateResource('my-collection', { key, baseValue: 'x', ...(targetFolder ? { targetFolder } : {}) }) + .updateResource('my-collection', { key, baseValue: 'x', ...(moveTo !== undefined ? { moveTo } : {}) }) .subscribe(); const patch = http.expectOne({ method: 'PATCH', url: RESOURCES_URL }); expect(patch.request.body.key).toBe(key); @@ -168,24 +168,32 @@ describe('BrowserStore entry writes', () => { it('should drop an entry sent to another folder from both caches', () => { searchMode(); - update('common.save', { resolvedKey: 'other.common.save', updated: true, resource: entry('save') }, 'other'); + update('common.save', { resolvedKey: 'other.save', updated: true, resource: entry('save') }, 'other'); expect(store.translations().map((item) => item.key)).toEqual(['dialog.title']); expect(store.searchResults().map((item) => item.key)).toEqual(['errors.save']); }); - it('should patch in place when the DTO carries no targetFolder, as a move to the root does', () => { + it('should patch in place when the DTO carries no moveTo', () => { folderMode(); store.updateResource('my-collection', { key: 'common.save', baseValue: 'Save now' }).subscribe(); const patch = http.expectOne({ method: 'PATCH', url: RESOURCES_URL }); - expect('targetFolder' in patch.request.body).toBe(false); + expect('moveTo' in patch.request.body).toBe(false); patch.flush({ resolvedKey: 'common.save', updated: true, resource: entry('save', 'Save now') }); expect(store.translations().map((item) => item.key)).toEqual(['save', 'dialog.title']); expect(englishOf(store.translations(), 'save')).toBe('Save now'); }); + it('should drop an entry moved to the collection root (an empty moveTo)', () => { + folderMode(); + + update('common.save', { resolvedKey: 'save', updated: true, resource: entry('save') }, ''); + + expect(store.translations().map((item) => item.key)).toEqual(['dialog.title']); + }); + it('should leave the caches alone when the response carries no resource', () => { folderMode(); const before = store.translations(); diff --git a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts index afb45d3f..2b777989 100644 --- a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts @@ -92,14 +92,14 @@ export function withEntryWritesFeature<_>() { }, /** - * Updates the entry `dto.key` names. A DTO with a `targetFolder` moves the - * entry, so it leaves the caches. A DTO without one, including a move to - * the collection root (see `toUpdateDto`), is patched in place. + * Updates the entry `dto.key` names. A DTO with a `moveTo` (the collection + * root included) moves the entry, so it leaves the caches. A DTO without + * one is patched in place. */ updateResource(collectionName: string, dto: UpdateResourceDto): Observable { return api.updateResource(collectionName, dto).pipe( tap((response) => { - if (dto.targetFolder !== undefined) { + if (dto.moveTo !== undefined) { dropEntry(dto.key); } else if (response.resource) { patchEntry(dto.key, response.resource); diff --git a/architecture-docs/api.md b/architecture-docs/api.md index 60d9e416..1ae0cd9a 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -52,11 +52,11 @@ All paths are relative to the `/api` global prefix. URL path parameters that con | Method | Path | Purpose | Request DTO | Response DTO | |--------|------|---------|-------------|--------------| -| `POST` | `/collections/:collectionName/resources` | Create one or more [resources](glossary.md#resource) (batch-aware) | `CreateResourceDto \| CreateResourceDto[]` | `CreateResourceResponseDto` | -| `PATCH` | `/collections/:collectionName/resources` | Update a resource's base value, translations, comment, or tags | `UpdateResourceDto` | `UpdateResourceResponseDto` | +| `POST` | `/collections/:collectionName/resources` | Create one or more [resources](glossary.md#resource) (batch-aware). Target locales without a supplied translation are seeded by the collection's rule ([locale seeding](glossary.md#locale-seeding)). The body has no `baseLocale`: the collection's base locale always applies. A translation in a locale the collection does not have answers 400. | `CreateResourceDto \| CreateResourceDto[]` | `CreateResourceResponseDto` | +| `PATCH` | `/collections/:collectionName/resources` | Update a resource's base value, translations, comment, or tags. `key` is the full, existing key; `moveTo` (a folder path, `''` for the root) moves the entry there, 409 when the destination already has that entry key. | `UpdateResourceDto` | `UpdateResourceResponseDto` | | `DELETE` | `/collections/:collectionName/resources` | Delete one or more resources by key | `DeleteResourceDto` | `DeleteResourceResponseDto` | | `POST` | `/collections/:collectionName/resources/move` | Move or rename resources (single key or wildcard pattern, cross-collection supported) | `MoveResourceDto` | `MoveResourceResponseDto` | -| `POST` | `/collections/:collectionName/resources/translate` | Auto-translate a single resource via the configured provider | `TranslateResourceDto` | `TranslateResourceResponseDto` | +| `POST` | `/collections/:collectionName/resources/translate` | Auto-translate a single resource via the configured provider (422 when the collection has auto-translation off) | `TranslateResourceDto` | `TranslateResourceResponseDto` | | `GET` | `/collections/:collectionName/resources/tree` | Fetch the resource [tree](glossary.md#resource-tree) (or subtree) from the Collection Index | query: `path`, `includeNested` | `ResourceTreeDto \| TreeStatusResponseDto` | | `GET` | `/collections/:collectionName/resources/cache/status` | Poll the [Collection Index](glossary.md#collection-index) state (starts indexing) | — | `CacheStatusDto` | | `GET` | `/collections/:collectionName/resources/search` | Full-text search across the collection | query: `SearchTranslationsDto` | `SearchResultsDto` | @@ -68,8 +68,8 @@ All paths are relative to the `/api` global prefix. URL path parameters that con | Method | Path | Purpose | Request DTO | Response DTO | |--------|------|---------|-------------|--------------| | `POST` | `/collections/:collectionName/folders` | Create a [folder](glossary.md#folder) | `CreateFolderDto` | `CreateFolderResponseDto` | -| `DELETE` | `/collections/:collectionName/folders` | Delete a folder and all its contents | `DeleteFolderDto` | `DeleteFolderResponseDto` | -| `POST` | `/collections/:collectionName/folders/move` | Move a folder within or across collections | `MoveFolderDto` | `MoveFolderResponseDto` | +| `DELETE` | `/collections/:collectionName/folders` | Delete a folder and all its contents (404 when it does not exist, 400 for a malformed path) | `DeleteFolderDto` | `DeleteFolderResponseDto` | +| `POST` | `/collections/:collectionName/folders/move` | Move a folder within or across collections (400 for a malformed path or a move into its own descendant, 404 for a missing source; per-resource failures come back in `errors`) | `MoveFolderDto` | `MoveFolderResponseDto` | ### Locales @@ -119,7 +119,6 @@ graph TD end subgraph mappers["Mappers"] - RESMAP["resource.mapper\nCreateResourceDto → AddResourceParams"] TREEMP["resource-tree.mapper\nResourceTreeNode → ResourceTreeDto\nResourceTreeEntry → ResourceSummaryDto"] COLMAP["collection.mapper\nLingoTrackerCollectionDto ↔ LingoTrackerCollection"] CFGMAP["config.mapper\nLingoTrackerConfig → LingoTrackerConfigDto"] @@ -147,7 +146,6 @@ graph TD COLLC --> CONFIGS CONFIGC --> CONFIGS - RESC --> RESMAP RESC --> TREEMP RESC --> SRCHMAP FOLDC --> TREEMP @@ -168,7 +166,7 @@ graph TD style core fill:#d4edda,stroke:#28a745,color:#000 ``` -Controllers are the only layer that knows HTTP. They read the config from `ConfigService` (a thin wrapper over core `loadConfig()` that maps `ConfigNotFoundError` to 404 and parse/read failures to 500), turn the `:collectionName` route param into the effective `Collection` with `openRouteCollection()` (`collections/open-route-collection.ts`: decodes the name, calls core `openCollection()`, maps `CollectionNotFoundError` to 404), delegate business operations to `@simoncodes-ca/core` (see [core-library.md](core-library.md)), apply mappers at the boundary, and pass the `mutations` of every successful core write to `CollectionIndex.apply()`. Controllers do not catch core errors; the global exception filter maps them (see [Error Mapping](#error-mapping)). +Controllers are the only layer that knows HTTP. They read the config from `ConfigService` (a thin wrapper over core `loadConfig()` that maps `ConfigNotFoundError` to 404 and parse/read failures to 500), turn the `:collectionName` route param into the effective `Collection` with `openRouteCollection()` (`collections/open-route-collection.ts`: decodes the name, calls core `openCollection()`, maps `CollectionNotFoundError` to 404), delegate business operations to `@simoncodes-ca/core` (see [core-library.md](core-library.md)), apply mappers at the boundary, and pass the `mutations` of every successful core write to `CollectionIndex.apply()`. Controllers do not catch core errors; the global exception filter maps them (see [Error Mapping](#error-mapping)). The resource and folder handlers pass the opened `Collection` (and, for a cross-collection move, the one `openDestinationCollection()` returns) to core as the first argument and copy the DTO fields through; which locales get what on create or edit is core's [locale seeding](glossary.md#locale-seeding), not the controller's. **Read-only enforcement.** `WritableCollectionGuard` (`collections/guards/writable-collection.guard.ts`) is applied at the class level to the `Resources`, `Locales`, and `Folders` controllers. For any non-`GET` request it reads the `:collectionName` route param, opens the collection with core `openCollection(config, name, { writable: true })`, and maps `ReadOnlyCollectionError` to `403 Forbidden` (unknown collections pass through so the controller returns its 404). This is the single API choke-point for read-only enforcement. The `Collections` controller is intentionally **not** guarded: updating a collection's config entry or unregistering it (`PUT`/`DELETE /collections/:name`) is permitted even for read-only collections, since the lock protects resources, not the registration. On create, the controller defaults `readOnly` to `true` for `node_modules` paths (via the `isUnderNodeModules` domain helper) when the DTO omits it. @@ -181,10 +179,11 @@ Controllers are the only layer that knows HTTP. They read the config from `Confi | Thrown | Status | Body `message` | |---|---|---| | `HttpException` (thrown by a controller, guard, or `ConfigService`) | its own | its own | -| `CollectionNotFoundError`, `ResourceNotFoundError`, `BundleNotFoundError` | 404 (`NotFoundException`) | error message | +| `CollectionNotFoundError`, `ResourceNotFoundError`, `FolderNotFoundError`, `BundleNotFoundError` | 404 (`NotFoundException`) | error message | | `ReadOnlyCollectionError` | 403 (`ForbiddenException`) | error message | -| `BundleAlreadyExistsError` | 409 (`ConflictException`) | error message | -| `InvalidFolderPathError` | 400 (`BadRequestException`) | `Validation error: ` | +| `BundleAlreadyExistsError`, `ResourceAlreadyExistsError` | 409 (`ConflictException`) | error message | +| `AutoTranslationDisabledError` | 422 (`UnprocessableEntityException`) | error message | +| `InvalidFolderPathError`, `FolderMoveIntoDescendantError` | 400 (`BadRequestException`) | `Validation error: ` | | `InvalidResourceKeyError`, `InvalidLocaleError`, `LocaleNotFoundError`, `LocaleAlreadyExistsError`, `BaseLocaleImmutableError`, `InvalidBundleDefinitionError` | 400 (`BadRequestException`) | error message | | `TranslationError` with code `INVALID_REQUEST` | 400 (`BadRequestException`) | `Translation provider error: ` | | `TranslationError` with code `MISSING_API_KEY`, `UNKNOWN_PROVIDER`, or `AUTH_ERROR` (server misconfiguration) | 500 (`InternalServerErrorException`) | `Translation provider error: ` | @@ -200,7 +199,6 @@ Statuses that are kept from before the filter, although they do not match the cl - The `Collections` and `Config` controllers keep their own catch that answers **400** for every failure. So `CollectionNotFoundError` from `DELETE`/`PUT /collections/:name` is 400 (not 404), `CollectionAlreadyExistsError` is 400, and `PreferredTerminologyValidationError` is 400 with `{ message, errors }`. These errors never reach the filter. - The `Bundles` controller answers **400** for an untyped failure (the other controllers answer 500). - Route-level resolution keeps its own Nest exceptions, because the messages are route-specific: `openRouteCollection` / `openDestinationCollection` (404 `Collection "x" not found` / `Destination collection "x" not found`, 403 read-only), `WritableCollectionGuard` (403), and `ConfigService` (404 `Configuration file not found`, 500 `Invalid configuration file format` / `Failed to read configuration file`). -- `POST /collections/:name/folders/move` still answers 400 when `moveFolder` reports an error whose text has `Invalid`, `not found`, `circular`, or `descendant` and nothing moved. `moveFolder` reports failures as result strings, not typed errors. --- @@ -279,10 +277,12 @@ reindex or failed patch Each core write returns `mutations: ResourceMutation[]` (see [core-library.md](core-library.md) and the [glossary](glossary.md#resource-mutation)), which describe what changed on disk. The controller calls `index.apply(result.mutations)`. The index finds every entry whose translations folder is the mutation's `translationsFolder`, so a cross-collection move updates the source and the destination with no controller logic. +Mutations come back only from a write that returns. A core write that throws part-way returns no mutations, even when it already changed the disk. For example, `editResource` with a `moveTo` writes the destination folder before it removes the source entry; if the source save then throws, the destination entry is on disk and the index was not told. The controller applies nothing, so the index is out of date until its next revalidation: the first read after the throttle interval (`LINGO_TRACKER_REVALIDATE_INTERVAL_MS`) finds that the disk fingerprint no longer matches, drops the collection, and indexes it again. There is no rollback. (One gap: if a deferred fingerprint refresh from another request's own write runs after the partial write, the index adopts that fingerprint and does not see the change until the next outside change or restart.) + | Mutation | Returned by | Index action | |---|---|---| -| `upsert` (key, entry) | `addResource`, `editResource`, `translateExistingResource`, `moveResource` / `moveFolder` (destination) | Insert or replace the entry. Missing folders are created, as on disk. | -| `remove` (key) | `deleteResource`, `moveResource` / `moveFolder` (source) | Remove the entry. Missing entry → drop the collection. | +| `upsert` (key, entry) | `addResource`, `editResource` (at the destination after a `moveTo`), `translateExistingResource`, `moveResource` / `moveFolder` (destination) | Insert or replace the entry. Missing folders are created, as on disk. | +| `remove` (key) | `deleteResource`, `moveResource` / `moveFolder` (source), `editResource` with a `moveTo` (source) | Remove the entry. Missing entry → drop the collection. | | `add-folder` (path) | `createFolder` | Create the folder node (and missing parents). | | `remove-folder` (path) | `deleteFolder`, `moveFolder` (deleted source folder) | Remove the folder node. Missing folder → drop the collection. | | `reindex` | `addLocaleToCollection`, `removeLocaleFromCollection` | Drop the collection. Every folder's metadata changed. | @@ -370,13 +370,12 @@ sequenceDiagram ## Mapper Layer -The mapper layer enforces the boundary between `@simoncodes-ca/core`'s domain models and `@simoncodes-ca/data-transfer`'s DTOs. All transformation happens in `apps/api/src/app/mappers/`. No controller accesses a raw domain model object directly in its response, and no core function receives a DTO as its argument. +The mapper layer enforces the boundary between `@simoncodes-ca/core`'s domain models and `@simoncodes-ca/data-transfer`'s DTOs. All transformation happens in `apps/api/src/app/mappers/`, except the create and update requests: their fields map one to one onto the core parameters, so the resources controller copies them inline. No controller accesses a raw domain model object directly in its response, and no core function receives a DTO as its argument. For the entity types that mappers transform, see [domain-and-data-model.md](domain-and-data-model.md). | Mapper file | Direction | Key transformation | |-------------|-----------|-------------------| -| `resource.mapper.ts` | `CreateResourceDto` → `AddResourceParams` | Flat field-for-field projection; adds `allLocales` when auto-translation is active | | `resource-tree.mapper.ts` | `ResourceTreeNode` → `ResourceTreeDto` | Flattens `folderPathSegments[]` array to a dot-delimited `path` string; merges `source` (base locale value) into the `translations` record keyed by the base locale string; extracts per-locale `status` from the `metadata` record | | `resource-tree.mapper.ts` | `ResourceTreeEntry` → `ResourceSummaryDto` | Identifies the base locale by the absence of `status` and `baseChecksum` in the metadata entry; produces a flat `{ key, translations, status, comment, tags, inheritedTags }` shape. The `inheritedTags` field carries the parent collection's `tags` so the UI can render them distinctly without re-reading the config. | | `collection.mapper.ts` | `LingoTrackerCollectionDto` ↔ `LingoTrackerCollection` | Bidirectional; shallow clone of `locales[]` and `tags[]` arrays to prevent aliasing. Carries the `protectedTermsFile` setting in both directions. Drops resolved `protectedTerms` on the way back to config, because terms live in a file and the controller writes them there separately. | diff --git a/architecture-docs/cli.md b/architecture-docs/cli.md index d4e4af81..ed28d77d 100644 --- a/architecture-docs/cli.md +++ b/architecture-docs/cli.md @@ -40,8 +40,8 @@ All commands are registered in `apps/cli/src/main.ts`. Each row below lists the | `delete-collection` | `--collection-name` | `deleteCollectionByName()` | | `add-locale` | `--collection`, `--locale` | `addLocaleToCollection()` | | `remove-locale` | `--collection`, `--locale` | `removeLocaleFromCollection()` | -| `add-resource` | `--collection`, `--key`, `--value`, `--comment`, `--tags`, `--target-folder`, `--translations ` | `addResource()` | -| `edit-resource` | `--collection`, `--key`, `--base-value`, `--comment`, `--tags`, `--target-folder`, `--locale`, `--locale-value` | `editResource()` | +| `add-resource` | `--collection`, `--key`, `--value`, `--comment`, `--tags`, `--target-folder`, `--translations ` | `addResource()` (locales without a `--translations` value are seeded by core: [locale seeding](glossary.md#locale-seeding)) | +| `edit-resource` | `--collection`, `--key` (full key), `--base-value`, `--comment`, `--tags`, `--target-folder` (moves the entry into this folder; core `moveTo`), `--locale`, `--locale-value` | `editResource()` | | `delete-resource` | `--collection`, `--key`, `--yes` | `deleteResource()` | | `move` | `--collection`, `--source`, `--dest`, `--override`, `--verbose` | `moveResource()` | | `normalize` | `--collection`, `--all`, `--dry-run`, `--json` | `normalize()` | @@ -228,10 +228,10 @@ flowchart LR PROMPT --> NAME["collectionName: string"] NAME --> RESOLVE["resolveCollection(collectionName, config, cwd)"] RESOLVE --> RESOLVED["Collection (core)\n{ name, translationsFolder, baseLocale,\nlocales, targetLocales, translationConfig, ... }"] - RESOLVED --> CORE["@simoncodes-ca/core function\ne.g. addResource(collection.translationsFolder, params)"] + RESOLVED --> CORE["@simoncodes-ca/core function\ne.g. addResource(collection, params)"] ``` -The `translationsFolder` from the `Collection` is the first argument passed to every core resource operation, and its `baseLocale` / `locales` / `translationConfig` fill the remaining parameters. Commands never construct filesystem paths or effective settings themselves. +The `Collection` itself is the first argument passed to every core resource and folder operation (`addResource(collection, …)`, `editResource(collection, key, …)`, `moveResource(collection, …)`, `deleteResource(collection, …)`). Commands never construct filesystem paths or effective settings themselves, and they do not decide what untranslated locales get: core's [locale seeding](glossary.md#locale-seeding) does. --- diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index 36661c27..1d546cea 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -13,6 +13,8 @@ Return to [architecture README](README.md). - [Config and Collection Resolution](#config-and-collection-resolution) - [Error Model](#error-model) - [Resource CRUD Flows](#resource-crud-flows) + - [Collection-bound operations](#collection-bound-operations) + - [Locale seeding](#locale-seeding) - [add-resource](#add-resource) - [edit-resource](#edit-resource) - [delete-resource](#delete-resource) @@ -42,9 +44,10 @@ libs/core/src/ │ ├── bundle-definition.ts # BundleDefinition, CollectionBundleDefinition, EntrySelectionRule │ └── translation-config.ts # TranslationConfig (provider name, API key env var) │ -├── resource/ # Resource CRUD — reads/writes resource_entries.json + tracker_meta.json +├── resource/ # Resource CRUD on an opened Collection — reads/writes resource_entries.json + tracker_meta.json │ ├── add-resource.ts # addResource(): create or overwrite a single entry -│ ├── edit-resource.ts # editResource(): update value, comment, tags, or locale values +│ ├── edit-resource.ts # editResource(): update value, comment, tags, or locale values; moveTo moves the entry +│ ├── locale-seeding.ts # seedLocales(): what target locales get when a base value is written │ ├── delete-resource.ts # deleteResource(): remove one or more entries by key │ ├── move-resource.ts # moveResource(): rename/relocate entries (single or wildcard) │ ├── checksum.ts # calculateChecksum(): MD5 via node:crypto @@ -236,7 +239,7 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that | Group | What it holds | |---|---| -| Operations | The entry points the apps call. Resources: `addResource`, `editResource`, `deleteResource`, `moveResource`, `createDefaultTranslations`. Folders: `createFolder`, `deleteFolder`, `moveFolder`. Collections and locales: `addCollection`, `updateCollection`, `deleteCollectionByName`, `addLocaleToCollection`, `removeLocaleFromCollection`, `setGlobal/CollectionProtectedTerms[File]`. Bundles: `generateBundle`, `planBundle`, `add/update/deleteBundleDefinition`, `validateBundleKey`, `validateBundleDefinition`, `getBundleOutputPath`, `hasTypeDistConfigured`. Import: `importResources` and its adapters. Export: `runExport`, `exportTargetLocales`, the export argument checks, `loadResourcesFromCollections`. Also `normalize`, `translateLocale`, `translateExistingResource`, `validateResources`, `generateValidationSummary`, `describePreferredTermRule`. | +| Operations | The entry points the apps call. Resources: `addResource`, `editResource`, `deleteResource`, `moveResource`. Folders: `createFolder`, `deleteFolder`, `moveFolder`. Collections and locales: `addCollection`, `updateCollection`, `deleteCollectionByName`, `addLocaleToCollection`, `removeLocaleFromCollection`, `setGlobal/CollectionProtectedTerms[File]`. Bundles: `generateBundle`, `planBundle`, `add/update/deleteBundleDefinition`, `validateBundleKey`, `validateBundleDefinition`, `getBundleOutputPath`, `hasTypeDistConfigured`. Import: `importResources` and its adapters. Export: `runExport`, `exportTargetLocales`, the export argument checks, `loadResourcesFromCollections`. Also `normalize`, `translateLocale`, `translateExistingResource`, `validateResources`, `generateValidationSummary`, `describePreferredTermRule`. | | Collection & config | `loadConfig`, `openCollection`, `Collection`, `CONFIG_FILENAME`, `DEFAULT_CONFIG`, the config types (`LingoTrackerConfig`, `LingoTrackerCollection`, `TranslationConfig`, `BundleDefinition`, ...), and the protected-terms and preferred-terminology file readers and writers. | | ResourceFolder | `openResourceFolder`, `ResourceFolder` and the types in its methods, `resolveResourcePaths`. | | Read models | `loadResourceTree`, `extractSubtree`, `extractResourcesRecursively`, `searchTranslations`, `searchResourceTree`, `computeTreeFingerprint`, `treeFingerprintsMatch`, `reindexMutation` and their types. The API's [Collection Index](glossary.md#collection-index) is built from these. | @@ -254,7 +257,7 @@ Core owns the config file and the rule that turns a collection's config entry in - **`loadConfig({ cwd? })`** is the only reader of `.lingo-tracker.json`. It returns the file as written, with no validation and no fallbacks. It throws `ConfigNotFoundError` when the file does not exist and `ConfigParseError` when the file is not a JSON object; other I/O errors pass through. The CLI passes its `INIT_CWD`-aware directory, the API passes `process.cwd()`, and `createConfigFileOperations().read()` (used by the config writers) reads through it too. - **`openCollection(config, name, { cwd?, writable? })`** returns a `Collection`: `name`, the absolute `translationsFolder` (resolved against `cwd`), `baseLocale` (collection, else global, else `en`; an empty string counts as unset), `locales` (collection, else global, else `[]`), `targetLocales` (`locales` without `baseLocale`), `translationConfig` (collection, else global; not merged), normalized `tags`, `readOnly`, and the raw entry as `config`. It throws `CollectionNotFoundError` for an unknown name and, when `writable` is set, `ReadOnlyCollectionError` for a read-only collection. -The fallback rule lives only in `openCollection`. The collection operations in `collections-manager/` (`addLocaleToCollection`, `removeLocaleFromCollection`, `updateCollection`) use it for their locale checks. The [Import run](glossary.md#import-run) and the [Export run](glossary.md#export-run) take `Collection` objects, so they read the base locale and locales from there and never read the config file. Per-resource operations keep their `(translationsFolder, …, baseLocale, allLocales, translationConfig)` parameters; callers fill them from the `Collection`. The typed errors extend `LingoTrackerError`; see [Error Model](#error-model). +The fallback rule lives only in `openCollection`. The collection operations in `collections-manager/` (`addLocaleToCollection`, `removeLocaleFromCollection`, `updateCollection`) use it for their locale checks. The [Import run](glossary.md#import-run) and the [Export run](glossary.md#export-run) take `Collection` objects, so they read the base locale and locales from there and never read the config file. The resource and folder operations (`addResource`, `editResource`, `deleteResource`, `moveResource`, `translateExistingResource`, `createFolder`, `deleteFolder`, `moveFolder`) take the opened `Collection` as their first parameter too, so no caller passes a base locale, a locale list, a translation config, or a `cwd`. See [Collection-bound operations](#collection-bound-operations). The typed errors extend `LingoTrackerError`; see [Error Model](#error-model). --- @@ -270,12 +273,16 @@ Core raises a [typed error](glossary.md#typed-errors) for every failure that an | `CollectionAlreadyExistsError` | `COLLECTION_ALREADY_EXISTS` | `collectionName` | `addCollection`, `updateCollection` (rename) | | `ReadOnlyCollectionError` | `COLLECTION_READ_ONLY` | `collectionName` | `openCollection` with `{ writable: true }` | | `InvalidLocaleError` | `INVALID_LOCALE` | `locale` | `addLocaleToCollection`, `removeLocaleFromCollection` | -| `LocaleNotFoundError` | `LOCALE_NOT_FOUND` | `locale`, `collectionName` | `removeLocaleFromCollection` | +| `LocaleNotFoundError` | `LOCALE_NOT_FOUND` | `locale`, `collectionName` | `removeLocaleFromCollection`; `addResource` / `editResource` for a supplied translation in a locale the collection does not have | | `LocaleAlreadyExistsError` | `LOCALE_ALREADY_EXISTS` | `locale`, `collectionName` | `addLocaleToCollection` | | `BaseLocaleImmutableError` | `BASE_LOCALE_IMMUTABLE` | `locale` | `addLocaleToCollection`, `removeLocaleFromCollection` | -| `InvalidResourceKeyError` | `INVALID_RESOURCE_KEY` | `key` | `validateAndResolvePaths` (so `addResource`, `editResource`, `translateExistingResource`) | +| `InvalidResourceKeyError` | `INVALID_RESOURCE_KEY` | `key` | `validateAndResolvePaths` (so `addResource`, `editResource` including its `moveTo`, `translateExistingResource`) | | `ResourceNotFoundError` | `RESOURCE_NOT_FOUND` | `key` | `editResource`, `translateExistingResource` | -| `InvalidFolderPathError` | `INVALID_FOLDER_PATH` | `part`, `segment` | `createFolder` (`deleteFolder` and `moveFolder` report it in their result) | +| `ResourceAlreadyExistsError` | `RESOURCE_ALREADY_EXISTS` | `key` | `editResource` with a `moveTo` whose folder already has the entry key | +| `InvalidFolderPathError` | `INVALID_FOLDER_PATH` | `part`, `segment` | `createFolder`, `deleteFolder`, `moveFolder` | +| `FolderNotFoundError` | `FOLDER_NOT_FOUND` | `folderPath` | `deleteFolder`, `moveFolder` (source missing or not a directory) | +| `FolderMoveIntoDescendantError` | `FOLDER_MOVE_INTO_DESCENDANT` | `sourceFolderPath`, `destinationFolderPath` | `moveFolder` (same collection) | +| `AutoTranslationDisabledError` | `AUTO_TRANSLATION_DISABLED` | `collectionName` | `translateExistingResource` | | `BundleNotFoundError` | `BUNDLE_NOT_FOUND` | `bundleName` | `updateBundleDefinition`, `deleteBundleDefinition` | | `BundleAlreadyExistsError` | `BUNDLE_ALREADY_EXISTS` | `bundleName` | `addBundleDefinition`, `updateBundleDefinition` (rename) | | `InvalidBundleDefinitionError` | `INVALID_BUNDLE_DEFINITION` | `errors[]` | bundle definition add / update | @@ -285,22 +292,51 @@ Core raises a [typed error](glossary.md#typed-errors) for every failure that an Rules: - **Domain validators stay untyped.** `@simoncodes-ca/domain` has no error classes. `validateKey`, `validateTargetFolder`, and `validateLocale` throw a plain `Error`. Core wraps each call in one place and throws the typed error with the same message: `validateAndResolvePaths` for keys and target folders, and `assertValidLocale` (`collections-manager/assert-valid-locale.ts`) for locales. -- **Batch operations report, not throw.** `deleteResource`, `moveResource`, `deleteFolder`, and `moveFolder` put per-item failures into their result (`errors`, `error`) as strings. +- **Batch operations report per-item failures, not throw.** `deleteResource`, `moveResource`, and `moveFolder` put per-key failures into their result (`errors`) as strings. Bad input to the whole operation (a malformed folder path, a missing folder, a move into the folder's own descendant) is a typed error. - **Unexpected failures stay `Error`.** File I/O errors, invariant breaks (for example `ResourceFolder`'s "Resource entry not found"), and parser errors for import files are not typed. An adapter treats them as "something went wrong" and shows the message. --- ## Resource CRUD Flows -Resource CRUD is implemented across four functions in `libs/core/src/resource/`. Each function follows the same structural pattern: resolve the dot-delimited [resource key](glossary.md#resource-key) to a filesystem path, load the current JSON files, apply changes, recompute [checksums](glossary.md#checksum) and [translation status](glossary.md#translation-status), then write both files back. Both files are always written together by one call (`ResourceFolder.save()`); the writes are sequential, not atomic. +Resource CRUD is implemented across four functions in `libs/core/src/resource/`, each bound to an opened `Collection`. Each function follows the same structural pattern: resolve the dot-delimited [resource key](glossary.md#resource-key) to a filesystem path, load the current JSON files, apply changes, recompute [checksums](glossary.md#checksum) and [translation status](glossary.md#translation-status), then write both files back. Both files are always written together by one call (`ResourceFolder.save()`); the writes are sequential, not atomic. **All writes go through `ResourceFolder`.** `openResourceFolder(folderPath, { baseLocale })` in `lib/resource/resource-folder.ts` is the only owner of a [resource folder](glossary.md#resource-folder) (`resource_entries.json` + `tracker_meta.json`). Add, edit, delete, move, import, normalize, translate-locale, translate-existing-resource, and add/remove-locale all load the pair through it, change it with `setBase` / `setTranslation` / `setStatus` / `setDetails` / `setEntry` / `seedLocale` / `dropLocale` / `remove`, and persist with `save()` (which deletes both files when the folder becomes empty). `ResourceFolder` computes the checksums and applies the domain [staleness rule](glossary.md#staleness-rule) (`applyBaseChange`, `recordTranslation` in `libs/domain/src/lib/staleness.ts`), so no caller builds `{ checksum, baseChecksum, status }` by hand. Readers (tree loading, search, folder move/delete, folder cleanup) use it too, and `resolveResourcePaths()` is the only function that maps a key to its folder. **Writes return what changed.** Every write (add, edit, delete, move, translate-existing-resource, folder create/delete/move, add/remove-locale) returns `mutations: ResourceMutation[]` (`lib/resource/resource-mutation.ts`) next to its other results: an `upsert` with the stored entry as `ResourceFolder.treeEntry()` reads it, a `remove`, an `add-folder` / `remove-folder`, or a `reindex` when the change is too broad to describe. Each mutation carries the absolute translations folder it applies to. A move returns an `upsert` at the destination and a `remove` at the source for each moved key, and a folder move adds a `remove-folder` for the deleted source. The API's [Collection Index](glossary.md#collection-index) uses them to follow the disk without reading it again; the CLI ignores them. See [Resource Mutation](glossary.md#resource-mutation). +### Collection-bound operations + +Every resource and folder operation takes an opened [Collection](glossary.md#collection-resolved) as its first parameter, like the Import run: + +```ts +addResource(collection, { key, baseValue, comment?, tags?, targetFolder?, translations? }) +editResource(collection, key, { baseValue?, comment?, tags?, translations?, moveTo? }) +deleteResource(collection, { keys }) +moveResource(collection, { source, destination, override?, destinationCollection? }) +translateExistingResource(collection, key) +createFolder(collection, { folderName, parentPath? }) +deleteFolder(collection, { folderPath }) +moveFolder(collection, { sourceFolderPath, destinationFolderPath, override?, nestUnderDestination?, destinationCollection? }) +``` + +The base locale, the target locales, and the translation config come only from the `Collection`; there is no `'en'` fallback and no `cwd` (the `translationsFolder` is absolute). A cross-collection move takes the destination as a second `Collection`. + +**Key placement.** `addResource` stores `targetFolder.key` (`resolveResourceKey`, applied by `validateAndResolvePaths`). `editResource` takes the entry's full, existing key. Its `moveTo` is a destination folder (`''` is the collection root): the entry keeps its entry key (the last segment) and moves there, as a lossless copy, after the edit is saved. The destination must not already have that entry key (`ResourceAlreadyExistsError`). This is checked before anything is written, and again on a fresh read of the destination just before the move, because auto-translation may run in between; a collision found then throws with the edit already saved in the source folder. The destination is written before the source entry is removed. + +### Locale seeding + +[Locale seeding](glossary.md#locale-seeding) (`seedLocales` in `resource/locale-seeding.ts`) decides what each of `collection.targetLocales` gets when a base value is written: + +1. A translation the caller supplied → the caller's value and status. +2. Else, when `collection.translationConfig` is enabled → `autoTranslateResource()` (status `translated`). +3. Else, or when the provider skipped the locale (ICU) → a copy of the base value with status `new`. + +`addResource` applies it to every target locale. `editResource` applies it after a base value change, to the locales that need work by the [staleness rule](glossary.md#staleness-rule) (`needsTranslation` after `setBase`), with one limit: step 3 never overwrites a real translation. Only a missing locale, or one that held an untranslated copy of the old base, gets the copy; a real translation stays, marked `stale`. A supplied translation for a locale that is not in the collection throws `LocaleNotFoundError`; a value for the base locale is ignored. + ### add-resource -**Entry point:** `addResource(translationsFolder, params, options)` +**Entry point:** `addResource(collection, params)` Steps: @@ -308,30 +344,28 @@ Steps: 2. **Ensure directory** — `ensureDirectoryExists()` creates the folder tree with `mkdirSync({ recursive: true })`. 3. **Load existing files** — `openResourceFolder()` loads both files (missing files are empty). 4. **Normalize base value** — `translocoToICU()` converts any Transloco `{{ varName }}` syntax in the incoming base value to ICU `{varName}` before storage. -5. **Resolve translations** — three-way priority: - - Explicit translations in `params.translations` are used as-is. - - If no explicit translations and `translationConfig` is enabled, `autoTranslateResource()` is called (see [Auto-Translation Pipeline](#auto-translation-pipeline)). - - Otherwise, the entry is stored with no translations (all locales default to `new` status). +5. **Resolve translations** — [locale seeding](#locale-seeding): supplied translations first, then auto-translation or a copy of the base as `new` for every other target locale. All values are resolved before anything is written, so a provider failure writes nothing. 6. **Replace the entry** — `setEntry` / `setBase` / `setDetails` / `setTranslation` on the `ResourceFolder`. A translation equal to the base value is stored as `new`. 7. **Write files** — `folder.save()` writes both `resource_entries.json` and `tracker_meta.json`. ### edit-resource -**Entry point:** `editResource(translationsFolder, options)` +**Entry point:** `editResource(collection, key, changes)` Steps: -1. **Resolve paths and load** — same as add-resource. +1. **Resolve paths and load** — same as add-resource. `key` is the entry's full key. A `moveTo` is resolved and checked for a collision before anything changes. 2. **Throws if not found** — exits immediately if either JSON file or the specific entry key is absent. 3. **Update base value** (if changed) — `translocoToICU()` normalizes the incoming value; `folder.setBase()` recomputes the base checksum and applies the [staleness rule](glossary.md#staleness-rule) to every non-base locale. 4. **Update comment/tags** — simple field overwrites with change detection to avoid unnecessary writes. -5. **Update locale values** — for each locale in `options.locales`, normalizes with `translocoToICU()`, recomputes checksum via `calculateChecksum()`, and updates `status` (defaults to `'translated'` if not provided). +5. **Update locale values** — for each locale in `changes.translations`, normalizes with `translocoToICU()`, recomputes checksum via `calculateChecksum()`, and updates `status` (defaults to `'translated'` if not provided). 6. **Persist initial changes** — `folder.save()` before attempting auto-translation, so the base value change is durable even if the translation API call fails. -7. **Auto-translate on base change** — if `baseValueDidChange` and `translationConfig` is enabled, `autoTranslateResource()` is called for all non-base locales; results are written by a second `folder.save()`. +7. **Seed on base change** — if the base value changed, [locale seeding](#locale-seeding) runs for the locales that need work and were not supplied; results are written by a second `folder.save()`. +8. **Move** — with a `moveTo` naming another folder, the entry is copied as stored to the destination and removed from the source. The result's `resolvedKey` is the destination key, and `mutations` are an `upsert` there and a `remove` at the source. ### delete-resource -**Entry point:** `deleteResource(translationsFolder, { keys })` +**Entry point:** `deleteResource(collection, { keys })` Steps: @@ -343,7 +377,7 @@ Steps: ### move-resource -**Entry point:** `moveResource(translationsFolder, { source, destination, override })` +**Entry point:** `moveResource(collection, { source, destination, override, destinationCollection })` Two modes: @@ -377,7 +411,7 @@ Returns a `NormalizeResult` with counts: `entriesProcessed`, `localesAdded`, `va **Entry point:** `autoTranslateResource(params)` in `lib/translation/auto-translate-resources.ts` -This pipeline is called from `addResource()` and `editResource()` (on base value change), and also from the standalone `translateExistingResource()` function which targets only entries with `new` or `stale` status. +This pipeline is called by [locale seeding](#locale-seeding) (so from `addResource()` and from `editResource()` on a base value change), and also from the standalone `translateExistingResource()` function which targets only entries with `new` or `stale` status. diff --git a/architecture-docs/domain-and-data-model.md b/architecture-docs/domain-and-data-model.md index a4bf2743..da5d8aa0 100644 --- a/architecture-docs/domain-and-data-model.md +++ b/architecture-docs/domain-and-data-model.md @@ -44,7 +44,7 @@ apps.common.buttons.ok └── tracker_meta.json ← contains checksums and status for "ok" ``` -A [resolved key](glossary.md#resolved-key) is formed by prepending an optional [target folder](glossary.md#target-folder): `resolvedKey = targetFolder + "." + key`. For example, key `ok` with target folder `apps.common.buttons` resolves to `apps.common.buttons.ok` before the folder path is computed. The resolution logic lives in `resolveResourceKey()` in `@simoncodes-ca/domain`. +A [resolved key](glossary.md#resolved-key) is formed by prepending an optional [target folder](glossary.md#target-folder): `resolvedKey = targetFolder + "." + key`. For example, key `ok` with target folder `apps.common.buttons` resolves to `apps.common.buttons.ok` before the folder path is computed. The resolution logic lives in `resolveResourceKey()` in `@simoncodes-ca/domain`. Only creation (`addResource`) takes a target folder; an edit names the full existing key and changes folder with `moveTo` (see [core-library.md](core-library.md#collection-bound-operations)). A single-segment key (e.g. `title`) places the entry at the root of the collection's `translationsFolder` — no subdirectory is created. diff --git a/architecture-docs/frontend.md b/architecture-docs/frontend.md index be5d4078..a6db354b 100644 --- a/architecture-docs/frontend.md +++ b/architecture-docs/frontend.md @@ -311,8 +311,8 @@ The dialog also includes a tag chip input (Material `mat-chip-grid` + `mat-autoc | `folderEntryKeys(folderPath, known)` / `collisionFor(key, folderPath, known, ownKey?)` | Which entry keys a folder holds, from three sources in order: the expanded folder tree, the folder the browser shows, then folders the dialog fetched. Nested keys (with a dot) are not entries of the folder. The match is exact and case-sensitive, the same as `addResource`. The entry being edited never collides with itself. | | `contextTree(input, moreLabel)` | The "Where it lands" tree: the target folder among its siblings, and an 8-entry window of its entries around the key. The remaining entries are one "more" row. | | `addTag` / `removeTag` | Tag list operations. Tags are normalized with `normalizeTag`. Inherited tags cannot be removed. | -| `toCreateDto(draft, baseLocale)` | The create request. Every typed translation is sent with status `new`. | -| `toUpdateDto(draft, original)` / `editedLocales` | The update request. A locale is sent when it has a value or when its status changed. | +| `toCreateDto(draft)` | The create request. Every typed translation is sent with status `new`. Locales left empty are not sent; the server seeds them by the collection's rule ([locale seeding](glossary.md#locale-seeding)). The request has no base locale: the collection's applies. | +| `toUpdateDto(draft, original)` / `editedLocales` | The update request. `key` is the entry's full key where it lives now. A change of folder (the collection root included) is sent as `moveTo`, the destination folder. A locale is sent when it has a value or when its status changed. | | `hasUnsavedChanges(draft, initial, fieldsEdited)` | Closing loses work when a form field was edited, the folder moved, or the tags changed. | The key field validator is `segmentValidator` (`shared/validators/segment.validator.ts`). It uses the domain `isValidSegment` rule and reports under the `pattern` error key. The bundle name and the inline new-folder name use the same validator. The folder filter in the location popover uses `filterFolderTree` from `browser/store/folder-tree.utils.ts`, the same function as `BrowserStore.filteredFolders`. @@ -330,13 +330,13 @@ All UI writes of a resource entry go through `withEntryWritesFeature` on `Browse | Method | Caller | After a successful write | |---|---|---| | `createResource(collectionName, dto)` | `TranslationEditorDialog` (create) | Reloads the current folder with `selectFolder`. | -| `updateResource(collectionName, dto)` | `TranslationEditorDialog` (edit) | Patches the entry in place. If the DTO has a `targetFolder` property, removes the entry instead. | +| `updateResource(collectionName, dto)` | `TranslationEditorDialog` (edit) | Patches the entry in place. If the DTO has a `moveTo` property, removes the entry instead (it moved). | | `deleteResource(collectionName, fullKey)` | `withItemActions.deleteTranslation` | Removes the entry when `entriesDeleted > 0`. | | `translateResource(collectionName, fullKey)` | `withItemActions.translateResource` | Patches the entry in place. | Each method takes the full dot-delimited key and returns the API `Observable`. The caller subscribes and keeps its own error handling, for example the dialog's 409 conflict dialog and its 400 and 404 messages. The store changes its caches only on success. -`toUpdateDto` includes `targetFolder` only when the entry moves to a different non-root folder. A move to the collection root is sent without `targetFolder`, as before this change. The server then edits the entry in its current folder, so the store patches the row in place. The store rule and the DTO rule use the same test: the `targetFolder` property is present or absent. +`toUpdateDto` includes `moveTo` only when the entry changes folder, and `''` means the collection root. The server edits the entry, then moves it there (core `editResource` with `moveTo`), so the store drops the row. The store rule and the DTO rule use the same test: the `moveTo` property is present or absent. The two caches use different keys. `translations` uses the key relative to `currentFolderPath`, so a nested entry keeps its sub-path (`dialog.title`). `searchResults` uses the full key. The store converts the key with `listKeyFor` in one place. The API returns a bare entry key, so the store also replaces the key of the returned resource. Callers do not convert keys. diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index c558c248..f36b4e06 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -51,7 +51,7 @@ Collections may declare a `tags?: string[]` array. These are **collection-level Example collections from the project's own config: `trackerResources` (the Tracker UI's own strings), `TestDataPlayground`, and `mockDesignSystem`. -**Collection (resolved).** Code outside the config module never reads a collection's raw entry to get its settings. `openCollection(config, name)` in `@simoncodes-ca/core` returns a `Collection` with the effective values: `baseLocale` (collection, else global, else `en`), `locales` (collection, else global, else none), `targetLocales` (the locales without the base locale), `translationConfig` (collection, else global; the two are not merged), the absolute `translationsFolder`, normalized `tags`, and `readOnly`. It throws `CollectionNotFoundError` for an unknown name, and `ReadOnlyCollectionError` when `{ writable: true }` is set on a read-only collection. The CLI and the API both open collections this way. +**Collection (resolved).** Code outside the config module never reads a collection's raw entry to get its settings. `openCollection(config, name)` in `@simoncodes-ca/core` returns a `Collection` with the effective values: `baseLocale` (collection, else global, else `en`), `locales` (collection, else global, else none), `targetLocales` (the locales without the base locale), `translationConfig` (collection, else global; the two are not merged), the absolute `translationsFolder`, normalized `tags`, and `readOnly`. It throws `CollectionNotFoundError` for an unknown name, and `ReadOnlyCollectionError` when `{ writable: true }` is set on a read-only collection. The CLI and the API both open collections this way. Every resource and folder operation (`addResource`, `editResource`, `deleteResource`, `moveResource`, `translateExistingResource`, `createFolder`, `deleteFolder`, `moveFolder`) takes the opened `Collection` as its first parameter, like the [Import run](#import-run), so the base locale and locales come only from it. Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [`cli.md`](cli.md), [`core-library.md`](core-library.md#config-and-collection-resolution) @@ -97,6 +97,14 @@ Explained in context: [`core-library.md`](core-library.md#import-pipeline) ## L +### Locale Seeding + +What each of a [collection's](#collection) target locales gets when a resource's base value is written: the translation the caller supplied, else an auto-translation when the collection enables it, else a copy of the base value with status `new`. In code, `seedLocales(collection, request)` in `libs/core/src/resource/locale-seeding.ts`. `addResource` applies it to every target locale; `editResource` applies it after a base value change, to the locales that need work by the [staleness rule](#staleness-rule), and never replaces a real translation with a copy. The API, the CLI and the Tracker do not decide this themselves. + +Explained in context: [`core-library.md`](core-library.md#locale-seeding) + +--- + ### Locale Metadata The per-locale record stored within [`tracker_meta.json`](#tracker-metadata) for each [resource entry](#resource-entry). Defined by the `LocaleMetadata` interface in `@simoncodes-ca/domain`: @@ -261,11 +269,13 @@ Explained in context: [`core-library.md`](core-library.md#resource-crud-flows) ### Target Folder -An optional dot-delimited path prefix that scopes an input [resource key](#resource-key) to a specific folder within the collection's translation hierarchy. Used in `add-resource`, `edit-resource`, and the REST API to place a short key (e.g. `ok`) at a specific location (e.g. `apps.common.buttons`) without repeating the full path in the key itself. +An optional dot-delimited path prefix that scopes an input [resource key](#resource-key) to a specific folder within the collection's translation hierarchy. Used when a resource is created (`addResource`, `add-resource --target-folder`, `CreateResourceDto.targetFolder`) to place a short key (e.g. `ok`) at a specific location (e.g. `apps.common.buttons`) without repeating the full path in the key itself. Validated to the same segment rules as a resource key (`[A-Za-z0-9_-]`). An empty string means no folder scoping. -Explained in context: [`libs-domain.md`](libs-domain.md), [`apps-cli.md`](apps-cli.md) +An edit does not use it. `editResource(collection, key, { moveTo })` takes the full existing key, and `moveTo` is the folder the entry moves to (`''` for the collection root); `UpdateResourceDto.moveTo` and `edit-resource --target-folder` map to it. + +Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [`core-library.md`](core-library.md#collection-bound-operations), [`cli.md`](cli.md) --- diff --git a/architecture-docs/user-flows.md b/architecture-docs/user-flows.md index a0572600..5cef7eeb 100644 --- a/architecture-docs/user-flows.md +++ b/architecture-docs/user-flows.md @@ -37,14 +37,14 @@ sequenceDiagram Note over Dev,FS: 1. Create resource Dev->>CLI: add-resource apps.common.ok "OK" - CLI->>Core: addResource(translationsFolder, params) + CLI->>Core: addResource(collection, params) Core->>Domain: validateKey("apps.common.ok") Domain-->>Core: valid Core->>Domain: resolveResourceKey() → folderPath Core->>FS: ensureDirectoryExists(folderPath) Core->>FS: openResourceFolder(folderPath) — reads resource_entries.json + tracker_meta.json Core->>Domain: translocoToICU("OK") → "OK" - Core->>Core: autoTranslateResource() [if translationConfig.enabled] + Core->>Core: seedLocales() — auto-translate if collection.translationConfig is enabled, else copy base as new Core->>Provider: translate("OK", en→fr, en→de, ...) Provider-->>Core: { fr: "OK", de: "OK", ... } Core->>Core: ResourceFolder.setBase() + setTranslation() — MD5 checksums, status=translated @@ -54,21 +54,21 @@ sequenceDiagram Note over Dev,FS: 2. Edit base value — triggers stale Dev->>CLI: edit-resource apps.common.ok "OK" --base "Confirm" - CLI->>Core: editResource(translationsFolder, options) + CLI->>Core: editResource(collection, key, changes) Core->>FS: openResourceFolder(folderPath) — reads resource_entries.json + tracker_meta.json Core->>Domain: translocoToICU("Confirm") → "Confirm" Core->>Core: ResourceFolder.setBase() — applies the Staleness rule Note right of Core: new baseChecksum ≠ stored baseChecksum
for each locale → status = "stale" Core->>Core: ResourceFolder.setTranslation() / setStatus() [explicit locale edits] Core->>FS: ResourceFolder.save() — persists stale status before API call - Core->>Core: autoTranslateResource() [on base value change] + Core->>Core: seedLocales() [on base value change; auto-translate when enabled] Core->>Provider: translate("Confirm", en→fr, ...) Provider-->>Core: { fr: "Confirmer", ... } Core->>FS: ResourceFolder.setTranslation() + save() — second pass with translated values Note over Dev,FS: 3. Manual re-translate (UI trigger) Dev->>CLI: translate-resource apps.common.ok - CLI->>Core: translateExistingResource(translationsFolder, key) + CLI->>Core: translateExistingResource(collection, key) Core->>FS: read current entries + metadata Note right of Core: Only translates locales with status
"new" or "stale" Core->>Provider: translate(baseValue, ...) @@ -77,7 +77,7 @@ sequenceDiagram Note over Dev,FS: 4. Verify Dev->>CLI: edit-resource apps.common.ok --locale fr --status verified - CLI->>Core: editResource(..., { locales: [{ locale: "fr", status: "verified" }] }) + CLI->>Core: editResource(collection, key, { translations: { fr: { value, status: "verified" } } }) Core->>FS: write tracker_meta.json (fr.status = "verified") Core-->>CLI: EditResourceResult diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 180fe9e1..2e94732c 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -3,7 +3,7 @@ // Domain rules and types (TranslationStatus, TokenCasing, ImportStrategy, ...) come from @simoncodes-ca/domain. // Operations: resources -export { addResource, createDefaultTranslations, deleteResource, editResource, moveResource } from './resource'; +export { addResource, deleteResource, editResource, moveResource } from './resource'; // Operations: folders export { createFolder, deleteFolder, moveFolder } from './lib/folder'; @@ -112,6 +112,7 @@ export { // Errors export { PreferredTerminologyValidationError } from './lib/config'; export { + AutoTranslationDisabledError, BaseLocaleImmutableError, BundleAlreadyExistsError, BundleNotFoundError, @@ -119,6 +120,8 @@ export { CollectionNotFoundError, ConfigNotFoundError, ConfigParseError, + FolderMoveIntoDescendantError, + FolderNotFoundError, type FolderPathPart, InvalidBundleDefinitionError, InvalidFolderPathError, @@ -128,6 +131,7 @@ export { LocaleAlreadyExistsError, LocaleNotFoundError, ReadOnlyCollectionError, + ResourceAlreadyExistsError, ResourceNotFoundError, } from './lib/errors'; export { TranslationError } from './lib/translation'; @@ -190,7 +194,6 @@ export type { SearchTreeParams, } from './lib/resource'; export type { - TranslateExistingResourceOptions, TranslateExistingResourceResult, TranslateLocaleParams, TranslateLocaleProgress, @@ -198,13 +201,14 @@ export type { } from './lib/translation'; export type { ResourceValidationResult, ValidationOptions } from './lib/validate'; export type { - AddResourceOptions, AddResourceParams, + AddResourceResult, DeleteResourceParams, DeleteResourceResult, - EditResourceOptions, + EditResourceChanges, EditResourceResult, MoveResourceParams, MoveResourceResult, ResourceEntryMetadata, + ResourceTranslation, } from './resource'; diff --git a/libs/core/src/lib/errors/error-messages.ts b/libs/core/src/lib/errors/error-messages.ts index fee84852..2f8d980c 100644 --- a/libs/core/src/lib/errors/error-messages.ts +++ b/libs/core/src/lib/errors/error-messages.ts @@ -20,6 +20,15 @@ export const ErrorMessages = { resourceNotFound: (key: string) => `Resource not found: ${key}`, + resourceAlreadyExists: (key: string) => `Resource already exists: ${key}`, + + folderNotFound: (folderPath: string) => `Folder not found: ${folderPath}`, + + folderMoveIntoDescendant: (source: string, destination: string) => + `Cannot move folder "${source}" into its own descendant "${destination}"`, + + autoTranslationDisabled: (collection: string) => `Auto-translation is not enabled for collection "${collection}"`, + collectionNotFound: (name: string) => `Collection "${name}" not found`, collectionReadOnly: (name: string) => `Collection "${name}" is read-only. Its resources cannot be modified.`, diff --git a/libs/core/src/lib/errors/index.ts b/libs/core/src/lib/errors/index.ts index 185a231d..e5eafbb6 100644 --- a/libs/core/src/lib/errors/index.ts +++ b/libs/core/src/lib/errors/index.ts @@ -2,6 +2,7 @@ export type { FolderPathPart } from './error-messages'; export { + AutoTranslationDisabledError, BaseLocaleImmutableError, BundleAlreadyExistsError, BundleNotFoundError, @@ -9,6 +10,8 @@ export { CollectionNotFoundError, ConfigNotFoundError, ConfigParseError, + FolderMoveIntoDescendantError, + FolderNotFoundError, InvalidBundleDefinitionError, InvalidFolderPathError, InvalidLocaleError, @@ -17,5 +20,6 @@ export { LocaleAlreadyExistsError, LocaleNotFoundError, ReadOnlyCollectionError, + ResourceAlreadyExistsError, ResourceNotFoundError, } from './lingo-tracker-error'; diff --git a/libs/core/src/lib/errors/lingo-tracker-error.spec.ts b/libs/core/src/lib/errors/lingo-tracker-error.spec.ts index 074db401..907e51bf 100644 --- a/libs/core/src/lib/errors/lingo-tracker-error.spec.ts +++ b/libs/core/src/lib/errors/lingo-tracker-error.spec.ts @@ -3,6 +3,7 @@ import { PreferredTerminologyValidationError } from '../config/preferred-termino import { TranslationError } from '../translation/translation-provider'; import { ErrorMessages } from './error-messages'; import { + AutoTranslationDisabledError, BaseLocaleImmutableError, BundleAlreadyExistsError, BundleNotFoundError, @@ -10,6 +11,8 @@ import { CollectionNotFoundError, ConfigNotFoundError, ConfigParseError, + FolderMoveIntoDescendantError, + FolderNotFoundError, InvalidBundleDefinitionError, InvalidFolderPathError, InvalidLocaleError, @@ -18,6 +21,7 @@ import { LocaleAlreadyExistsError, LocaleNotFoundError, ReadOnlyCollectionError, + ResourceAlreadyExistsError, ResourceNotFoundError, } from './lingo-tracker-error'; @@ -89,6 +93,24 @@ describe('LingoTrackerError subclasses', () => { code: 'RESOURCE_NOT_FOUND', message: ErrorMessages.resourceNotFound('common.ok'), }, + { + error: new ResourceAlreadyExistsError('common.ok'), + name: 'ResourceAlreadyExistsError', + code: 'RESOURCE_ALREADY_EXISTS', + message: ErrorMessages.resourceAlreadyExists('common.ok'), + }, + { + error: new FolderNotFoundError('apps.common'), + name: 'FolderNotFoundError', + code: 'FOLDER_NOT_FOUND', + message: ErrorMessages.folderNotFound('apps.common'), + }, + { + error: new FolderMoveIntoDescendantError('apps', 'apps.common'), + name: 'FolderMoveIntoDescendantError', + code: 'FOLDER_MOVE_INTO_DESCENDANT', + message: ErrorMessages.folderMoveIntoDescendant('apps', 'apps.common'), + }, { error: new InvalidFolderPathError('folder name', 'a b'), name: 'InvalidFolderPathError', @@ -101,6 +123,12 @@ describe('LingoTrackerError subclasses', () => { code: 'BUNDLE_NOT_FOUND', message: ErrorMessages.bundleNotFound('main'), }, + { + error: new AutoTranslationDisabledError('main'), + name: 'AutoTranslationDisabledError', + code: 'AUTO_TRANSLATION_DISABLED', + message: ErrorMessages.autoTranslationDisabled('main'), + }, { error: new BundleAlreadyExistsError('main'), name: 'BundleAlreadyExistsError', diff --git a/libs/core/src/lib/errors/lingo-tracker-error.ts b/libs/core/src/lib/errors/lingo-tracker-error.ts index b2c63895..4c2e123e 100644 --- a/libs/core/src/lib/errors/lingo-tracker-error.ts +++ b/libs/core/src/lib/errors/lingo-tracker-error.ts @@ -142,6 +142,41 @@ export class ResourceNotFoundError extends LingoTrackerError { } } +/** A resource already exists at this (resolved) key, and the operation does not overwrite it. */ +export class ResourceAlreadyExistsError extends LingoTrackerError { + readonly key: string; + + constructor(key: string) { + super(ErrorMessages.resourceAlreadyExists(key), 'RESOURCE_ALREADY_EXISTS'); + this.key = key; + } +} + +/** No folder exists at this dot-delimited path (or the path is not a directory). */ +export class FolderNotFoundError extends LingoTrackerError { + readonly folderPath: string; + + constructor(folderPath: string) { + super(ErrorMessages.folderNotFound(folderPath), 'FOLDER_NOT_FOUND'); + this.folderPath = folderPath; + } +} + +/** A folder move names a destination inside the folder being moved. */ +export class FolderMoveIntoDescendantError extends LingoTrackerError { + readonly sourceFolderPath: string; + readonly destinationFolderPath: string; + + constructor(sourceFolderPath: string, destinationFolderPath: string) { + super( + ErrorMessages.folderMoveIntoDescendant(sourceFolderPath, destinationFolderPath), + 'FOLDER_MOVE_INTO_DESCENDANT', + ); + this.sourceFolderPath = sourceFolderPath; + this.destinationFolderPath = destinationFolderPath; + } +} + /** A segment of a dot-delimited folder path is malformed. */ export class InvalidFolderPathError extends LingoTrackerError { readonly segment: string; @@ -154,6 +189,18 @@ export class InvalidFolderPathError extends LingoTrackerError { } } +// --- Translation ------------------------------------------------------------- + +/** An auto-translate operation was asked of a collection whose translation config is missing or disabled. */ +export class AutoTranslationDisabledError extends LingoTrackerError { + readonly collectionName: string; + + constructor(collectionName: string) { + super(ErrorMessages.autoTranslationDisabled(collectionName), 'AUTO_TRANSLATION_DISABLED'); + this.collectionName = collectionName; + } +} + // --- Bundles ----------------------------------------------------------------- /** The config has no bundle with this name. */ diff --git a/libs/core/src/lib/folder/create-folder.spec.ts b/libs/core/src/lib/folder/create-folder.spec.ts index f5a12766..88a64d2c 100644 --- a/libs/core/src/lib/folder/create-folder.spec.ts +++ b/libs/core/src/lib/folder/create-folder.spec.ts @@ -1,8 +1,23 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; -import { createFolder } from './create-folder'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Collection } from '../config/open-collection'; import * as directoryOps from '../file-io/directory-operations'; +import { createFolder } from './create-folder'; + +function collection(translationsFolder: string): Collection { + return { + name: 'main', + translationsFolder, + baseLocale: 'en', + locales: ['en'], + targetLocales: [], + translationConfig: undefined, + tags: [], + readOnly: false, + config: { translationsFolder }, + }; +} vi.mock('node:fs'); vi.mock('../file-io/directory-operations'); @@ -24,7 +39,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/app/translations/apps')); expect(result.created).toBe(true); @@ -43,7 +58,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName, parentPath }); + const result = createFolder(collection(translationsFolder), { folderName, parentPath }); expect(result.folderPath).toBe(resolve('/app/translations/apps/common/buttons')); expect(result.created).toBe(true); @@ -61,7 +76,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/app/translations/apps/common/buttons')); expect(result.created).toBe(true); @@ -74,7 +89,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/app/translations/apps')); expect(result.created).toBe(false); @@ -87,7 +102,7 @@ describe('createFolder', () => { const translationsFolder = '/app/translations'; const folderName = 'invalid!name'; - expect(() => createFolder(translationsFolder, { folderName })).toThrow( + expect(() => createFolder(collection(translationsFolder), { folderName })).toThrow( 'Invalid folder name segment "invalid!name". Segments must match pattern [A-Za-z0-9_-]+', ); }); @@ -96,7 +111,7 @@ describe('createFolder', () => { const translationsFolder = '/app/translations'; const folderName = 'apps.common.bad@segment'; - expect(() => createFolder(translationsFolder, { folderName })).toThrow( + expect(() => createFolder(collection(translationsFolder), { folderName })).toThrow( 'Invalid folder name segment "bad@segment". Segments must match pattern [A-Za-z0-9_-]+', ); }); @@ -106,7 +121,7 @@ describe('createFolder', () => { const folderName = 'buttons'; const parentPath = 'apps.invalid#path'; - expect(() => createFolder(translationsFolder, { folderName, parentPath })).toThrow( + expect(() => createFolder(collection(translationsFolder), { folderName, parentPath })).toThrow( 'Invalid parent path segment "invalid#path". Segments must match pattern [A-Za-z0-9_-]+', ); }); @@ -118,7 +133,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/app/translations/abc123')); }); @@ -130,7 +145,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/app/translations/my-folder')); }); @@ -142,7 +157,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/app/translations/my_folder')); }); @@ -154,7 +169,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/app/translations/my-complex_Folder123')); }); @@ -169,7 +184,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName, parentPath }); + const result = createFolder(collection(translationsFolder), { folderName, parentPath }); expect(result.folderPath).toBe(resolve('/app/translations/apps')); }); @@ -182,7 +197,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName, parentPath }); + const result = createFolder(collection(translationsFolder), { folderName, parentPath }); expect(result.folderPath).toBe(resolve('/app/translations/apps')); }); @@ -195,7 +210,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName, parentPath }); + const result = createFolder(collection(translationsFolder), { folderName, parentPath }); expect(result.folderPath).toBe(resolve('/app/translations/apps/common/buttons/ok')); }); @@ -207,7 +222,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('translations', 'apps')); }); @@ -221,7 +236,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - createFolder(translationsFolder, { folderName }); + createFolder(collection(translationsFolder), { folderName }); expect(existsSync).toHaveBeenCalledWith(resolve('/app/translations/apps')); }); @@ -233,7 +248,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - createFolder(translationsFolder, { folderName }); + createFolder(collection(translationsFolder), { folderName }); expect(directoryOps.ensureDirectoryExists).toHaveBeenCalled(); }); @@ -247,7 +262,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/project/translations/apps')); expect(result.created).toBe(true); @@ -261,7 +276,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName, parentPath }); + const result = createFolder(collection(translationsFolder), { folderName, parentPath }); expect(result.folderPath).toBe(resolve('/project/translations/apps/common/components/forms/inputs')); expect(result.created).toBe(true); @@ -274,7 +289,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/workspace/i18n/common')); expect(result.created).toBe(true); @@ -294,7 +309,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve('/app/translations/a')); expect(result.created).toBe(true); @@ -307,7 +322,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName }); + const result = createFolder(collection(translationsFolder), { folderName }); expect(result.folderPath).toBe(resolve(`/app/translations/${'a'.repeat(100)}`)); expect(result.created).toBe(true); @@ -321,7 +336,7 @@ describe('createFolder', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(directoryOps.ensureDirectoryExists).mockImplementation(() => undefined); - const result = createFolder(translationsFolder, { folderName, parentPath }); + const result = createFolder(collection(translationsFolder), { folderName, parentPath }); expect(result.folderPath).toBe(resolve('/app/translations/level1/level2/level3/level4/level5')); expect(result.created).toBe(true); diff --git a/libs/core/src/lib/folder/create-folder.ts b/libs/core/src/lib/folder/create-folder.ts index 0daf6202..fb64da88 100644 --- a/libs/core/src/lib/folder/create-folder.ts +++ b/libs/core/src/lib/folder/create-folder.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { isValidSegment } from '@simoncodes-ca/domain'; +import type { Collection } from '../config/open-collection'; import { InvalidFolderPathError } from '../errors/lingo-tracker-error'; import { ensureDirectoryExists } from '../file-io/directory-operations'; import { folderMutation, type ResourceMutation } from '../resource/resource-mutation'; @@ -22,7 +23,7 @@ export interface CreateFolderResult { } /** - * Creates a folder in the translations directory structure. + * Creates a folder in a collection's translations folder. * * This function: * 1. Validates the folder name segments using the same rules as resource keys @@ -31,35 +32,36 @@ export interface CreateFolderResult { * 4. Creates the directory (and any parent directories) if needed * 5. Returns whether the folder was newly created * - * @param translationsFolder - Root translations folder path + * @param collection - The collection to create the folder in * @param params - Folder creation parameters * @returns Object containing the folder path and creation status - * @throws Error if folder name contains invalid segments + * @throws {InvalidFolderPathError} The folder name or parent path has a malformed segment. * * @example * ```typescript * // Create a top-level folder - * const result = createFolder('/app/translations', { + * const result = createFolder(collection, { * folderName: 'apps' * }); - * // Result: { folderPath: '/app/translations/apps', created: true } + * // Result: { folderPath: '/apps', created: true } * * // Create a nested folder - * const result = createFolder('/app/translations', { + * const result = createFolder(collection, { * folderName: 'buttons', * parentPath: 'apps.common' * }); - * // Result: { folderPath: '/app/translations/apps/common/buttons', created: true } + * // Result: { folderPath: '/apps/common/buttons', created: true } * * // Create a multi-segment folder - * const result = createFolder('/app/translations', { + * const result = createFolder(collection, { * folderName: 'apps.common.buttons' * }); - * // Result: { folderPath: '/app/translations/apps/common/buttons', created: true } + * // Result: { folderPath: '/apps/common/buttons', created: true } * ``` */ -export function createFolder(translationsFolder: string, params: CreateFolderParams): CreateFolderResult { +export function createFolder(collection: Collection, params: CreateFolderParams): CreateFolderResult { const { folderName, parentPath } = params; + const { translationsFolder } = collection; // Validate folderName segments const folderSegments = folderName.split('.'); diff --git a/libs/core/src/lib/folder/delete-folder.ts b/libs/core/src/lib/folder/delete-folder.ts index a2a0278a..f7019aa6 100644 --- a/libs/core/src/lib/folder/delete-folder.ts +++ b/libs/core/src/lib/folder/delete-folder.ts @@ -2,7 +2,8 @@ import { existsSync, rmSync, statSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { walkFolders } from '../normalize/iterative-folder-walker'; import { isValidSegment } from '@simoncodes-ca/domain'; -import { InvalidFolderPathError } from '../errors/lingo-tracker-error'; +import type { Collection } from '../config/open-collection'; +import { FolderNotFoundError, InvalidFolderPathError } from '../errors/lingo-tracker-error'; import { openResourceFolder } from '../resource/resource-folder'; import { folderMutation, type ResourceMutation } from '../resource/resource-mutation'; @@ -14,18 +15,14 @@ export interface DeleteFolderParams { export interface DeleteFolderResult { /** The dot-delimited folder path that was deleted */ readonly folderPath: string; - /** Whether the folder was successfully deleted */ - readonly deleted: boolean; /** Number of resource entries that were deleted */ readonly resourcesDeleted: number; - /** Error message if deletion failed */ - readonly error?: string; - /** A `remove-folder` when the folder was deleted, otherwise empty. */ + /** The `remove-folder` for the deleted folder. */ readonly mutations: ResourceMutation[]; } /** - * Deletes a folder and all its contents from the translations directory structure. + * Deletes a folder and all its contents from a collection's translations folder. * * This function: * 1. Validates the folder path segments @@ -33,89 +30,43 @@ export interface DeleteFolderResult { * 3. Counts all resource entries in the folder tree * 4. Recursively deletes the folder and all its contents * - * @param translationsFolder - Root translations folder path + * @param collection - The collection to delete the folder from * @param params - Folder deletion parameters - * @returns Object containing deletion status and resource count + * @returns The deleted folder and how many resource entries went with it + * @throws {InvalidFolderPathError} The folder path has a malformed segment. + * @throws {FolderNotFoundError} No folder exists at the path. * * @example * ```typescript - * // Delete a folder - * const result = deleteFolder('/app/translations', { - * folderPath: 'apps.common.buttons' - * }); - * // Result: { folderPath: 'apps.common.buttons', deleted: true, resourcesDeleted: 5 } - * - * // Attempt to delete non-existent folder - * const result = deleteFolder('/app/translations', { - * folderPath: 'apps.nonexistent' - * }); - * // Result: { folderPath: 'apps.nonexistent', deleted: false, resourcesDeleted: 0, error: '...' } + * const result = deleteFolder(collection, { folderPath: 'apps.common.buttons' }); + * // Result: { folderPath: 'apps.common.buttons', resourcesDeleted: 5, mutations: [...] } * ``` */ -export function deleteFolder(translationsFolder: string, params: DeleteFolderParams): DeleteFolderResult { +export function deleteFolder(collection: Collection, params: DeleteFolderParams): DeleteFolderResult { const { folderPath } = params; + const { translationsFolder } = collection; - try { - // Validate folder path segments - const pathSegments = folderPath.split('.'); - for (const segment of pathSegments) { - if (!isValidSegment(segment)) { - throw new InvalidFolderPathError('folder path', segment); - } - } - - // Convert dot-delimited path to filesystem path - const relativeFolderPath = pathSegments.length ? join(translationsFolder, ...pathSegments) : translationsFolder; - - // Resolve to absolute path - const absoluteFolderPath = resolve(relativeFolderPath); - - // Check if folder exists - if (!existsSync(absoluteFolderPath)) { - return { - folderPath, - deleted: false, - resourcesDeleted: 0, - mutations: [], - error: `Folder not found: ${absoluteFolderPath}`, - }; - } - - // Verify it's a directory - const stats = statSync(absoluteFolderPath); - if (!stats.isDirectory()) { - return { - folderPath, - deleted: false, - resourcesDeleted: 0, - mutations: [], - error: `Path is not a directory: ${absoluteFolderPath}`, - }; + const pathSegments = folderPath.split('.'); + for (const segment of pathSegments) { + if (!isValidSegment(segment)) { + throw new InvalidFolderPathError('folder path', segment); } + } - // Count resources before deletion - const resourcesDeleted = countResourcesInFolder(absoluteFolderPath); + const absoluteFolderPath = resolve(join(translationsFolder, ...pathSegments)); + if (!existsSync(absoluteFolderPath) || !statSync(absoluteFolderPath).isDirectory()) { + throw new FolderNotFoundError(folderPath); + } - // Delete the folder recursively - rmSync(absoluteFolderPath, { recursive: true, force: true }); + const resourcesDeleted = countResourcesInFolder(absoluteFolderPath); + rmSync(absoluteFolderPath, { recursive: true, force: true }); - return { - folderPath, - deleted: true, - resourcesDeleted, - mutations: [folderMutation('remove-folder', translationsFolder, folderPath)], - }; - } catch (error) { - return { - folderPath, - deleted: false, - resourcesDeleted: 0, - mutations: [], - error: error instanceof Error ? error.message : String(error), - }; - } + return { + folderPath, + resourcesDeleted, + mutations: [folderMutation('remove-folder', translationsFolder, folderPath)], + }; } - /** * Counts all resource entries in a folder tree. * diff --git a/libs/core/src/lib/folder/move-folder.real-fs.spec.ts b/libs/core/src/lib/folder/move-folder.real-fs.spec.ts index d84a9c0a..a14907fb 100644 --- a/libs/core/src/lib/folder/move-folder.real-fs.spec.ts +++ b/libs/core/src/lib/folder/move-folder.real-fs.spec.ts @@ -1,11 +1,26 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { addResource } from '../../resource/add-resource'; +import type { Collection } from '../config/open-collection'; import { openResourceFolder } from '../resource/resource-folder'; import { moveFolder } from './move-folder'; +function collection(translationsFolder: string): Collection { + return { + name: 'main', + translationsFolder, + baseLocale: 'en', + locales: ['en'], + targetLocales: [], + translationConfig: undefined, + tags: [], + readOnly: false, + config: { translationsFolder }, + }; +} + /** * Regression: a folder that could not be read (malformed tracker_meta.json) was silently skipped * while enumerating keys, so moveFolder deleted the source tree with that folder's entries never copied. @@ -34,7 +49,10 @@ describe('moveFolder with an unreadable folder (real fs)', () => { writeFolder(JSON.stringify({ ok: { en: { checksum: 'x' } } }), 'apps', 'good'); writeFolder('{ not json', 'apps', 'bad'); - const result = await moveFolder(root, { sourceFolderPath: 'apps', destinationFolderPath: 'shared' }); + const result = await moveFolder(collection(root), { + sourceFolderPath: 'apps', + destinationFolderPath: 'shared', + }); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toContain('apps.bad'); @@ -48,7 +66,10 @@ describe('moveFolder with an unreadable folder (real fs)', () => { it('does not delete a source folder whose only resources are unreadable', async () => { writeFolder('{ not json', 'apps', 'bad'); - const result = await moveFolder(root, { sourceFolderPath: 'apps.bad', destinationFolderPath: 'shared' }); + const result = await moveFolder(collection(root), { + sourceFolderPath: 'apps.bad', + destinationFolderPath: 'shared', + }); expect(result.errors).toHaveLength(1); expect(result.foldersDeleted).toBe(0); @@ -65,9 +86,9 @@ describe('moveFolder with a destination collision (real fs)', () => { beforeEach(async () => { root = mkdtempSync(join(tmpdir(), 'move-folder-collision-')); - await addResource(root, { key: 'src.a', baseValue: 'Source A' }); - await addResource(root, { key: 'src.b', baseValue: 'Source B' }); - await addResource(root, { key: 'dst.src.a', baseValue: 'Existing A' }); + await addResource(collection(root), { key: 'src.a', baseValue: 'Source A' }); + await addResource(collection(root), { key: 'src.b', baseValue: 'Source B' }); + await addResource(collection(root), { key: 'dst.src.a', baseValue: 'Existing A' }); }); afterEach(() => { @@ -75,7 +96,11 @@ describe('moveFolder with a destination collision (real fs)', () => { }); it('moves the other resources, keeps the source folder with the skipped one, and reports matching mutations', async () => { - const result = await moveFolder(root, { sourceFolderPath: 'src', destinationFolderPath: 'dst', override: false }); + const result = await moveFolder(collection(root), { + sourceFolderPath: 'src', + destinationFolderPath: 'dst', + override: false, + }); expect(result.movedCount).toBe(1); expect(result.foldersDeleted).toBe(0); diff --git a/libs/core/src/lib/folder/move-folder.spec.ts b/libs/core/src/lib/folder/move-folder.spec.ts index 5946134a..716000ac 100644 --- a/libs/core/src/lib/folder/move-folder.spec.ts +++ b/libs/core/src/lib/folder/move-folder.spec.ts @@ -1,8 +1,28 @@ +import * as fs from 'node:fs'; import { join, resolve } from 'node:path'; -import { moveFolder } from './move-folder'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; -import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; -import * as fs from 'node:fs'; +import type { Collection } from '../config/open-collection'; +import { + FolderMoveIntoDescendantError, + FolderNotFoundError, + InvalidFolderPathError, +} from '../errors/lingo-tracker-error'; +import { moveFolder } from './move-folder'; + +function collection(translationsFolder: string, name = 'main'): Collection { + return { + name, + translationsFolder, + baseLocale: 'en', + locales: ['en', 'fr'], + targetLocales: ['fr'], + translationConfig: undefined, + tags: [], + readOnly: false, + config: { translationsFolder }, + }; +} // Mock node:fs vi.mock('node:fs', () => { @@ -147,7 +167,7 @@ describe('Move Folder', () => { }), ); - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'apps.common.buttons', destinationFolderPath: 'apps.shared', }); @@ -210,7 +230,7 @@ describe('Move Folder', () => { }), ); - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'apps.common.buttons', destinationFolderPath: 'apps.shared', }); @@ -243,7 +263,7 @@ describe('Move Folder', () => { mockDirectories.add(join(testDir, 'apps')); mockDirectories.add(emptyFolder); - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'apps.empty', destinationFolderPath: 'apps.shared', }); @@ -254,33 +274,29 @@ describe('Move Folder', () => { expect(result.warnings[0]).toContain('No resources found'); }); - it('should return error for non-existent source folder', async () => { - const result = await moveFolder(testDir, { - sourceFolderPath: 'apps.nonexistent', - destinationFolderPath: 'apps.shared', - }); - - expect(result.movedCount).toBe(0); - expect(result.foldersDeleted).toBe(0); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('Source folder not found'); + it('should throw for non-existent source folder', async () => { + await expect( + moveFolder(collection(testDir), { + sourceFolderPath: 'apps.nonexistent', + destinationFolderPath: 'apps.shared', + }), + ).rejects.toThrow(FolderNotFoundError); + expect(mockDirectories.has(join(testDir, 'apps', 'nonexistent'))).toBe(false); }); - it('should return error when source is not a directory', async () => { + it('should throw when source is not a directory', async () => { // Create a file instead of directory const filePath = join(testDir, 'apps', 'notadir'); mockDirectories.add(join(testDir, 'apps')); mockFileSystem.set(filePath, 'some content'); - const result = await moveFolder(testDir, { - sourceFolderPath: 'apps.notadir', - destinationFolderPath: 'apps.shared', - }); - - expect(result.movedCount).toBe(0); - expect(result.foldersDeleted).toBe(0); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('not a directory'); + await expect( + moveFolder(collection(testDir), { + sourceFolderPath: 'apps.notadir', + destinationFolderPath: 'apps.shared', + }), + ).rejects.toThrow(FolderNotFoundError); + expect(mockFileSystem.get(filePath)).toBe('some content'); }); }); @@ -293,15 +309,13 @@ describe('Move Folder', () => { const buttonsFolder = join(commonFolder, 'buttons'); mockDirectories.add(buttonsFolder); - const result = await moveFolder(testDir, { - sourceFolderPath: 'apps.common', - destinationFolderPath: 'apps.common.buttons', - }); - - expect(result.movedCount).toBe(0); - expect(result.foldersDeleted).toBe(0); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('Cannot move folder into its own descendant'); + await expect( + moveFolder(collection(testDir), { + sourceFolderPath: 'apps.common', + destinationFolderPath: 'apps.common.buttons', + }), + ).rejects.toThrow(FolderMoveIntoDescendantError); + expect(mockDirectories.has(commonFolder)).toBe(true); }); it('should prevent moving folder into deeply nested descendant', async () => { @@ -309,14 +323,13 @@ describe('Move Folder', () => { mockDirectories.add(join(testDir, 'apps')); mockDirectories.add(commonFolder); - const result = await moveFolder(testDir, { - sourceFolderPath: 'apps.common', - destinationFolderPath: 'apps.common.buttons.nested.deep', - }); - - expect(result.movedCount).toBe(0); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('Cannot move folder into its own descendant'); + await expect( + moveFolder(collection(testDir), { + sourceFolderPath: 'apps.common', + destinationFolderPath: 'apps.common.buttons.nested.deep', + }), + ).rejects.toThrow(FolderMoveIntoDescendantError); + expect(mockDirectories.has(commonFolder)).toBe(true); }); it('should allow moving to sibling folder', async () => { @@ -343,7 +356,7 @@ describe('Move Folder', () => { // Move to sibling: apps.actions // With new default (nestUnderDestination: true), creates apps.actions.buttons.ok - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'apps.buttons', destinationFolderPath: 'apps.actions', }); @@ -370,7 +383,7 @@ describe('Move Folder', () => { mockDirectories.add(join(testDir, 'apps', 'common')); mockDirectories.add(buttonsFolder); - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'apps.common.buttons', destinationFolderPath: 'apps.common.buttons', }); @@ -381,6 +394,23 @@ describe('Move Folder', () => { expect(result.warnings[0]).toContain('Source and destination are the same'); }); + it('should treat a destination collection with the same translations folder as the same collection', async () => { + const buttonsFolder = join(testDir, 'apps', 'buttons'); + mockDirectories.add(join(testDir, 'apps')); + mockDirectories.add(buttonsFolder); + + const result = await moveFolder(collection(testDir), { + sourceFolderPath: 'apps.buttons', + destinationFolderPath: 'apps.buttons', + destinationCollection: collection(testDir, 'alias'), + }); + + expect(result.movedCount).toBe(0); + expect(result.foldersDeleted).toBe(0); + expect(result.warnings).toEqual(['Source and destination are the same. No move performed.']); + expect(mockDirectories.has(buttonsFolder)).toBe(true); + }); + it('should return warning when moving folder to its own parent with nestUnderDestination', async () => { // Setup source folder: apps.common.buttons with one resource const buttonsFolder = join(testDir, 'apps', 'common', 'buttons'); @@ -406,7 +436,7 @@ describe('Move Folder', () => { // Move apps.common.buttons to apps.common (its parent) // With nestUnderDestination=true, this would result in apps.common.buttons.ok -> apps.common.buttons.ok (no-op) - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'apps.common.buttons', destinationFolderPath: 'apps.common', }); @@ -447,11 +477,11 @@ describe('Move Folder', () => { const collectionBFolder = join(testDir, 'collectionB'); mockDirectories.add(collectionBFolder); - const result = await moveFolder(collectionAFolder, { + const result = await moveFolder(collection(collectionAFolder, 'collectionA'), { sourceFolderPath: 'apps.buttons', destinationFolderPath: 'shared.buttons', nestUnderDestination: false, - destinationTranslationsFolder: collectionBFolder, + destinationCollection: collection(collectionBFolder, 'collectionB'), }); expect(result.movedCount).toBe(1); @@ -499,11 +529,11 @@ describe('Move Folder', () => { mockDirectories.add(collectionBFolder); // Same path but different collection should work - const result = await moveFolder(collectionAFolder, { + const result = await moveFolder(collection(collectionAFolder, 'collectionA'), { sourceFolderPath: 'apps.buttons', destinationFolderPath: 'apps.buttons', nestUnderDestination: false, - destinationTranslationsFolder: collectionBFolder, + destinationCollection: collection(collectionBFolder, 'collectionB'), }); expect(result.movedCount).toBe(1); @@ -519,14 +549,12 @@ describe('Move Folder', () => { describe('Validation', () => { it('should reject invalid source folder path segments', async () => { - const result = await moveFolder(testDir, { - sourceFolderPath: 'apps.invalid@char.buttons', - destinationFolderPath: 'apps.shared', - }); - - expect(result.movedCount).toBe(0); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('Invalid source folder path segment'); + await expect( + moveFolder(collection(testDir), { + sourceFolderPath: 'apps.invalid@char.buttons', + destinationFolderPath: 'apps.shared', + }), + ).rejects.toThrow(InvalidFolderPathError); }); it('should reject invalid destination folder path segments', async () => { @@ -534,14 +562,12 @@ describe('Move Folder', () => { mockDirectories.add(join(testDir, 'apps')); mockDirectories.add(buttonsFolder); - const result = await moveFolder(testDir, { - sourceFolderPath: 'apps.buttons', - destinationFolderPath: 'apps.invalid@char', - }); - - expect(result.movedCount).toBe(0); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('Invalid destination folder path segment'); + await expect( + moveFolder(collection(testDir), { + sourceFolderPath: 'apps.buttons', + destinationFolderPath: 'apps.invalid@char', + }), + ).rejects.toThrow(InvalidFolderPathError); }); }); @@ -569,7 +595,7 @@ describe('Move Folder', () => { ); // Move testdata into common (both are depth 1) - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'testdata', destinationFolderPath: 'common', nestUnderDestination: true, @@ -613,7 +639,7 @@ describe('Move Folder', () => { ); // Move data.testdata into common (depth 2 -> depth 1) - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'data.testdata', destinationFolderPath: 'common', nestUnderDestination: true, @@ -657,7 +683,7 @@ describe('Move Folder', () => { ); // Move common.testdata to root (empty destination) - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'common.testdata', destinationFolderPath: '', nestUnderDestination: true, @@ -721,7 +747,7 @@ describe('Move Folder', () => { ); // Move testdata into common (should merge with existing common.testdata) - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'testdata', destinationFolderPath: 'common', nestUnderDestination: true, @@ -766,7 +792,7 @@ describe('Move Folder', () => { ); // Move testdata into common - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'testdata', destinationFolderPath: 'common', nestUnderDestination: true, @@ -810,7 +836,7 @@ describe('Move Folder', () => { ); // Move testdata into common with legacy behavior (both are depth 1, should RENAME) - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'testdata', destinationFolderPath: 'common', nestUnderDestination: false, @@ -854,7 +880,7 @@ describe('Move Folder', () => { ); // Move data.testdata into common (depth 2 -> depth 1, should NEST) - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'data.testdata', destinationFolderPath: 'common', nestUnderDestination: false, @@ -901,7 +927,7 @@ describe('Move Folder', () => { }), ); - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'apps.common.buttons', destinationFolderPath: 'apps.shared', nestUnderDestination: false, @@ -946,7 +972,7 @@ describe('Move Folder', () => { ); // Move testdata into common WITHOUT specifying nestUnderDestination - const result = await moveFolder(testDir, { + const result = await moveFolder(collection(testDir), { sourceFolderPath: 'testdata', destinationFolderPath: 'common', // nestUnderDestination not specified, should default to true diff --git a/libs/core/src/lib/folder/move-folder.ts b/libs/core/src/lib/folder/move-folder.ts index 6363e6c1..cbd3f5e6 100644 --- a/libs/core/src/lib/folder/move-folder.ts +++ b/libs/core/src/lib/folder/move-folder.ts @@ -2,9 +2,14 @@ import { existsSync, statSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { walkFolders } from '../normalize/iterative-folder-walker'; import { isValidSegment } from '@simoncodes-ca/domain'; -import { InvalidFolderPathError } from '../errors/lingo-tracker-error'; +import type { Collection } from '../config/open-collection'; +import { + FolderMoveIntoDescendantError, + FolderNotFoundError, + InvalidFolderPathError, +} from '../errors/lingo-tracker-error'; import { moveResource, type MoveResourceResult } from '../../resource/move-resource'; -import { deleteFolder, type DeleteFolderResult } from './delete-folder'; +import { deleteFolder } from './delete-folder'; import { openResourceFolder } from '../resource/resource-folder'; import type { ResourceMutation } from '../resource/resource-mutation'; @@ -15,8 +20,8 @@ export interface MoveFolderParams { readonly destinationFolderPath: string; /** If true, override existing resources at destination */ readonly override?: boolean; - /** Optional destination translations folder for cross-collection moves */ - readonly destinationTranslationsFolder?: string; + /** Destination collection for a cross-collection move. Default: the source collection. */ + readonly destinationCollection?: Collection; /** * When true, the source folder is nested under the destination as a child folder. * When false, uses depth-based rename/nest heuristic (legacy behavior). @@ -48,37 +53,30 @@ export interface MoveFolderResult { * 4. Moves each resource to the corresponding destination path * 5. Deletes the source folder once every resource in it was moved (otherwise keeps it and warns) * - * @param translationsFolder - Root translations folder path + * Bad input throws; failures of individual resources are reported in the result. + * + * @param collection - The collection the source folder is in * @param params - Folder move parameters * @returns Object containing move statistics and any warnings/errors + * @throws {InvalidFolderPathError} A folder path has a malformed segment. + * @throws {FolderMoveIntoDescendantError} The destination is inside the source folder (same collection). + * @throws {FolderNotFoundError} The source folder does not exist. * * @example * ```typescript * // Move a folder with all its contents - * const result = moveFolder('/app/translations', { + * const result = await moveFolder(collection, { * sourceFolderPath: 'apps.common.buttons', * destinationFolderPath: 'apps.shared' * }); * // Result: { movedCount: 5, foldersDeleted: 1, warnings: [], errors: [] } * // Resources like 'apps.common.buttons.ok' become 'apps.shared.buttons.ok' - * - * // Prevent circular dependency - * const result = moveFolder('/app/translations', { - * sourceFolderPath: 'apps.common', - * destinationFolderPath: 'apps.common.nested' - * }); - * // Result: { movedCount: 0, foldersDeleted: 0, warnings: [], errors: ['Cannot move...'] } * ``` */ -export async function moveFolder(translationsFolder: string, params: MoveFolderParams): Promise { - const { - sourceFolderPath, - destinationFolderPath, - override = false, - destinationTranslationsFolder, - nestUnderDestination = true, - } = params; - const targetFolder = destinationTranslationsFolder || translationsFolder; +export async function moveFolder(collection: Collection, params: MoveFolderParams): Promise { + const { sourceFolderPath, destinationFolderPath, override = false, nestUnderDestination = true } = params; + const destinationCollection = params.destinationCollection ?? collection; + const sameCollection = destinationCollection.translationsFolder === collection.translationsFolder; const result: MoveFolderResult = { movedCount: 0, @@ -88,177 +86,154 @@ export async function moveFolder(translationsFolder: string, params: MoveFolderP mutations: [], }; - try { - // Validate folder path segments and split for later use - const sourceFolderSegments = sourceFolderPath.split('.'); - const destinationFolderSegments = destinationFolderPath.split('.'); + // Validate folder path segments and split for later use + const sourceFolderSegments = sourceFolderPath.split('.'); + const destinationFolderSegments = destinationFolderPath.split('.'); - for (const segment of sourceFolderSegments) { - if (!isValidSegment(segment)) { - throw new InvalidFolderPathError('source folder path', segment); - } + for (const segment of sourceFolderSegments) { + if (!isValidSegment(segment)) { + throw new InvalidFolderPathError('source folder path', segment); } + } - // Skip validation if destination is empty (root-level move) - if (destinationFolderPath !== '') { - for (const segment of destinationFolderSegments) { - if (!isValidSegment(segment)) { - throw new InvalidFolderPathError('destination folder path', segment); - } + // Skip validation if destination is empty (root-level move) + if (destinationFolderPath !== '') { + for (const segment of destinationFolderSegments) { + if (!isValidSegment(segment)) { + throw new InvalidFolderPathError('destination folder path', segment); } } + } - // Check for same-folder move (no-op) - if (sourceFolderPath === destinationFolderPath && !destinationTranslationsFolder) { - result.warnings.push('Source and destination are the same. No move performed.'); - return result; - } - - // Check for circular dependency: prevent moving folder into its own descendant - // If destination starts with source + '.', it's a descendant - if (destinationFolderPath.startsWith(`${sourceFolderPath}.`) && !destinationTranslationsFolder) { - result.errors.push('Cannot move folder into its own descendant'); - return result; - } - - // When nesting, check if destination is the source's parent (would be a no-op) - if (nestUnderDestination && !destinationTranslationsFolder) { - const sourceParentPath = sourceFolderSegments.slice(0, -1).join('.'); - if (sourceParentPath === destinationFolderPath) { - result.warnings.push('Folder is already at this location. No move performed.'); - return result; - } - } + // Check for same-folder move (no-op) + if (sourceFolderPath === destinationFolderPath && sameCollection) { + result.warnings.push('Source and destination are the same. No move performed.'); + return result; + } - // Convert dot-delimited paths to filesystem paths - const sourceFolderFsPath = sourceFolderSegments.length - ? join(translationsFolder, ...sourceFolderSegments) - : translationsFolder; - const absoluteSourcePath = resolve(sourceFolderFsPath); + // Prevent moving a folder into its own descendant + if (destinationFolderPath.startsWith(`${sourceFolderPath}.`) && sameCollection) { + throw new FolderMoveIntoDescendantError(sourceFolderPath, destinationFolderPath); + } - // Check if source folder exists - if (!existsSync(absoluteSourcePath)) { - result.errors.push(`Source folder not found: ${sourceFolderPath}`); + // When nesting, check if destination is the source's parent (would be a no-op) + if (nestUnderDestination && sameCollection) { + const sourceParentPath = sourceFolderSegments.slice(0, -1).join('.'); + if (sourceParentPath === destinationFolderPath) { + result.warnings.push('Folder is already at this location. No move performed.'); return result; } + } - // Verify it's a directory - const stats = statSync(absoluteSourcePath); - if (!stats.isDirectory()) { - result.errors.push(`Source path is not a directory: ${sourceFolderPath}`); - return result; - } + const absoluteSourcePath = resolve(join(collection.translationsFolder, ...sourceFolderSegments)); + if (!existsSync(absoluteSourcePath) || !statSync(absoluteSourcePath).isDirectory()) { + throw new FolderNotFoundError(sourceFolderPath); + } - // Extract all resource keys from the source folder tree - const { keys: resourceKeys, errors: enumerationErrors } = extractAllResourceKeysFromFolder( - absoluteSourcePath, - sourceFolderPath, - ); + // Extract all resource keys from the source folder tree + const { keys: resourceKeys, errors: enumerationErrors } = extractAllResourceKeysFromFolder( + absoluteSourcePath, + sourceFolderPath, + ); - // An unreadable folder would be deleted without its entries being copied; stop before any move/delete. - if (enumerationErrors.length > 0) { - result.errors.push(...enumerationErrors); - return result; - } + // An unreadable folder would be deleted without its entries being copied; stop before any move/delete. + if (enumerationErrors.length > 0) { + result.errors.push(...enumerationErrors); + return result; + } - if (resourceKeys.length === 0) { - result.warnings.push('No resources found in source folder. Nothing to move.'); - // Still delete the empty folder - const deleteResult = deleteFolder(translationsFolder, { folderPath: sourceFolderPath }); - result.mutations.push(...deleteResult.mutations); - if (deleteResult.deleted) { - result.foldersDeleted++; - } else if (deleteResult.error) { - result.errors.push(`Failed to delete empty source folder: ${deleteResult.error}`); - } - return result; + if (resourceKeys.length === 0) { + result.warnings.push('No resources found in source folder. Nothing to move.'); + // Still delete the empty folder + try { + result.mutations.push(...deleteFolder(collection, { folderPath: sourceFolderPath }).mutations); + result.foldersDeleted++; + } catch (error) { + result.errors.push(`Failed to delete empty source folder: ${errorMessage(error)}`); } + return result; + } - // Move each resource - // Calculate depth once for all resources - const sourceDepth = sourceFolderSegments.length; - const destDepth = destinationFolderSegments.length; - const lastSourceSegment = sourceFolderSegments[sourceFolderSegments.length - 1]; - // Keys that stayed in the source (collision without override, or an error); the source folder must be kept. - const keptKeys: string[] = []; + // Calculate depth once for all resources + const sourceDepth = sourceFolderSegments.length; + const destDepth = destinationFolderSegments.length; + const lastSourceSegment = sourceFolderSegments[sourceFolderSegments.length - 1]; + // Keys that stayed in the source (collision without override, or an error); the source folder must be kept. + const keptKeys: string[] = []; - for (const sourceKey of resourceKeys) { - // Calculate destination key by replacing source folder prefix with destination folder prefix - // - // When nestUnderDestination is true (default): - // - ALWAYS append source folder name to destination - // - testdata.foo.bar + common => common.testdata.foo.bar - // - data.testdata.foo + common => common.testdata.foo - // - testdata.foo + "" (root) => testdata.foo + for (const sourceKey of resourceKeys) { + // Calculate destination key by replacing source folder prefix with destination folder prefix + // + // When nestUnderDestination is true (default): + // - ALWAYS append source folder name to destination + // - testdata.foo.bar + common => common.testdata.foo.bar + // - data.testdata.foo + common => common.testdata.foo + // - testdata.foo + "" (root) => testdata.foo - // Extract the relative suffix after the source folder - const suffix = sourceKey.slice(sourceFolderPath.length); - // If sourceKey === sourceFolderPath exactly, suffix will be empty - // Otherwise suffix will start with '.' + // Extract the relative suffix after the source folder + const suffix = sourceKey.slice(sourceFolderPath.length); + // If sourceKey === sourceFolderPath exactly, suffix will be empty + // Otherwise suffix will start with '.' - let destinationKey: string; - if (nestUnderDestination) { - // always nest the source folder under destination - const sourceFolderName = lastSourceSegment; - if (destinationFolderPath) { - destinationKey = suffix - ? `${destinationFolderPath}.${sourceFolderName}${suffix}` - : `${destinationFolderPath}.${sourceFolderName}`; - } else { - // Root-level move: just use source folder name + suffix - destinationKey = suffix ? `${sourceFolderName}${suffix}` : sourceFolderName; - } + let destinationKey: string; + if (nestUnderDestination) { + // always nest the source folder under destination + const sourceFolderName = lastSourceSegment; + if (destinationFolderPath) { + destinationKey = suffix + ? `${destinationFolderPath}.${sourceFolderName}${suffix}` + : `${destinationFolderPath}.${sourceFolderName}`; } else { - // depth-based RENAME/NEST logic - if (destDepth === sourceDepth) { - // Same depth: RENAME - replace entire source path with destination - // apps.buttons.ok -> apps.actions becomes apps.actions.ok - destinationKey = suffix ? `${destinationFolderPath}${suffix}` : destinationFolderPath; - } else { - // Different depth: NEST - append last segment of source to destination - // apps.common.buttons.ok -> apps.shared becomes apps.shared.buttons.ok - destinationKey = suffix - ? `${destinationFolderPath}.${lastSourceSegment}${suffix}` - : `${destinationFolderPath}.${lastSourceSegment}`; - } + // Root-level move: just use source folder name + suffix + destinationKey = suffix ? `${sourceFolderName}${suffix}` : sourceFolderName; } + } else if (destDepth === sourceDepth) { + // Same depth: RENAME - replace entire source path with destination + // apps.buttons.ok -> apps.actions becomes apps.actions.ok + destinationKey = suffix ? `${destinationFolderPath}${suffix}` : destinationFolderPath; + } else { + // Different depth: NEST - append last segment of source to destination + // apps.common.buttons.ok -> apps.shared becomes apps.shared.buttons.ok + destinationKey = suffix + ? `${destinationFolderPath}.${lastSourceSegment}${suffix}` + : `${destinationFolderPath}.${lastSourceSegment}`; + } - const moveResult: MoveResourceResult = await moveResource(translationsFolder, { - source: sourceKey, - destination: destinationKey, - override, - destinationTranslationsFolder: targetFolder, - }); + const moveResult: MoveResourceResult = await moveResource(collection, { + source: sourceKey, + destination: destinationKey, + override, + destinationCollection, + }); - result.movedCount += moveResult.movedCount; - result.warnings.push(...moveResult.warnings); - result.errors.push(...moveResult.errors); - result.mutations.push(...moveResult.mutations); - if (moveResult.movedCount === 0) { - keptKeys.push(sourceKey); - } + result.movedCount += moveResult.movedCount; + result.warnings.push(...moveResult.warnings); + result.errors.push(...moveResult.errors); + result.mutations.push(...moveResult.mutations); + if (moveResult.movedCount === 0) { + keptKeys.push(sourceKey); } + } - if (keptKeys.length > 0) { - result.warnings.push(`Source folder kept; resources not moved: ${keptKeys.join(', ')}`); - } + if (keptKeys.length > 0) { + result.warnings.push(`Source folder kept; resources not moved: ${keptKeys.join(', ')}`); + } - // Only delete the source folder when every resource in it was moved - if (keptKeys.length === 0 && result.errors.length === 0) { - const deleteResult: DeleteFolderResult = deleteFolder(translationsFolder, { folderPath: sourceFolderPath }); - result.mutations.push(...deleteResult.mutations); - if (deleteResult.deleted) { - result.foldersDeleted++; - } else if (deleteResult.error) { - result.warnings.push(`Resources moved but failed to delete source folder: ${deleteResult.error}`); - } + // Only delete the source folder when every resource in it was moved + if (keptKeys.length === 0 && result.errors.length === 0) { + try { + result.mutations.push(...deleteFolder(collection, { folderPath: sourceFolderPath }).mutations); + result.foldersDeleted++; + } catch (error) { + result.warnings.push(`Resources moved but failed to delete source folder: ${errorMessage(error)}`); } - - return result; - } catch (error) { - result.errors.push(error instanceof Error ? error.message : String(error)); - return result; } + + return result; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } /** diff --git a/libs/core/src/lib/resource/load-full-resource-tree.spec.ts b/libs/core/src/lib/resource/load-full-resource-tree.spec.ts deleted file mode 100644 index ed4052f4..00000000 --- a/libs/core/src/lib/resource/load-full-resource-tree.spec.ts +++ /dev/null @@ -1,762 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { loadFullResourceTree } from './load-full-resource-tree'; -import * as fs from 'node:fs'; - -const mockFs = vi.hoisted(() => { - return new Map([ - // Root directory and files - ['/test/translations', 'directory'], - [ - '/test/translations/resource_entries.json', - JSON.stringify({ - rootKey: { - source: 'Root Value', - es: 'Valor Raíz', - }, - }), - ], - [ - '/test/translations/tracker_meta.json', - JSON.stringify({ - rootKey: { - en: { checksum: 'root123' }, - es: { - status: 'translated', - checksum: 'root456', - baseChecksum: 'root123', - }, - }, - }), - ], - - // Level 1: apps directory - ['/test/translations/apps', 'directory'], - [ - '/test/translations/apps/resource_entries.json', - JSON.stringify({ - appsKey: { - source: 'Apps Value', - es: 'Valor Apps', - }, - }), - ], - [ - '/test/translations/apps/tracker_meta.json', - JSON.stringify({ - appsKey: { - en: { checksum: 'apps123' }, - es: { - status: 'verified', - checksum: 'apps456', - baseChecksum: 'apps123', - }, - }, - }), - ], - - // Level 2: apps/common directory - ['/test/translations/apps/common', 'directory'], - [ - '/test/translations/apps/common/resource_entries.json', - JSON.stringify({ - commonKey: { - source: 'Common Value', - es: 'Valor Común', - }, - }), - ], - [ - '/test/translations/apps/common/tracker_meta.json', - JSON.stringify({ - commonKey: { - en: { checksum: 'common123' }, - es: { - status: 'translated', - checksum: 'common456', - baseChecksum: 'common123', - }, - }, - }), - ], - - // Level 3: apps/common/buttons directory - ['/test/translations/apps/common/buttons', 'directory'], - [ - '/test/translations/apps/common/buttons/resource_entries.json', - JSON.stringify({ - ok: { - source: 'OK', - es: 'Aceptar', - }, - }), - ], - [ - '/test/translations/apps/common/buttons/tracker_meta.json', - JSON.stringify({ - ok: { - en: { checksum: 'ok123' }, - es: { status: 'verified', checksum: 'ok456', baseChecksum: 'ok123' }, - }, - }), - ], - - // Level 4: apps/common/buttons/actions directory - ['/test/translations/apps/common/buttons/actions', 'directory'], - [ - '/test/translations/apps/common/buttons/actions/resource_entries.json', - JSON.stringify({ - submit: { - source: 'Submit', - es: 'Enviar', - }, - }), - ], - [ - '/test/translations/apps/common/buttons/actions/tracker_meta.json', - JSON.stringify({ - submit: { - en: { checksum: 'submit123' }, - es: { - status: 'translated', - checksum: 'submit456', - baseChecksum: 'submit123', - }, - }, - }), - ], - - // Level 5: apps/common/buttons/actions/primary directory - ['/test/translations/apps/common/buttons/actions/primary', 'directory'], - [ - '/test/translations/apps/common/buttons/actions/primary/resource_entries.json', - JSON.stringify({ - save: { - source: 'Save', - es: 'Guardar', - }, - }), - ], - [ - '/test/translations/apps/common/buttons/actions/primary/tracker_meta.json', - JSON.stringify({ - save: { - en: { checksum: 'save123' }, - es: { - status: 'verified', - checksum: 'save456', - baseChecksum: 'save123', - }, - }, - }), - ], - - // Level 6: apps/common/buttons/actions/primary/forms directory - ['/test/translations/apps/common/buttons/actions/primary/forms', 'directory'], - [ - '/test/translations/apps/common/buttons/actions/primary/forms/resource_entries.json', - JSON.stringify({ - create: { - source: 'Create', - es: 'Crear', - }, - }), - ], - [ - '/test/translations/apps/common/buttons/actions/primary/forms/tracker_meta.json', - JSON.stringify({ - create: { - en: { checksum: 'create123' }, - es: { - status: 'translated', - checksum: 'create456', - baseChecksum: 'create123', - }, - }, - }), - ], - ]); -}); - -const createMockFileSystem = () => { - return new Map([ - // Root directory and files - ['/test/translations', 'directory'], - [ - '/test/translations/resource_entries.json', - JSON.stringify({ - rootKey: { - source: 'Root Value', - es: 'Valor Raíz', - }, - }), - ], - [ - '/test/translations/tracker_meta.json', - JSON.stringify({ - rootKey: { - en: { checksum: 'root123' }, - es: { - status: 'translated', - checksum: 'root456', - baseChecksum: 'root123', - }, - }, - }), - ], - - // Level 1 - ['/test/translations/apps', 'directory'], - [ - '/test/translations/apps/resource_entries.json', - JSON.stringify({ - appsKey: { - source: 'Apps Value', - es: 'Valor Apps', - }, - }), - ], - [ - '/test/translations/apps/tracker_meta.json', - JSON.stringify({ - appsKey: { - en: { checksum: 'apps123' }, - es: { - status: 'verified', - checksum: 'apps456', - baseChecksum: 'apps123', - }, - }, - }), - ], - - // Level 2 - ['/test/translations/apps/common', 'directory'], - [ - '/test/translations/apps/common/resource_entries.json', - JSON.stringify({ - commonKey: { - source: 'Common Value', - es: 'Valor Común', - }, - }), - ], - [ - '/test/translations/apps/common/tracker_meta.json', - JSON.stringify({ - commonKey: { - en: { checksum: 'common123' }, - es: { - status: 'translated', - checksum: 'common456', - baseChecksum: 'common123', - }, - }, - }), - ], - - // Level 3 - ['/test/translations/apps/common/buttons', 'directory'], - [ - '/test/translations/apps/common/buttons/resource_entries.json', - JSON.stringify({ - ok: { - source: 'OK', - es: 'Aceptar', - }, - }), - ], - [ - '/test/translations/apps/common/buttons/tracker_meta.json', - JSON.stringify({ - ok: { - en: { checksum: 'ok123' }, - es: { status: 'verified', checksum: 'ok456', baseChecksum: 'ok123' }, - }, - }), - ], - - // Level 4 - ['/test/translations/apps/common/buttons/actions', 'directory'], - [ - '/test/translations/apps/common/buttons/actions/resource_entries.json', - JSON.stringify({ - submit: { - source: 'Submit', - es: 'Enviar', - }, - }), - ], - [ - '/test/translations/apps/common/buttons/actions/tracker_meta.json', - JSON.stringify({ - submit: { - en: { checksum: 'submit123' }, - es: { - status: 'translated', - checksum: 'submit456', - baseChecksum: 'submit123', - }, - }, - }), - ], - - // Level 5 - ['/test/translations/apps/common/buttons/actions/primary', 'directory'], - [ - '/test/translations/apps/common/buttons/actions/primary/resource_entries.json', - JSON.stringify({ - save: { - source: 'Save', - es: 'Guardar', - }, - }), - ], - [ - '/test/translations/apps/common/buttons/actions/primary/tracker_meta.json', - JSON.stringify({ - save: { - en: { checksum: 'save123' }, - es: { - status: 'verified', - checksum: 'save456', - baseChecksum: 'save123', - }, - }, - }), - ], - - // Level 6 - ['/test/translations/apps/common/buttons/actions/primary/forms', 'directory'], - [ - '/test/translations/apps/common/buttons/actions/primary/forms/resource_entries.json', - JSON.stringify({ - create: { - source: 'Create', - es: 'Crear', - }, - }), - ], - [ - '/test/translations/apps/common/buttons/actions/primary/forms/tracker_meta.json', - JSON.stringify({ - create: { - en: { checksum: 'create123' }, - es: { - status: 'translated', - checksum: 'create456', - baseChecksum: 'create123', - }, - }, - }), - ], - ]); -}; - -/** - * The fixture map in this suite is keyed on POSIX paths, while the code under test resolves to - * platform-native form. Stripping a drive letter and backslashes is the identity on POSIX. - */ -const toFixtureKey = vi.hoisted( - () => - (p: { toString(): string }): string => - p - .toString() - .replace(/^[A-Za-z]:/, '') - .replace(/\\/g, '/'), -); - -vi.mock('node:fs', () => ({ - existsSync: vi.fn((filePath: fs.PathLike) => { - return mockFs.has(toFixtureKey(filePath)); - }), - readFileSync: vi.fn((filePath: fs.PathLike) => { - const content = mockFs.get(toFixtureKey(filePath)); - if (content === 'directory' || content === undefined) { - throw new Error(`ENOENT: no such file or directory, open '${filePath}'`); - } - return content; - }), - realpathSync: vi.fn((filePath: fs.PathLike) => { - return filePath.toString(); - }), - readdirSync: vi.fn((dirPath: fs.PathLike, _options?: any) => { - const dirPathStr = toFixtureKey(dirPath); - const entries: fs.Dirent[] = []; - - for (const [fsPath, type] of mockFs.entries()) { - const pathParts = fsPath.split('/').filter(Boolean); - const dirParts = dirPathStr.split('/').filter(Boolean); - - // Check if this is a direct child of dirPath - if (pathParts.length === dirParts.length + 1 && fsPath.startsWith(`${dirPathStr}/`)) { - const name = pathParts[pathParts.length - 1]; - const isDirectory = type === 'directory'; - - entries.push({ - name, - isDirectory: () => isDirectory, - isFile: () => !isDirectory, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isSymbolicLink: () => false, - isFIFO: () => false, - isSocket: () => false, - parentPath: dirPathStr, - path: dirPathStr, - } as fs.Dirent); - } - } - - return entries; - }), -})); - -describe('loadFullResourceTree', () => { - const translationsFolder = '/test/translations'; - - beforeEach(() => { - // Reset the mock filesystem to initial state - mockFs.clear(); - const initialFs = createMockFileSystem(); - for (const [key, value] of initialFs.entries()) { - mockFs.set(key, value); - } - }); - - describe('single level loading', () => { - it('should load tree with 1 level of folders', () => { - // Create simple filesystem with just root and one level - mockFs.clear(); - mockFs.set('/test/simple', 'directory'); - mockFs.set( - '/test/simple/resource_entries.json', - JSON.stringify({ - root: { source: 'Root', es: 'Raíz' }, - }), - ); - mockFs.set( - '/test/simple/tracker_meta.json', - JSON.stringify({ - root: { - en: { checksum: 'r1' }, - es: { status: 'translated', checksum: 'r2', baseChecksum: 'r1' }, - }, - }), - ); - mockFs.set('/test/simple/level1', 'directory'); - mockFs.set( - '/test/simple/level1/resource_entries.json', - JSON.stringify({ - child: { source: 'Child', es: 'Niño' }, - }), - ); - mockFs.set( - '/test/simple/level1/tracker_meta.json', - JSON.stringify({ - child: { - en: { checksum: 'c1' }, - es: { status: 'verified', checksum: 'c2', baseChecksum: 'c1' }, - }, - }), - ); - - const result = loadFullResourceTree({ - translationsFolder: '/test/simple', - cwd: '/', - }); - - // Root should have resources - expect(result.folderPathSegments).toEqual([]); - expect(result.resources).toHaveLength(1); - expect(result.resources[0].key).toBe('root'); - - // Should have one child folder, fully loaded - expect(result.children).toHaveLength(1); - expect(result.children[0].name).toBe('level1'); - expect(result.children[0].loaded).toBe(true); - expect(result.children[0].tree).toBeDefined(); - - // Child folder should have resources - const childTree = result.children[0].tree; - expect(childTree).toBeDefined(); - if (!childTree) return; - expect(childTree.resources).toHaveLength(1); - expect(childTree.resources[0].key).toBe('child'); - expect(childTree.children).toHaveLength(0); - }); - }); - - describe('deep nesting', () => { - it('should load tree with 6+ levels of deep nesting', () => { - const result = loadFullResourceTree({ - translationsFolder, - cwd: '/', - }); - - // Verify root level - expect(result.folderPathSegments).toEqual([]); - expect(result.resources).toHaveLength(1); - expect(result.resources[0].key).toBe('rootKey'); - - // Level 1: apps - expect(result.children).toHaveLength(1); - expect(result.children[0].name).toBe('apps'); - expect(result.children[0].loaded).toBe(true); - const level1 = result.children[0].tree; - expect(level1).toBeDefined(); - if (!level1) return; - expect(level1.resources[0].key).toBe('appsKey'); - - // Level 2: apps/common - expect(level1.children).toHaveLength(1); - expect(level1.children[0].name).toBe('common'); - expect(level1.children[0].loaded).toBe(true); - const level2 = level1.children[0].tree; - expect(level2).toBeDefined(); - if (!level2) return; - expect(level2.resources[0].key).toBe('commonKey'); - - // Level 3: apps/common/buttons - expect(level2.children).toHaveLength(1); - expect(level2.children[0].name).toBe('buttons'); - expect(level2.children[0].loaded).toBe(true); - const level3 = level2.children[0].tree; - expect(level3).toBeDefined(); - if (!level3) return; - expect(level3.resources[0].key).toBe('ok'); - - // Level 4: apps/common/buttons/actions - expect(level3.children).toHaveLength(1); - expect(level3.children[0].name).toBe('actions'); - expect(level3.children[0].loaded).toBe(true); - const level4 = level3.children[0].tree; - expect(level4).toBeDefined(); - if (!level4) return; - expect(level4.resources[0].key).toBe('submit'); - - // Level 5: apps/common/buttons/actions/primary - expect(level4.children).toHaveLength(1); - expect(level4.children[0].name).toBe('primary'); - expect(level4.children[0].loaded).toBe(true); - const level5 = level4.children[0].tree; - expect(level5).toBeDefined(); - if (!level5) return; - expect(level5.resources[0].key).toBe('save'); - - // Level 6: apps/common/buttons/actions/primary/forms - expect(level5.children).toHaveLength(1); - expect(level5.children[0].name).toBe('forms'); - expect(level5.children[0].loaded).toBe(true); - const level6 = level5.children[0].tree; - expect(level6).toBeDefined(); - if (!level6) return; - expect(level6.resources[0].key).toBe('create'); - expect(level6.children).toHaveLength(0); - }); - - it('should mark all folders as loaded: true', () => { - const result = loadFullResourceTree({ - translationsFolder, - cwd: '/', - }); - - // Helper function to recursively check all children - const checkAllLoaded = (node: any): void => { - for (const child of node.children) { - expect(child.loaded).toBe(true); - expect(child.tree).toBeDefined(); - if (child.tree) { - checkAllLoaded(child.tree); - } - } - }; - - checkAllLoaded(result); - }); - }); - - describe('error handling', () => { - it('should return empty tree for missing translations folder', () => { - const result = loadFullResourceTree({ - translationsFolder: '/test/nonexistent', - cwd: '/', - }); - expect(result.folderPathSegments).toEqual([]); - expect(result.resources).toEqual([]); - expect(result.children).toEqual([]); - }); - - it('should handle empty folders gracefully', () => { - mockFs.clear(); - mockFs.set('/test/empty', 'directory'); - - const result = loadFullResourceTree({ - translationsFolder: '/test/empty', - cwd: '/', - }); - - expect(result.folderPathSegments).toEqual([]); - expect(result.resources).toHaveLength(0); - expect(result.children).toHaveLength(0); - }); - - it('should handle folders with malformed JSON files', () => { - mockFs.clear(); - mockFs.set('/test/malformed', 'directory'); - mockFs.set('/test/malformed/resource_entries.json', 'not valid json {{{'); - mockFs.set( - '/test/malformed/tracker_meta.json', - JSON.stringify({ - key: { en: { checksum: 'test' } }, - }), - ); - mockFs.set('/test/malformed/child', 'directory'); - mockFs.set( - '/test/malformed/child/resource_entries.json', - JSON.stringify({ - valid: { source: 'Valid', es: 'Válido' }, - }), - ); - mockFs.set( - '/test/malformed/child/tracker_meta.json', - JSON.stringify({ - valid: { - en: { checksum: 'v1' }, - es: { status: 'translated', checksum: 'v2', baseChecksum: 'v1' }, - }, - }), - ); - - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const result = loadFullResourceTree({ - translationsFolder: '/test/malformed', - cwd: '/', - }); - - // Root should have no resources due to malformed JSON - expect(result.resources).toHaveLength(0); - - // But child folders should still load successfully - expect(result.children).toHaveLength(1); - expect(result.children[0].loaded).toBe(true); - const childTree = result.children[0].tree; - expect(childTree).toBeDefined(); - if (!childTree) return; - expect(childTree.resources).toHaveLength(1); - expect(childTree.resources[0].key).toBe('valid'); - - consoleWarnSpy.mockRestore(); - }); - }); - - describe('cycle detection', () => { - it('should detect and handle circular symlinks', () => { - // Create a circular reference: parent -> child -> parent - mockFs.clear(); - mockFs.set('/test/cycle', 'directory'); - mockFs.set( - '/test/cycle/resource_entries.json', - JSON.stringify({ - root: { source: 'Root', es: 'Raíz' }, - }), - ); - mockFs.set( - '/test/cycle/tracker_meta.json', - JSON.stringify({ - root: { - en: { checksum: 'r1' }, - es: { status: 'translated', checksum: 'r2', baseChecksum: 'r1' }, - }, - }), - ); - mockFs.set('/test/cycle/child', 'directory'); - mockFs.set( - '/test/cycle/child/resource_entries.json', - JSON.stringify({ - child: { source: 'Child', es: 'Niño' }, - }), - ); - mockFs.set( - '/test/cycle/child/tracker_meta.json', - JSON.stringify({ - child: { - en: { checksum: 'c1' }, - es: { status: 'translated', checksum: 'c2', baseChecksum: 'c1' }, - }, - }), - ); - - // Mock realpathSync to create a cycle - const originalRealpathSync = vi.mocked(fs.realpathSync); - originalRealpathSync.mockImplementation((filePath: fs.PathLike) => { - const pathStr = filePath.toString(); - // Make child/parent point back to root, creating a cycle - if (pathStr === '/test/cycle/child/parent') { - return '/test/cycle'; - } - return pathStr; - }); - - // Add the symlink to mockFs - mockFs.set('/test/cycle/child/parent', 'directory'); - - const result = loadFullResourceTree({ - translationsFolder: '/test/cycle', - cwd: '/', - }); - - // Should load successfully - expect(result.resources).toHaveLength(1); - expect(result.children).toHaveLength(1); - - // Child should be loaded - const childTree = result.children[0].tree; - expect(childTree).toBeDefined(); - if (!childTree) return; - expect(childTree.resources).toHaveLength(1); - - // The circular link (parent) should return empty node - expect(childTree.children).toHaveLength(1); - expect(childTree.children[0].name).toBe('parent'); - expect(childTree.children[0].loaded).toBe(true); - const cycleTree = childTree.children[0].tree; - expect(cycleTree).toBeDefined(); - if (!cycleTree) return; - expect(cycleTree.resources).toHaveLength(0); - expect(cycleTree.children).toHaveLength(0); - }); - }); - - describe('metadata preservation', () => { - it('should preserve all metadata in deeply nested resources', () => { - const result = loadFullResourceTree({ - translationsFolder, - cwd: '/', - }); - - // Navigate to deepest level - const level1 = result.children[0].tree; - if (!level1) return; - const level2 = level1.children[0].tree; - if (!level2) return; - const level3 = level2.children[0].tree; - if (!level3) return; - const level4 = level3.children[0].tree; - if (!level4) return; - const level5 = level4.children[0].tree; - if (!level5) return; - const level6 = level5.children[0].tree; - if (!level6) return; - - const createResource = level6.resources[0]; - expect(createResource.key).toBe('create'); - expect(createResource.source).toBe('Create'); - expect(createResource.translations.es).toBe('Crear'); - expect(createResource.metadata.en.checksum).toBe('create123'); - expect(createResource.metadata.es.checksum).toBe('create456'); - expect(createResource.metadata.es.baseChecksum).toBe('create123'); - expect(createResource.metadata.es.status).toBe('translated'); - }); - }); -}); diff --git a/libs/core/src/lib/resource/load-full-resource-tree.ts b/libs/core/src/lib/resource/load-full-resource-tree.ts deleted file mode 100644 index 9915d7a7..00000000 --- a/libs/core/src/lib/resource/load-full-resource-tree.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { loadResourceTree, type ResourceTreeNode } from './load-resource-tree'; - -export interface LoadFullResourceTreeOptions { - /** Root translations folder path */ - translationsFolder: string; - - /** Current working directory */ - cwd?: string; -} - -/** - * Loads the complete resource tree without any depth limitations. - * This function loads ALL folders and resources in the entire translation hierarchy, - * ensuring that every child node has `loaded: true`. - * - * Used for backend caching to build a complete in-memory representation of the - * translation structure, eliminating the need for progressive loading. - * - * @param options Configuration options including translations folder path - * @returns Complete resource tree with all descendants loaded, or an empty tree if the folder does not exist yet - */ -export function loadFullResourceTree(options: LoadFullResourceTreeOptions): ResourceTreeNode { - const { translationsFolder, cwd = process.cwd() } = options; - - // Load the entire tree by passing Infinity as depth - // The existing loadResourceTree handles cycle detection via visitedPaths - return loadResourceTree({ - translationsFolder, - path: '', - depth: Infinity, - cwd, - }); -} diff --git a/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts b/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts index b324ae18..1dee6f89 100644 --- a/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts +++ b/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts @@ -6,6 +6,8 @@ import { addResource } from '../../resource/add-resource'; import { deleteResource } from '../../resource/delete-resource'; import { editResource } from '../../resource/edit-resource'; import { moveResource } from '../../resource/move-resource'; +import type { Collection } from '../config/open-collection'; +import { FolderNotFoundError } from '../errors/lingo-tracker-error'; import { createFolder } from '../folder/create-folder'; import { deleteFolder } from '../folder/delete-folder'; import { moveFolder } from '../folder/move-folder'; @@ -15,10 +17,22 @@ import { openResourceFolder } from './resource-folder'; describe('mutations returned by core writes (real fs)', () => { let root: string; + const collection = (translationsFolder = root, name = 'main'): Collection => ({ + name, + translationsFolder, + baseLocale: 'en', + locales: ['en', 'fr'], + targetLocales: ['fr'], + translationConfig: undefined, + tags: [], + readOnly: false, + config: { translationsFolder }, + }); + beforeEach(async () => { root = mkdtempSync(join(tmpdir(), 'resource-mutation-')); - await addResource(root, { key: 'common.ok', baseValue: 'OK' }); - await addResource(root, { key: 'common.cancel', baseValue: 'Cancel' }); + await addResource(collection(), { key: 'common.ok', baseValue: 'OK' }); + await addResource(collection(), { key: 'common.cancel', baseValue: 'Cancel' }); }); afterEach(() => { @@ -26,7 +40,7 @@ describe('mutations returned by core writes (real fs)', () => { }); it('addResource returns the stored entry, as the tree loader would read it', async () => { - const result = await addResource(root, { + const result = await addResource(collection(), { key: 'apps.greeting', baseValue: 'Hello {{ name }}', comment: 'Shown on login', @@ -49,7 +63,7 @@ describe('mutations returned by core writes (real fs)', () => { }); it('addResource on an existing key returns one upsert with the replaced entry', async () => { - const result = await addResource(root, { key: 'common.ok', baseValue: 'Okay' }); + const result = await addResource(collection(), { key: 'common.ok', baseValue: 'Okay' }); expect(result.created).toBe(false); expect(result.mutations).toEqual([ @@ -64,7 +78,7 @@ describe('mutations returned by core writes (real fs)', () => { }); it('editResource returns the updated entry, and nothing when nothing changed', async () => { - const edited = await editResource(root, { key: 'common.ok', baseValue: 'Okay' }); + const edited = await editResource(collection(), 'common.ok', { baseValue: 'Okay' }); expect(edited.mutations).toEqual([ { kind: 'upsert', @@ -74,31 +88,32 @@ describe('mutations returned by core writes (real fs)', () => { }, ]); - const unchanged = await editResource(root, { key: 'common.ok', baseValue: 'Okay' }); + const unchanged = await editResource(collection(), 'common.ok', { baseValue: 'Okay' }); expect(unchanged.mutations).toEqual([]); }); - it('editResource with targetFolder returns an upsert keyed by the fully resolved key', async () => { - const result = await editResource(root, { key: 'ok', targetFolder: 'common', baseValue: 'Okay' }); + it('editResource with moveTo returns an upsert and removal keyed by their fully resolved keys', async () => { + const result = await editResource(collection(), 'common.ok', { moveTo: 'shared', baseValue: 'Okay' }); expect(result.mutations).toEqual([ { kind: 'upsert', translationsFolder: root, - key: 'common.ok', + key: 'shared.ok', entry: expect.objectContaining({ source: 'Okay' }), }, + { kind: 'remove', translationsFolder: root, key: 'common.ok' }, ]); }); it('deleteResource returns a remove for each deleted key only', () => { - const result = deleteResource(root, { keys: ['common.ok', 'common.missing'] }); + const result = deleteResource(collection(), { keys: ['common.ok', 'common.missing'] }); expect(result.mutations).toEqual([{ kind: 'remove', translationsFolder: root, key: 'common.ok' }]); }); it('moveResource by pattern returns an upsert and a remove per moved key', async () => { - const result = await moveResource(root, { source: 'common.*', destination: 'shared' }); + const result = await moveResource(collection(), { source: 'common.*', destination: 'shared' }); expect(result.mutations.map((mutation) => [mutation.kind, 'key' in mutation ? mutation.key : ''])).toEqual( expect.arrayContaining([ @@ -114,10 +129,10 @@ describe('mutations returned by core writes (real fs)', () => { it('moveResource to another translations folder puts the upsert there', async () => { const other = mkdtempSync(join(tmpdir(), 'resource-mutation-other-')); try { - const result = await moveResource(root, { + const result = await moveResource(collection(), { source: 'common.ok', destination: 'imported.ok', - destinationTranslationsFolder: other, + destinationCollection: collection(other, 'other'), }); expect(result.mutations).toEqual([ @@ -135,7 +150,7 @@ describe('mutations returned by core writes (real fs)', () => { }); it('moveFolder returns the per-key moves, then the removal of the source folder', async () => { - const result = await moveFolder(root, { sourceFolderPath: 'common', destinationFolderPath: 'apps' }); + const result = await moveFolder(collection(), { sourceFolderPath: 'common', destinationFolderPath: 'apps' }); expect(result.mutations).toHaveLength(5); expect(result.mutations[result.mutations.length - 1]).toEqual({ @@ -149,17 +164,17 @@ describe('mutations returned by core writes (real fs)', () => { }); it('createFolder and deleteFolder return the folder change', () => { - expect(createFolder(root, { folderName: 'empty', parentPath: 'apps' }).mutations).toEqual([ + expect(createFolder(collection(), { folderName: 'empty', parentPath: 'apps' }).mutations).toEqual([ { kind: 'add-folder', translationsFolder: root, path: 'apps.empty' }, ]); - expect(deleteFolder(root, { folderPath: 'apps.empty' }).mutations).toEqual([ + expect(deleteFolder(collection(), { folderPath: 'apps.empty' }).mutations).toEqual([ { kind: 'remove-folder', translationsFolder: root, path: 'apps.empty' }, ]); - expect(deleteFolder(root, { folderPath: 'apps.empty' }).mutations).toEqual([]); + expect(() => deleteFolder(collection(), { folderPath: 'apps.empty' })).toThrow(FolderNotFoundError); }); it('createFolder on an existing folder returns created: false and no mutations', () => { - const result = createFolder(root, { folderName: 'common' }); + const result = createFolder(collection(), { folderName: 'common' }); expect(result.created).toBe(false); expect(result.mutations).toEqual([]); diff --git a/libs/core/src/lib/translation/index.ts b/libs/core/src/lib/translation/index.ts index 3a2cb811..fb3dec35 100644 --- a/libs/core/src/lib/translation/index.ts +++ b/libs/core/src/lib/translation/index.ts @@ -1,7 +1,6 @@ // The translation module: machine-translate one resource or a whole locale through the configured provider. export { - type TranslateExistingResourceOptions, type TranslateExistingResourceResult, translateExistingResource, } from './translate-existing-resource'; diff --git a/libs/core/src/lib/translation/translate-existing-resource.spec.ts b/libs/core/src/lib/translation/translate-existing-resource.spec.ts index 6d655455..1a061655 100644 --- a/libs/core/src/lib/translation/translate-existing-resource.spec.ts +++ b/libs/core/src/lib/translation/translate-existing-resource.spec.ts @@ -1,8 +1,10 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { translateExistingResource } from './translate-existing-resource'; import * as fs from 'node:fs'; -import { type SafeAny, RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TranslationConfig } from '../../config/translation-config'; +import { RESOURCE_ENTRIES_FILENAME, type SafeAny, TRACKER_META_FILENAME } from '../../constants'; +import type { Collection } from '../config/open-collection'; +import { AutoTranslationDisabledError } from '../errors/lingo-tracker-error'; +import { translateExistingResource } from './translate-existing-resource'; vi.mock('node:fs'); vi.mock('./auto-translate-resources'); @@ -10,8 +12,7 @@ vi.mock('./auto-translate-resources'); import { autoTranslateResource } from './auto-translate-resources'; describe('translateExistingResource', () => { - const translationsFolder = 'translations'; - const cwd = '/test'; + const translationsFolder = '/test/translations'; const enabledTranslationConfig: TranslationConfig = { enabled: true, @@ -19,6 +20,20 @@ describe('translateExistingResource', () => { apiKeyEnv: 'GOOGLE_TRANSLATE_API_KEY', }; + function collection(locales: readonly string[], translationConfig = enabledTranslationConfig): Collection { + return { + name: 'main', + translationsFolder, + baseLocale: 'en', + locales, + targetLocales: locales.filter((locale) => locale !== 'en'), + translationConfig, + tags: [], + readOnly: false, + config: { translationsFolder }, + }; + } + const baseResourceEntries = { save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, }; @@ -57,31 +72,17 @@ describe('translateExistingResource', () => { it('should throw when the resource files do not exist', async () => { vi.mocked(fs.existsSync).mockReturnValue(false); - await expect( - translateExistingResource({ - key: 'buttons.save', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'fr-ca', 'es'], - baseLocale: 'en', - cwd, - }), - ).rejects.toThrow(/Resource not found/); + await expect(translateExistingResource(collection(['en', 'fr-ca', 'es']), 'buttons.save')).rejects.toThrow( + /Resource not found/, + ); }); it('should throw when the entry key is not present in the files', async () => { mockFileSystem({}, {}); - await expect( - translateExistingResource({ - key: 'buttons.save', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'fr-ca'], - baseLocale: 'en', - cwd, - }), - ).rejects.toThrow(/Resource not found/); + await expect(translateExistingResource(collection(['en', 'fr-ca']), 'buttons.save')).rejects.toThrow( + /Resource not found/, + ); }); it('should return translatedCount 0 when no locales have new or stale status', async () => { @@ -94,14 +95,7 @@ describe('translateExistingResource', () => { }; mockFileSystem(baseResourceEntries, allTranslatedMeta); - const result = await translateExistingResource({ - key: 'buttons.save', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'fr-ca', 'es'], - baseLocale: 'en', - cwd, - }); + const result = await translateExistingResource(collection(['en', 'fr-ca', 'es']), 'buttons.save'); expect(result.translatedCount).toBe(0); expect(result.skippedLocales).toEqual([]); @@ -119,14 +113,7 @@ describe('translateExistingResource', () => { }; mockFileSystem(resourceEntries, allVerifiedMeta); - const result = await translateExistingResource({ - key: 'buttons.save', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'fr-ca'], - baseLocale: 'en', - cwd, - }); + const result = await translateExistingResource(collection(['en', 'fr-ca']), 'buttons.save'); expect(result.entry.key).toBe('save'); expect(result.entry.source).toBe('Save'); @@ -152,14 +139,7 @@ describe('translateExistingResource', () => { skippedLocales: [], }); - const result = await translateExistingResource({ - key: 'buttons.save', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'fr-ca', 'es'], - baseLocale: 'en', - cwd, - }); + const result = await translateExistingResource(collection(['en', 'fr-ca', 'es']), 'buttons.save'); expect(result.translatedCount).toBe(2); expect(result.skippedLocales).toEqual([]); @@ -182,14 +162,7 @@ describe('translateExistingResource', () => { skippedLocales: [], }); - const result = await translateExistingResource({ - key: 'buttons.save', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'fr-ca'], - baseLocale: 'en', - cwd, - }); + const result = await translateExistingResource(collection(['en', 'fr-ca']), 'buttons.save'); expect(result.translatedCount).toBe(1); expect(result.entry.translations['fr-ca']).toBe('Enregistrer'); @@ -217,14 +190,7 @@ describe('translateExistingResource', () => { skippedLocales: [], }); - const result = await translateExistingResource({ - key: 'buttons.save', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'fr-ca', 'es', 'de'], - baseLocale: 'en', - cwd, - }); + const result = await translateExistingResource(collection(['en', 'fr-ca', 'es', 'de']), 'buttons.save'); expect(autoTranslateResource).toHaveBeenCalledWith( expect.objectContaining({ @@ -252,14 +218,7 @@ describe('translateExistingResource', () => { skippedLocales: ['fr-ca', 'es'], }); - const result = await translateExistingResource({ - key: 'messages.plural', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'fr-ca', 'es'], - baseLocale: 'en', - cwd, - }); + const result = await translateExistingResource(collection(['en', 'fr-ca', 'es']), 'messages.plural'); expect(result.translatedCount).toBe(0); expect(result.skippedLocales).toEqual(['fr-ca', 'es']); @@ -280,14 +239,7 @@ describe('translateExistingResource', () => { skippedLocales: [], }); - await translateExistingResource({ - key: 'buttons.save', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'fr-ca'], - baseLocale: 'en', - cwd, - }); + await translateExistingResource(collection(['en', 'fr-ca']), 'buttons.save'); const writeCalls = vi.mocked(fs.writeFileSync).mock.calls; expect(writeCalls).toHaveLength(2); @@ -314,14 +266,7 @@ describe('translateExistingResource', () => { skippedLocales: [], }); - await translateExistingResource({ - key: 'messages.greeting', - translationsFolder, - translationConfig: enabledTranslationConfig, - allLocales: ['en', 'de'], - baseLocale: 'en', - cwd, - }); + await translateExistingResource(collection(['en', 'de']), 'messages.greeting'); expect(autoTranslateResource).toHaveBeenCalledWith({ baseValue: 'Hello World', @@ -330,4 +275,10 @@ describe('translateExistingResource', () => { translationConfig: enabledTranslationConfig, }); }); + + it('should throw a typed error when auto-translation is disabled', async () => { + await expect( + translateExistingResource(collection(['en', 'fr-ca'], { ...enabledTranslationConfig, enabled: false }), 'x.y'), + ).rejects.toThrow(AutoTranslationDisabledError); + }); }); diff --git a/libs/core/src/lib/translation/translate-existing-resource.ts b/libs/core/src/lib/translation/translate-existing-resource.ts index 46122bdf..e7b240ad 100644 --- a/libs/core/src/lib/translation/translate-existing-resource.ts +++ b/libs/core/src/lib/translation/translate-existing-resource.ts @@ -1,22 +1,12 @@ -import { resolve } from 'node:path'; import { needsTranslation } from '@simoncodes-ca/domain'; -import type { TranslationConfig } from '../../config/translation-config'; +import type { Collection } from '../config/open-collection'; import type { ResourceTreeEntry } from '../resource/load-resource-tree'; -import { ResourceNotFoundError } from '../errors/lingo-tracker-error'; +import { AutoTranslationDisabledError, ResourceNotFoundError } from '../errors/lingo-tracker-error'; import { validateAndResolvePaths } from '../resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../resource/resource-folder'; import { type ResourceMutation, upsertMutation } from '../resource/resource-mutation'; import { autoTranslateResource } from './auto-translate-resources'; -export interface TranslateExistingResourceOptions { - readonly key: string; - readonly translationsFolder: string; - readonly translationConfig: TranslationConfig; - readonly allLocales: readonly string[]; - readonly baseLocale: string; - readonly cwd?: string; -} - export interface TranslateExistingResourceResult { readonly translatedCount: number; readonly skippedLocales: string[]; @@ -26,27 +16,27 @@ export interface TranslateExistingResourceResult { } /** - * Translates an existing resource entry for all locales with 'new' or 'stale' status. - * - * Resolves the resource key to its file paths, reads the current state, identifies - * which locales still need translation (status is 'new' or 'stale'), calls the - * auto-translate provider, updates the resource entries and tracker metadata, and - * writes both files to disk. + * Auto-translates an existing resource entry of a collection, for every target locale + * that needs translation (no metadata, or status `new` or `stale`). * * Returns early with `translatedCount: 0` when no locales require translation. * - * Throws {@link TranslationError} if the translation provider fails — callers - * should map this to an appropriate HTTP error (e.g. 502 Bad Gateway). - * - * @param options - Resolution and translation parameters for this resource. - * @returns The updated resource entry along with translation and skip counts. + * @param key - The entry's full key. + * @throws {AutoTranslationDisabledError} The collection has no enabled translation config. + * @throws {InvalidResourceKeyError} The key is malformed. + * @throws {ResourceNotFoundError} No entry exists at the key. + * @throws {TranslationError} The translation provider failed. */ export async function translateExistingResource( - options: TranslateExistingResourceOptions, + collection: Collection, + key: string, ): Promise { - const { key, translationsFolder, translationConfig, allLocales, baseLocale, cwd = process.cwd() } = options; + const { translationConfig, baseLocale, translationsFolder } = collection; + if (!translationConfig?.enabled) { + throw new AutoTranslationDisabledError(collection.name); + } - const paths = validateAndResolvePaths({ key, translationsFolder, cwd }); + const paths = validateAndResolvePaths({ key, translationsFolder }); const folder = openResourceFolder(paths.folderPath, { baseLocale }); const current = folder.get(paths.entryKey); @@ -56,7 +46,7 @@ export async function translateExistingResource( } const { entry, meta } = current; - const targetLocales = allLocales.filter((locale) => locale !== baseLocale && needsTranslation(meta[locale])); + const targetLocales = collection.targetLocales.filter((locale) => needsTranslation(meta[locale])); if (targetLocales.length === 0) { return { @@ -89,9 +79,7 @@ export async function translateExistingResource( skippedLocales, entry: updatedEntry, mutations: - translatedEntries.length > 0 - ? [upsertMutation(resolve(cwd, translationsFolder), paths.resolvedKey, updatedEntry)] - : [], + translatedEntries.length > 0 ? [upsertMutation(translationsFolder, paths.resolvedKey, updatedEntry)] : [], }; } diff --git a/libs/core/src/resource/add-resource.spec.ts b/libs/core/src/resource/add-resource.spec.ts index a8a06836..78e24649 100644 --- a/libs/core/src/resource/add-resource.spec.ts +++ b/libs/core/src/resource/add-resource.spec.ts @@ -1,455 +1,252 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LingoTrackerConfig } from '../config/lingo-tracker-config'; +import type { TranslationConfig } from '../config/translation-config'; +import { type Collection, openCollection } from '../lib/config/open-collection'; +import { InvalidResourceKeyError, LocaleNotFoundError } from '../lib/errors/lingo-tracker-error'; +import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; +import { TranslationError } from '../lib/translation/translation-provider'; import { addResource } from './add-resource'; -import * as fs from 'node:fs'; -import { resolve } from 'node:path'; -import type { SafeAny } from '../constants'; +import { calculateChecksum as md5 } from './checksum'; -vi.mock('node:fs'); +vi.mock('../lib/translation/auto-translate-resources'); -describe('addResource', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(fs.existsSync).mockReturnValue(false); - vi.mocked(fs.mkdirSync).mockImplementation(() => ''); - vi.mocked(fs.readFileSync).mockImplementation(() => JSON.stringify({})); - vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); - }); +const AUTO: TranslationConfig = { enabled: true, provider: 'google-translate', apiKeyEnv: 'KEY' }; - it('should create a new resource with base value only', async () => { - const result = await addResource( - 'translations', - { - key: 'app.button.ok', - baseValue: 'OK', - }, - { cwd: '/test' }, - ); +describe('addResource (real fs)', () => { + let root: string; - expect(result.resolvedKey).toBe('app.button.ok'); - expect(result.created).toBe(true); + function collection(options: { translation?: TranslationConfig; locales?: string[]; baseLocale?: string } = {}) { + const config: LingoTrackerConfig = { + exportFolder: 'dist', + importFolder: 'import', + baseLocale: options.baseLocale ?? 'en', + locales: options.locales ?? ['en', 'fr', 'de'], + collections: { main: { translationsFolder: join(root, 'translations') } }, + ...(options.translation && { translation: options.translation }), + }; + return openCollection(config, 'main'); + } - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - expect(writeCall.length).toBe(2); // resource_entries.json and tracker_meta.json + function read(file: 'resource_entries.json' | 'tracker_meta.json', ...segments: string[]) { + return JSON.parse(readFileSync(join(root, 'translations', ...segments, file), 'utf8')); + } - const resourceContent = JSON.parse(writeCall[0][1] as string); - expect(resourceContent.ok).toEqual({ - source: 'OK', - }); + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'add-resource-')); + vi.mocked(autoTranslateResource).mockReset(); }); - it('should include optional comment and tags in entry', async () => { - await addResource( - 'translations', - { - key: 'button.cancel', - baseValue: 'Cancel', - comment: 'Button to cancel operations', - tags: ['ui', 'buttons'], - }, - { cwd: '/test' }, - ); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const resourceContent = JSON.parse(writeCall[0][1] as string); - expect(resourceContent.cancel).toEqual({ - source: 'Cancel', - comment: 'Button to cancel operations', - tags: ['ui', 'buttons'], - }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); }); - it('should resolve key with target folder', async () => { - const result = await addResource( - 'translations', - { - key: 'ok', - baseValue: 'OK', - targetFolder: 'app.button', - }, - { cwd: '/test' }, - ); + describe('locale seeding', () => { + it('copies the base value as `new` into every target locale when auto-translation is off', async () => { + const result = await addResource(collection(), { key: 'common.ok', baseValue: 'OK' }); - expect(result.resolvedKey).toBe('app.button.ok'); - }); + expect(read('resource_entries.json', 'common')).toEqual({ ok: { source: 'OK', fr: 'OK', de: 'OK' } }); + expect(read('tracker_meta.json', 'common').ok).toEqual({ + en: { checksum: md5('OK') }, + fr: { checksum: md5('OK'), baseChecksum: md5('OK'), status: 'new' }, + de: { checksum: md5('OK'), baseChecksum: md5('OK'), status: 'new' }, + }); + expect(result.translations.map(({ locale, status }) => [locale, status])).toEqual([ + ['fr', 'new'], + ['de', 'new'], + ]); + expect(result.skippedLocales).toBeUndefined(); + expect(autoTranslateResource).not.toHaveBeenCalled(); + }); - it('should create nested folder structure', async () => { - await addResource( - 'translations', - { - key: 'app.button.ok', - baseValue: 'OK', - }, - { cwd: '/test' }, - ); + it('keeps supplied translations and seeds only the missing locales', async () => { + await addResource(collection(), { + key: 'common.save', + baseValue: 'Save', + translations: [{ locale: 'fr', value: 'Enregistrer', status: 'translated' }], + }); - const mkdirCall = vi.mocked(fs.mkdirSync).mock.calls[0]; - expect(mkdirCall[0]).toBe(resolve('/test', 'translations/app/button')); - expect(mkdirCall[1]).toEqual({ recursive: true }); - }); + expect(read('resource_entries.json', 'common').save).toEqual({ source: 'Save', fr: 'Enregistrer', de: 'Save' }); + const meta = read('tracker_meta.json', 'common').save; + expect(meta.fr.status).toBe('translated'); + expect(meta.de.status).toBe('new'); + }); - it('should create tracker metadata with checksums', async () => { - await addResource( - 'translations', - { - key: 'button.ok', - baseValue: 'OK', - }, - { cwd: '/test' }, - ); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const metaContent = JSON.parse(writeCall[1][1] as string); - expect(metaContent.ok).toHaveProperty('en'); - expect(metaContent.ok.en).toHaveProperty('checksum'); - expect(metaContent.ok.en.checksum).toHaveLength(32); // MD5 is 32 chars - expect(metaContent.ok.en.status).toBeUndefined(); // Base locale has no status - }); + it('auto-translates the missing locales when the collection enables it', async () => { + vi.mocked(autoTranslateResource).mockResolvedValue({ + translations: [{ locale: 'de', value: 'Speichern', status: 'translated' }], + skippedLocales: [], + }); - it('should add translations with status and baseChecksum using array format', async () => { - await addResource( - 'translations', - { - key: 'button.ok', - baseValue: 'OK', - translations: [ - { locale: 'fr-ca', value: "D'accord", status: 'translated' }, - { locale: 'es', value: 'Aceptar', status: 'verified' }, - ], - }, - { cwd: '/test' }, - ); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const resourceContent = JSON.parse(writeCall[0][1] as string); - expect(resourceContent.ok['fr-ca']).toBe("D'accord"); - expect(resourceContent.ok.es).toBe('Aceptar'); - - const metaContent = JSON.parse(writeCall[1][1] as string); - expect(metaContent.ok['fr-ca']).toHaveProperty('checksum'); - expect(metaContent.ok['fr-ca']).toHaveProperty('baseChecksum'); - expect(metaContent.ok['fr-ca'].status).toBe('translated'); - expect(metaContent.ok.es.status).toBe('verified'); - }); + const result = await addResource(collection({ translation: AUTO }), { + key: 'common.save', + baseValue: 'Save', + translations: [{ locale: 'fr', value: 'Enregistrer', status: 'verified' }], + }); - it('should override status to "new" when translation checksum matches base checksum', async () => { - await addResource( - 'translations', - { - key: 'button.ok', - baseValue: 'OK', - translations: [ - { locale: 'fr-ca', value: 'OK', status: 'translated' }, // Same as base value - ], - }, - { cwd: '/test' }, - ); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const metaContent = JSON.parse(writeCall[1][1] as string); - expect(metaContent.ok['fr-ca'].status).toBe('new'); // Should be overridden to 'new' - expect(metaContent.ok['fr-ca'].checksum).toBe(metaContent.ok.en.checksum); - }); + expect(autoTranslateResource).toHaveBeenCalledWith({ + baseValue: 'Save', + baseLocale: 'en', + targetLocales: ['de'], + translationConfig: AUTO, + }); + expect(read('resource_entries.json', 'common').save).toEqual({ + source: 'Save', + fr: 'Enregistrer', + de: 'Speichern', + }); + const meta = read('tracker_meta.json', 'common').save; + expect(meta.fr.status).toBe('verified'); + expect(meta.de.status).toBe('translated'); + expect(result.skippedLocales).toEqual([]); + }); - it('should validate key and throw on invalid format', async () => { - await expect( - addResource( - 'translations', - { - key: 'invalid..key', - baseValue: 'Value', - }, - { cwd: '/test' }, - ), - ).rejects.toThrow(); - }); + it('copies the base value as `new` into a locale the provider skipped, and reports it', async () => { + vi.mocked(autoTranslateResource).mockResolvedValue({ + translations: [{ locale: 'fr', value: '{count, plural, other {# éléments}}', status: 'translated' }], + skippedLocales: ['de'], + }); - it('should validate target folder and throw on invalid format', async () => { - await expect( - addResource( - 'translations', - { - key: 'ok', - baseValue: 'OK', - targetFolder: 'invalid@folder', - }, - { cwd: '/test' }, - ), - ).rejects.toThrow(); - }); + const result = await addResource(collection({ translation: AUTO }), { + key: 'items', + baseValue: '{count, plural, other {# items}}', + }); - it('should detect new entry when resource file does not exist', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + const entry = read('resource_entries.json').items; + expect(entry.de).toBe('{count, plural, other {# items}}'); + expect(read('tracker_meta.json').items.de.status).toBe('new'); + expect(result.skippedLocales).toEqual(['de']); + }); - const result = await addResource( - 'translations', - { - key: 'button.ok', + it('does not call the provider when every target locale was supplied', async () => { + await addResource(collection({ translation: AUTO }), { + key: 'ok', baseValue: 'OK', - }, - { cwd: '/test' }, - ); + translations: [ + { locale: 'fr', value: "D'accord", status: 'translated' }, + { locale: 'de', value: 'Okay', status: 'translated' }, + ], + }); - expect(result.created).toBe(true); - }); + expect(autoTranslateResource).not.toHaveBeenCalled(); + }); - it('should detect update when entry already exists', async () => { - // Simulate existing file - vi.mocked(fs.existsSync).mockImplementation((path: SafeAny) => { - return (path as string).includes('resource_entries.json'); + it('does not auto-translate when the collection translation config is disabled', async () => { + await addResource(collection({ translation: { ...AUTO, enabled: false } }), { key: 'ok', baseValue: 'OK' }); + + expect(autoTranslateResource).not.toHaveBeenCalled(); + expect(read('tracker_meta.json').ok.fr.status).toBe('new'); }); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ ok: { source: 'OK' } })); - - const result = await addResource( - 'translations', - { - key: 'button.ok', - baseValue: 'OK - Updated', - }, - { cwd: '/test' }, - ); - - expect(result.created).toBe(false); - }); - it('should merge with existing entries in file', async () => { - const existingContent = { another: { source: 'Another' } }; - vi.mocked(fs.existsSync).mockImplementation((path: SafeAny) => { - return (path as string).includes('resource_entries.json'); + it('writes nothing when the provider fails', async () => { + vi.mocked(autoTranslateResource).mockRejectedValue(new TranslationError('quota', 'RATE_LIMIT', true)); + + await expect( + addResource(collection({ translation: AUTO }), { key: 'common.ok', baseValue: 'OK' }), + ).rejects.toThrow(TranslationError); + expect(existsSync(join(root, 'translations', 'common', 'resource_entries.json'))).toBe(false); }); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingContent)); - await addResource( - 'translations', - { - key: 'button.ok', + it('stores an untranslated copy of the base value as `new`, whatever status was requested', async () => { + await addResource(collection(), { + key: 'ok', baseValue: 'OK', - }, - { cwd: '/test' }, - ); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const resourceContent = JSON.parse(writeCall[0][1] as string); - expect(resourceContent.another).toBeDefined(); - expect(resourceContent.ok).toBeDefined(); - }); + translations: [{ locale: 'fr', value: 'OK', status: 'verified' }], + }); - it('should use custom base locale', async () => { - await addResource( - 'translations', - { - key: 'button.ok', - baseValue: 'OK', - baseLocale: 'fr', - }, - { cwd: '/test' }, - ); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const metaContent = JSON.parse(writeCall[1][1] as string); - expect(metaContent.ok).toHaveProperty('fr'); - expect(metaContent.ok.fr.status).toBeUndefined(); - }); + expect(read('tracker_meta.json').ok.fr.status).toBe('new'); + }); - it('should use process.cwd() when cwd not provided', async () => { - const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/default'); - vi.mocked(fs.existsSync).mockReturnValue(false); + it('takes base and target locales from the collection only', async () => { + await addResource(collection({ baseLocale: 'fr', locales: ['fr', 'en'] }), { + key: 'ok', + baseValue: "D'accord", + translations: [{ locale: 'fr', value: 'ignored: base locale', status: 'translated' }], + }); - await addResource('translations', { - key: 'button.ok', - baseValue: 'OK', + expect(read('resource_entries.json').ok).toEqual({ source: "D'accord", en: "D'accord" }); + expect(Object.keys(read('tracker_meta.json').ok)).toEqual(['fr', 'en']); }); - expect(cwdSpy).toHaveBeenCalled(); - cwdSpy.mockRestore(); - }); - - it('should handle single-level key', async () => { - const result = await addResource( - 'translations', - { - key: 'cancel', - baseValue: 'Cancel', - }, - { cwd: '/test' }, - ); - - expect(result.resolvedKey).toBe('cancel'); - - const mkdirCall = vi.mocked(fs.mkdirSync).mock.calls; - // Should not create nested folders for single-level keys - expect(mkdirCall.length > 0).toBe(true); + it('rejects a translation for a locale the collection does not have', async () => { + await expect( + addResource(collection(), { + key: 'ok', + baseValue: 'OK', + translations: [{ locale: 'ja', value: 'OK', status: 'translated' }], + }), + ).rejects.toThrow(LocaleNotFoundError); + expect(existsSync(join(root, 'translations'))).toBe(false); + }); }); - it('should allow overlapping segments between target folder and key (no de-dup)', async () => { - const result = await addResource( - 'translations', - { - key: 'app.ok', + describe('entry', () => { + it('stores comment and normalized tags', async () => { + await addResource(collection({ locales: ['en'] }), { + key: 'ok', baseValue: 'OK', - targetFolder: 'app.button', - }, - { cwd: '/test' }, - ); + comment: 'Button', + tags: ['UI', 'ui', ' forms '], + }); - // app.button + app.ok = app.button.app.ok (no de-duplication) - expect(result.resolvedKey).toBe('app.button.app.ok'); - }); + expect(read('resource_entries.json').ok).toEqual({ source: 'OK', comment: 'Button', tags: ['ui', 'forms'] }); + }); - describe('idempotency', () => { - it('should handle repeated calls with same parameters', async () => { - const existingContent = { - ok: { source: 'OK', checksum: 'abc123' }, - }; - vi.mocked(fs.existsSync).mockImplementation((path: SafeAny) => { - return (path as string).includes('resource_entries.json'); + it('places the key under targetFolder', async () => { + const result = await addResource(collection(), { + key: 'buttons.ok', + baseValue: 'OK', + targetFolder: 'apps.common', }); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingContent)); - - // First call - const result1 = await addResource( - 'translations', - { - key: 'button.ok', - baseValue: 'OK', - }, - { cwd: '/test' }, - ); - - // Second call with same params - const result2 = await addResource( - 'translations', - { - key: 'button.ok', - baseValue: 'OK', - }, - { cwd: '/test' }, - ); - // Both should produce same resolved key - expect(result1.resolvedKey).toBe(result2.resolvedKey); + expect(result.resolvedKey).toBe('apps.common.buttons.ok'); + expect(read('resource_entries.json', 'apps', 'common', 'buttons').ok.source).toBe('OK'); }); - }); - describe('integration: base value change detection', () => { - it('should create new checksum when base value changes', async () => { - const existingContent = { - ok: { source: 'OK', 'fr-ca': "D'accord" }, - }; - const existingMeta = { - ok: { - en: { checksum: 'old-base-hash' }, - 'fr-ca': { - checksum: 'trans-hash', - baseChecksum: 'old-base-hash', - status: 'translated', - }, - }, - }; - - vi.mocked(fs.existsSync).mockImplementation((path: SafeAny) => { - return (path as string).includes('resource'); - }); - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes('resource_entries.json')) { - return JSON.stringify(existingContent); - } - return JSON.stringify(existingMeta); + it('normalizes Transloco placeholders to ICU in base and translations', async () => { + await addResource(collection(), { + key: 'hello', + baseValue: 'Hello {{ name }}', + translations: [{ locale: 'fr', value: 'Bonjour {{name}}', status: 'translated' }], }); - await addResource( - 'translations', - { - key: 'button.ok', - baseValue: 'OK - NEW VALUE', - translations: [{ locale: 'fr-ca', value: "D'accord", status: 'translated' }], - }, - { cwd: '/test' }, - ); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const metaContent = JSON.parse(writeCall[1][1] as string); - - // Base checksum should be different - expect(metaContent.ok.en.checksum).not.toBe('old-base-hash'); - // Translations should still be there but might be stale in real usage - expect(metaContent.ok['fr-ca']).toBeDefined(); + expect(read('resource_entries.json').hello).toEqual({ + source: 'Hello {name}', + fr: 'Bonjour {name}', + de: 'Hello {name}', + }); }); - }); - describe('Security', () => { - it('should reject invalid keys with path traversal characters', async () => { - await expect( - addResource('translations', { - key: '../secret.key', - baseValue: 'test', - }), - ).rejects.toThrow('Key validation: Invalid key format'); - }); + it('replaces an existing entry, keeps its position, and reports created: false', async () => { + const target = collection(); + await addResource(target, { key: 'a', baseValue: 'A', comment: 'old' }); + await addResource(target, { key: 'b', baseValue: 'B' }); - it('should reject invalid targetFolder with path traversal characters', async () => { - await expect( - addResource('translations', { - key: 'valid.key', - targetFolder: '../secret', - baseValue: 'test', - }), - ).rejects.toThrow('Invalid targetFolder segment'); - }); + const result = await addResource(target, { key: 'a', baseValue: 'A2' }); - it('should NOT create folders for invalid paths', async () => { - try { - await addResource('translations', { - key: 'valid.key', - targetFolder: '../secret', - baseValue: 'test', - }); - } catch (_e) { - // Ignore error - } - - const mkdirCall = vi.mocked(fs.mkdirSync).mock.calls; - const _secretPath = resolve('translations', '..', 'secret'); - // Check that no call was made with the secret path - const callWithSecret = mkdirCall.find((call) => call[0].toString().includes('secret')); - expect(callWithSecret).toBeUndefined(); + expect(result.created).toBe(false); + expect(Object.keys(read('resource_entries.json'))).toEqual(['a', 'b']); + expect(read('resource_entries.json').a).toEqual({ source: 'A2', fr: 'A2', de: 'A2' }); }); - }); - describe('auto-translation', () => { - it('should return translations from auto-translate when enabled and no explicit translations provided', async () => { - const mockAutoTranslate = vi.fn().mockResolvedValue({ - translations: [ - { locale: 'fr-ca', value: "D'accord", status: 'translated' }, - { locale: 'es', value: 'Aceptar', status: 'translated' }, - ], - skippedLocales: [], - }); + it('returns an upsert mutation for the stored entry', async () => { + const target: Collection = collection(); + const result = await addResource(target, { key: 'common.ok', baseValue: 'OK' }); - vi.doMock('../lib/translation/auto-translate-resources', () => ({ - autoTranslateResource: mockAutoTranslate, - })); + expect(result.created).toBe(true); + expect(result.mutations).toEqual([ + expect.objectContaining({ kind: 'upsert', translationsFolder: target.translationsFolder, key: 'common.ok' }), + ]); + }); - // Since we cannot easily re-mock within the same test file after initial vi.mock, - // we verify the behavior through the absence of auto-translation when config is disabled. - const result = await addResource( - 'translations', - { - key: 'button.ok', - baseValue: 'OK', - allLocales: ['en', 'fr-ca', 'es'], - }, - { - cwd: '/test', - translationConfig: { enabled: false, provider: 'google-translate', apiKeyEnv: 'GOOGLE_API_KEY' }, - }, - ); - - // With disabled config, no auto-translation should happen - expect(result.resolvedKey).toBe('button.ok'); - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const resourceContent = JSON.parse(writeCall[0][1] as string); - // No locale keys added because translation is disabled - expect(resourceContent.ok['fr-ca']).toBeUndefined(); + it.each([ + ['a key with path traversal', { key: '../evil', baseValue: 'x' }], + ['a malformed targetFolder', { key: 'ok', baseValue: 'x', targetFolder: '../evil' }], + ])('rejects %s and creates nothing', async (_label, params) => { + await expect(addResource(collection(), params)).rejects.toThrow(InvalidResourceKeyError); + expect(existsSync(join(root, 'translations'))).toBe(false); }); }); }); diff --git a/libs/core/src/resource/add-resource.ts b/libs/core/src/resource/add-resource.ts index c7144fd8..8a762fd2 100644 --- a/libs/core/src/resource/add-resource.ts +++ b/libs/core/src/resource/add-resource.ts @@ -1,189 +1,103 @@ -import { resolve } from 'node:path'; -import type { TranslationStatus } from '@simoncodes-ca/domain'; -import { validateAndResolvePaths } from '../lib/resource/resource-file-paths'; +import { isUntranslatedCopy, normalizeTags, translocoToICU } from '@simoncodes-ca/domain'; +import type { Collection } from '../lib/config/open-collection'; import { ensureDirectoryExists } from '../lib/file-io/directory-operations'; +import { validateAndResolvePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder } from '../lib/resource/resource-folder'; import { type ResourceMutation, upsertMutation } from '../lib/resource/resource-mutation'; -import type { TranslationConfig } from '../config/translation-config'; -import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; -import { translocoToICU, normalizeTags, isUntranslatedCopy } from '@simoncodes-ca/domain'; - -export interface AddResourceOptions { - cwd?: string; - translationConfig?: TranslationConfig; -} +import { assertCollectionLocales, type ResourceTranslation, seedLocales } from './locale-seeding'; export interface AddResourceParams { - /** Dot-delimited key, e.g., "apps.common.buttons.ok" */ - key: string; - /** Base locale value (the source text) */ - baseValue: string; - /** Optional context for translators */ - comment?: string; - /** Optional tags (will be stored as array) */ - tags?: string[]; - /** Optional target folder to override part of the path */ - targetFolder?: string; - /** Base locale (defaults to "en") */ - baseLocale?: string; - /** Localized translations with locale, value, and status */ - translations?: Array<{ - locale: string; - value: string; - status: TranslationStatus; - }>; + /** Dot-delimited key, e.g., "apps.common.buttons.ok". */ + readonly key: string; + /** Base locale value (the source text). */ + readonly baseValue: string; + /** Optional context for translators. */ + readonly comment?: string; + /** Optional tags (normalized before they are stored). */ + readonly tags?: readonly string[]; + /** Optional dot-delimited folder the key is placed under: the stored key is `targetFolder.key`. */ + readonly targetFolder?: string; /** - * All configured locales. Required when using auto-translation so the - * orchestrator knows which target locales to translate into. + * Translations the caller supplies. Each locale must be one of the collection's locales + * (a value for the base locale is ignored). Target locales without one are seeded + * (see {@link seedLocales}). */ - allLocales?: readonly string[]; + readonly translations?: readonly ResourceTranslation[]; +} + +export interface AddResourceResult { + /** The stored key (`targetFolder.key`). */ + readonly resolvedKey: string; + /** False when an existing entry was replaced. */ + readonly created: boolean; + /** Every translation written, supplied and seeded. */ + readonly translations: ResourceTranslation[]; + /** Locales the provider did not translate (ICU messages). Present only when auto-translation ran. */ + readonly skippedLocales?: string[]; + /** The `upsert` for the stored entry. */ + readonly mutations: ResourceMutation[]; } /** - * Adds or updates a resource entry in the translations folder. - * Creates nested folders and files as needed at each level. + * Adds a resource entry to a collection, or replaces the entry at that key (its previous + * translations and metadata are dropped). Creates the folders it needs. + * + * Every target locale of the collection gets a value: the supplied translation, else an + * auto-translation when the collection has it enabled, else a copy of the base value as + * `new` (the Locale seeding rule, {@link seedLocales}). A translation identical to the base + * value is stored as `new` whatever its requested status (Staleness rule). * - * When `options.translationConfig` is provided and enabled, and no explicit - * translations are supplied, the base value is automatically translated to all - * non-base locales using the configured provider. + * Values are normalized to ICU before they are stored. Nothing is written when the + * translation provider fails. * - * @param translationsFolder - Root translations folder path - * @param params - Resource creation parameters - * @param options - Additional options (e.g., cwd, translationConfig) - * @returns Object with the resolved key, status, actual translations written to disk, - * any locales skipped due to ICU format (only present when auto-translation ran), - * and the mutation that describes the stored entry + * @throws {InvalidResourceKeyError} The key or `targetFolder` is malformed. + * @throws {LocaleNotFoundError} A supplied translation names a locale the collection does not have. + * @throws {TranslationError} The translation provider failed. */ -export async function addResource( - translationsFolder: string, - params: AddResourceParams, - options: AddResourceOptions = {}, -): Promise<{ - resolvedKey: string; - created: boolean; - translations: Array<{ locale: string; value: string; status: TranslationStatus }>; - skippedLocales?: string[]; - mutations: ResourceMutation[]; -}> { - const { cwd = process.cwd(), translationConfig } = options; - const baseLocale = params.baseLocale || 'en'; - - // Validate and resolve paths - const paths = validateAndResolvePaths({ - key: params.key, - translationsFolder, - targetFolder: params.targetFolder, - cwd, - }); - - // Ensure directory exists - ensureDirectoryExists({ - directoryPath: paths.folderPath, - errorContext: 'Creating resource folder', - }); - +export async function addResource(collection: Collection, params: AddResourceParams): Promise { + const { baseLocale, translationsFolder } = collection; + const paths = validateAndResolvePaths({ key: params.key, translationsFolder, targetFolder: params.targetFolder }); + + const supplied = (params.translations ?? []).filter(({ locale }) => locale !== baseLocale); + assertCollectionLocales( + collection, + supplied.map(({ locale }) => locale), + ); + + const baseValue = translocoToICU(params.baseValue); + // Resolve every value before touching the disk, so a provider failure writes nothing. + const seeding = await seedLocales(collection, { baseValue, supplied: supplied.map(({ locale }) => locale) }); + const translations: ResourceTranslation[] = [ + ...supplied.map(({ locale, value, status }) => { + const normalized = translocoToICU(value); + return { locale, value: normalized, status: isUntranslatedCopy(normalized, baseValue) ? 'new' : status }; + }), + ...seeding.translations.map((translation) => + isUntranslatedCopy(translation.value, baseValue) ? { ...translation, status: 'new' as const } : translation, + ), + ]; + + ensureDirectoryExists({ directoryPath: paths.folderPath, errorContext: 'Creating resource folder' }); const folder = openResourceFolder(paths.folderPath, { baseLocale }); - const isNewEntry = !folder.has(paths.entryKey); - - // Normalize values to ICU format before storing - const normalizedBaseValue = translocoToICU(params.baseValue); - const normalizedTags = normalizeTags(params.tags ?? []); - - // Resolve translations: prefer explicit translations, fall back to auto-translation, then nothing. - // Pass the ICU-normalized base value so the translation provider receives the stored form, - // not the raw Transloco-style input from the caller. - const resolveResult = await resolveTranslations({ - params, - normalizedBaseValue, - baseLocale, - translationConfig, + const created = !folder.has(paths.entryKey); + + // setEntry clears the entry in place, so an existing key keeps its position in the file. + folder.setEntry(paths.entryKey, { source: baseValue }, {}); + folder.setBase(paths.entryKey, baseValue); + folder.setDetails(paths.entryKey, { + comment: params.comment || undefined, + tags: normalizeTags([...(params.tags ?? [])]), }); - - const normalizedTranslations = resolveResult?.translations.map(({ locale, value, status }) => ({ - locale, - value: translocoToICU(value), - status, - })); - - // add-resource replaces the whole entry (previous translations and metadata are dropped). - // setEntry clears it in place so an existing key keeps its position in the file. - folder.setEntry(paths.entryKey, { source: normalizedBaseValue }, {}); - folder.setBase(paths.entryKey, normalizedBaseValue); - folder.setDetails(paths.entryKey, { comment: params.comment || undefined, tags: normalizedTags }); - - // Skip the base locale — its value is the entry's 'source'. - for (const { locale, value, status } of normalizedTranslations ?? []) { - if (locale === baseLocale) continue; - // Staleness rule: an untranslated copy of the base is 'new', whatever status was requested. - folder.setTranslation( - paths.entryKey, - locale, - value, - isUntranslatedCopy(value, normalizedBaseValue) ? 'new' : status, - ); + for (const { locale, value, status } of translations) { + folder.setTranslation(paths.entryKey, locale, value, status); } - folder.save(); return { resolvedKey: paths.resolvedKey, - created: isNewEntry, - translations: normalizedTranslations ?? [], - ...(resolveResult?.skippedLocales !== undefined && { skippedLocales: resolveResult.skippedLocales }), - mutations: [upsertMutation(resolve(cwd, translationsFolder), paths.resolvedKey, folder.treeEntry(paths.entryKey))], - }; -} - -interface ResolveTranslationsParams { - readonly params: AddResourceParams; - /** ICU-normalized form of the base value — this is what gets stored and what the translation provider should receive. */ - readonly normalizedBaseValue: string; - readonly baseLocale: string; - readonly translationConfig: TranslationConfig | undefined; -} - -interface ResolveTranslationsResult { - readonly translations: Array<{ locale: string; value: string; status: TranslationStatus }>; - readonly skippedLocales: string[]; -} - -/** - * Determines which translations to use for the resource entry. - * - * Priority: - * 1. Explicit `params.translations` — used as-is when provided (no `skippedLocales`). - * 2. Auto-translation — triggered when `translationConfig` is enabled and - * `params.allLocales` is set (caller must supply target locale list). - * 3. No translations — returns `undefined` so the entry is stored without them. - */ -async function resolveTranslations( - resolveParams: ResolveTranslationsParams, -): Promise { - const { params, normalizedBaseValue, baseLocale, translationConfig } = resolveParams; - - if (params.translations && params.translations.length > 0) { - return { translations: params.translations, skippedLocales: [] }; - } - - const shouldAutoTranslate = translationConfig?.enabled && params.allLocales && params.allLocales.length > 0; - if (!shouldAutoTranslate || !translationConfig || !params.allLocales) { - return undefined; - } - - const targetLocales = params.allLocales.filter((locale) => locale !== baseLocale); - if (targetLocales.length === 0) { - return undefined; - } - - const autoTranslateResult = await autoTranslateResource({ - baseValue: normalizedBaseValue, - baseLocale, - targetLocales, - translationConfig, - }); - - return { - translations: autoTranslateResult.translations.map(({ locale, value, status }) => ({ locale, value, status })), - skippedLocales: autoTranslateResult.skippedLocales, + created, + translations, + ...(seeding.skippedLocales !== undefined && { skippedLocales: seeding.skippedLocales }), + mutations: [upsertMutation(translationsFolder, paths.resolvedKey, folder.treeEntry(paths.entryKey))], }; } diff --git a/libs/core/src/resource/checksum.spec.ts b/libs/core/src/resource/checksum.spec.ts index c1db5191..78688a61 100644 --- a/libs/core/src/resource/checksum.spec.ts +++ b/libs/core/src/resource/checksum.spec.ts @@ -1,4 +1,4 @@ -import { calculateChecksum, verifyChecksum } from './checksum'; +import { calculateChecksum } from './checksum'; describe('Checksum Utilities', () => { describe('calculateChecksum', () => { @@ -33,25 +33,4 @@ describe('Checksum Utilities', () => { expect(checksum).toHaveLength(32); // MD5 produces 32 hex chars }); }); - - describe('verifyChecksum', () => { - it('should verify matching checksums', () => { - const value = 'test value'; - const checksum = calculateChecksum(value); - expect(verifyChecksum(value, checksum)).toBe(true); - }); - - it('should reject mismatching checksums', () => { - const value = 'test value'; - const wrongChecksum = calculateChecksum('different value'); - expect(verifyChecksum(value, wrongChecksum)).toBe(false); - }); - - it('should be case-insensitive for stored checksums', () => { - const value = 'test value'; - const checksum = calculateChecksum(value).toUpperCase(); - // Note: lowercase comparison should work - expect(verifyChecksum(value, checksum.toLowerCase())).toBe(true); - }); - }); }); diff --git a/libs/core/src/resource/checksum.ts b/libs/core/src/resource/checksum.ts index 3314feff..fbcf5849 100644 --- a/libs/core/src/resource/checksum.ts +++ b/libs/core/src/resource/checksum.ts @@ -8,13 +8,3 @@ import * as crypto from 'node:crypto'; export function calculateChecksum(value: string): string { return crypto.createHash('md5').update(value).digest('hex'); } - -/** - * Verifies that a value matches its stored checksum. - * @param value - The string value to verify - * @param storedChecksum - The stored checksum to compare against - * @returns true if the value's checksum matches the stored checksum - */ -export function verifyChecksum(value: string, storedChecksum: string): boolean { - return calculateChecksum(value) === storedChecksum; -} diff --git a/libs/core/src/resource/delete-resource.spec.ts b/libs/core/src/resource/delete-resource.spec.ts index 28f10c0e..706f61e3 100644 --- a/libs/core/src/resource/delete-resource.spec.ts +++ b/libs/core/src/resource/delete-resource.spec.ts @@ -1,6 +1,19 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { deleteResource } from './delete-resource'; import * as fs from 'node:fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Collection } from '../lib/config/open-collection'; +import { deleteResource } from './delete-resource'; + +const collection: Collection = { + name: 'main', + translationsFolder: 'translations', + baseLocale: 'en', + locales: ['en'], + targetLocales: [], + translationConfig: undefined, + tags: [], + readOnly: false, + config: { translationsFolder: 'translations' }, +}; vi.mock('node:fs'); @@ -28,7 +41,7 @@ describe('deleteResource', () => { }); vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); - const result = deleteResource('translations', { keys: ['app.button.ok'] }); + const result = deleteResource(collection, { keys: ['app.button.ok'] }); expect(result.entriesDeleted).toBe(1); expect(result.errors).toBeUndefined(); @@ -47,7 +60,7 @@ describe('deleteResource', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(resourceEntries)); - const result = deleteResource('translations', { keys: ['app.button.ok'] }); + const result = deleteResource(collection, { keys: ['app.button.ok'] }); expect(result.entriesDeleted).toBe(0); expect(result.errors).toBeDefined(); @@ -59,7 +72,7 @@ describe('deleteResource', () => { it('should collect error when folder does not exist', () => { vi.mocked(fs.existsSync).mockReturnValue(false); - const result = deleteResource('translations', { keys: ['app.button.ok'] }); + const result = deleteResource(collection, { keys: ['app.button.ok'] }); expect(result.entriesDeleted).toBe(0); expect(result.errors).toBeDefined(); @@ -69,7 +82,7 @@ describe('deleteResource', () => { }); it('should collect error for invalid key format', () => { - const result = deleteResource('translations', { keys: ['invalid key!'] }); + const result = deleteResource(collection, { keys: ['invalid key!'] }); expect(result.entriesDeleted).toBe(0); expect(result.errors).toBeDefined(); @@ -91,7 +104,7 @@ describe('deleteResource', () => { }); vi.mocked(fs.unlinkSync).mockImplementation(() => undefined); - const result = deleteResource('translations', { keys: ['app.button.ok'] }); + const result = deleteResource(collection, { keys: ['app.button.ok'] }); expect(result.entriesDeleted).toBe(1); expect(result.errors).toBeUndefined(); @@ -122,7 +135,7 @@ describe('deleteResource', () => { vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); vi.mocked(fs.unlinkSync).mockImplementation(() => undefined); - const result = deleteResource('translations', { keys: ['app.button.ok'] }); + const result = deleteResource(collection, { keys: ['app.button.ok'] }); expect(result.entriesDeleted).toBe(1); expect(result.errors).toBeUndefined(); @@ -143,7 +156,7 @@ describe('deleteResource', () => { }); vi.mocked(fs.unlinkSync).mockImplementation(() => undefined); - const result = deleteResource('translations', { + const result = deleteResource(collection, { keys: ['apps.common.buttons.ok'], }); @@ -164,7 +177,7 @@ describe('deleteResource', () => { vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(resourceEntries)); vi.mocked(fs.unlinkSync).mockImplementation(() => undefined); - const result = deleteResource('translations', { keys: ['app.button.ok'] }); + const result = deleteResource(collection, { keys: ['app.button.ok'] }); expect(result.entriesDeleted).toBe(1); expect(result.errors).toBeUndefined(); @@ -195,7 +208,7 @@ describe('deleteResource', () => { }); vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); - const result = deleteResource('translations', { + const result = deleteResource(collection, { keys: ['app.button.ok', 'app.button.cancel'], }); @@ -223,7 +236,7 @@ describe('deleteResource', () => { }); vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); - const result = deleteResource('translations', { + const result = deleteResource(collection, { keys: ['app.button.ok', 'invalid key!', 'app.button.cancel'], }); @@ -235,14 +248,14 @@ describe('deleteResource', () => { }); it('should handle empty array', () => { - const result = deleteResource('translations', { keys: [] }); + const result = deleteResource(collection, { keys: [] }); expect(result.entriesDeleted).toBe(0); expect(result.errors).toBeUndefined(); }); it('should handle all keys invalid scenario', () => { - const result = deleteResource('translations', { + const result = deleteResource(collection, { keys: ['invalid key!', 'another bad@key', 'bad#key'], }); @@ -267,7 +280,7 @@ describe('deleteResource', () => { }); vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); - const result = deleteResource('translations', { + const result = deleteResource(collection, { keys: ['app.button.ok', 'app.button.notfound', 'app.button.missing'], }); @@ -303,7 +316,7 @@ describe('deleteResource', () => { callCount.writeCount++; }); - const result = deleteResource('translations', { + const result = deleteResource(collection, { keys: ['app.button.ok', 'common.label.cancel'], }); @@ -314,7 +327,7 @@ describe('deleteResource', () => { describe('Security', () => { it('should reject invalid keys with path traversal characters', () => { - const result = deleteResource('translations', { + const result = deleteResource(collection, { keys: ['../secret.key'], }); @@ -325,7 +338,7 @@ describe('deleteResource', () => { }); it('should NOT attempt to delete files for invalid paths', () => { - deleteResource('translations', { + deleteResource(collection, { keys: ['../secret.key'], }); diff --git a/libs/core/src/resource/delete-resource.ts b/libs/core/src/resource/delete-resource.ts index abbc0ddf..0dfcaa27 100644 --- a/libs/core/src/resource/delete-resource.ts +++ b/libs/core/src/resource/delete-resource.ts @@ -3,6 +3,7 @@ import { resolveResourcePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder } from '../lib/resource/resource-folder'; import { removeMutation, type ResourceMutation } from '../lib/resource/resource-mutation'; import { validateKey } from '@simoncodes-ca/domain'; +import type { Collection } from '../lib/config/open-collection'; export interface DeleteResourceParams { keys: string[]; @@ -18,14 +19,16 @@ export interface DeleteResourceResult { mutations: ResourceMutation[]; } -export function deleteResource(translationsFolder: string, params: DeleteResourceParams): DeleteResourceResult { +/** Deletes entries from a collection. Per-key failures are reported in the result, not thrown. */ +export function deleteResource(collection: Collection, params: DeleteResourceParams): DeleteResourceResult { + const { translationsFolder, baseLocale } = collection; let entriesDeleted = 0; const errors: Array<{ key: string; error: string }> = []; const mutations: ResourceMutation[] = []; for (const key of params.keys) { try { - const deletionSucceeded = deleteSingleResource(translationsFolder, key); + const deletionSucceeded = deleteSingleResource(translationsFolder, baseLocale, key); if (deletionSucceeded) { entriesDeleted++; mutations.push(removeMutation(translationsFolder, key)); @@ -45,7 +48,7 @@ export function deleteResource(translationsFolder: string, params: DeleteResourc }; } -function deleteSingleResource(translationsFolder: string, key: string): boolean { +function deleteSingleResource(translationsFolder: string, baseLocale: string, key: string): boolean { validateKey(key); const paths = resolveResourcePaths({ @@ -61,7 +64,7 @@ function deleteSingleResource(translationsFolder: string, key: string): boolean throw new Error(`Resource file not found: ${paths.resourceEntriesPath}`); } - const folder = openResourceFolder(paths.folderPath); + const folder = openResourceFolder(paths.folderPath, { baseLocale }); if (!folder.remove(paths.entryKey)) { throw new Error(`Resource entry not found: ${key}`); diff --git a/libs/core/src/resource/edit-resource.spec.ts b/libs/core/src/resource/edit-resource.spec.ts index 55953cd1..8528287a 100644 --- a/libs/core/src/resource/edit-resource.spec.ts +++ b/libs/core/src/resource/edit-resource.spec.ts @@ -1,469 +1,311 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LingoTrackerConfig } from '../config/lingo-tracker-config'; +import type { TranslationConfig } from '../config/translation-config'; +import { type Collection, openCollection } from '../lib/config/open-collection'; +import { + InvalidResourceKeyError, + LocaleNotFoundError, + ResourceAlreadyExistsError, + ResourceNotFoundError, +} from '../lib/errors/lingo-tracker-error'; +import { openResourceFolder } from '../lib/resource/resource-folder'; +import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; +import { TranslationError } from '../lib/translation/translation-provider'; +import { calculateChecksum as md5 } from './checksum'; import { editResource } from './edit-resource'; -import * as fs from 'node:fs'; -import { type SafeAny, RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../constants'; -vi.mock('node:fs'); vi.mock('../lib/translation/auto-translate-resources'); -import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; +const AUTO: TranslationConfig = { enabled: true, provider: 'google-translate', apiKeyEnv: 'KEY' }; + +describe('editResource (real fs)', () => { + let root: string; -describe('editResource', () => { - const translationsFolder = 'translations'; - const cwd = '/test'; + function collection(translation?: TranslationConfig): Collection { + const config: LingoTrackerConfig = { + exportFolder: 'dist', + importFolder: 'import', + baseLocale: 'en', + locales: ['en', 'fr', 'de', 'es'], + collections: { main: { translationsFolder: join(root, 'translations') } }, + ...(translation && { translation }), + }; + return openCollection(config, 'main'); + } + + /** + * Writes `common.save` with base "Save": fr a real translation (verified), de an untranslated + * copy (new), es missing. + */ + function seedEntry(): void { + const folder = openResourceFolder(join(root, 'translations', 'common'), { baseLocale: 'en' }); + folder.setBase('save', 'Save'); + folder.setTranslation('save', 'fr', 'Enregistrer', 'verified'); + folder.setTranslation('save', 'de', 'Save', 'new'); + folder.save(); + } + + function read(file: 'resource_entries.json' | 'tracker_meta.json', ...segments: string[]) { + return JSON.parse(readFileSync(join(root, 'translations', ...segments, file), 'utf8')); + } beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('{}'); - vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + root = mkdtempSync(join(tmpdir(), 'edit-resource-')); + vi.mocked(autoTranslateResource).mockReset(); + seedEntry(); }); - it('should throw error if resource file does not exist', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - await expect(editResource(translationsFolder, { key: 'buttons.save', cwd })).rejects.toThrow(/Resource not found/); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); }); - it('should throw error if resource entry does not exist in file', async () => { - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({})); - await expect(editResource(translationsFolder, { key: 'buttons.save', cwd })).rejects.toThrow(/Resource not found/); + it('throws ResourceNotFoundError for a missing entry', async () => { + await expect(editResource(collection(), 'common.missing', { comment: 'x' })).rejects.toThrow(ResourceNotFoundError); + await expect(editResource(collection(), 'nowhere.save', { comment: 'x' })).rejects.toThrow(ResourceNotFoundError); }); - it('should return updated: false if no changes are made', async () => { - const initialResources = { - save: { source: 'Save' }, - }; - const initialMeta = { - save: { en: { checksum: 'abc' } }, - }; - - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; - }); + it('reports no changes and writes nothing when nothing differs', async () => { + const result = await editResource(collection(), 'common.save', { baseValue: 'Save', translations: {} }); - const result = await editResource(translationsFolder, { - key: 'buttons.save', - baseValue: 'Save', - cwd, + expect(result).toEqual({ + resolvedKey: 'common.save', + updated: false, + message: 'No changes detected', + mutations: [], }); - - expect(result.updated).toBe(false); }); - it('should update base value and mark other locales as stale', async () => { - const initialResources = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - const initialMeta = { - save: { - en: { checksum: 'old_base_hash' }, - 'fr-ca': { - checksum: 'fr_hash', - baseChecksum: 'old_base_hash', - status: 'translated', - }, - }, - }; - - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; + describe('base value change', () => { + it('keeps real translations as `stale` and re-seeds copies and missing locales as `new`', async () => { + const result = await editResource(collection(), 'common.save', { baseValue: 'Save all' }); + + expect(read('resource_entries.json', 'common').save).toEqual({ + source: 'Save all', + fr: 'Enregistrer', + de: 'Save all', + es: 'Save all', + }); + const meta = read('tracker_meta.json', 'common').save; + expect(meta.en).toEqual({ checksum: md5('Save all') }); + expect(meta.fr).toEqual({ checksum: md5('Enregistrer'), baseChecksum: md5('Save all'), status: 'stale' }); + expect(meta.de).toEqual({ checksum: md5('Save all'), baseChecksum: md5('Save all'), status: 'new' }); + expect(meta.es.status).toBe('new'); + expect(result.updated).toBe(true); + expect(result.skippedLocales).toBeUndefined(); + expect(autoTranslateResource).not.toHaveBeenCalled(); }); - const result = await editResource(translationsFolder, { - key: 'buttons.save', - baseValue: 'Save Item', - cwd, + it('auto-translates every locale that needs work, except those supplied in the same edit', async () => { + vi.mocked(autoTranslateResource).mockResolvedValue({ + translations: [{ locale: 'de', value: 'Alles speichern', status: 'translated' }], + skippedLocales: ['es'], + }); + + const result = await editResource(collection(AUTO), 'common.save', { + baseValue: 'Save all', + translations: { fr: { value: 'Tout enregistrer', status: 'translated' } }, + }); + + expect(autoTranslateResource).toHaveBeenCalledWith({ + baseValue: 'Save all', + baseLocale: 'en', + targetLocales: ['de', 'es'], + translationConfig: AUTO, + }); + expect(read('resource_entries.json', 'common').save).toEqual({ + source: 'Save all', + fr: 'Tout enregistrer', + de: 'Alles speichern', + es: 'Save all', + }); + const meta = read('tracker_meta.json', 'common').save; + expect(meta.fr.status).toBe('translated'); + expect(meta.de).toEqual({ + checksum: md5('Alles speichern'), + baseChecksum: md5('Save all'), + status: 'translated', + }); + expect(meta.es.status).toBe('new'); + expect(result.skippedLocales).toEqual(['es']); }); - expect(result.updated).toBe(true); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedMeta = JSON.parse(writeCall[1][1] as string); - - expect(updatedMeta.save.en.checksum).not.toBe('old_base_hash'); - expect(updatedMeta.save['fr-ca'].status).toBe('stale'); - expect(updatedMeta.save['fr-ca'].baseChecksum).toBe(updatedMeta.save.en.checksum); + it('keeps the saved edit when the provider fails', async () => { + vi.mocked(autoTranslateResource).mockRejectedValue(new TranslationError('down', 'SERVICE_ERROR', true)); - const updatedResources = JSON.parse(writeCall[0][1] as string); - expect(updatedResources.save.source).toBe('Save Item'); - }); + await expect(editResource(collection(AUTO), 'common.save', { baseValue: 'Save all' })).rejects.toThrow( + TranslationError, + ); - it('should update comment and tags', async () => { - const initialResources = { - save: { source: 'Save' }, - }; - const initialMeta = { - save: { en: { checksum: 'abc' } }, - }; - - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; - }); - - const result = await editResource(translationsFolder, { - key: 'buttons.save', - comment: 'New comment', - tags: ['ui', 'action'], - cwd, + expect(read('resource_entries.json', 'common').save.source).toBe('Save all'); + expect(read('tracker_meta.json', 'common').save.fr.status).toBe('stale'); }); - expect(result.updated).toBe(true); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedResources = JSON.parse(writeCall[0][1] as string); - expect(updatedResources.save.comment).toBe('New comment'); - expect(updatedResources.save.tags).toEqual(['ui', 'action']); - }); + it('does not seed anything when only the comment changes', async () => { + await editResource(collection(AUTO), 'common.save', { comment: 'Toolbar button' }); - it('should default status to "translated" when no status is supplied', async () => { - const initialResources = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - const initialMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { - checksum: 'old_fr_hash', - baseChecksum: 'base_hash', - status: 'stale', - }, - }, - }; - - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; + expect(autoTranslateResource).not.toHaveBeenCalled(); + expect(read('resource_entries.json', 'common').save.es).toBeUndefined(); }); - const result = await editResource(translationsFolder, { - key: 'buttons.save', - locales: { 'fr-ca': { value: 'Enregistrer' } }, - cwd, - }); - - expect(result.updated).toBe(true); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedResources = JSON.parse(writeCall[0][1] as string); - expect(updatedResources.save['fr-ca']).toBe('Enregistrer'); - - const updatedMeta = JSON.parse(writeCall[1][1] as string); - expect(updatedMeta.save['fr-ca'].status).toBe('translated'); - expect(updatedMeta.save['fr-ca'].checksum).not.toBe('old_fr_hash'); - }); - - it('should persist caller-supplied status', async () => { - const initialResources = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - const initialMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { - checksum: 'old_fr_hash', - baseChecksum: 'base_hash', - status: 'stale', - }, - }, - }; - - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; - }); + it('normalizes a Transloco base value to ICU', async () => { + await editResource(collection(), 'common.save', { baseValue: 'Save {{ count }}' }); - const result = await editResource(translationsFolder, { - key: 'buttons.save', - locales: { 'fr-ca': { value: 'Enregistrer', status: 'verified' } }, - cwd, + expect(read('resource_entries.json', 'common').save.source).toBe('Save {count}'); }); - - expect(result.updated).toBe(true); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedMeta = JSON.parse(writeCall[1][1] as string); - expect(updatedMeta.save['fr-ca'].status).toBe('verified'); }); - it('should update status when only status changes', async () => { - const initialResources = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - const initialMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { - checksum: 'existing_fr_hash', - baseChecksum: 'base_hash', - status: 'translated', - }, - }, - }; + describe('details and translations', () => { + it('updates comment and normalized tags', async () => { + await editResource(collection(), 'common.save', { comment: 'Button', tags: ['UI', 'forms'] }); - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; + const entry = read('resource_entries.json', 'common').save; + expect(entry.comment).toBe('Button'); + expect(entry.tags).toEqual(['ui', 'forms']); }); - const result = await editResource(translationsFolder, { - key: 'buttons.save', - locales: { 'fr-ca': { value: 'Sauvegarder', status: 'verified' } }, - cwd, - }); - - expect(result.updated).toBe(true); + it('writes a translation with status `translated` by default, or the supplied status', async () => { + await editResource(collection(), 'common.save', { + translations: { de: { value: 'Speichern' }, es: { value: 'Guardar', status: 'verified' } }, + }); - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedMeta = JSON.parse(writeCall[1][1] as string); - expect(updatedMeta.save['fr-ca'].status).toBe('verified'); - }); - - it('should convert Transloco syntax to ICU format when updating base value', async () => { - const initialResources = { - save: { source: 'Save' }, - }; - const initialMeta = { - save: { en: { checksum: 'abc' } }, - }; - - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; - }); - - await editResource(translationsFolder, { - key: 'buttons.save', - baseValue: 'Hello {{ name }}', - cwd, - }); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedResources = JSON.parse(writeCall[0][1] as string); - expect(updatedResources.save.source).toBe('Hello {name}'); - }); - - it('should convert Transloco syntax to ICU format when updating a locale translation', async () => { - const initialResources = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - const initialMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { - checksum: 'old_fr_hash', - baseChecksum: 'base_hash', - status: 'stale', - }, - }, - }; - - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; + const meta = read('tracker_meta.json', 'common').save; + expect(meta.de.status).toBe('translated'); + expect(meta.es.status).toBe('verified'); + expect(read('resource_entries.json', 'common').save.de).toBe('Speichern'); }); - await editResource(translationsFolder, { - key: 'buttons.save', - locales: { 'fr-ca': { value: 'Bonjour {{ name }}' } }, - cwd, - }); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedResources = JSON.parse(writeCall[0][1] as string); - expect(updatedResources.save['fr-ca']).toBe('Bonjour {name}'); - }); - - it('should preserve already-ICU-formatted baseValue unchanged', async () => { - const initialResources = { - save: { source: 'Save' }, - }; - const initialMeta = { - save: { en: { checksum: 'abc' } }, - }; + it('changes only the status when the value is unchanged', async () => { + await editResource(collection(), 'common.save', { + translations: { fr: { value: 'Enregistrer', status: 'stale' } }, + }); - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; + const meta = read('tracker_meta.json', 'common').save; + expect(meta.fr).toEqual({ checksum: md5('Enregistrer'), baseChecksum: md5('Save'), status: 'stale' }); }); - await editResource(translationsFolder, { - key: 'buttons.save', - baseValue: 'Hello {name}', - cwd, - }); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedResources = JSON.parse(writeCall[0][1] as string); - expect(updatedResources.save.source).toBe('Hello {name}'); - }); - - it('should preserve already-ICU-formatted locale value unchanged', async () => { - const initialResources = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - const initialMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { - checksum: 'old_fr_hash', - baseChecksum: 'base_hash', - status: 'stale', - }, - }, - }; + it('ignores a value for the base locale', async () => { + const result = await editResource(collection(), 'common.save', { translations: { en: { value: 'Nope' } } }); - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; + expect(result.updated).toBe(false); + expect(read('resource_entries.json', 'common').save.source).toBe('Save'); }); - await editResource(translationsFolder, { - key: 'buttons.save', - locales: { 'fr-ca': { value: 'Bonjour {name}' } }, - cwd, + it('rejects a locale the collection does not have', async () => { + await expect( + editResource(collection(), 'common.save', { translations: { ja: { value: '保存' } } }), + ).rejects.toThrow(LocaleNotFoundError); }); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedResources = JSON.parse(writeCall[0][1] as string); - expect(updatedResources.save['fr-ca']).toBe('Bonjour {name}'); }); - it('should not auto-translate when translationConfig is disabled', async () => { - const initialResources = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - const initialMeta = { - save: { - en: { checksum: 'old_base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'old_base_hash', status: 'translated' }, - }, - }; - - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; + describe('moveTo', () => { + it('moves the edited entry into another folder, keeping its entry key', async () => { + const target = collection(); + const result = await editResource(target, 'common.save', { comment: 'Moved', moveTo: 'dialogs.actions' }); + + expect(result.resolvedKey).toBe('dialogs.actions.save'); + expect(result.entry?.comment).toBe('Moved'); + expect(read('resource_entries.json', 'dialogs', 'actions').save).toEqual({ + source: 'Save', + comment: 'Moved', + fr: 'Enregistrer', + de: 'Save', + }); + expect(read('tracker_meta.json', 'dialogs', 'actions').save.fr.status).toBe('verified'); + expect(existsSync(join(root, 'translations', 'common', 'resource_entries.json'))).toBe(false); + expect(result.mutations).toEqual([ + expect.objectContaining({ kind: 'upsert', key: 'dialogs.actions.save' }), + expect.objectContaining({ kind: 'remove', key: 'common.save', translationsFolder: target.translationsFolder }), + ]); }); - const result = await editResource(translationsFolder, { - key: 'buttons.save', - baseValue: 'Save Item', - cwd, - translationConfig: { enabled: false, provider: 'google-translate', apiKeyEnv: 'GOOGLE_API_KEY' }, - allLocales: ['en', 'fr-ca'], - }); - - expect(result.updated).toBe(true); - - // With disabled config the locale remains stale (auto-translate did not run) - const writeCall = vi.mocked(fs.writeFileSync).mock.calls; - const updatedMeta = JSON.parse(writeCall[1][1] as string); - expect(updatedMeta.save['fr-ca'].status).toBe('stale'); - }); - - it('should normalize Transloco syntax returned by auto-translation provider to ICU format', async () => { - const initialResources = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - const initialMeta = { - save: { - en: { checksum: 'old_base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'old_base_hash', status: 'translated' }, - }, - }; + it('moves the entry to the collection root with an empty moveTo, even with no other change', async () => { + const result = await editResource(collection(), 'common.save', { moveTo: '' }); - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; + expect(result.updated).toBe(true); + expect(result.resolvedKey).toBe('save'); + expect(read('resource_entries.json').save.source).toBe('Save'); }); - // Provider returns Transloco-style {{ }} — storage contract requires ICU {}. - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [{ locale: 'fr-ca', value: 'Bonjour {{ name }}', status: 'translated' }], - skippedLocales: [], - }); + it('edits in place when moveTo is the current folder', async () => { + const result = await editResource(collection(), 'common.save', { comment: 'Same', moveTo: 'common' }); - await editResource(translationsFolder, { - key: 'buttons.save', - baseValue: 'Hello {name}', - cwd, - translationConfig: { enabled: true, provider: 'google-translate', apiKeyEnv: 'GOOGLE_API_KEY' }, - allLocales: ['en', 'fr-ca'], + expect(result.resolvedKey).toBe('common.save'); + expect(result.mutations).toEqual([expect.objectContaining({ kind: 'upsert', key: 'common.save' })]); + expect(read('resource_entries.json', 'common').save.comment).toBe('Same'); }); - const writeCalls = vi.mocked(fs.writeFileSync).mock.calls; - const lastResourcesWrite = JSON.parse(writeCalls[writeCalls.length - 2][1] as string); - expect(lastResourcesWrite.save['fr-ca']).toBe('Bonjour {name}'); - - // Checksum must be computed from the normalized ICU value, not the raw provider output. - const lastMetaWrite = JSON.parse(writeCalls[writeCalls.length - 1][1] as string); - const { calculateChecksum } = await import('./checksum'); - expect(lastMetaWrite.save['fr-ca'].checksum).toBe(calculateChecksum('Bonjour {name}')); - }); + it('refuses to overwrite an entry at the destination and changes nothing', async () => { + const other = openResourceFolder(join(root, 'translations', 'dialogs'), { baseLocale: 'en' }); + other.setBase('save', 'Other'); + other.save(); - it('should auto-translate and write translated values when translationConfig is enabled and base value changes', async () => { - const initialResources = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - const initialMeta = { - save: { - en: { checksum: 'old_base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'old_base_hash', status: 'translated' }, - }, - }; - - vi.mocked(fs.readFileSync).mockImplementation((path: SafeAny) => { - if ((path as string).includes(RESOURCE_ENTRIES_FILENAME)) return JSON.stringify(initialResources); - if ((path as string).includes(TRACKER_META_FILENAME)) return JSON.stringify(initialMeta); - return '{}'; + await expect(editResource(collection(), 'common.save', { comment: 'Lost?', moveTo: 'dialogs' })).rejects.toThrow( + ResourceAlreadyExistsError, + ); + expect(read('resource_entries.json', 'common').save.comment).toBeUndefined(); + expect(read('resource_entries.json', 'dialogs').save.source).toBe('Other'); }); - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [{ locale: 'fr-ca', value: "Enregistrer l'élément", status: 'translated' }], - skippedLocales: [], + it('rejects a malformed destination folder', async () => { + await expect(editResource(collection(), 'common.save', { moveTo: '../evil' })).rejects.toThrow( + InvalidResourceKeyError, + ); }); - const result = await editResource(translationsFolder, { - key: 'buttons.save', - baseValue: 'Save Item', - cwd, - translationConfig: { enabled: true, provider: 'google-translate', apiKeyEnv: 'GOOGLE_API_KEY' }, - allLocales: ['en', 'fr-ca'], + describe('while auto-translation is awaited', () => { + /** Makes the provider wait until `release()`; `called` resolves once the edit is awaiting it. */ + function holdProvider(): { called: Promise; release: () => void } { + let release = (): void => undefined; + let markCalled = (): void => undefined; + const called = new Promise((resolve) => { + markCalled = resolve; + }); + vi.mocked(autoTranslateResource).mockImplementation(() => { + markCalled(); + return new Promise((resolve) => { + release = () => resolve({ translations: [], skippedLocales: [] }); + }); + }); + return { called, release: () => release() }; + } + + function writeDestinationEntry(key: string, value: string): void { + const other = openResourceFolder(join(root, 'translations', 'dialogs'), { baseLocale: 'en' }); + other.setBase(key, value); + other.save(); + } + + it('keeps an entry written to the destination folder meanwhile, and still moves the edited entry', async () => { + const provider = holdProvider(); + + const editing = editResource(collection(AUTO), 'common.save', { baseValue: 'Save all', moveTo: 'dialogs' }); + await provider.called; + writeDestinationEntry('cancel', 'Cancel'); + provider.release(); + const result = await editing; + + const destination = read('resource_entries.json', 'dialogs'); + expect(destination.cancel).toEqual({ source: 'Cancel' }); + expect(destination.save.source).toBe('Save all'); + expect(result.resolvedKey).toBe('dialogs.save'); + }); + + it('throws ResourceAlreadyExistsError when the entry key was taken meanwhile, keeping both entries', async () => { + const provider = holdProvider(); + + const editing = editResource(collection(AUTO), 'common.save', { baseValue: 'Save all', moveTo: 'dialogs' }); + await provider.called; + writeDestinationEntry('save', 'Other'); + provider.release(); + + await expect(editing).rejects.toThrow(ResourceAlreadyExistsError); + expect(read('resource_entries.json', 'dialogs').save).toEqual({ source: 'Other' }); + // The edit itself was saved before the move was attempted. + expect(read('resource_entries.json', 'common').save.source).toBe('Save all'); + }); }); - - expect(result.updated).toBe(true); - expect(autoTranslateResource).toHaveBeenCalledWith({ - baseValue: 'Save Item', - baseLocale: 'en', - targetLocales: ['fr-ca'], - translationConfig: { enabled: true, provider: 'google-translate', apiKeyEnv: 'GOOGLE_API_KEY' }, - }); - - // writeFileSync is called twice: once for the initial save, once after auto-translation. - // The second pair of calls contains the auto-translated values. - const writeCalls = vi.mocked(fs.writeFileSync).mock.calls; - const lastResourcesWrite = JSON.parse(writeCalls[writeCalls.length - 2][1] as string); - const lastMetaWrite = JSON.parse(writeCalls[writeCalls.length - 1][1] as string); - - expect(lastResourcesWrite.save['fr-ca']).toBe("Enregistrer l'élément"); - expect(lastMetaWrite.save['fr-ca'].status).toBe('translated'); - // Checksum must be recalculated — it should differ from the stale one - expect(lastMetaWrite.save['fr-ca'].checksum).not.toBe('fr_hash'); - // baseChecksum must reflect the new base value - expect(lastMetaWrite.save['fr-ca'].baseChecksum).toBe(lastMetaWrite.save.en.checksum); }); }); diff --git a/libs/core/src/resource/edit-resource.ts b/libs/core/src/resource/edit-resource.ts index e28b5ed2..af574f83 100644 --- a/libs/core/src/resource/edit-resource.ts +++ b/libs/core/src/resource/edit-resource.ts @@ -1,216 +1,248 @@ -import { resolve } from 'node:path'; -import { ResourceNotFoundError } from '../lib/errors/lingo-tracker-error'; +import { + isUntranslatedCopy, + needsTranslation, + normalizeTags, + type TranslationStatus, + translocoToICU, +} from '@simoncodes-ca/domain'; +import type { Collection } from '../lib/config/open-collection'; +import { ResourceAlreadyExistsError, ResourceNotFoundError } from '../lib/errors/lingo-tracker-error'; +import type { ResourceTreeEntry } from '../lib/resource/load-resource-tree'; import { validateAndResolvePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../lib/resource/resource-folder'; -import type { ResourceTreeEntry } from '../lib/resource/load-resource-tree'; -import { type ResourceMutation, upsertMutation } from '../lib/resource/resource-mutation'; -import type { TranslationConfig } from '../config/translation-config'; -import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; -import type { TranslationStatus } from '@simoncodes-ca/domain'; -import { translocoToICU, normalizeTags } from '@simoncodes-ca/domain'; - -export interface EditResourceOptions { - key: string; - targetFolder?: string; - baseValue?: string; - comment?: string; - tags?: string[]; - locales?: Record; - baseLocale?: string; - cwd?: string; - translationConfig?: TranslationConfig; +import { removeMutation, type ResourceMutation, upsertMutation } from '../lib/resource/resource-mutation'; +import { assertCollectionLocales, seedLocales } from './locale-seeding'; + +/** What to change on an entry. `undefined` leaves a field alone. */ +export interface EditResourceChanges { + /** New base value. When it changes, the Staleness rule and Locale seeding run (see {@link editResource}). */ + readonly baseValue?: string; + readonly comment?: string; + /** Replaces the tags; an empty list removes them. */ + readonly tags?: readonly string[]; + /** Translations by locale. `status` defaults to `translated`. A value for the base locale is ignored. */ + readonly translations?: Readonly>; /** - * All configured locales. Required when using auto-translation after a base - * value change so the orchestrator knows which target locales to update. + * Destination folder (dot-delimited; `''` for the collection root). The entry keeps its + * entry key (the last key segment) and moves there, with its values, metadata and edits. */ - allLocales?: readonly string[]; + readonly moveTo?: string; } export interface EditResourceResult { - resolvedKey: string; - updated: boolean; - message?: string; - entry?: ResourceTreeEntry; - skippedLocales?: string[]; + /** The entry's key after the edit: the destination key when it moved. */ + readonly resolvedKey: string; + readonly updated: boolean; + readonly message?: string; + readonly entry?: ResourceTreeEntry; + /** Locales the provider did not translate (ICU messages). Present only when auto-translation ran. */ + readonly skippedLocales?: string[]; /** What changed on disk (empty when nothing was updated). */ - mutations: ResourceMutation[]; + readonly mutations: ResourceMutation[]; } /** - * Edits an existing resource entry in the translations folder. + * Edits an existing resource entry of a collection. + * + * When the base value changes, the Staleness rule updates every translation's status, then + * Locale seeding ({@link seedLocales}) fills the locales that need work and were not supplied + * in `changes.translations`: auto-translated when the collection has it enabled, else a copy + * of the new base value as `new` for a locale that has no value or held an untranslated copy + * of the old base value. A real translation is kept (and is `stale`). * - * When `options.translationConfig` is enabled and `options.baseValue` changes, - * all non-base locales are automatically re-translated. The updated translations - * are written in a second pass after the initial save, so the base value change - * is persisted even if auto-translation fails. + * The edit is saved before auto-translation runs, so it is kept even if the provider fails. + * With `moveTo`, the edited entry then moves to the destination folder: the destination is + * written before the source entry is removed. `moveTo` is validated, and the destination + * checked for a collision, before anything is written. The destination is read again just + * before the move (auto-translation may have taken a while), and a collision found then + * throws `ResourceAlreadyExistsError` with the edit already saved in the source folder. * - * @param translationsFolder - Root translations folder path - * @param options - Edit options including the resource key and fields to update - * @returns Result object indicating what changed + * @param key - The entry's full, existing key. + * @throws {InvalidResourceKeyError} `key` or `moveTo` is malformed. + * @throws {ResourceNotFoundError} No entry exists at `key`. + * @throws {ResourceAlreadyExistsError} The destination folder already has an entry with this entry key + * (checked before the edit, and again, on fresh disk state, just before the move). + * @throws {LocaleNotFoundError} A translation names a locale the collection does not have. + * @throws {TranslationError} The translation provider failed (the edit itself is saved). */ export async function editResource( - translationsFolder: string, - options: EditResourceOptions, + collection: Collection, + key: string, + changes: EditResourceChanges, ): Promise { - const { cwd = process.cwd(), baseLocale = 'en' } = options; - - const paths = validateAndResolvePaths({ - key: options.key, - translationsFolder, - targetFolder: options.targetFolder, - cwd, - }); - + const { baseLocale, translationsFolder } = collection; + const paths = validateAndResolvePaths({ key, translationsFolder }); const folder = openResourceFolder(paths.folderPath, { baseLocale }); const current = folder.get(paths.entryKey); - if (!current?.meta) { throw new ResourceNotFoundError(paths.resolvedKey); } - const key = paths.entryKey; + const translations = Object.entries(changes.translations ?? {}).filter(([locale]) => locale !== baseLocale); + assertCollectionLocales( + collection, + translations.map(([locale]) => locale), + ); + + const destination = changes.moveTo === undefined ? undefined : resolveDestination(collection, paths, changes.moveTo); + + const entryKey = paths.entryKey; // Live view of the stored entry: it reflects every change made through `folder`. const { entry } = current; + const previousBase = entry.source; let hasChanges = false; - // 1. Update Base Value — normalize to ICU format before comparing and storing. // setBase applies the Staleness rule to every translation. - const normalizedBaseValue = options.baseValue !== undefined ? translocoToICU(options.baseValue) : undefined; - const baseValueDidChange = normalizedBaseValue !== undefined && normalizedBaseValue !== entry.source; - - if (baseValueDidChange) { - folder.setBase(key, normalizedBaseValue); + const baseValue = changes.baseValue === undefined ? undefined : translocoToICU(changes.baseValue); + const baseChanged = baseValue !== undefined && baseValue !== previousBase; + if (baseChanged) { + folder.setBase(entryKey, baseValue); hasChanges = true; } - // 2. Update Comment - if (options.comment !== undefined && folder.setDetails(key, { comment: options.comment })) { + if (changes.comment !== undefined && folder.setDetails(entryKey, { comment: changes.comment })) { hasChanges = true; } - - // 3. Update Tags - if (options.tags !== undefined && folder.setDetails(key, { tags: normalizeTags(options.tags) })) { + if (changes.tags !== undefined && folder.setDetails(entryKey, { tags: normalizeTags([...changes.tags]) })) { hasChanges = true; } - // 4. Update Locales - if (options.locales) { - for (const [locale, { value, status }] of Object.entries(options.locales)) { - if (locale === baseLocale) continue; // Base value handled separately - - const normalizedLocaleValue = translocoToICU(value); - const resolvedStatus = status ?? 'translated'; - const localeMeta = folder.get(key)?.meta?.[locale]; - - if (normalizedLocaleValue !== entry[locale]) { - folder.setTranslation(key, locale, normalizedLocaleValue, resolvedStatus); - hasChanges = true; - } else if (localeMeta && localeMeta.status !== resolvedStatus) { - folder.setStatus(key, locale, resolvedStatus); + for (const [locale, { value, status = 'translated' }] of translations) { + const normalized = translocoToICU(value); + if (normalized !== entry[locale]) { + folder.setTranslation(entryKey, locale, normalized, status); + hasChanges = true; + } else { + const localeMeta = folder.get(entryKey)?.meta?.[locale]; + if (localeMeta && localeMeta.status !== status) { + folder.setStatus(entryKey, locale, status); hasChanges = true; } } } - if (!hasChanges) { - return { - resolvedKey: paths.resolvedKey, - updated: false, - message: 'No changes detected', - mutations: [], - }; + if (!hasChanges && !destination) { + return { resolvedKey: paths.resolvedKey, updated: false, message: 'No changes detected', mutations: [] }; } - // Two-phase write: persist the edit before attempting auto-translation, so the - // base value update is durable even if the translation API call fails. - folder.save(); - - // 5. Auto-translate when base value changed and translation is configured - let autoTranslateSkippedLocales: string[] | undefined; - - if (baseValueDidChange) { - const autoTranslateResult = await applyAutoTranslationsAfterBaseValueChange({ - folder, - key, - baseValue: normalizedBaseValue, - baseLocale, - allLocales: options.allLocales, - translationConfig: options.translationConfig, - }); + // Two-phase write: the edit is saved before auto-translation, so it is kept if the provider fails. + if (hasChanges) { + folder.save(); + } - if (autoTranslateResult.skippedLocales.length > 0) { - autoTranslateSkippedLocales = autoTranslateResult.skippedLocales; + let skippedLocales: string[] | undefined; + if (baseChanged) { + const seeding = await seedLocales(collection, { + baseValue, + supplied: translations.map(([locale]) => locale), + needsWork: (locale) => needsTranslation(folder.get(entryKey)?.meta?.[locale]), + // A real translation is kept; no value, or an untranslated copy of the old base, is not. + keepsValue: (locale) => { + const value = entry[locale]; + return typeof value === 'string' && !isUntranslatedCopy(value, previousBase); + }, + }); + for (const translation of seeding.translations) { + folder.setTranslation(entryKey, translation.locale, translation.value, translation.status); } - - if (autoTranslateResult.didTranslate) { - // Second phase: persist the translated values. + if (seeding.translations.length > 0) { folder.save(); } + if (seeding.skippedLocales && seeding.skippedLocales.length > 0) { + skippedLocales = seeding.skippedLocales; + } } - const updatedEntry = folder.treeEntry(key); + const moved = destination ? moveEntry(collection, folder, paths.resolvedKey, destination) : undefined; + const resolvedKey = moved?.resolvedKey ?? paths.resolvedKey; + const updatedEntry = moved?.entry ?? folder.treeEntry(entryKey); if (!updatedEntry) { - throw new ResourceNotFoundError(paths.resolvedKey); + throw new ResourceNotFoundError(resolvedKey); } return { - resolvedKey: paths.resolvedKey, + resolvedKey, updated: true, entry: updatedEntry, - mutations: [upsertMutation(resolve(cwd, translationsFolder), paths.resolvedKey, updatedEntry)], - ...(autoTranslateSkippedLocales !== undefined && { skippedLocales: autoTranslateSkippedLocales }), + mutations: moved?.mutations ?? [upsertMutation(translationsFolder, resolvedKey, updatedEntry)], + ...(skippedLocales !== undefined && { skippedLocales }), }; } -interface ApplyAutoTranslationsParams { - readonly folder: ResourceFolder; - readonly key: string; - readonly baseValue: string; - readonly baseLocale: string; - readonly allLocales: readonly string[] | undefined; - readonly translationConfig: TranslationConfig | undefined; -} - -interface ApplyAutoTranslationsResult { - readonly didTranslate: boolean; - readonly skippedLocales: string[]; +/** Where `moveTo` sends the entry. Holds paths only: the folder is read from disk when the move happens. */ +interface Destination { + readonly resolvedKey: string; + readonly entryKey: string; + readonly folderPath: string; } /** - * Translates the updated base value to all non-base locales and records the - * results in `folder` (not saved). - * - * Returns `{ didTranslate: false, skippedLocales: [] }` when auto-translation is - * not configured, disabled, or when no target locales are available. + * Where `moveTo` sends the entry; `undefined` when it is the entry's own folder. + * @throws {InvalidResourceKeyError} `moveTo` is malformed. + * @throws {ResourceAlreadyExistsError} The destination already has this entry key. */ -async function applyAutoTranslationsAfterBaseValueChange( - params: ApplyAutoTranslationsParams, -): Promise { - const { folder, key, baseValue, baseLocale, allLocales, translationConfig } = params; - - if (!translationConfig?.enabled || !allLocales || allLocales.length === 0) { - return { didTranslate: false, skippedLocales: [] }; - } - - const targetLocales = allLocales.filter((locale) => locale !== baseLocale); - if (targetLocales.length === 0) { - return { didTranslate: false, skippedLocales: [] }; +function resolveDestination( + collection: Collection, + source: { readonly resolvedKey: string; readonly entryKey: string }, + moveTo: string, +): Destination | undefined { + const paths = validateAndResolvePaths({ + key: source.entryKey, + translationsFolder: collection.translationsFolder, + targetFolder: moveTo, + }); + if (paths.resolvedKey === source.resolvedKey) { + return undefined; } - const autoTranslateResult = await autoTranslateResource({ - baseValue, - baseLocale, - targetLocales, - translationConfig, - }); + const destination = { resolvedKey: paths.resolvedKey, entryKey: paths.entryKey, folderPath: paths.folderPath }; + openDestination(collection, destination); + return destination; +} - if (autoTranslateResult.translations.length === 0) { - return { didTranslate: false, skippedLocales: autoTranslateResult.skippedLocales }; +/** + * Opens the destination folder as it is on disk now. + * @throws {ResourceAlreadyExistsError} It already has the entry key. + */ +function openDestination(collection: Collection, destination: Destination): ResourceFolder { + const folder = openResourceFolder(destination.folderPath, { baseLocale: collection.baseLocale }); + if (folder.has(destination.entryKey)) { + throw new ResourceAlreadyExistsError(destination.resolvedKey); } + return folder; +} - for (const { locale, value } of autoTranslateResult.translations) { - folder.setTranslation(key, locale, translocoToICU(value), 'translated'); +/** + * Moves the entry, as stored, to the destination (a lossless copy, like `moveResource`). + * The destination folder is re-read here, not kept from before the edit, so writes made to it + * meanwhile are kept and a new collision is caught. The destination is written before the + * source entry is removed. + * @throws {ResourceAlreadyExistsError} The destination has the entry key now. + */ +function moveEntry( + collection: Collection, + source: ResourceFolder, + sourceKey: string, + destination: Destination, +): { resolvedKey: string; entry: ResourceTreeEntry | undefined; mutations: ResourceMutation[] } { + const entryKey = destination.entryKey; + const stored = source.get(entryKey); + if (!stored) { + throw new ResourceNotFoundError(sourceKey); } + const destinationFolder = openDestination(collection, destination); + destinationFolder.setEntry(entryKey, stored.entry, stored.meta ?? {}); + destinationFolder.save(); + source.remove(entryKey); + source.save(); - return { didTranslate: true, skippedLocales: autoTranslateResult.skippedLocales }; + const entry = destinationFolder.treeEntry(entryKey); + return { + resolvedKey: destination.resolvedKey, + entry, + mutations: [ + upsertMutation(collection.translationsFolder, destination.resolvedKey, entry), + removeMutation(collection.translationsFolder, sourceKey), + ], + }; } diff --git a/libs/core/src/resource/index.ts b/libs/core/src/resource/index.ts index 571dca3b..e68a9339 100644 --- a/libs/core/src/resource/index.ts +++ b/libs/core/src/resource/index.ts @@ -1,8 +1,8 @@ -// Resource operations: add, edit, delete and move one entry. +// Resource operations on an opened Collection: add, edit, delete and move entries. -export { type AddResourceOptions, type AddResourceParams, addResource } from './add-resource'; +export { type AddResourceParams, type AddResourceResult, addResource } from './add-resource'; export { type DeleteResourceParams, type DeleteResourceResult, deleteResource } from './delete-resource'; -export { type EditResourceOptions, type EditResourceResult, editResource } from './edit-resource'; +export { type EditResourceChanges, type EditResourceResult, editResource } from './edit-resource'; +export type { ResourceTranslation } from './locale-seeding'; export { type MoveResourceParams, type MoveResourceResult, moveResource } from './move-resource'; export type { ResourceEntryMetadata } from './resource-entry-metadata'; -export { createDefaultTranslations } from './translation-helpers'; diff --git a/libs/core/src/resource/locale-seeding.ts b/libs/core/src/resource/locale-seeding.ts new file mode 100644 index 00000000..47ac4c9f --- /dev/null +++ b/libs/core/src/resource/locale-seeding.ts @@ -0,0 +1,93 @@ +import { type TranslationStatus, translocoToICU } from '@simoncodes-ca/domain'; +import type { Collection } from '../lib/config/open-collection'; +import { LocaleNotFoundError } from '../lib/errors/lingo-tracker-error'; +import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; + +/** A value for one locale and the status it is stored with. */ +export interface ResourceTranslation { + readonly locale: string; + readonly value: string; + readonly status: TranslationStatus; +} + +export interface LocaleSeedingRequest { + /** The stored (ICU) base value. */ + readonly baseValue: string; + /** Locales the caller supplied a translation for. The caller's value wins, so they are not seeded. */ + readonly supplied: Iterable; + /** Which target locales need work. Default: all of them. */ + readonly needsWork?: (locale: string) => boolean; + /** + * True when the locale holds a translation worth keeping. Such a locale is auto-translated, + * but never overwritten by a copy of the base value. Default: none. + */ + readonly keepsValue?: (locale: string) => boolean; +} + +export interface LocaleSeeding { + /** Values to write, auto-translated ones first. */ + readonly translations: ResourceTranslation[]; + /** Locales the provider did not translate (ICU messages). Present only when auto-translation ran. */ + readonly skippedLocales?: string[]; +} + +/** + * Locale seeding: what a collection's target locales get when a resource's base value is written. + * For each of `collection.targetLocales` that needs work: + * + * 1. the caller supplied a translation → the caller writes it (the locale is skipped here); + * 2. the collection has auto-translation enabled → the provider's translation, as `translated`; + * 3. otherwise (or the provider skipped the locale) → a copy of the base value, as `new`, + * unless the locale holds a translation worth keeping (`keepsValue`), which the + * Staleness rule has already marked. + * + * Returns the values; the caller writes them to its Resource Folder. + * + * @throws {TranslationError} The provider failed. + */ +export async function seedLocales(collection: Collection, request: LocaleSeedingRequest): Promise { + const supplied = new Set(request.supplied); + const open = collection.targetLocales.filter( + (locale) => !supplied.has(locale) && (request.needsWork?.(locale) ?? true), + ); + + const { translationConfig, baseLocale } = collection; + const translations: ResourceTranslation[] = []; + let skippedLocales: string[] | undefined; + + if (translationConfig?.enabled && open.length > 0) { + const result = await autoTranslateResource({ + baseValue: request.baseValue, + baseLocale, + targetLocales: open, + translationConfig, + }); + for (const { locale, value } of result.translations) { + translations.push({ locale, value: translocoToICU(value), status: 'translated' }); + } + skippedLocales = result.skippedLocales; + } + + const translated = new Set(translations.map(({ locale }) => locale)); + for (const locale of open) { + if (!translated.has(locale) && !request.keepsValue?.(locale)) { + translations.push({ locale, value: request.baseValue, status: 'new' }); + } + } + + return { translations, ...(skippedLocales !== undefined && { skippedLocales }) }; +} + +/** + * Checks that every supplied translation names one of the collection's locales. + * A value for the base locale is allowed (callers ignore it: the base value is the entry's `source`). + * + * @throws {LocaleNotFoundError} A locale the collection does not have. + */ +export function assertCollectionLocales(collection: Collection, locales: Iterable): void { + for (const locale of locales) { + if (locale !== collection.baseLocale && !collection.targetLocales.includes(locale)) { + throw new LocaleNotFoundError(locale, collection.name); + } + } +} diff --git a/libs/core/src/resource/move-resource.real-fs.spec.ts b/libs/core/src/resource/move-resource.real-fs.spec.ts index cef3fab3..a80aa28f 100644 --- a/libs/core/src/resource/move-resource.real-fs.spec.ts +++ b/libs/core/src/resource/move-resource.real-fs.spec.ts @@ -1,10 +1,11 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { moveResource } from './move-resource'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Collection } from '../lib/config/open-collection'; import { moveFolder } from '../lib/folder/move-folder'; import { calculateChecksum } from './checksum'; +import { moveResource } from './move-resource'; const md5 = calculateChecksum; @@ -15,6 +16,18 @@ const md5 = calculateChecksum; describe('moving resources keeps metadata (real fs)', () => { let root: string; + const collection = (translationsFolder = root, name = 'main'): Collection => ({ + name, + translationsFolder, + baseLocale: 'en', + locales: ['en', 'fr', 'es'], + targetLocales: ['fr', 'es'], + translationConfig: undefined, + tags: [], + readOnly: false, + config: { translationsFolder }, + }); + const entries = { ok: { source: 'OK', comment: 'Button', tags: ['ui'], fr: "D'accord", es: 'Vale' } }; const meta = { ok: { @@ -46,7 +59,7 @@ describe('moving resources keeps metadata (real fs)', () => { it('moveResource carries values, details, checksums, and statuses', async () => { writeFolder('common'); - const result = await moveResource(root, { source: 'common.ok', destination: 'shared.buttons.confirm' }); + const result = await moveResource(collection(), { source: 'common.ok', destination: 'shared.buttons.confirm' }); expect(result).toEqual({ movedCount: 1, @@ -66,7 +79,7 @@ describe('moving resources keeps metadata (real fs)', () => { it('moveResource by pattern keeps statuses', async () => { writeFolder('common', 'buttons'); - await moveResource(root, { source: 'common.*', destination: 'shared' }); + await moveResource(collection(), { source: 'common.*', destination: 'shared' }); expect(read('tracker_meta.json', 'shared', 'buttons').ok.fr.status).toBe('verified'); expect(read('tracker_meta.json', 'shared', 'buttons').ok.es.status).toBe('stale'); @@ -76,10 +89,10 @@ describe('moving resources keeps metadata (real fs)', () => { writeFolder('common'); const otherCollection = join(root, 'other'); - await moveResource(root, { + await moveResource(collection(), { source: 'common.ok', destination: 'common.ok', - destinationTranslationsFolder: otherCollection, + destinationCollection: collection(otherCollection, 'other'), }); expect(read('tracker_meta.json', 'other', 'common')).toEqual(meta); @@ -88,7 +101,10 @@ describe('moving resources keeps metadata (real fs)', () => { it('moveFolder keeps statuses', async () => { writeFolder('apps', 'buttons'); - const result = await moveFolder(root, { sourceFolderPath: 'apps.buttons', destinationFolderPath: 'shared' }); + const result = await moveFolder(collection(), { + sourceFolderPath: 'apps.buttons', + destinationFolderPath: 'shared', + }); expect(result.errors).toEqual([]); expect(read('resource_entries.json', 'shared', 'buttons')).toEqual(entries); @@ -100,7 +116,7 @@ describe('moving resources keeps metadata (real fs)', () => { writeFolder('common'); writeFolder('shared'); - const result = await moveResource(root, { source: 'common.ok', destination: 'shared.ok' }); + const result = await moveResource(collection(), { source: 'common.ok', destination: 'shared.ok' }); expect(result.movedCount).toBe(0); expect(result.warnings[0]).toContain('Destination key already exists'); diff --git a/libs/core/src/resource/move-resource.spec.ts b/libs/core/src/resource/move-resource.spec.ts index ade3dbfb..f15c4a48 100644 --- a/libs/core/src/resource/move-resource.spec.ts +++ b/libs/core/src/resource/move-resource.spec.ts @@ -1,8 +1,23 @@ +import * as fs from 'node:fs'; import { join, resolve } from 'node:path'; -import { moveResource } from './move-resource'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { RESOURCE_ENTRIES_FILENAME } from '../constants'; -import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; -import * as fs from 'node:fs'; +import type { Collection } from '../lib/config/open-collection'; +import { moveResource } from './move-resource'; + +function collection(translationsFolder: string, name = 'main'): Collection { + return { + name, + translationsFolder, + baseLocale: 'en', + locales: ['en'], + targetLocales: [], + translationConfig: undefined, + tags: [], + readOnly: false, + config: { translationsFolder }, + }; +} // Mock node:fs vi.mock('node:fs', () => { @@ -125,7 +140,7 @@ describe('Move Resource', () => { }), ); - const result = await moveResource(testDir, { + const result = await moveResource(collection(testDir), { source: 'common.buttons.ok', destination: 'common.actions.ok', }); @@ -172,7 +187,7 @@ describe('Move Resource', () => { }), ); - const result = await moveResource(testDir, { + const result = await moveResource(collection(testDir), { source: 'a.key', destination: 'b.key', override: false, @@ -212,7 +227,7 @@ describe('Move Resource', () => { }), ); - const result = await moveResource(testDir, { + const result = await moveResource(collection(testDir), { source: 'a.key', destination: 'b.key', override: true, @@ -242,7 +257,7 @@ describe('Move Resource', () => { }), ); - const result = await moveResource(testDir, { + const result = await moveResource(collection(testDir), { source: 'common.buttons.*', destination: 'common.actions', }); @@ -284,7 +299,7 @@ describe('Move Resource', () => { }), ); - const result = await moveResource(testDir, { + const result = await moveResource(collection(testDir), { source: 'common.buttons.*', destination: 'common.actions', }); @@ -326,10 +341,10 @@ describe('Move Resource', () => { const collectionBFolder = join(testDir, 'collectionB'); mockDirectories.add(collectionBFolder); - const result = await moveResource(collectionAFolder, { + const result = await moveResource(collection(collectionAFolder, 'collectionA'), { source: 'common.buttons.ok', destination: 'common.actions.ok', - destinationTranslationsFolder: collectionBFolder, + destinationCollection: collection(collectionBFolder, 'collectionB'), }); expect(result.movedCount).toBe(1); @@ -369,10 +384,10 @@ describe('Move Resource', () => { const collectionBFolder = join(testDir, 'collectionB'); mockDirectories.add(collectionBFolder); - const result = await moveResource(collectionAFolder, { + const result = await moveResource(collection(collectionAFolder, 'collectionA'), { source: 'common.buttons.*', destination: 'common.actions', - destinationTranslationsFolder: collectionBFolder, + destinationCollection: collection(collectionBFolder, 'collectionB'), }); expect(result.movedCount).toBe(2); @@ -396,7 +411,7 @@ describe('Move Resource', () => { const invalidPattern = 'invalid@char*'; const invalidPath = join(testDir, 'invalid@char'); - const result = await moveResource(testDir, { + const result = await moveResource(collection(testDir), { source: invalidPattern, destination: 'dest', }); diff --git a/libs/core/src/resource/move-resource.ts b/libs/core/src/resource/move-resource.ts index f3724aea..b8c8001a 100644 --- a/libs/core/src/resource/move-resource.ts +++ b/libs/core/src/resource/move-resource.ts @@ -7,12 +7,17 @@ import { resolveResourcePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../lib/resource/resource-folder'; import { type ResourceMutation, upsertMutation } from '../lib/resource/resource-mutation'; import { RESOURCE_ENTRIES_FILENAME } from '../constants'; +import type { Collection } from '../lib/config/open-collection'; export interface MoveResourceParams { - source: string; - destination: string; - override?: boolean; - destinationTranslationsFolder?: string; + /** Full source key, or a prefix pattern ending with `*` (`common.buttons.*`). */ + readonly source: string; + /** Full destination key; for a pattern, the prefix the matched keys move under. */ + readonly destination: string; + /** Replace an existing destination entry. Default: false (the key is skipped with a warning). */ + readonly override?: boolean; + /** Destination collection for a cross-collection move. Default: the source collection. */ + readonly destinationCollection?: Collection; } export interface MoveResourceResult { @@ -24,30 +29,29 @@ export interface MoveResourceResult { } /** - * Moves resources from source to destination. + * Moves resources from source to destination, within a collection or into another one. * Supports single key move and wildcard pattern move (ending with *). + * Per-key failures are reported in the result, not thrown. */ -export async function moveResource( - translationsFolder: string, - params: MoveResourceParams, -): Promise { - const { source, destination, override = false, destinationTranslationsFolder } = params; - const targetFolder = destinationTranslationsFolder || translationsFolder; +export async function moveResource(collection: Collection, params: MoveResourceParams): Promise { + const { source, destination, override = false, destinationCollection = collection } = params; if (source.endsWith('*')) { - return moveResourcesByPattern(translationsFolder, source, destination, override, targetFolder); + return moveResourcesByPattern(collection, source, destination, override, destinationCollection); } else { - return moveSingleResource(translationsFolder, source, destination, override, targetFolder); + return moveSingleResource(collection, source, destination, override, destinationCollection); } } async function moveSingleResource( - sourceTranslationsFolder: string, + sourceCollection: Collection, sourceKey: string, destinationKey: string, override: boolean, - destinationTranslationsFolder: string, + destinationCollection: Collection, ): Promise { + const sourceTranslationsFolder = sourceCollection.translationsFolder; + const destinationTranslationsFolder = destinationCollection.translationsFolder; const result: MoveResourceResult = { movedCount: 0, warnings: [], @@ -73,7 +77,7 @@ async function moveSingleResource( let sourceFolder: ResourceFolder; try { - sourceFolder = openResourceFolder(sourcePaths.folderPath); + sourceFolder = openResourceFolder(sourcePaths.folderPath, { baseLocale: sourceCollection.baseLocale }); } catch { result.errors.push(`Failed to read source file for key: ${sourceKey}`); return result; @@ -94,7 +98,9 @@ async function moveSingleResource( // 3. Perform Move — a lossless copy: values, comment, tags, checksums, and statuses // (including 'verified' and 'stale') are carried as they are. No auto-translation. try { - const destinationFolder = openResourceFolder(destinationPaths.folderPath); + const destinationFolder = openResourceFolder(destinationPaths.folderPath, { + baseLocale: destinationCollection.baseLocale, + }); if (destinationFolder.has(destinationPaths.entryKey) && !override) { result.warnings.push(`Destination key already exists: ${destinationKey}. Use override option to force move.`); return result; @@ -116,7 +122,7 @@ async function moveSingleResource( // Delete from source try { - result.mutations.push(...deleteResource(sourceTranslationsFolder, { keys: [sourceKey] }).mutations); + result.mutations.push(...deleteResource(sourceCollection, { keys: [sourceKey] }).mutations); } catch (error) { result.warnings.push( `Resource moved to ${destinationKey} but failed to delete source ${sourceKey}: ${(error as Error).message}`, @@ -131,12 +137,13 @@ async function moveSingleResource( } async function moveResourcesByPattern( - sourceTranslationsFolder: string, + sourceCollection: Collection, pattern: string, destinationKey: string, override: boolean, - destinationTranslationsFolder: string, + destinationCollection: Collection, ): Promise { + const sourceTranslationsFolder = sourceCollection.translationsFolder; const result: MoveResourceResult = { movedCount: 0, warnings: [], @@ -184,13 +191,7 @@ async function moveResourcesByPattern( const suffix = sourceKey.slice(cleanPrefix.length + 1); // +1 for dot const newKey = `${destinationKey}.${suffix}`; - const singleResult = await moveSingleResource( - sourceTranslationsFolder, - sourceKey, - newKey, - override, - destinationTranslationsFolder, - ); + const singleResult = await moveSingleResource(sourceCollection, sourceKey, newKey, override, destinationCollection); result.movedCount += singleResult.movedCount; result.warnings.push(...singleResult.warnings); diff --git a/libs/core/src/resource/translation-helpers.spec.ts b/libs/core/src/resource/translation-helpers.spec.ts deleted file mode 100644 index a8ded61d..00000000 --- a/libs/core/src/resource/translation-helpers.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createDefaultTranslations } from './translation-helpers'; - -describe('createDefaultTranslations', () => { - it('should create translations for all non-base locales', () => { - const locales = ['en', 'fr-ca', 'es', 'de']; - const baseLocale = 'en'; - const baseValue = 'OK'; - - const result = createDefaultTranslations(locales, baseLocale, baseValue); - - expect(result).toEqual([ - { locale: 'fr-ca', value: 'OK', status: 'new' }, - { locale: 'es', value: 'OK', status: 'new' }, - { locale: 'de', value: 'OK', status: 'new' }, - ]); - }); - - it('should return undefined when no non-base locales exist', () => { - const locales = ['en']; - const baseLocale = 'en'; - const baseValue = 'OK'; - - const result = createDefaultTranslations(locales, baseLocale, baseValue); - - expect(result).toBeUndefined(); - }); - - it('should handle custom base locale', () => { - const locales = ['en', 'fr-ca', 'es']; - const baseLocale = 'fr-ca'; - const baseValue = 'Annuler'; - - const result = createDefaultTranslations(locales, baseLocale, baseValue); - - expect(result).toEqual([ - { locale: 'en', value: 'Annuler', status: 'new' }, - { locale: 'es', value: 'Annuler', status: 'new' }, - ]); - }); - - it('should handle empty locales array', () => { - const locales: string[] = []; - const baseLocale = 'en'; - const baseValue = 'OK'; - - const result = createDefaultTranslations(locales, baseLocale, baseValue); - - expect(result).toBeUndefined(); - }); - - it('should filter out base locale correctly even with duplicates', () => { - const locales = ['en', 'fr-ca', 'en', 'es']; // duplicate 'en' - const baseLocale = 'en'; - const baseValue = 'OK'; - - const result = createDefaultTranslations(locales, baseLocale, baseValue); - - // Should filter out all instances of base locale, but keep other duplicates - expect(result).toEqual([ - { locale: 'fr-ca', value: 'OK', status: 'new' }, - { locale: 'es', value: 'OK', status: 'new' }, - ]); - }); -}); diff --git a/libs/core/src/resource/translation-helpers.ts b/libs/core/src/resource/translation-helpers.ts deleted file mode 100644 index bd5df8d0..00000000 --- a/libs/core/src/resource/translation-helpers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { TranslationStatus } from '@simoncodes-ca/domain'; - -/** - * Creates default translations for all non-base locales using the base value. - * This is used when no translations are explicitly provided. - * @param locales - Array of all available locales - * @param baseLocale - The base locale (will be excluded from translations) - * @param baseValue - The base value to use for all translations - * @returns Array of translation objects with 'new' status, or undefined if no non-base locales exist - */ -export function createDefaultTranslations( - locales: readonly string[], - baseLocale: string, - baseValue: string, -): Array<{ locale: string; value: string; status: TranslationStatus }> | undefined { - const nonBaseLocales = locales.filter((locale) => locale !== baseLocale); - - if (nonBaseLocales.length === 0) { - return undefined; - } - - return nonBaseLocales.map((locale) => ({ - locale, - value: baseValue, - status: 'new' as TranslationStatus, - })); -} diff --git a/libs/data-transfer/src/lib/create-resource.dto.ts b/libs/data-transfer/src/lib/create-resource.dto.ts index f8f2baa0..d6b148fb 100644 --- a/libs/data-transfer/src/lib/create-resource.dto.ts +++ b/libs/data-transfer/src/lib/create-resource.dto.ts @@ -12,11 +12,12 @@ export interface CreateResourceDto { comment?: string; /** Optional tags (will be stored as array) */ tags?: string[]; - /** Optional target folder to override part of the path */ + /** Optional dot-delimited folder the key is placed under: the stored key is `targetFolder.key` */ targetFolder?: string; - /** Base locale (defaults to "en") */ - baseLocale?: string; - /** Localized translations with locale, value, and status */ + /** + * Translations for some of the collection's locales. Target locales left out are seeded by + * the collection's rule: auto-translated when enabled, else a copy of the base value as `new`. + */ translations?: Array<{ locale: string; value: string; diff --git a/libs/data-transfer/src/lib/delete-folder.dto.ts b/libs/data-transfer/src/lib/delete-folder.dto.ts index 82849faa..ff10ffe1 100644 --- a/libs/data-transfer/src/lib/delete-folder.dto.ts +++ b/libs/data-transfer/src/lib/delete-folder.dto.ts @@ -12,7 +12,7 @@ export interface DeleteFolderDto { * Response DTO for folder deletion operation. */ export interface DeleteFolderResponseDto { - /** Whether the folder was successfully deleted */ + /** Always true: a failed deletion answers with an HTTP error instead (404 for a missing folder). */ deleted: boolean; /** The dot-delimited folder path that was targeted for deletion */ @@ -20,7 +20,4 @@ export interface DeleteFolderResponseDto { /** Number of resource entries that were deleted with the folder */ resourcesDeleted: number; - - /** Error message if deletion failed */ - error?: string; } diff --git a/libs/data-transfer/src/lib/update-resource.dto.ts b/libs/data-transfer/src/lib/update-resource.dto.ts index 686d7241..7d539dd3 100644 --- a/libs/data-transfer/src/lib/update-resource.dto.ts +++ b/libs/data-transfer/src/lib/update-resource.dto.ts @@ -6,8 +6,13 @@ export interface LocaleUpdateDto { } export interface UpdateResourceDto { + /** The entry's full, existing key. */ key: string; - targetFolder?: string; + /** + * Destination folder (dot-delimited; `''` for the collection root). The entry keeps its + * entry key (the last key segment) and moves there. Omit to leave it where it is. + */ + moveTo?: string; baseValue?: string; comment?: string; tags?: string[]; From 76b87e7ca4b32af9a9d722ad5276b4cdf8ef5b0a Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 13:39:48 -0700 Subject: [PATCH 10/20] refactor: one Resource Summary with an explicit address buildResourceSummary(fullKey, entry, collection) in libs/domain builds the one read model of a resource entry: fullKey, folderPath, entryKey, base {locale, value}, one target per collection target locale (value, status, needsWork, sameAsBase from the staleness rule), comment, tags and inheritedTags. The base locale comes from the Collection, never from the metadata shape. ResourceSummaryDto is that type. API: resource-tree and search-result mappers take the opened Collection and the folder path; nested entries resolve against the requested path; SearchResult carries source and metadata. Tracker: the store is keyed by fullKey; key-resolution.ts and listKeyFor/storeKey are gone. The pure row-view.ts module decides what a row shows (locale order, compact line, markers, rollup counts, canTranslate, long values) and the four row components only present it. The editor uses the shared status label tokens. BrowserApiService hides the HTTP 202 "index not ready" retry (5 x 1 s) and raises CollectionIndexNotReadyError; when that gives up mid-session the stores keep the tree on screen and toast instead of replacing it with an error. Behaviour changes: - DTO: key/translations/status replaced by fullKey, folderPath, entryKey, base and targets; tags and inheritedTags always arrays; targets list every collection target locale; metadata for locales the collection does not have is dropped. - Optimistic move now removes the moved row below the root (fixes the full-key vs list-key filter). Drag data carries the real folderPath. - Translate button also enabled for a target locale with no metadata. - Rollup counts collection targets only. - Not-ready tree reads retry for ~5 s, then report an error instead of being silently ignored. Co-Authored-By: Claude Fable 5.1 --- .../resources/resources.controller.spec.ts | 130 ++++++----- .../resources/resources.controller.ts | 12 +- .../app/mappers/resource-tree.mapper.spec.ts | 86 ++++--- .../src/app/mappers/resource-tree.mapper.ts | 72 ++---- .../src/app/mappers/search-result.mapper.ts | 28 +-- .../resource-entry-draft.spec.ts | 58 +++-- .../resource-entry-draft.ts | 36 ++- .../similar-translations.html | 2 +- .../similar-translations.ts | 14 +- .../similar-value-filter.ts | 3 +- .../translation-editor-dialog.html | 7 +- .../translation-editor-dialog.spec.ts | 210 +++++++++--------- .../translation-editor-dialog.ts | 59 ++--- .../services/browser-api.service.spec.ts | 75 ++++++- .../browser/services/browser-api.service.ts | 53 ++++- .../translation-editor-launcher.spec.ts | 33 ++- .../services/translation-editor-launcher.ts | 28 +-- .../app/browser/store/browser.store.spec.ts | 160 +++++++++---- .../src/app/browser/store/browser.store.ts | 2 +- .../with-entry-writes.feature.spec.ts | 124 ++++++----- .../features/with-entry-writes.feature.ts | 59 ++--- .../features/with-folder-tree.feature.ts | 78 ++++--- .../features/with-translations.feature.ts | 54 +++-- .../list/store/key-resolution.spec.ts | 80 ------- .../translations/list/store/key-resolution.ts | 63 ------ .../list/store/with-item-actions.feature.ts | 38 +--- .../list/store/with-item-ui-state.feature.ts | 1 + .../list/translation-item/item-header.html | 5 +- .../list/translation-item/item-header.ts | 41 ++-- .../list/translation-item/item-locales.ts | 62 ++---- .../list/translation-item/row-view.spec.ts | 203 +++++++++++++++++ .../list/translation-item/row-view.ts | 150 +++++++++++++ .../translation-item/translation-item.html | 4 +- .../translation-item/translation-item.spec.ts | 157 +++++-------- .../list/translation-item/translation-item.ts | 163 +++----------- .../translation-item/translation-rollup.ts | 28 +-- .../list/translation-list.spec.ts | 99 ++++----- .../translations/list/translation-list.ts | 2 +- .../translations/utils/sort-translations.ts | 28 +-- .../app/shared/tag-list/tag-list.component.ts | 4 +- architecture-docs/api.md | 18 +- architecture-docs/core-library.md | 2 +- architecture-docs/domain-and-data-model.md | 25 +++ architecture-docs/frontend.md | 34 ++- architecture-docs/glossary.md | 10 +- architecture-docs/user-flows.md | 8 +- libs/core/src/lib/resource/search.ts | 11 + .../src/lib/resource-tree.dto.ts | 30 +-- .../src/lib/search-result.dto.ts | 4 +- libs/domain/src/index.spec.ts | 2 + libs/domain/src/index.ts | 10 + libs/domain/src/lib/resource-summary.spec.ts | 117 ++++++++++ libs/domain/src/lib/resource-summary.ts | 109 +++++++++ 53 files changed, 1653 insertions(+), 1238 deletions(-) delete mode 100644 apps/tracker/src/app/browser/translations/list/store/key-resolution.spec.ts delete mode 100644 apps/tracker/src/app/browser/translations/list/store/key-resolution.ts create mode 100644 apps/tracker/src/app/browser/translations/list/translation-item/row-view.spec.ts create mode 100644 apps/tracker/src/app/browser/translations/list/translation-item/row-view.ts create mode 100644 libs/domain/src/lib/resource-summary.spec.ts create mode 100644 libs/domain/src/lib/resource-summary.ts diff --git a/apps/api/src/app/collections/resources/resources.controller.spec.ts b/apps/api/src/app/collections/resources/resources.controller.spec.ts index 3c503927..da19d626 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -1,10 +1,11 @@ import { resolve } from 'node:path'; import { HttpException, NotFoundException } from '@nestjs/common'; import { Test, type TestingModule } from '@nestjs/testing'; +import type { Response } from 'express'; import * as core from '@simoncodes-ca/core'; import { TranslationError } from '@simoncodes-ca/core'; import type { ResourceTreeDto } from '@simoncodes-ca/data-transfer'; -import type { LocaleMetadata, TranslationStatus } from '@simoncodes-ca/domain'; +import type { TranslationStatus } from '@simoncodes-ca/domain'; import { CollectionIndex } from '../../cache/collection-index.service'; import { ConfigService } from '../../config/config.service'; import { toHttpException } from '../../errors/lingo-tracker-exception.filter'; @@ -32,61 +33,6 @@ jest.mock('@simoncodes-ca/core', () => { }; }); -// Mock the resource tree mapper -jest.mock('../../mappers/resource-tree.mapper', () => ({ - mapResourceEntryToSummary: jest.fn((entry) => ({ - key: entry.key, - translations: { en: entry.source, ...entry.translations }, - status: Object.fromEntries( - Object.entries(entry.metadata).map(([locale, meta]: [string, any]) => [locale, meta.status]), - ), - comment: entry.comment, - tags: entry.tags, - })), - mapResourceTreeToDto: jest.fn((treeNode) => { - // Simple pass-through mapper for tests that mimics the real mapper - return { - path: treeNode.folderPathSegments.join('.'), - resources: treeNode.resources.map((r: any) => { - // Find base locale - let baseLocale: string | undefined; - for (const [locale, meta] of Object.entries(r.metadata)) { - if (meta.status === undefined && meta.baseChecksum === undefined) { - baseLocale = locale; - break; - } - } - - // Combine source and translations - const translations: Record = { ...r.translations }; - if (baseLocale) { - translations[baseLocale] = r.source; - } - - // Extract status - const status: Record = {}; - for (const [locale, meta] of Object.entries(r.metadata)) { - status[locale] = meta.status; - } - - return { - key: r.key, - translations, - status, - comment: r.comment, - tags: r.tags, - }; - }), - children: treeNode.children.map((c: any) => ({ - name: c.name, - fullPath: c.fullPathSegments.join('.'), - loaded: c.loaded, - tree: c.tree ? { path: c.fullPathSegments.join('.'), resources: [], children: [] } : undefined, - })), - }; - }), -})); - describe('ResourcesController', () => { let resourcesModule: TestingModule; let resourcesController: ResourcesController; @@ -838,6 +784,24 @@ describe('ResourcesController', () => { ); }); + it('should return the updated resource addressed at its resolved key', async () => { + const editResource = core.editResource as jest.Mock; + editResource.mockReturnValue({ + resolvedKey: 'shared.ok', + updated: true, + entry: { key: 'ok', source: 'OK', translations: {}, metadata: { en: { checksum: 'a' } } }, + }); + + const result = await resourcesController.update('test-collection', { key: 'app.button.ok', moveTo: 'shared' }); + + expect(result.resource).toMatchObject({ + fullKey: 'shared.ok', + folderPath: 'shared', + entryKey: 'ok', + base: { locale: 'en', value: 'OK' }, + }); + }); + it('should pass moveTo through to core', async () => { const editResource = core.editResource as jest.Mock; editResource.mockReturnValue({ resolvedKey: 'shared.ok', updated: true }); @@ -950,7 +914,20 @@ describe('ResourcesController', () => { )) as ResourceTreeDto; expect(tree.path).toBe(''); - expect(tree.resources.map((r) => r.key)).toEqual(['title']); + expect(tree.resources).toEqual([ + { + fullKey: 'title', + folderPath: '', + entryKey: 'title', + base: { locale: 'en', value: 'Title' }, + targets: [ + { locale: 'fr-ca', value: undefined, status: undefined, needsWork: true, sameAsBase: false }, + { locale: 'es', value: 'Título', status: 'new', needsWork: true, sameAsBase: false }, + ], + tags: [], + inheritedTags: [], + }, + ]); expect(mockIndex.tree).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-collection' }), ''); expect(response.status).not.toHaveBeenCalled(); }); @@ -966,6 +943,9 @@ describe('ResourcesController', () => { )) as ResourceTreeDto; expect(tree.path).toBe('apps'); + expect(tree.resources.map((r) => [r.fullKey, r.folderPath, r.entryKey])).toEqual([ + ['apps.title', 'apps', 'title'], + ]); expect(mockIndex.tree).toHaveBeenCalledWith(expect.anything(), 'apps'); }); @@ -975,7 +955,7 @@ describe('ResourcesController', () => { extractResourcesRecursively.mockReturnValue([ ...mockTreeNode.resources, { - key: 'save', + key: 'dialog.save', source: 'Save', translations: { es: 'Guardar' }, metadata: { @@ -993,7 +973,29 @@ describe('ResourcesController', () => { )) as ResourceTreeDto; expect(extractResourcesRecursively).toHaveBeenCalledWith(mockTreeNode); - expect(tree.resources.map((r) => r.key)).toEqual(['title', 'save']); + expect(tree.resources.map((r) => r.fullKey)).toEqual(['title', 'dialog.save']); + }); + + it('should give nested resources their full address below a non-root path', async () => { + const extractResourcesRecursively = core.extractResourcesRecursively as jest.Mock; + const appsNode = { ...mockTreeNode, folderPathSegments: ['apps'] }; + mockIndex.tree.mockReturnValue({ status: 'ready', tree: appsNode }); + extractResourcesRecursively.mockReturnValue([ + ...appsNode.resources, + { key: 'dialog.save', source: 'Save', translations: {}, metadata: { en: { checksum: 'b' } } }, + ]); + + const tree = (await resourcesController.getTree( + 'test-collection', + 'apps', + 'true', + mockResponse() as unknown as Response, + )) as ResourceTreeDto; + + expect(tree.resources.map((r) => [r.fullKey, r.folderPath, r.entryKey])).toEqual([ + ['apps.title', 'apps', 'title'], + ['apps.dialog.save', 'apps.dialog', 'save'], + ]); }); it.each([ @@ -1080,7 +1082,12 @@ describe('ResourcesController', () => { const result = await resourcesController.search('test-collection', { query: 'lingo' }); expect(result.query).toBe('lingo'); - expect(result.results.map((r) => r.key)).toEqual(['app.title']); + expect(result.results.map((r) => [r.fullKey, r.folderPath, r.entryKey])).toEqual([['app.title', 'app', 'title']]); + expect(result.results[0].base).toEqual({ locale: 'en', value: 'LingoTracker' }); + expect(result.results[0].targets.map((t) => [t.locale, t.status, t.sameAsBase])).toEqual([ + ['fr-ca', undefined, false], + ['es', 'translated', true], + ]); expect(result.limited).toBe(false); expect(mockIndex.search).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-collection' }), 'lingo', 101); }); @@ -1185,7 +1192,8 @@ describe('ResourcesController', () => { expect(result.translatedCount).toBe(2); expect(result.skippedLocales).toEqual([]); - expect(result.resource.key).toBe('save'); + expect(result.resource).toMatchObject({ fullKey: 'buttons.save', folderPath: 'buttons', entryKey: 'save' }); + expect(result.resource.targets.map((t) => t.status)).toEqual(['translated', 'translated']); }); it('should pass the opened collection and resource key to core', async () => { diff --git a/apps/api/src/app/collections/resources/resources.controller.ts b/apps/api/src/app/collections/resources/resources.controller.ts index 2e9d06b9..76a7612a 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -24,6 +24,7 @@ import { extractResourcesRecursively, type Collection, } from '@simoncodes-ca/core'; +import { buildResourceSummary } from '@simoncodes-ca/domain'; import type { CreateResourceDto, CreateResourceResponseDto, @@ -76,7 +77,7 @@ export class ResourcesController { this.#index.apply(result.mutations); return { - resource: mapResourceEntryToSummary(result.entry, collection.tags), + resource: buildResourceSummary(dto.key, result.entry, collection), skippedLocales: result.skippedLocales, translatedCount: result.translatedCount, }; @@ -220,7 +221,7 @@ export class ResourcesController { this.#index.apply(result.mutations); const resourceDto: ResourceSummaryDto | undefined = - result.updated && result.entry ? mapResourceEntryToSummary(result.entry, collection.tags) : undefined; + result.updated && result.entry ? buildResourceSummary(result.resolvedKey, result.entry, collection) : undefined; return { resolvedKey: result.resolvedKey, @@ -261,11 +262,12 @@ export class ResourcesController { // An empty path addresses the collection root, which the artificial root node in the // Tracker sidebar selects. It is a folder like any other here, so it honours // includeNested too and can list every resource in the collection. - const treeDto = mapResourceTreeToDto(read.tree, collection.tags); + const treeDto = mapResourceTreeToDto(read.tree, collection); if (includeNested === 'true') { + // Nested entries carry keys relative to the requested folder, so they resolve against it. treeDto.resources = extractResourcesRecursively(read.tree).map((res) => - mapResourceEntryToSummary(res, collection.tags), + mapResourceEntryToSummary(res, treeDto.path, collection), ); } @@ -303,7 +305,7 @@ export class ResourcesController { // Check if results were limited const limited = searchResults.length > maxResults; const coreResults = limited ? searchResults.slice(0, maxResults) : searchResults; - const results = mapSearchResultsToDto(coreResults, collection.tags); + const results = mapSearchResultsToDto(coreResults, collection); return { query: dto.query, diff --git a/apps/api/src/app/mappers/resource-tree.mapper.spec.ts b/apps/api/src/app/mappers/resource-tree.mapper.spec.ts index 52e5556b..ee569f7b 100644 --- a/apps/api/src/app/mappers/resource-tree.mapper.spec.ts +++ b/apps/api/src/app/mappers/resource-tree.mapper.spec.ts @@ -1,8 +1,24 @@ +import type { Collection, ResourceTreeNode } from '@simoncodes-ca/core'; import { mapResourceTreeToDto } from './resource-tree.mapper'; -import type { ResourceTreeNode } from '@simoncodes-ca/core'; + +/** The mapper only reads the base locale, the target locales and the tags. */ +function collectionWith(overrides: Partial = {}): Collection { + return { + name: 'test', + translationsFolder: '/t', + baseLocale: 'en', + locales: ['en', 'es', 'fr'], + targetLocales: ['es', 'fr'], + translationConfig: undefined, + tags: [], + readOnly: false, + config: { translationsFolder: '/t' }, + ...overrides, + }; +} describe('mapResourceTreeToDto', () => { - it('should map simple tree node to DTO', () => { + it('should map a resource to its Resource Summary', () => { const node: ResourceTreeNode = { folderPathSegments: [], resources: [ @@ -20,24 +36,40 @@ describe('mapResourceTreeToDto', () => { children: [], }; - const dto = mapResourceTreeToDto(node); + const dto = mapResourceTreeToDto(node, collectionWith()); expect(dto.path).toBe(''); - expect(dto.resources).toHaveLength(1); - expect(dto.resources[0].key).toBe('title'); - expect(dto.resources[0].translations).toEqual({ - en: 'App Title', - es: 'Título', - fr: 'Titre', - }); - expect(dto.resources[0].status).toEqual({ - en: undefined, - es: 'translated', - fr: 'stale', - }); + expect(dto.resources).toEqual([ + { + fullKey: 'title', + folderPath: '', + entryKey: 'title', + base: { locale: 'en', value: 'App Title' }, + targets: [ + { locale: 'es', value: 'Título', status: 'translated', needsWork: false, sameAsBase: false }, + { locale: 'fr', value: 'Titre', status: 'stale', needsWork: true, sameAsBase: false }, + ], + tags: [], + inheritedTags: [], + }, + ]); expect(dto.children).toEqual([]); }); + it('should take the base locale from the collection, whatever the metadata looks like', () => { + const node: ResourceTreeNode = { + folderPathSegments: [], + // "es" has neither status nor baseChecksum: the old mapper took it for the base locale. + resources: [{ key: 'x', source: 'Source', translations: { es: 'Fuente' }, metadata: { es: { checksum: 'e' } } }], + children: [], + }; + + const [summary] = mapResourceTreeToDto(node, collectionWith()).resources; + + expect(summary.base).toEqual({ locale: 'en', value: 'Source' }); + expect(summary.targets.map((target) => target.locale)).toEqual(['es', 'fr']); + }); + it('should convert path segments to dot-delimited string', () => { const node: ResourceTreeNode = { folderPathSegments: ['apps', 'common'], @@ -45,11 +77,11 @@ describe('mapResourceTreeToDto', () => { children: [], }; - const dto = mapResourceTreeToDto(node); + const dto = mapResourceTreeToDto(node, collectionWith()); expect(dto.path).toBe('apps.common'); }); - it('should map loaded children recursively', () => { + it('should map loaded children recursively, each resource with its full address', () => { const node: ResourceTreeNode = { folderPathSegments: [], resources: [], @@ -77,24 +109,17 @@ describe('mapResourceTreeToDto', () => { ], }; - const dto = mapResourceTreeToDto(node); + const dto = mapResourceTreeToDto(node, collectionWith()); expect(dto.children).toHaveLength(1); expect(dto.children[0].name).toBe('apps'); expect(dto.children[0].fullPath).toBe('apps'); expect(dto.children[0].loaded).toBe(true); - expect(dto.children[0].tree).toBeDefined(); const tree = dto.children[0].tree; expect(tree).toBeDefined(); - if (tree) { - expect(tree.path).toBe('apps'); - expect(tree.resources).toHaveLength(1); - expect(tree.resources[0].translations).toEqual({ - en: 'Test', - es: 'Prueba', - }); - } + expect(tree?.path).toBe('apps'); + expect(tree?.resources.map((r) => [r.fullKey, r.folderPath, r.entryKey])).toEqual([['apps.test', 'apps', 'test']]); }); it('should map unloaded children without tree', () => { @@ -110,7 +135,7 @@ describe('mapResourceTreeToDto', () => { ], }; - const dto = mapResourceTreeToDto(node); + const dto = mapResourceTreeToDto(node, collectionWith()); expect(dto.children).toHaveLength(1); expect(dto.children[0].name).toBe('apps'); @@ -118,7 +143,7 @@ describe('mapResourceTreeToDto', () => { expect(dto.children[0].tree).toBeUndefined(); }); - it('should include optional comment and tags', () => { + it('should include the comment, own tags and the collection tags', () => { const node: ResourceTreeNode = { folderPathSegments: [], resources: [ @@ -136,9 +161,10 @@ describe('mapResourceTreeToDto', () => { children: [], }; - const dto = mapResourceTreeToDto(node); + const dto = mapResourceTreeToDto(node, collectionWith({ tags: ['app'] })); expect(dto.resources[0].comment).toBe('Test comment'); expect(dto.resources[0].tags).toEqual(['ui', 'test']); + expect(dto.resources[0].inheritedTags).toEqual(['app']); }); }); diff --git a/apps/api/src/app/mappers/resource-tree.mapper.ts b/apps/api/src/app/mappers/resource-tree.mapper.ts index 70da9a96..e7af3d4a 100644 --- a/apps/api/src/app/mappers/resource-tree.mapper.ts +++ b/apps/api/src/app/mappers/resource-tree.mapper.ts @@ -1,67 +1,37 @@ -import type { - ResourceTreeDto, - ResourceSummaryDto, - FolderNodeDto, - TranslationStatus, -} from '@simoncodes-ca/data-transfer'; -import type { ResourceTreeNode, ResourceTreeEntry } from '@simoncodes-ca/core'; +import type { FolderNodeDto, ResourceSummaryDto, ResourceTreeDto } from '@simoncodes-ca/data-transfer'; +import type { Collection, FolderChild, ResourceTreeEntry, ResourceTreeNode } from '@simoncodes-ca/core'; +import { buildResourceSummary, resolveResourceKey } from '@simoncodes-ca/domain'; -export function mapResourceTreeToDto(node: ResourceTreeNode, collectionTags?: readonly string[]): ResourceTreeDto { +/** Maps a tree node to its DTO. Every resource becomes a Resource Summary of `collection`. */ +export function mapResourceTreeToDto(node: ResourceTreeNode, collection: Collection): ResourceTreeDto { + const path = node.folderPathSegments.join('.'); return { - path: node.folderPathSegments.join('.'), - resources: node.resources.map((e) => mapResourceEntryToSummary(e, collectionTags)), - children: node.children.map((c) => mapFolderChildToDto(c, collectionTags)), + path, + resources: node.resources.map((entry) => mapResourceEntryToSummary(entry, path, collection)), + children: node.children.map((child) => mapFolderChildToDto(child, collection)), }; } +/** + * Maps a stored entry to its Resource Summary. + * + * @param entry - The entry; its `key` is relative to `folderPath` (a single segment, or a + * sub-path for entries collected from nested folders). + * @param folderPath - Dot-delimited folder `entry.key` is relative to; `''` for the root. + */ export function mapResourceEntryToSummary( entry: ResourceTreeEntry, - collectionTags?: readonly string[], + folderPath: string, + collection: Collection, ): ResourceSummaryDto { - // Find base locale (the one without status/baseChecksum in metadata) - let baseLocale: string | undefined; - for (const [locale, meta] of Object.entries(entry.metadata)) { - if (meta.status === undefined && meta.baseChecksum === undefined) { - baseLocale = locale; - break; - } - } - - // Combine source and translations - const translations: Record = { ...entry.translations }; - if (baseLocale) { - translations[baseLocale] = entry.source; - } - - // Extract status from metadata for each locale - const status: Record = {}; - for (const [locale, meta] of Object.entries(entry.metadata)) { - status[locale] = meta.status; - } - - return { - key: entry.key, - translations, - status, - comment: entry.comment, - tags: entry.tags, - inheritedTags: collectionTags && collectionTags.length > 0 ? [...collectionTags] : undefined, - }; + return buildResourceSummary(resolveResourceKey(entry.key, folderPath), entry, collection); } -function mapFolderChildToDto( - child: { - name: string; - fullPathSegments: string[]; - loaded: boolean; - tree?: ResourceTreeNode; - }, - collectionTags?: readonly string[], -): FolderNodeDto { +function mapFolderChildToDto(child: FolderChild, collection: Collection): FolderNodeDto { return { name: child.name, fullPath: child.fullPathSegments.join('.'), loaded: child.loaded, - tree: child.tree ? mapResourceTreeToDto(child.tree, collectionTags) : undefined, + tree: child.tree ? mapResourceTreeToDto(child.tree, collection) : undefined, }; } diff --git a/apps/api/src/app/mappers/search-result.mapper.ts b/apps/api/src/app/mappers/search-result.mapper.ts index ec81448e..a8718215 100644 --- a/apps/api/src/app/mappers/search-result.mapper.ts +++ b/apps/api/src/app/mappers/search-result.mapper.ts @@ -1,29 +1,17 @@ -import type { SearchResult } from '@simoncodes-ca/core'; +import type { Collection, SearchResult } from '@simoncodes-ca/core'; import type { SearchResultDto } from '@simoncodes-ca/data-transfer'; +import { buildResourceSummary } from '@simoncodes-ca/domain'; -/** - * Maps a SearchResult from the core domain model to SearchResultDto for API responses. - * The types are structurally identical, but we create explicit DTOs for API boundary clarity. - */ -export function mapSearchResultToDto(searchResult: SearchResult, collectionTags?: readonly string[]): SearchResultDto { +/** Maps a search hit to its DTO: the entry's Resource Summary plus how it matched. */ +export function mapSearchResultToDto(searchResult: SearchResult, collection: Collection): SearchResultDto { return { - key: searchResult.key, - translations: searchResult.translations, - status: searchResult.status, + ...buildResourceSummary(searchResult.key, searchResult, collection), matchType: searchResult.matchType, matchedLocales: searchResult.matchedLocales, - comment: searchResult.comment, - tags: searchResult.tags, - inheritedTags: collectionTags && collectionTags.length > 0 ? [...collectionTags] : undefined, }; } -/** - * Maps an array of SearchResults to SearchResultDto array. - */ -export function mapSearchResultsToDto( - searchResults: SearchResult[], - collectionTags?: readonly string[], -): SearchResultDto[] { - return searchResults.map((r) => mapSearchResultToDto(r, collectionTags)); +/** Maps search hits to DTOs, keeping their order. */ +export function mapSearchResultsToDto(searchResults: SearchResult[], collection: Collection): SearchResultDto[] { + return searchResults.map((result) => mapSearchResultToDto(result, collection)); } diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts index cd740f59..d7a0ed2c 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts @@ -13,14 +13,25 @@ import { type KeyAbsorption, type KnownEntries, type LocaleDraft, - type OriginalEntry, type ResourceEntryDraft, removeTag, toCreateDto, toUpdateDto, } from './resource-entry-draft'; -const entry = (key: string): ResourceSummaryDto => ({ key, translations: { en: key }, status: {} }); +const entry = (fullKey: string): ResourceSummaryDto => { + const segments = fullKey.split('.'); + const entryKey = segments.pop() ?? ''; + return { + fullKey, + folderPath: segments.join('.'), + entryKey, + base: { locale: 'en', value: entryKey }, + targets: [], + tags: [], + inheritedTags: [], + }; +}; const folder = (fullPath: string, tree?: FolderNodeDto['tree']): FolderNodeDto => ({ name: fullPath.split('.').at(-1) ?? fullPath, @@ -106,7 +117,7 @@ describe('folderEntryKeys', () => { it('should leave out the nested resources a folder listing folds in', () => { const keys = folderEntryKeys( 'common', - known({ browserFolderPath: 'common', browserEntries: [entry('ok'), entry('dialog.title')] }), + known({ browserFolderPath: 'common', browserEntries: [entry('common.ok'), entry('common.dialog.title')] }), ); expect(keys && [...keys]).toEqual(['ok']); }); @@ -115,9 +126,9 @@ describe('folderEntryKeys', () => { const keys = folderEntryKeys( 'common', known({ - rootFolders: [folder('common', { path: 'common', resources: [entry('fromTree')], children: [] })], + rootFolders: [folder('common', { path: 'common', resources: [entry('common.fromTree')], children: [] })], browserFolderPath: 'common', - browserEntries: [entry('fromList')], + browserEntries: [entry('common.fromList')], }), ); expect(keys && [...keys]).toEqual(['fromTree']); @@ -129,11 +140,13 @@ describe('collisionFor', () => { folder('common', { path: 'common', resources: [], - children: [folder('common.buttons', { path: 'common.buttons', resources: [entry('save')], children: [] })], + children: [ + folder('common.buttons', { path: 'common.buttons', resources: [entry('common.buttons.save')], children: [] }), + ], }), ]; const listing = (path: string, ...keys: string[]): KnownEntries => - known({ browserFolderPath: path, browserEntries: keys.map(entry) }); + known({ browserFolderPath: path, browserEntries: keys.map((key) => entry(path ? `${path}.${key}` : key)) }); const buttons = listing('common.buttons', 'ok'); it.each<[string, string, string, KnownEntries, string | undefined, boolean]>([ @@ -253,7 +266,7 @@ describe('contextTree', () => { input({ known: known({ browserFolderPath: 'common.buttons', - browserEntries: [entry('ok'), entry('confirm.dialog.title')], + browserEntries: [entry('common.buttons.ok'), entry('common.buttons.confirm.dialog.title')], }), }), moreLabel, @@ -264,7 +277,10 @@ describe('contextTree', () => { }); describe('marks', () => { - const holdingOk = known({ browserFolderPath: 'common.buttons', browserEntries: [entry('ok'), entry('cancel')] }); + const holdingOk = known({ + browserFolderPath: 'common.buttons', + browserEntries: [entry('common.buttons.ok'), entry('common.buttons.cancel')], + }); it.each<[string, Partial, 'new' | 'exists' | 'editing' | undefined]>([ ['a free key is new', { key: 'save', known: holdingOk }, 'new'], @@ -284,7 +300,7 @@ describe('contextTree', () => { describe(`the ${CONTEXT_TREE_ENTRY_LIMIT}-entry window`, () => { // k01 … k20, already sorted. - const twenty = Array.from({ length: 20 }, (_, i) => entry(`k${String(i + 1).padStart(2, '0')}`)); + const twenty = Array.from({ length: 20 }, (_, i) => entry(`common.buttons.k${String(i + 1).padStart(2, '0')}`)); const full = known({ browserFolderPath: 'common.buttons', browserEntries: twenty }); const range = (from: number, to: number): string[] => Array.from({ length: to - from + 1 }, (_, i) => `k${String(from + i).padStart(2, '0')}`); @@ -375,13 +391,17 @@ describe('toCreateDto', () => { }); describe('toUpdateDto and editedLocales', () => { - const original: OriginalEntry = { - resource: { - key: 'ok', - translations: { en: 'OK', fr: 'Oui', de: 'Ja' }, - status: { fr: 'translated', de: 'verified' }, - }, + const original: ResourceSummaryDto = { + fullKey: 'common.buttons.ok', folderPath: 'common.buttons', + entryKey: 'ok', + base: { locale: 'en', value: 'OK' }, + targets: [ + { locale: 'fr', value: 'Oui', status: 'translated', needsWork: false, sameAsBase: false }, + { locale: 'de', value: 'Ja', status: 'verified', needsWork: false, sameAsBase: false }, + ], + tags: [], + inheritedTags: [], }; const locales = (fr: [string, TranslationStatus], de: [string, TranslationStatus]): LocaleDraft[] => [ @@ -417,7 +437,7 @@ describe('toUpdateDto and editedLocales', () => { const edited = draft({ translations }); expect(toUpdateDto(edited, original).locales).toEqual(expected); - expect(editedLocales(edited, original.resource).map((t) => t.locale)).toEqual(Object.keys(expected ?? {})); + expect(editedLocales(edited, original).map((t) => t.locale)).toEqual(Object.keys(expected ?? {})); }); it('should name the entry by its original full key and always send the tags', () => { @@ -428,7 +448,7 @@ describe('toUpdateDto and editedLocales', () => { }); it('should keep a root-level entry on its bare key', () => { - expect(toUpdateDto(draft({ folderPath: '' }), { ...original, folderPath: '' }).key).toBe('ok'); + expect(toUpdateDto(draft({ folderPath: '' }), entry('ok')).key).toBe('ok'); }); it('should send a move to another folder as moveTo, with the full original key', () => { @@ -443,7 +463,7 @@ describe('toUpdateDto and editedLocales', () => { }); it('should send a move out of the collection root', () => { - const dto = toUpdateDto(draft({ folderPath: 'common' }), { ...original, folderPath: '' }); + const dto = toUpdateDto(draft({ folderPath: 'common' }), entry('ok')); expect(dto.key).toBe('ok'); expect(dto.moveTo).toBe('common'); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts index 45ad2265..e35d813d 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts @@ -5,7 +5,7 @@ import type { TranslationStatus, UpdateResourceDto, } from '@simoncodes-ca/data-transfer'; -import { isValidSegment, normalizeTag, resolveResourceKey } from '@simoncodes-ca/domain'; +import { isValidSegment, normalizeTag, resolveResourceKey, summaryTarget } from '@simoncodes-ca/domain'; import { findFolderInTree } from '../../store/folder-tree.utils'; /* @@ -37,13 +37,6 @@ export interface ResourceEntryDraft { translations: readonly LocaleDraft[]; } -/** The entry an edit started from. */ -export interface OriginalEntry { - resource: ResourceSummaryDto; - /** The folder it lives in; '' for the collection root. */ - folderPath: string; -} - // ── Dotted-key absorption ──────────────────────────────────────────────────── export interface KeyAbsorption { @@ -110,15 +103,16 @@ export interface KnownEntries { * Three sources, cheapest first: a folder already expanded in the tree, the folder * the browser is showing, then anything the editor fetched. * - * An entry key is a single segment. The browser lists a folder with its nested - * resources folded in, under keys relative to the folder (`dialog.title`, not - * `title`). Those live in another folder, so they are left out. + * The browser lists a folder with its nested resources folded in. Those live in + * another folder, so only resources whose `folderPath` is this folder count. */ export function folderEntryKeys(folderPath: string, known: KnownEntries): ReadonlySet | undefined { const expanded = folderPath ? findFolderInTree(known.rootFolders, folderPath)?.tree?.resources : undefined; const listed = expanded ?? (known.browserFolderPath === folderPath ? known.browserEntries : undefined); - const keys = listed?.map((resource) => resource.key) ?? known.fetched.get(folderPath); - return keys ? new Set(keys.filter((key) => !key.includes('.'))) : undefined; + const keys = + listed?.filter((resource) => resource.folderPath === folderPath).map((resource) => resource.entryKey) ?? + known.fetched.get(folderPath); + return keys ? new Set(keys) : undefined; } /** @@ -317,20 +311,20 @@ export function toCreateDto(draft: ResourceEntryDraft): CreateResourceDto { export function editedLocales(draft: ResourceEntryDraft, original: ResourceSummaryDto): LocaleDraft[] { return draft.translations.filter((translation) => { const hasValue = translation.value.trim().length > 0; - const statusChanged = translation.status !== (original.status[translation.locale] ?? 'new'); + const statusChanged = translation.status !== (summaryTarget(original, translation.locale)?.status ?? 'new'); return hasValue || statusChanged; }); } /** - * The update request. The key is the entry's full key where it lives now. A - * change of folder, the collection root included, travels as `moveTo` (the - * destination folder; '' for the root). Tags are always sent, so removing the - * last one clears them. + * The update request. The key is the entry's full key where it lives now (the + * `original` it started from). A change of folder, the collection root included, + * travels as `moveTo` (the destination folder; '' for the root). Tags are always + * sent, so removing the last one clears them. */ -export function toUpdateDto(draft: ResourceEntryDraft, original: OriginalEntry): UpdateResourceDto { +export function toUpdateDto(draft: ResourceEntryDraft, original: ResourceSummaryDto): UpdateResourceDto { const dto: UpdateResourceDto = { - key: resolveResourceKey(original.resource.key, original.folderPath), + key: original.fullKey, baseValue: draft.baseValue, comment: draft.comment.trim() || undefined, tags: [...draft.tags], @@ -340,7 +334,7 @@ export function toUpdateDto(draft: ResourceEntryDraft, original: OriginalEntry): dto.moveTo = draft.folderPath; } - const locales = editedLocales(draft, original.resource); + const locales = editedLocales(draft, original); if (locales.length > 0) { dto.locales = Object.fromEntries( locales.map((translation) => [translation.locale, { value: translation.value, status: translation.status }]), diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.html b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.html index 06a04d4b..b6cc5f9f 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.html +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.html @@ -34,7 +34,7 @@

{{ TOKENS.BROWSER.SIMILARTRANSLATIONS.TITLE | transloco } @case ('results') {
- @for (row of displayedRows(); track row.result.key) { + @for (row of displayedRows(); track row.result.fullKey) { @for (statusOption of translationStatusOptions; track statusOption) { } @@ -594,7 +594,6 @@

[results]="similarResources()" [isLoading]="isSearchingSimilar()" [hasSearchQuery]="hasSearchQuery()" - [baseLocale]="data.baseLocale" [exactKey]="exactMatchKey()" (resourceClicked)="onSimilarResourceClick($event)" /> @@ -627,7 +626,7 @@

}

} @empty { diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts index 9bb5d29e..b9524dd8 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts @@ -6,7 +6,12 @@ import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dial import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; import { patchState } from '@ngrx/signals'; -import type { LingoTrackerConfigDto, ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; +import type { + LingoTrackerConfigDto, + ResourceSummaryDto, + SearchResultDto, + TranslationStatus, +} from '@simoncodes-ca/data-transfer'; import { of, Subject, throwError } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; @@ -28,7 +33,13 @@ describe('TranslationEditorDialog', () => { let component: TranslationEditorDialog; let fixture: ComponentFixture; let spectator: Spectator; - let dialogRef: { close: Mock; afterOpened: Mock }; + let dialogRef: { + close: Mock; + afterOpened: Mock; + keydownEvents: Mock; + backdropClick: Mock; + disableClose: boolean; + }; let mockDialog: { open: Mock }; let mockBrowserApi: { createResource: Mock; @@ -39,6 +50,32 @@ describe('TranslationEditorDialog', () => { let mockNotifications: { success: Mock; info: Mock; warning: Mock; error: Mock }; let mockConfig: WritableSignal; + const summary = ( + fullKey: string, + baseValue: string, + targets: Record = {}, + extra: Partial = {}, + ): ResourceSummaryDto => { + const segments = fullKey.split('.'); + const entryKey = segments.pop() ?? ''; + return { + fullKey, + folderPath: segments.join('.'), + entryKey, + base: { locale: 'en', value: baseValue }, + targets: Object.entries(targets).map(([locale, [value, status]]) => ({ + locale, + value, + status, + needsWork: status === undefined || status === 'new' || status === 'stale', + sameAsBase: (value?.trim() ?? '').length > 0 && value?.trim() === baseValue.trim(), + })), + tags: [], + inheritedTags: [], + ...extra, + }; + }; + const createMockData = (mode: 'create' | 'edit', resource?: ResourceSummaryDto): TranslationEditorDialogData => ({ mode, resource, @@ -130,11 +167,7 @@ describe('TranslationEditorDialog', () => { }); it('should display edit mode title and subtitle', async () => { - const editData = createMockData('edit', { - key: 'test_key', - translations: { en: 'Test Value' }, - status: {}, - }); + const editData = createMockData('edit', summary('common.buttons.test_key', 'Test Value')); renderDialog(editData); expect(component.dialogTitle()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.EDITTITLE); @@ -217,11 +250,7 @@ describe('TranslationEditorDialog', () => { }); it('should not absorb dots in edit mode, where the key is readonly', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value' }, - status: {}, - }; + const mockResource = summary('common.buttons.existing_key', 'Existing Value'); renderDialog(createMockData('edit', mockResource)); component.form.controls.key.setValue('apps.common.ok'); @@ -409,12 +438,12 @@ describe('TranslationEditorDialog', () => { describe('Edit Mode', () => { it('should pre-populate form with resource data', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value', fr: 'Valeur existante' }, - status: {}, - comment: 'Existing comment', - }; + const mockResource = summary( + 'common.buttons.existing_key', + 'Existing Value', + { fr: ['Valeur existante', undefined] }, + { comment: 'Existing comment' }, + ); const editData = createMockData('edit', mockResource); renderDialog(editData); @@ -425,11 +454,7 @@ describe('TranslationEditorDialog', () => { }); it('should handle missing base locale translation', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { fr: 'Valeur' }, - status: {}, - }; + const mockResource = summary('common.buttons.existing_key', '', { fr: ['Valeur', undefined] }); const editData = createMockData('edit', mockResource); renderDialog(editData); @@ -438,11 +463,7 @@ describe('TranslationEditorDialog', () => { }); it('should handle missing comment', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value' }, - status: {}, - }; + const mockResource = summary('common.buttons.existing_key', 'Existing Value'); const editData = createMockData('edit', mockResource); renderDialog(editData); @@ -451,11 +472,7 @@ describe('TranslationEditorDialog', () => { }); it('should display correct save button label in edit mode', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value' }, - status: {}, - }; + const mockResource = summary('common.buttons.existing_key', 'Existing Value'); const editData = createMockData('edit', mockResource); renderDialog(editData); @@ -468,18 +485,10 @@ describe('TranslationEditorDialog', () => { }); it('should pre-populate other locale translations in edit mode', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { - en: 'Existing Value', - fr: 'Valeur existante', - de: 'Vorhandener Wert', - }, - status: { - fr: 'translated', - de: 'verified', - }, - }; + const mockResource = summary('common.buttons.existing_key', 'Existing Value', { + fr: ['Valeur existante', 'translated'], + de: ['Vorhandener Wert', 'verified'], + }); const editData = createMockData('edit', mockResource); renderDialog(editData); @@ -607,12 +616,7 @@ describe('TranslationEditorDialog', () => { it('should focus the comment field in edit mode when user clicks "Add Comment"', async () => { renderDialog( - createMockData('edit', { - key: 'test_key', - translations: { en: 'Test Value' }, - status: {}, - comment: 'Existing comment', - }), + createMockData('edit', summary('common.buttons.test_key', 'Test Value', {}, { comment: 'Existing comment' })), ); mockDialog.open.mockReturnValue({ afterClosed: vi.fn().mockReturnValue(of(false)) }); @@ -751,12 +755,7 @@ describe('TranslationEditorDialog', () => { }); it('should include skippedLocales in update result when API returns them', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value' }, - status: {}, - comment: 'A comment', - }; + const mockResource = summary('common.buttons.existing_key', 'Existing Value', {}, { comment: 'A comment' }); const editData = createMockData('edit', mockResource); mockBrowserApi.updateResource.mockReturnValue( @@ -774,12 +773,7 @@ describe('TranslationEditorDialog', () => { }); it('should omit skippedLocales from update result when API returns empty array', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value' }, - status: {}, - comment: 'A comment', - }; + const mockResource = summary('common.buttons.existing_key', 'Existing Value', {}, { comment: 'A comment' }); const editData = createMockData('edit', mockResource); mockBrowserApi.updateResource.mockReturnValue( @@ -935,7 +929,7 @@ describe('TranslationEditorDialog', () => { }); it('should highlight the row of the entry being edited', () => { - renderDialog(createMockData('edit', { key: 'ok', translations: { en: 'OK' }, status: {} })); + renderDialog(createMockData('edit', summary('common.buttons.ok', 'OK'))); spectator.detectChanges(); const rows = spectator.queryAll('[data-testid="context-tree"] .ftree-n--target'); @@ -955,11 +949,13 @@ describe('TranslationEditorDialog', () => { it('should list only the locales that are new or stale', () => { renderDialog( - createMockData('edit', { - key: 'ok', - translations: { en: 'OK', fr: 'Oui', de: 'Ja' }, - status: { fr: 'stale', de: 'verified' }, - }), + createMockData( + 'edit', + summary('common.buttons.ok', 'OK', { + fr: ['Oui', 'stale'], + de: ['Ja', 'verified'], + }), + ), ); expect(component.localesNeedingWork().map((locale) => locale.locale)).toEqual(['fr']); @@ -971,11 +967,13 @@ describe('TranslationEditorDialog', () => { it('should show the caught-up line instead of an empty list', () => { renderDialog( - createMockData('edit', { - key: 'ok', - translations: { en: 'OK', fr: 'Oui', de: 'Ja' }, - status: { fr: 'translated', de: 'verified' }, - }), + createMockData( + 'edit', + summary('common.buttons.ok', 'OK', { + fr: ['Oui', 'translated'], + de: ['Ja', 'verified'], + }), + ), ); expect(component.localesNeedingWork()).toHaveLength(0); @@ -985,12 +983,15 @@ describe('TranslationEditorDialog', () => { }); describe('Key collision', () => { - const entry = (key: string): ResourceSummaryDto => ({ key, translations: { en: key }, status: {} }); + const entry = (fullKey: string): ResourceSummaryDto => summary(fullKey, fullKey.split('.').at(-1) ?? fullKey); /** Puts entries in the folder the browser is showing, the cheapest source. */ const seedBrowserFolder = (folderPath: string, keys: string[]): void => { const store = spectator.inject(BrowserStore); - patchState(store, { currentFolderPath: folderPath, translations: keys.map(entry) }); + patchState(store, { + currentFolderPath: folderPath, + translations: keys.map((key) => entry(folderPath ? `${folderPath}.${key}` : key)), + }); }; it('should detect a collision against the entries the browser already holds', () => { @@ -1132,7 +1133,10 @@ describe('TranslationEditorDialog', () => { }); describe('Sticky similar values', () => { - const hit = (key: string, value: string) => ({ key, translations: { en: value }, status: {} }); + const hit = (fullKey: string, value: string): SearchResultDto => ({ + ...summary(fullKey, value), + matchType: 'partial-value', + }); const searchReturns = (results: ReturnType[]): void => { mockBrowserApi.searchTranslations.mockReturnValue( @@ -1202,9 +1206,7 @@ describe('TranslationEditorDialog', () => { it('should show nothing in edit mode until the value differs, and clear again on revert', () => { vi.useRealTimers(); - renderDialog( - createMockData('edit', { key: 'saveShortcutHint', translations: { en: 'Press Ctrl + Enter' }, status: {} }), - ); + renderDialog(createMockData('edit', summary('common.buttons.saveShortcutHint', 'Press Ctrl + Enter'))); vi.useFakeTimers(); searchReturns([hit('common.actions.save', 'Press Ctrl + Enter')]); @@ -1253,7 +1255,7 @@ describe('TranslationEditorDialog', () => { ]); typeAndSettle('Save draft'); - expect(component.similarResources().map((result) => result.key)).toEqual(['common.actions.save']); + expect(component.similarResources().map((result) => result.fullKey)).toEqual(['common.actions.save']); expect(component.similarCount()).toBe(1); }); @@ -1274,7 +1276,7 @@ describe('TranslationEditorDialog', () => { typeAndSettle('Save'); expect(component.similarCount()).toBe(2); - expect(component.similarResources().map((result) => result.key)).toEqual([ + expect(component.similarResources().map((result) => result.fullKey)).toEqual([ 'browser.translationEditor.saveAnyway', 'common.actions.save', ]); @@ -1352,12 +1354,12 @@ describe('TranslationEditorDialog', () => { describe('Edit Mode API Integration', () => { it('should call updateResource API when submitting in edit mode', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value', fr: 'Valeur existante' }, - status: { fr: 'translated' }, - comment: 'Existing comment', - }; + const mockResource = summary( + 'common.buttons.existing_key', + 'Existing Value', + { fr: ['Valeur existante', 'translated'] }, + { comment: 'Existing comment' }, + ); const editData = createMockData('edit', mockResource); mockBrowserApi.updateResource.mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })); @@ -1380,11 +1382,9 @@ describe('TranslationEditorDialog', () => { }); it('should include translations in update API call', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value', fr: 'Valeur existante' }, - status: { fr: 'translated' }, - }; + const mockResource = summary('common.buttons.existing_key', 'Existing Value', { + fr: ['Valeur existante', 'translated'], + }); const editData = createMockData('edit', mockResource); mockBrowserApi.updateResource.mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })); @@ -1407,11 +1407,7 @@ describe('TranslationEditorDialog', () => { }); it('should handle update API errors', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value' }, - status: {}, - }; + const mockResource = summary('common.buttons.existing_key', 'Existing Value'); const editData = createMockData('edit', mockResource); mockBrowserApi.updateResource.mockReturnValue( @@ -1433,12 +1429,12 @@ describe('TranslationEditorDialog', () => { }); it('should close dialog with success result on successful update', async () => { - const mockResource: ResourceSummaryDto = { - key: 'existing_key', - translations: { en: 'Existing Value', fr: 'Valeur existante' }, - status: { fr: 'translated' }, - comment: 'Existing comment', - }; + const mockResource = summary( + 'common.buttons.existing_key', + 'Existing Value', + { fr: ['Valeur existante', 'translated'] }, + { comment: 'Existing comment' }, + ); const editData = createMockData('edit', mockResource); mockBrowserApi.updateResource.mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })); @@ -1496,7 +1492,7 @@ describe('TranslationEditorDialog', () => { const store = spectator.inject(BrowserStore); patchState(store, { currentFolderPath: 'common.buttons', - translations: [{ key: 'ok', translations: { en: 'OK' }, status: {} }], + translations: [summary('common.buttons.ok', 'OK')], }); spectator.detectChanges(); @@ -1526,7 +1522,7 @@ describe('TranslationEditorDialog', () => { spectator.query('#translation-editor-base-value'); const openEditing = (baseValue: string): void => { - renderDialog(createMockData('edit', { key: 'label', translations: { en: baseValue }, status: {} })); + renderDialog(createMockData('edit', summary('common.buttons.label', baseValue))); }; const type = (value: string, settle = true): void => { @@ -1715,7 +1711,7 @@ describe('TranslationEditorDialog', () => { it('should advise without offering Use when read-only', () => { renderDialog({ - ...createMockData('edit', { key: 'label', translations: { en: 'Expenditure' }, status: {} }), + ...createMockData('edit', summary('common.buttons.label', 'Expenditure')), readOnly: true, }); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts index 86866de9..63fdbee0 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts @@ -37,6 +37,7 @@ import { findPreferredTermFindings, type PreferredTermRule, resolveResourceKey, + summaryTarget, } from '@simoncodes-ca/domain'; import { of, Subject } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil, tap } from 'rxjs/operators'; @@ -45,6 +46,7 @@ import { CollectionsStore } from '../../../collections/store/collections.store'; import { ConfirmationDialog } from '../../../shared/components/confirmation-dialog/confirmation-dialog'; import type { ConfirmationDialogData } from '../../../shared/components/confirmation-dialog/confirmation-dialog-data'; import { NotificationService } from '../../../shared/notification'; +import { statusLabelTokenFor } from '../../../shared/translation-status/translation-status-presentation'; import { segmentValidator } from '../../../shared/validators/segment.validator'; import { BrowserApiService } from '../../services/browser-api.service'; import { BrowserStore } from '../../store/browser.store'; @@ -62,7 +64,6 @@ import { hasUnsavedChanges, type KnownEntries, type LocaleDraft, - type OriginalEntry, removeTag, type ResourceEntryDraft, toCreateDto, @@ -312,7 +313,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit * The entry being edited, by its own key. Edit mode locks the key, so the * draft module never lets it collide with itself and marks it `editing`. */ - readonly #ownKey = this.data.mode === 'edit' ? this.data.resource?.key : undefined; + readonly #ownKey = this.data.mode === 'edit' ? this.data.resource?.entryKey : undefined; /** Everything the draft module needs to know which entries a folder holds. */ readonly #knownEntries = computed(() => ({ @@ -346,13 +347,8 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit return this.form.controls.baseValue.invalid && (this.form.controls.baseValue.touched || this.submitAttempted()); }); - /** Localized label for a translation status, so the spine never shows raw enum text. */ - readonly statusLabels: Record = { - new: TRACKER_TOKENS.BROWSER.STATUS.NEW, - translated: TRACKER_TOKENS.BROWSER.STATUS.TRANSLATED, - stale: TRACKER_TOKENS.BROWSER.STATUS.STALE, - verified: TRACKER_TOKENS.BROWSER.STATUS.VERIFIED, - }; + /** Transloco token for a status label, from the shared status presentation, so the spine never shows raw enum text. */ + readonly statusLabelToken = statusLabelTokenFor; /** Explains a disabled Other locales row instead of leaving it silently grey. */ readonly otherLocalesDisabledTooltip = computed(() => @@ -430,13 +426,11 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit if (!typed) { return undefined; } - return this.similarResources().find( - (result) => (result.translations[this.data.baseLocale] ?? '').trim().toLowerCase() === typed, - ); + return this.similarResources().find((result) => result.base.value.trim().toLowerCase() === typed); }); /** The key carrying the exact same text, or '' when no hit matches verbatim. */ - readonly exactMatchKey = computed(() => this.exactMatch()?.key ?? ''); + readonly exactMatchKey = computed(() => this.exactMatch()?.fullKey ?? ''); /** The one-line summary the narrow "Context" disclosure carries. */ readonly contextSummary = computed(() => { @@ -486,7 +480,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit readonly allTagSuggestions = computed(() => { const seen = new Set(); for (const resource of this.browserStore.translations()) { - for (const tag of resource.tags ?? []) { + for (const tag of resource.tags) { seen.add(tag); } } @@ -505,16 +499,16 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit this.#initializeOtherLocaleFormControls(); if (this.isEditMode() && this.data.resource) { - const baseValue = this.data.resource.translations[this.data.baseLocale] || ''; + const baseValue = this.data.resource.base.value; const comment = this.data.resource.comment || ''; this.form.patchValue({ - key: this.data.resource.key, + key: this.data.resource.entryKey, baseValue, comment, }); - this.tagsList.set(this.data.resource.tags ?? []); + this.tagsList.set([...this.data.resource.tags]); this.#populateOtherLocaleTranslations(); } @@ -583,10 +577,8 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit } /** The entry an edit started from, or undefined in create mode. */ - #originalEntry(): OriginalEntry | undefined { - return this.isEditMode() && this.data.resource - ? { resource: this.data.resource, folderPath: this.data.folderPath || '' } - : undefined; + #originalEntry(): ResourceSummaryDto | undefined { + return this.isEditMode() ? this.data.resource : undefined; } ngOnDestroy(): void { @@ -635,8 +627,9 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit if (!locale) { return; } - const value = this.data.resource?.translations[locale] || ''; - const status = this.data.resource?.status[locale] || 'new'; + const target = this.data.resource ? summaryTarget(this.data.resource, locale) : undefined; + const value = target?.value ?? ''; + const status = target?.status ?? 'new'; control.patchValue({ value, status }); }); @@ -691,17 +684,14 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit .subscribe((searchResults) => { // Filter out current resource in edit mode const original = this.#originalEntry(); - const ownFullKey = original ? resolveResourceKey(original.resource.key, original.folderPath) : undefined; - const withoutSelf = ownFullKey - ? searchResults.results.filter((r) => r.key !== ownFullKey) + const withoutSelf = original + ? searchResults.results.filter((r) => r.fullKey !== original.fullKey) : searchResults.results; // The API matches keys too, and reports a key match ahead of a value one. // Everything downstream — the count, the exact-duplicate caption, what // stays pinned — reads this signal, so the key-only hits go before it. - this.similarResources.set( - filterSimilarByValue(withoutSelf, searchResults.query || this.baseValueText(), this.data.baseLocale), - ); + this.similarResources.set(filterSimilarByValue(withoutSelf, searchResults.query || this.baseValueText())); }); } @@ -840,10 +830,8 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit .pipe(takeUntil(this.destroy$)) .subscribe({ next: (tree) => { - if ('resources' in tree) { - const keys = tree.resources.map((resource) => resource.key); - this.#loadedFolderEntries.update((entries) => new Map(entries).set(folderPath, keys)); - } + const keys = tree.resources.map((resource) => resource.entryKey); + this.#loadedFolderEntries.update((entries) => new Map(entries).set(folderPath, keys)); this.#finishFolderLoad(folderPath); }, // A folder we cannot read claims nothing. The save path still guards. @@ -1075,8 +1063,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit } onSimilarResourceClick(result: SearchResultDto): void { - const fullKey = result.key; - this.#copyToClipboard(fullKey, this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.KEYCOPIED)); + this.#copyToClipboard(result.fullKey, this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.KEYCOPIED)); } /** @@ -1185,7 +1172,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit // The key control is readonly in edit mode (`html`), so `draft.key` can only // ever equal the original; renaming is a move, handled by the CLI. - const edited = editedLocales(draft, original.resource); + const edited = editedLocales(draft, original); this.browserStore.updateResource(this.data.collectionName, toUpdateDto(draft, original)).subscribe({ next: (response: UpdateResourceResponseDto) => { diff --git a/apps/tracker/src/app/browser/services/browser-api.service.spec.ts b/apps/tracker/src/app/browser/services/browser-api.service.spec.ts index abd27d6e..deb07e92 100644 --- a/apps/tracker/src/app/browser/services/browser-api.service.spec.ts +++ b/apps/tracker/src/app/browser/services/browser-api.service.spec.ts @@ -11,8 +11,13 @@ import type { UpdateResourceResponseDto, } from '@simoncodes-ca/data-transfer'; import { firstValueFrom } from 'rxjs'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { BrowserApiService } from './browser-api.service'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BrowserApiService, + CollectionIndexNotReadyError, + TREE_NOT_READY_RETRIES, + TREE_NOT_READY_RETRY_DELAY_MS, +} from './browser-api.service'; describe('BrowserApiService', () => { let service: BrowserApiService; @@ -83,6 +88,62 @@ describe('BrowserApiService', () => { const data = await firstValueFrom(result$); expect(data).toEqual(mockResponse); }); + + describe('while the collection is being indexed', () => { + const url = '/api/collections/c/resources/tree?path=&includeNested=false'; + const notReady = { status: 'indexing', message: 'Collection is currently being indexed.' }; + const tree: ResourceTreeDto = { path: '', resources: [], children: [] }; + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('asks again after a pause and hands the caller only the tree', () => { + const received: ResourceTreeDto[] = []; + service.getResourceTree('c').subscribe((value) => received.push(value)); + + httpMock.expectOne(url).flush(notReady, { status: 202, statusText: 'Accepted' }); + httpMock.expectNone(url); + + vi.advanceTimersByTime(TREE_NOT_READY_RETRY_DELAY_MS); + httpMock.expectOne(url).flush(tree); + + expect(received).toEqual([tree]); + }); + + it('gives up with CollectionIndexNotReadyError once the retries are spent', () => { + let failure: unknown; + service.getResourceTree('c').subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + + for (let attempt = 0; attempt <= TREE_NOT_READY_RETRIES; attempt++) { + httpMock.expectOne(url).flush(notReady, { status: 202, statusText: 'Accepted' }); + vi.advanceTimersByTime(TREE_NOT_READY_RETRY_DELAY_MS); + } + + httpMock.expectNone(url); + expect(failure).toBeInstanceOf(CollectionIndexNotReadyError); + expect((failure as Error).message).toBe(notReady.message); + }); + + it('does not retry an HTTP error', () => { + let failure: unknown; + service.getResourceTree('c').subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + + httpMock.expectOne(url).flush('boom', { status: 500, statusText: 'Server Error' }); + vi.advanceTimersByTime(TREE_NOT_READY_RETRY_DELAY_MS); + + httpMock.expectNone(url); + expect(failure).toBeDefined(); + expect(failure).not.toBeInstanceOf(CollectionIndexNotReadyError); + }); + }); }); describe('searchTranslations', () => { @@ -95,9 +156,13 @@ describe('BrowserApiService', () => { query: 'button', results: [ { - key: 'common.buttons.save', - translations: { en: 'Save', es: 'Guardar' }, - status: { en: 'verified', es: 'verified' }, + fullKey: 'common.buttons.save', + folderPath: 'common.buttons', + entryKey: 'save', + base: { locale: 'en', value: 'Save' }, + targets: [{ locale: 'es', value: 'Guardar', status: 'verified', needsWork: false, sameAsBase: false }], + tags: [], + inheritedTags: [], matchType: 'partial-key', }, ], diff --git a/apps/tracker/src/app/browser/services/browser-api.service.ts b/apps/tracker/src/app/browser/services/browser-api.service.ts index 671400a8..1cc53898 100644 --- a/apps/tracker/src/app/browser/services/browser-api.service.ts +++ b/apps/tracker/src/app/browser/services/browser-api.service.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import type { Observable } from 'rxjs'; +import { map, type Observable, retry, throwError, timer } from 'rxjs'; import type { ResourceTreeDto, TreeStatusResponseDto, @@ -24,6 +24,23 @@ import type { TranslateResourceResponseDto, } from '@simoncodes-ca/data-transfer'; +/** How many times a tree read is asked again while the collection is still being indexed. */ +export const TREE_NOT_READY_RETRIES = 5; + +/** Pause between those attempts, in milliseconds. */ +export const TREE_NOT_READY_RETRY_DELAY_MS = 1000; + +/** + * The collection's index was still not ready after every retry (the tree endpoint kept + * answering HTTP 202 with a {@link TreeStatusResponseDto}). + */ +export class CollectionIndexNotReadyError extends Error { + constructor(message: string) { + super(message); + this.name = 'CollectionIndexNotReadyError'; + } +} + /** * API service for browser-related operations. */ @@ -46,26 +63,38 @@ export class BrowserApiService { } /** - * Gets the resource tree for a collection. - * The API now returns the full tree or subtree without depth limits. + * Gets the resource tree (or the subtree at `path`) for a collection. + * + * While the collection is still being indexed the endpoint answers HTTP 202 with a + * status body instead of a tree. That wait is handled here: the read is asked again + * up to {@link TREE_NOT_READY_RETRIES} times, {@link TREE_NOT_READY_RETRY_DELAY_MS} + * apart, so callers only ever receive a tree. When the index is still not ready after + * that, the observable errors with {@link CollectionIndexNotReadyError}. * * @param collectionName - Name of the collection * @param path - Folder path (empty string for root) * @param includeNested - Whether to include nested resources in the resources array - * @returns Observable of ResourceTreeDto */ - getResourceTree( - collectionName: string, - path = '', - includeNested = false, - ): Observable { + getResourceTree(collectionName: string, path = '', includeNested = false): Observable { const encodedName = encodeURIComponent(collectionName); const encodedPath = encodeURIComponent(path); const params = new HttpParams().set('path', encodedPath).set('includeNested', includeNested.toString()); - return this.#http.get(`${this.#baseUrl}/${encodedName}/resources/tree`, { - params, - }); + return this.#http + .get(`${this.#baseUrl}/${encodedName}/resources/tree`, { params }) + .pipe( + map((body) => { + if ('resources' in body) return body; + throw new CollectionIndexNotReadyError(body.message); + }), + retry({ + count: TREE_NOT_READY_RETRIES, + delay: (error: unknown) => + error instanceof CollectionIndexNotReadyError + ? timer(TREE_NOT_READY_RETRY_DELAY_MS) + : throwError(() => error), + }), + ); } /** diff --git a/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts b/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts index 7af71fd1..f23facbe 100644 --- a/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts +++ b/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts @@ -11,6 +11,7 @@ import { BrowserStore } from '../store/browser.store'; import { BrowserApiService } from './browser-api.service'; import { TRANSLATION_EDITOR_TITLE_ID } from '../dialogs/translation-editor'; import { TranslationEditorLauncher } from './translation-editor-launcher'; +import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; describe('TranslationEditorLauncher', () => { let launcher: TranslationEditorLauncher; @@ -19,11 +20,21 @@ describe('TranslationEditorLauncher', () => { let mockApi: { getResourceTree: Mock }; let notifications: { success: Mock; info: Mock; warning: Mock; error: Mock }; - const resource = { key: 'backButton', translations: { en: 'Back' }, status: {} }; + const resource: ResourceSummaryDto = { + fullKey: 'browser.header.backButton', + folderPath: 'browser.header', + entryKey: 'backButton', + base: { locale: 'en', value: 'Back' }, + targets: [{ locale: 'fr', needsWork: true, sameAsBase: false }], + tags: [], + inheritedTags: [], + }; beforeEach(() => { mockDialog = { open: vi.fn().mockReturnValue({ afterClosed: () => of(undefined) }) }; - mockApi = { getResourceTree: vi.fn().mockReturnValue(of({ resources: [resource], folders: [] })) }; + mockApi = { + getResourceTree: vi.fn().mockReturnValue(of({ path: 'browser.header', resources: [resource], children: [] })), + }; notifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; TestBed.configureTestingModule({ @@ -54,7 +65,7 @@ describe('TranslationEditorLauncher', () => { mode: 'edit', collectionName: 'test-collection', folderPath: 'browser.header', - resource: { key: 'backButton' }, + resource: { fullKey: 'browser.header.backButton', entryKey: 'backButton' }, }); expect(config.panelClass).toBe('translation-editor-dialog-panel'); expect(config.ariaLabelledBy).toBe(TRANSLATION_EDITOR_TITLE_ID); @@ -75,6 +86,10 @@ describe('TranslationEditorLauncher', () => { }); it('should resolve a root-level key against the collection root', () => { + mockApi.getResourceTree.mockReturnValue( + of({ path: '', resources: [{ ...resource, fullKey: 'backButton', folderPath: '' }], children: [] }), + ); + launcher.openByFullKey('backButton', 'test-collection'); expect(mockApi.getResourceTree).toHaveBeenCalledWith('test-collection', '', false); @@ -82,7 +97,7 @@ describe('TranslationEditorLauncher', () => { }); it('should report a key the folder no longer holds instead of opening an empty editor', () => { - mockApi.getResourceTree.mockReturnValue(of({ resources: [], folders: [] })); + mockApi.getResourceTree.mockReturnValue(of({ path: 'browser.header', resources: [], children: [] })); launcher.openByFullKey('browser.header.backButton', 'test-collection'); @@ -108,13 +123,13 @@ describe('TranslationEditorLauncher', () => { baseValue: 'Back', folderPath, success: true, - resource: { ...resource, translations: { en: 'Go back' } }, + resource: { ...resource, base: { locale: 'en', value: 'Go back' } }, }), }); // The save itself, and the cache patch that follows it, belong to // BrowserStore.updateResource; the launcher only reports on it. - it('should flash the row under its store key and confirm the save', () => { + it('should flash the row under its full key and confirm the save', () => { patchState(store, { translations: [resource] }); const onUpdated = vi.fn(); mockDialog.open.mockReturnValue(savedInto('browser.header')); @@ -122,12 +137,10 @@ describe('TranslationEditorLauncher', () => { launcher.openEditor({ resource, collectionName: 'test-collection', - folderPath: 'browser.header', - storeKey: 'backButton', onUpdated, }); - expect(onUpdated).toHaveBeenCalledWith('backButton'); + expect(onUpdated).toHaveBeenCalledWith('browser.header.backButton'); expect(notifications.success).toHaveBeenCalled(); expect(store.translations()).toEqual([resource]); }); @@ -139,8 +152,6 @@ describe('TranslationEditorLauncher', () => { launcher.openEditor({ resource, collectionName: 'test-collection', - folderPath: 'browser.header', - storeKey: 'backButton', onUpdated, }); diff --git a/apps/tracker/src/app/browser/services/translation-editor-launcher.ts b/apps/tracker/src/app/browser/services/translation-editor-launcher.ts index 70d1c9cd..b53567e3 100644 --- a/apps/tracker/src/app/browser/services/translation-editor-launcher.ts +++ b/apps/tracker/src/app/browser/services/translation-editor-launcher.ts @@ -12,22 +12,15 @@ import { } from '../dialogs/translation-editor'; import { BrowserApiService } from './browser-api.service'; import { BrowserStore } from '../store/browser.store'; -import { splitKey } from '../translations/list/store/key-resolution'; +import { splitResolvedKey } from '@simoncodes-ca/domain'; /** What the caller knows about the entry it wants opened in the editor. */ export interface OpenEditorParams { - /** The resource as the dialog wants it: `key` is the entry name inside `folderPath`. */ + /** The resource; its explicit address (`fullKey`, `folderPath`, `entryKey`) says where it lives. */ resource: ResourceSummaryDto; collectionName: string; - /** Dot-delimited folder the entry lives in; '' for the collection root. */ - folderPath: string; - /** - * The key the list renders this resource under — relative in folder mode, full - * in search mode — handed back through `onUpdated` for the row's flash. - */ - storeKey: string; - /** Called with the store key after an in-place update, for the row's flash. */ - onUpdated?: (storeKey: string) => void; + /** Called with the resource's full key after an in-place update, for the row's flash. */ + onUpdated?: (fullKey: string) => void; } /** @@ -49,7 +42,8 @@ export class TranslationEditorLauncher { /** Opens the editor for a resource the caller already holds. */ openEditor(params: OpenEditorParams): void { - const { resource, collectionName, folderPath, storeKey, onUpdated } = params; + const { resource, collectionName, onUpdated } = params; + const { folderPath } = resource; const dialogData: TranslationEditorDialogData = { mode: 'edit', @@ -83,7 +77,7 @@ export class TranslationEditorLauncher { // Saved into another folder: the entry has left this list, so there is no row to flash. if (result.folderPath !== folderPath) return; - onUpdated?.(storeKey); + onUpdated?.(resource.fullKey); this.#notifications.success(this.#transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.TRANSLATIONUPDATED)); if (result.skippedLocales?.length) { @@ -104,12 +98,12 @@ export class TranslationEditorLauncher { * The browser is moved to the entry's folder first, so the dialog closes onto the * list the entry is actually in rather than back onto an unrelated folder. */ - openByFullKey(fullKey: string, collectionName: string, onUpdated?: (storeKey: string) => void): void { - const { folderPath, entryKey } = splitKey(fullKey); + openByFullKey(fullKey: string, collectionName: string, onUpdated?: (fullKey: string) => void): void { + const folderPath = splitResolvedKey(fullKey).folderPath.join('.'); this.#api.getResourceTree(collectionName, folderPath, false).subscribe({ next: (tree) => { - const resource = 'resources' in tree ? tree.resources.find((item) => item.key === entryKey) : undefined; + const resource = tree.resources.find((item) => item.fullKey === fullKey); if (!resource) { this.#notifyNotFound(); return; @@ -122,7 +116,7 @@ export class TranslationEditorLauncher { } this.#browserStore.selectFolder(folderPath); - this.openEditor({ resource, collectionName, folderPath, storeKey: entryKey, onUpdated }); + this.openEditor({ resource, collectionName, onUpdated }); }, error: () => this.#notifyNotFound(), }); diff --git a/apps/tracker/src/app/browser/store/browser.store.spec.ts b/apps/tracker/src/app/browser/store/browser.store.spec.ts index 1266fb98..cb468640 100644 --- a/apps/tracker/src/app/browser/store/browser.store.spec.ts +++ b/apps/tracker/src/app/browser/store/browser.store.spec.ts @@ -1,10 +1,16 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { createServiceFactory, type SpectatorService } from '@ngneat/spectator/vitest'; -import type { CacheStatusDto, ResourceTreeDto } from '@simoncodes-ca/data-transfer'; +import type { + CacheStatusDto, + ResourceSummaryDto, + ResourceTreeDto, + TranslationStatus, +} from '@simoncodes-ca/data-transfer'; import { NEVER, of, throwError } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../testing/transloco-testing.module'; -import { BrowserApiService } from '../services/browser-api.service'; +import { NotificationService } from '../../shared/notification'; +import { BrowserApiService, CollectionIndexNotReadyError } from '../services/browser-api.service'; import { BrowserStore } from './browser.store'; /** @@ -15,20 +21,38 @@ import { BrowserStore } from './browser.store'; */ const waitForSignals = () => new Promise((resolve) => setTimeout(resolve, 10)); +const summary = ( + fullKey: string, + baseValue: string, + targets: Record = {}, +): ResourceSummaryDto => { + const segments = fullKey.split('.'); + const entryKey = segments.pop() ?? ''; + return { + fullKey, + folderPath: segments.join('.'), + entryKey, + base: { locale: 'en', value: baseValue }, + targets: Object.entries(targets).map(([locale, [value, status]]) => ({ + locale, + value, + status, + needsWork: status === undefined || status === 'new' || status === 'stale', + sameAsBase: (value?.trim() ?? '').length > 0 && value?.trim() === baseValue.trim(), + })), + tags: [], + inheritedTags: [], + }; +}; + describe('BrowserStore', () => { let store: InstanceType; - let spectator: SpectatorService; + let spectator: SpectatorService>; let apiService: BrowserApiService; const mockTreeRoot: ResourceTreeDto = { path: '', - resources: [ - { - key: 'welcome', - translations: { en: 'Welcome', es: 'Bienvenido' }, - status: { es: 'translated' as const }, - }, - ], + resources: [summary('welcome', 'Welcome', { es: ['Bienvenido', 'translated'] })], children: [ { name: 'common', fullPath: 'common', loaded: false }, { name: 'errors', fullPath: 'errors', loaded: false }, @@ -37,13 +61,7 @@ describe('BrowserStore', () => { const mockTreeCommon: ResourceTreeDto = { path: 'common', - resources: [ - { - key: 'save', - translations: { en: 'Save', es: 'Guardar' }, - status: { es: 'translated' as const }, - }, - ], + resources: [summary('common.save', 'Save', { es: ['Guardar', 'translated'] })], children: [{ name: 'buttons', fullPath: 'common.buttons', loaded: false }], }; @@ -234,6 +252,26 @@ describe('BrowserStore', () => { expect(store.error()).toBe('Collection not found'); }); + it('should keep the already-loaded root folders when the index is still not ready after the retries', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + const getTree = vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeRoot)); + const notifyError = vi.spyOn(spectator.inject(NotificationService), 'error').mockImplementation(() => undefined); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: ['en', 'es'] }); + await waitForSignals(); + expect(store.rootFolders()).toEqual(mockTreeRoot.children); + + getTree.mockReturnValue(throwError(() => new CollectionIndexNotReadyError('Collection is being indexed.'))); + store.loadRootFolders(); + await waitForSignals(); + + expect(store.rootFolders()).toEqual(mockTreeRoot.children); + expect(store.translations()).toEqual(mockTreeRoot.resources); + expect(store.isFolderTreeLoading()).toBe(false); + expect(store.error()).toBeNull(); + expect(notifyError).toHaveBeenCalledWith('Collection is being indexed.'); + }); + it('should set loading state during folder tree fetch', async () => { vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeRoot)); @@ -349,6 +387,27 @@ describe('BrowserStore', () => { expect(store.error()).toBe('api error: load translations'); }); + it('should keep the tree and the shown list, and notify, when the index is still not ready after the retries', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree') + .mockReturnValueOnce(of(mockTreeRoot)) + .mockReturnValueOnce(throwError(() => new CollectionIndexNotReadyError('Collection is being indexed.'))); + const notifyError = vi.spyOn(spectator.inject(NotificationService), 'error').mockImplementation(() => undefined); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + store.selectFolder('common'); + await waitForSignals(); + + expect(store.rootFolders()).toEqual(mockTreeRoot.children); + expect(store.translations()).toEqual(mockTreeRoot.resources); + expect(store.currentFolderPath()).toBe(''); + expect(store.isTranslationsLoading()).toBe(false); + expect(store.error()).toBeNull(); + expect(notifyError).toHaveBeenCalledWith('Collection is being indexed.'); + }); + it('should set loading state during translation fetch', async () => { vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); vi.spyOn(apiService, 'getResourceTree') @@ -527,7 +586,9 @@ describe('BrowserStore', () => { it('should prune expanded paths under a deleted folder', async () => { vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeWithNesting)); - vi.spyOn(apiService, 'deleteFolder').mockReturnValue(of({ deleted: true, path: 'common' })); + vi.spyOn(apiService, 'deleteFolder').mockReturnValue( + of({ deleted: true, folderPath: 'common', resourcesDeleted: 0 }), + ); store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); await waitForSignals(); @@ -950,6 +1011,28 @@ describe('BrowserStore', () => { }); }); + describe('moveResource', () => { + it('should optimistically remove a non-root folder row by its full key', async () => { + const row = summary('common.buttons.save', 'Save'); + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockImplementation((_collection, path) => + of({ path: path ?? '', resources: path === 'common.buttons' ? [row] : [], children: [] }), + ); + vi.spyOn(apiService, 'moveResource').mockReturnValue(NEVER); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: ['en'] }); + await waitForSignals(); + store.selectFolder('common.buttons'); + await waitForSignals(); + expect(store.translations()).toEqual([row]); + + store.moveResource({ sourceKey: 'common.buttons.save', destinationFolderPath: 'archive' }); + + expect(store.translations()).toEqual([]); + expect(apiService.moveResource).toHaveBeenCalledWith('app-translations', 'common.buttons.save', 'archive.save'); + }); + }); + describe('Locale Filtering', () => { beforeEach(async () => { vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); @@ -1179,12 +1262,7 @@ describe('BrowserStore', () => { it('should return search results when in search mode', () => { const mockSearchResults = [ - { - key: 'found', - translations: { en: 'Found', es: 'Encontrado' }, - status: { es: 'verified' as const }, - matchType: 'exact-key' as const, - }, + { ...summary('found', 'Found', { es: ['Encontrado', 'verified'] }), matchType: 'exact-key' as const }, ]; store.setSearchQuery('found'); @@ -1212,9 +1290,7 @@ describe('BrowserStore', () => { it('should search translations and update results', async () => { const mockSearchResults = [ { - key: 'common.save', - translations: { en: 'Save', es: 'Guardar' }, - status: { es: 'verified' as const }, + ...summary('common.save', 'Save', { es: ['Guardar', 'verified'] }), matchType: 'partial-key' as const, }, ]; @@ -1287,21 +1363,21 @@ describe('BrowserStore', () => { const mockStatusTree: ResourceTreeDto = { path: '', resources: [ - { - key: 'alpha', - translations: { en: 'Alpha', es: 'Alfa', fr: 'Alpha', de: 'Alpha' }, - status: { es: 'stale' as const, fr: 'new' as const, de: 'verified' as const }, - }, - { - key: 'beta', - translations: { en: 'Beta', es: 'Beta', fr: 'Beta', de: 'Beta' }, - status: { es: 'new' as const, fr: 'translated' as const, de: 'translated' as const }, - }, - { - key: 'gamma', - translations: { en: 'Gamma', es: 'Gamma', fr: 'Gamma', de: 'Gamma' }, - status: { es: 'verified' as const, fr: 'verified' as const, de: 'verified' as const }, - }, + summary('alpha', 'Alpha', { + es: ['Alfa', 'stale'], + fr: ['Alpha', 'new'], + de: ['Alpha', 'verified'], + }), + summary('beta', 'Beta', { + es: ['Beta', 'new'], + fr: ['Beta', 'translated'], + de: ['Beta', 'translated'], + }), + summary('gamma', 'Gamma', { + es: ['Gamma', 'verified'], + fr: ['Gamma', 'verified'], + de: ['Gamma', 'verified'], + }), ], children: [], }; diff --git a/apps/tracker/src/app/browser/store/browser.store.ts b/apps/tracker/src/app/browser/store/browser.store.ts index 6942e8fd..af8536ac 100644 --- a/apps/tracker/src/app/browser/store/browser.store.ts +++ b/apps/tracker/src/app/browser/store/browser.store.ts @@ -210,7 +210,7 @@ export const BrowserStore = signalStore( const destinationKey = destinationFolderPath ? `${destinationFolderPath}.${entryName}` : entryName; const currentTranslations = store.translations(); - const optimisticTranslations = currentTranslations.filter((r) => r.key !== sourceKey); + const optimisticTranslations = currentTranslations.filter((r) => r.fullKey !== sourceKey); patchState(store, { translations: optimisticTranslations }); return api.moveResource(collection, sourceKey, destinationKey).pipe( diff --git a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts index 409c0228..04c0b1eb 100644 --- a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts +++ b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts @@ -6,23 +6,25 @@ import type { ResourceSummaryDto, SearchResultDto } from '@simoncodes-ca/data-tr import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; import { BrowserStore } from '../browser.store'; -import { listKeyFor } from './with-entry-writes.feature'; const RESOURCES_URL = '/api/collections/my-collection/resources'; -const entry = (key: string, en = key): ResourceSummaryDto => ({ key, translations: { en }, status: {} }); -const hit = (key: string, en = key): SearchResultDto => ({ ...entry(key, en), matchType: 'value' }); - -describe('listKeyFor', () => { - it.each<[string, string, string | undefined]>([ - ['common.save', '', 'common.save'], - ['common.save', 'common', 'save'], - ['common.dialog.title', 'common', 'dialog.title'], - ['commonly.save', 'common', undefined], - ['errors.save', 'common', undefined], - ])('%s in the list of "%s" is %s', (fullKey, listFolderPath, expected) => { - expect(listKeyFor(fullKey, listFolderPath)).toBe(expected); - }); +const entry = (fullKey: string, en = fullKey, fr?: string): ResourceSummaryDto => { + const segments = fullKey.split('.'); + const entryKey = segments.pop() ?? ''; + return { + fullKey, + folderPath: segments.join('.'), + entryKey, + base: { locale: 'en', value: en }, + targets: fr === undefined ? [] : [{ locale: 'fr', value: fr, needsWork: true, sameAsBase: fr === en }], + tags: [], + inheritedTags: [], + }; +}; +const hit = (fullKey: string, en = fullKey): SearchResultDto => ({ + ...entry(fullKey, en), + matchType: 'partial-value', }); describe('BrowserStore entry writes', () => { @@ -34,7 +36,7 @@ describe('BrowserStore entry writes', () => { patchState(store, { selectedCollection: 'my-collection', currentFolderPath: 'common', - translations: [entry('save', 'Save'), entry('dialog.title', 'Title')], + translations: [entry('common.save', 'Save'), entry('common.dialog.title', 'Title')], }); }; @@ -48,8 +50,8 @@ describe('BrowserStore entry writes', () => { }); }; - const englishOf = (items: readonly ResourceSummaryDto[], key: string): string | undefined => - items.find((item) => item.key === key)?.translations['en']; + const englishOf = (items: readonly ResourceSummaryDto[], fullKey: string): string | undefined => + items.find((item) => item.fullKey === fullKey)?.base.value; beforeEach(() => { TestBed.configureTestingModule({ @@ -77,10 +79,10 @@ describe('BrowserStore entry writes', () => { const reload = http.expectOne((req) => req.url === `${RESOURCES_URL}/tree`); expect(reload.request.params.get('path')).toBe('common'); - reload.flush({ path: 'common', resources: [entry('ok'), entry('save')], children: [] }); + reload.flush({ path: 'common', resources: [entry('common.ok'), entry('common.save')], children: [] }); expect(next).toHaveBeenCalledWith({ entriesCreated: 1, created: true }); - expect(store.translations().map((item) => item.key)).toEqual(['ok', 'save']); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.ok', 'common.save']); }); it('should cancel a folder load already in flight, so only the reload lands', () => { @@ -93,9 +95,9 @@ describe('BrowserStore entry writes', () => { const [stale, reload] = treeRequests(); expect(stale.cancelled).toBe(true); - reload.flush({ path: 'common', resources: [entry('ok'), entry('save')], children: [] }); + reload.flush({ path: 'common', resources: [entry('common.ok'), entry('common.save')], children: [] }); - expect(store.translations().map((item) => item.key)).toEqual(['ok', 'save']); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.ok', 'common.save']); }); it('should hand a failure to the caller without reloading', () => { @@ -122,44 +124,44 @@ describe('BrowserStore entry writes', () => { patch.flush(response); }; - it('should patch the folder list under the key relative to its folder', () => { + it('should patch the folder list under its full key', () => { folderMode(); - update('common.save', { resolvedKey: 'common.save', updated: true, resource: entry('save', 'Save now') }); + update('common.save', { resolvedKey: 'common.save', updated: true, resource: entry('common.save', 'Save now') }); - expect(englishOf(store.translations(), 'save')).toBe('Save now'); - expect(store.translations().map((item) => item.key)).toEqual(['save', 'dialog.title']); + expect(englishOf(store.translations(), 'common.save')).toBe('Save now'); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.save', 'common.dialog.title']); }); - it('should keep the sub-path of a nested entry, which the API reports by its bare key', () => { + it('should patch a nested entry by its full key', () => { folderMode(); update('common.dialog.title', { resolvedKey: 'common.dialog.title', updated: true, - resource: entry('title', 'New'), + resource: entry('common.dialog.title', 'New'), }); - expect(englishOf(store.translations(), 'dialog.title')).toBe('New'); + expect(englishOf(store.translations(), 'common.dialog.title')).toBe('New'); }); - it('should patch a search result under its full key and the folder list under its relative key', () => { + it('should patch both caches under the same full key', () => { searchMode(); - update('common.save', { resolvedKey: 'common.save', updated: true, resource: entry('save', 'Save now') }); + update('common.save', { resolvedKey: 'common.save', updated: true, resource: entry('common.save', 'Save now') }); - const result = store.searchResults().find((item) => item.key === 'common.save'); - expect(result?.translations['en']).toBe('Save now'); - expect(result?.matchType).toBe('value'); + const result = store.searchResults().find((item) => item.fullKey === 'common.save'); + expect(result?.base.value).toBe('Save now'); + expect(result?.matchType).toBe('partial-value'); expect(englishOf(store.searchResults(), 'errors.save')).toBe('Save'); - expect(englishOf(store.translations(), 'save')).toBe('Save now'); + expect(englishOf(store.translations(), 'common.save')).toBe('Save now'); }); it('should patch a search result outside the folder list without touching the list', () => { searchMode(); const listBefore = store.translations(); - update('errors.save', { resolvedKey: 'errors.save', updated: true, resource: entry('save', 'Retry') }); + update('errors.save', { resolvedKey: 'errors.save', updated: true, resource: entry('errors.save', 'Retry') }); expect(englishOf(store.searchResults(), 'errors.save')).toBe('Retry'); expect(store.translations()).toBe(listBefore); @@ -168,10 +170,10 @@ describe('BrowserStore entry writes', () => { it('should drop an entry sent to another folder from both caches', () => { searchMode(); - update('common.save', { resolvedKey: 'other.save', updated: true, resource: entry('save') }, 'other'); + update('common.save', { resolvedKey: 'other.save', updated: true, resource: entry('other.save') }, 'other'); - expect(store.translations().map((item) => item.key)).toEqual(['dialog.title']); - expect(store.searchResults().map((item) => item.key)).toEqual(['errors.save']); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.dialog.title']); + expect(store.searchResults().map((item) => item.fullKey)).toEqual(['errors.save']); }); it('should patch in place when the DTO carries no moveTo', () => { @@ -180,10 +182,10 @@ describe('BrowserStore entry writes', () => { store.updateResource('my-collection', { key: 'common.save', baseValue: 'Save now' }).subscribe(); const patch = http.expectOne({ method: 'PATCH', url: RESOURCES_URL }); expect('moveTo' in patch.request.body).toBe(false); - patch.flush({ resolvedKey: 'common.save', updated: true, resource: entry('save', 'Save now') }); + patch.flush({ resolvedKey: 'common.save', updated: true, resource: entry('common.save', 'Save now') }); - expect(store.translations().map((item) => item.key)).toEqual(['save', 'dialog.title']); - expect(englishOf(store.translations(), 'save')).toBe('Save now'); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.save', 'common.dialog.title']); + expect(englishOf(store.translations(), 'common.save')).toBe('Save now'); }); it('should drop an entry moved to the collection root (an empty moveTo)', () => { @@ -191,7 +193,7 @@ describe('BrowserStore entry writes', () => { update('common.save', { resolvedKey: 'save', updated: true, resource: entry('save') }, ''); - expect(store.translations().map((item) => item.key)).toEqual(['dialog.title']); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.dialog.title']); }); it('should leave the caches alone when the response carries no resource', () => { @@ -226,12 +228,12 @@ describe('BrowserStore entry writes', () => { request.flush({ entriesDeleted }); }; - it('should drop the entry from the folder list under its relative key', () => { + it('should drop the entry from the folder list under its full key', () => { folderMode(); remove('common.dialog.title', 1); - expect(store.translations().map((item) => item.key)).toEqual(['save']); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.save']); }); it('should drop a search result under its full key, and the folder row with it', () => { @@ -239,8 +241,8 @@ describe('BrowserStore entry writes', () => { remove('common.save', 1); - expect(store.searchResults().map((item) => item.key)).toEqual(['errors.save']); - expect(store.translations().map((item) => item.key)).toEqual(['dialog.title']); + expect(store.searchResults().map((item) => item.fullKey)).toEqual(['errors.save']); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.dialog.title']); }); it('should keep everything when the server deleted nothing', () => { @@ -248,7 +250,7 @@ describe('BrowserStore entry writes', () => { remove('common.save', 0); - expect(store.translations().map((item) => item.key)).toEqual(['save', 'dialog.title']); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.save', 'common.dialog.title']); }); it('should ignore a key that is not cached', () => { @@ -256,7 +258,7 @@ describe('BrowserStore entry writes', () => { remove('common.missing', 1); - expect(store.translations().map((item) => item.key)).toEqual(['save', 'dialog.title']); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.save', 'common.dialog.title']); }); }); @@ -268,22 +270,32 @@ describe('BrowserStore entry writes', () => { request.flush({ resource, translatedCount: 1, skippedLocales: [] }); }; - it('should patch the folder list, rewriting the bare API key to the relative one', () => { + it('should patch a nested folder row by its full key', () => { folderMode(); - translate('common.dialog.title', { key: 'title', translations: { en: 'Title', fr: 'Titre' }, status: {} }); + translate('common.dialog.title', entry('common.dialog.title', 'Title', 'Titre')); - const row = store.translations().find((item) => item.key === 'dialog.title'); - expect(row?.translations['fr']).toBe('Titre'); + const row = store.translations().find((item) => item.fullKey === 'common.dialog.title'); + expect(row?.targets.find((target) => target.locale === 'fr')?.value).toBe('Titre'); }); it('should patch a search result under its full key', () => { searchMode(); - translate('common.save', { key: 'save', translations: { en: 'Save', fr: 'Enregistrer' }, status: {} }); - - expect(store.searchResults().find((item) => item.key === 'common.save')?.translations['fr']).toBe('Enregistrer'); - expect(store.translations().find((item) => item.key === 'save')?.translations['fr']).toBe('Enregistrer'); + translate('common.save', entry('common.save', 'Save', 'Enregistrer')); + + expect( + store + .searchResults() + .find((item) => item.fullKey === 'common.save') + ?.targets.find((target) => target.locale === 'fr')?.value, + ).toBe('Enregistrer'); + expect( + store + .translations() + .find((item) => item.fullKey === 'common.save') + ?.targets.find((target) => target.locale === 'fr')?.value, + ).toBe('Enregistrer'); }); }); }); diff --git a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts index 2b777989..30acf946 100644 --- a/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts @@ -13,19 +13,6 @@ import type { import { type Observable, tap } from 'rxjs'; import { BrowserApiService } from '../../services/browser-api.service'; -/** - * The key the folder list files an entry under: relative to the folder the list - * shows (nested entries keep their sub-path), or undefined when the entry lies - * outside that folder and so cannot be in the list. - */ -export function listKeyFor(fullKey: string, listFolderPath: string): string | undefined { - if (!listFolderPath) { - return fullKey; - } - const prefix = `${listFolderPath}.`; - return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : undefined; -} - /** * How a Resource entry is written from the UI. * @@ -34,16 +21,14 @@ export function listKeyFor(fullKey: string, listFolderPath: string): string | un * the editor's 409 conflict dialog, a failure toast. On success the store brings * its caches in line before the caller hears back. * - * The caches are keyed by what they render: the folder list by the key relative - * to its folder, search results by the full key. Callers never rewrite keys; - * that happens here, once. + * Both caches (the folder list and the search results) are keyed by each + * entry's full key, so an entry is found the same way in either. */ export function withEntryWritesFeature<_>() { return signalStoreFeature( { state: type<{ currentFolderPath: string; - isSearchMode: boolean; translations: ResourceSummaryDto[]; searchResults: SearchResultDto[]; }>(), @@ -53,36 +38,26 @@ export function withEntryWritesFeature<_>() { withMethods((store) => { const api = inject(BrowserApiService); - /** Replaces the cached entry with what the server now holds, in both caches. */ + /** Replaces the cached entry with what the server now holds, in both caches. A cache without it is left as is. */ function patchEntry(fullKey: string, resource: ResourceSummaryDto): void { - const listKey = listKeyFor(fullKey, store.currentFolderPath()); - if (listKey !== undefined) { - patchState(store, { - translations: store - .translations() - .map((entry) => (entry.key === listKey ? { ...resource, key: listKey } : entry)), - }); - } - - if (store.isSearchMode()) { - patchState(store, { - searchResults: store - .searchResults() - .map((result) => (result.key === fullKey ? { ...result, ...resource, key: fullKey } : result)), - }); - } + const translations = store.translations(); + const searchResults = store.searchResults(); + patchState(store, { + translations: translations.some((entry) => entry.fullKey === fullKey) + ? translations.map((entry) => (entry.fullKey === fullKey ? resource : entry)) + : translations, + searchResults: searchResults.some((result) => result.fullKey === fullKey) + ? searchResults.map((result) => (result.fullKey === fullKey ? { ...result, ...resource } : result)) + : searchResults, + }); } /** Drops an entry that no longer lives where the caches show it. */ function dropEntry(fullKey: string): void { - const listKey = listKeyFor(fullKey, store.currentFolderPath()); - if (listKey !== undefined) { - patchState(store, { translations: store.translations().filter((entry) => entry.key !== listKey) }); - } - - if (store.isSearchMode()) { - patchState(store, { searchResults: store.searchResults().filter((result) => result.key !== fullKey) }); - } + patchState(store, { + translations: store.translations().filter((entry) => entry.fullKey !== fullKey), + searchResults: store.searchResults().filter((result) => result.fullKey !== fullKey), + }); } return { diff --git a/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts b/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts index 75cbdd0b..239d0487 100644 --- a/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts @@ -1,11 +1,11 @@ import { computed, inject } from '@angular/core'; import { signalStoreFeature, withState, withComputed, withMethods, patchState, type } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { pipe, tap, switchMap, catchError, of, from, retry, timer } from 'rxjs'; +import { pipe, tap, switchMap, catchError, of, from } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; import { TranslocoService } from '@jsverse/transloco'; import { NotificationService } from '../../../shared/notification'; -import { BrowserApiService } from '../../services/browser-api.service'; +import { BrowserApiService, CollectionIndexNotReadyError } from '../../services/browser-api.service'; import { extractFolderNameFromPath, extractParentFolderPath } from '../../utils/folder-path.utils'; import { insertFolderIntoTree, @@ -110,6 +110,19 @@ export function withFolderTreeFeature<_>() { withMethods((store) => { const api = inject(BrowserApiService); const transloco = inject(TranslocoService); + const notifications = inject(NotificationService); + + /** + * The index can go not-ready mid-session (reindex, outside change, eviction). When a tree read + * gives up for that reason and a tree is already on screen, keep it and toast: the `error` + * state would replace the tree. Returns false (not handled) for other errors and on first load. + */ + function keepTreeOnNotReady(error: unknown, message: string): boolean { + if (!(error instanceof CollectionIndexNotReadyError) || store.rootFolders().length === 0) return false; + patchState(store, { isFolderTreeLoading: false }); + notifications.error(message); + return true; + } function scheduleNewFolderClear(folderFullPath: string): void { setTimeout(() => { @@ -198,24 +211,23 @@ export function withFolderTreeFeature<_>() { } return api.getResourceTree(collection, '', includeNested).pipe( - tap((treeData) => { - if ('resources' in treeData) { - patchState(store, { - rootFolders: treeData.children, - translations: treeData.resources, - currentFolderPath: '', - isFolderTreeLoading: false, - error: null, - }); - } else { - patchState(store, { isFolderTreeLoading: false }); - } - }), - catchError((error: unknown) => { + tap((treeData) => patchState(store, { + rootFolders: treeData.children, + translations: treeData.resources, + currentFolderPath: '', isFolderTreeLoading: false, - error: toErrorMessage(error, transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.LOADFOLDERSFAILED)), - }); + error: null, + }), + ), + catchError((error: unknown) => { + const message = toErrorMessage( + error, + transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.LOADFOLDERSFAILED), + ); + if (!keepTreeOnNotReady(error, message)) { + patchState(store, { isFolderTreeLoading: false, error: message }); + } return of(null); }), ); @@ -236,7 +248,6 @@ export function withFolderTreeFeature<_>() { return api.getResourceTree(collection, folderPath, includeNested).pipe( tap((treeData) => { - if (!('resources' in treeData)) return; const updateFolder = (folders: FolderNodeDto[]): FolderNodeDto[] => folders.map((folder) => { if (folder.fullPath === folderPath) { @@ -258,13 +269,13 @@ export function withFolderTreeFeature<_>() { }); }), catchError((error: unknown) => { - patchState(store, { - isFolderTreeLoading: false, - error: toErrorMessage( - error, - transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.LOADFOLDERCHILDRENFAILED), - ), - }); + const message = toErrorMessage( + error, + transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.LOADFOLDERCHILDRENFAILED), + ); + if (!keepTreeOnNotReady(error, message)) { + patchState(store, { isFolderTreeLoading: false, error: message }); + } return of(null); }), ); @@ -490,17 +501,12 @@ export function withFolderTreeFeature<_>() { return api.getResourceTree(collection, movedFolderPath, includeNested).pipe( tap((tree) => { - if ('resources' in tree) { - patchState(store, { - translations: tree.resources, - error: null, - }); - store.setTranslationsLoading(false); - } else { - throw new Error('cache-not-ready'); - } + patchState(store, { + translations: tree.resources, + error: null, + }); + store.setTranslationsLoading(false); }), - retry({ count: 5, delay: () => timer(1000) }), catchError(() => { store.setTranslationsLoading(false); return of(null); diff --git a/apps/tracker/src/app/browser/store/features/with-translations.feature.ts b/apps/tracker/src/app/browser/store/features/with-translations.feature.ts index 1decd6a3..87ffb04b 100644 --- a/apps/tracker/src/app/browser/store/features/with-translations.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-translations.feature.ts @@ -1,12 +1,13 @@ import { computed, inject } from '@angular/core'; import { signalStoreFeature, withState, withComputed, withMethods, patchState, type } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { pipe, tap, switchMap, catchError, of } from 'rxjs'; +import { pipe, tap, switchMap, catchError, of, map } from 'rxjs'; import { TranslocoService } from '@jsverse/transloco'; -import { BrowserApiService } from '../../services/browser-api.service'; +import { NotificationService } from '../../../shared/notification'; +import { BrowserApiService, CollectionIndexNotReadyError } from '../../services/browser-api.service'; import { sortTranslations } from '../../translations/utils/sort-translations'; import type { ResourceSummaryDto, SearchResultDto } from '@simoncodes-ca/data-transfer'; -import { countByStatus, STATUS_PRECEDENCE, type TranslationStatus } from '@simoncodes-ca/domain'; +import { countByStatus, STATUS_PRECEDENCE, summaryTarget, type TranslationStatus } from '@simoncodes-ca/domain'; import { toErrorMessage } from '../async-error.utils'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; @@ -32,11 +33,11 @@ const NEEDS_WORK_STATUSES: readonly TranslationStatus[] = ['new', 'stale']; * the two can never drift into disagreeing about what a status means. */ function matchesAnyStatus( - item: { status?: Record }, + item: ResourceSummaryDto, locales: readonly string[], statuses: readonly TranslationStatus[], ): boolean { - const counts = countByStatus(locales.map((locale) => item.status?.[locale])); + const counts = countByStatus(locales.map((locale) => summaryTarget(item, locale)?.status)); return statuses.some((status) => counts[status] > 0); } @@ -133,18 +134,21 @@ export function withTranslationsFeature<_>() { withMethods((store) => { const api = inject(BrowserApiService); const transloco = inject(TranslocoService); + const notifications = inject(NotificationService); return { selectFolder: rxMethod( pipe( - tap((path) => + // The folder whose list is on screen, to go back to if the index is not ready (see below). + map((path) => ({ path, shownFolderPath: store.currentFolderPath() })), + tap(({ path }) => patchState(store, { currentFolderPath: path, isTranslationsLoading: true, error: null, }), ), - switchMap((path) => { + switchMap(({ path, shownFolderPath }) => { const collection = store.selectedCollection(); const includeNested = store.showNestedResources(); if (!collection) { @@ -153,25 +157,27 @@ export function withTranslationsFeature<_>() { } return api.getResourceTree(collection, path, includeNested).pipe( - tap((tree) => { - if ('resources' in tree) { - patchState(store, { - translations: tree.resources, - isTranslationsLoading: false, - error: null, - }); - } else { - patchState(store, { isTranslationsLoading: false }); - } - }), - catchError((error: unknown) => { + tap((tree) => patchState(store, { + translations: tree.resources, isTranslationsLoading: false, - error: toErrorMessage( - error, - transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.LOADTRANSLATIONSFAILED), - ), - }); + error: null, + }), + ), + catchError((error: unknown) => { + const message = toErrorMessage( + error, + transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.LOADTRANSLATIONSFAILED), + ); + // The index can go not-ready mid-session. selectFolder only runs once a tree is on + // screen (the first load is loadRootFolders), so keep the list and the folder it shows, + // and toast: the `error` state would replace the tree. + if (error instanceof CollectionIndexNotReadyError) { + patchState(store, { isTranslationsLoading: false, currentFolderPath: shownFolderPath }); + notifications.error(message); + return of(null); + } + patchState(store, { isTranslationsLoading: false, error: message }); return of(null); }), ); diff --git a/apps/tracker/src/app/browser/translations/list/store/key-resolution.spec.ts b/apps/tracker/src/app/browser/translations/list/store/key-resolution.spec.ts deleted file mode 100644 index 2f50656e..00000000 --- a/apps/tracker/src/app/browser/translations/list/store/key-resolution.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { resolveFullKey, splitKey, resolveEffectiveFolderPath, resolveResourceForDialog } from './key-resolution'; -import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; - -describe('key-resolution', () => { - describe('splitKey', () => { - it('should split a dotted key into folder path and entry key', () => { - expect(splitKey('forms.acceptedFormatsX')).toEqual({ folderPath: 'forms', entryKey: 'acceptedFormatsX' }); - }); - - it('should handle single-segment keys', () => { - expect(splitKey('acceptedFormatsX')).toEqual({ folderPath: '', entryKey: 'acceptedFormatsX' }); - }); - - it('should handle deeply nested keys', () => { - expect(splitKey('a.b.c.d')).toEqual({ folderPath: 'a.b.c', entryKey: 'd' }); - }); - }); - - describe('resolveFullKey', () => { - it('should return the key as-is in search mode', () => { - expect(resolveFullKey('forms.save', true, 'ignored')).toBe('forms.save'); - }); - - it('should prepend folder path in folder mode', () => { - expect(resolveFullKey('save', false, 'forms')).toBe('forms.save'); - }); - - it('should return key alone when folder path is empty', () => { - expect(resolveFullKey('save', false, '')).toBe('save'); - }); - }); - - describe('resolveEffectiveFolderPath', () => { - it('should extract folder from key in search mode', () => { - expect(resolveEffectiveFolderPath('forms.save', true, false, 'ignored')).toBe('forms'); - }); - - it('should return current folder path in non-search mode', () => { - expect(resolveEffectiveFolderPath('save', false, false, 'forms')).toBe('forms'); - }); - - it('should combine paths for nested resources', () => { - expect(resolveEffectiveFolderPath('fileUpload.acceptedFormatsX', false, true, 'forms')).toBe('forms.fileUpload'); - }); - - it('should return relative path alone when current path is empty (nested)', () => { - expect(resolveEffectiveFolderPath('fileUpload.acceptedFormatsX', false, true, '')).toBe('fileUpload'); - }); - - it('should not combine for non-dotted keys even with nested flag', () => { - expect(resolveEffectiveFolderPath('save', false, true, 'forms')).toBe('forms'); - }); - }); - - describe('resolveResourceForDialog', () => { - const resource: ResourceSummaryDto = { - key: 'forms.save', - translations: { en: 'Save' }, - status: {}, - }; - - it('should strip to entry key in search mode', () => { - expect(resolveResourceForDialog(resource, true, false).key).toBe('save'); - }); - - it('should strip to entry key in nested mode with dotted key', () => { - expect(resolveResourceForDialog(resource, false, true).key).toBe('save'); - }); - - it('should return unchanged in folder mode with simple key', () => { - const simple: ResourceSummaryDto = { ...resource, key: 'save' }; - expect(resolveResourceForDialog(simple, false, true).key).toBe('save'); - }); - - it('should return unchanged in non-search, non-nested mode', () => { - expect(resolveResourceForDialog(resource, false, false).key).toBe('forms.save'); - }); - }); -}); diff --git a/apps/tracker/src/app/browser/translations/list/store/key-resolution.ts b/apps/tracker/src/app/browser/translations/list/store/key-resolution.ts deleted file mode 100644 index d2e0f7a9..00000000 --- a/apps/tracker/src/app/browser/translations/list/store/key-resolution.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; - -/** - * Resolves the full dot-delimited key for a resource. - * In search mode the key already contains the full path. - * In folder mode, prepends currentFolderPath. - */ -export function resolveFullKey(translationKey: string, isSearchMode: boolean, currentFolderPath: string): string { - if (isSearchMode) return translationKey; - return currentFolderPath ? `${currentFolderPath}.${translationKey}` : translationKey; -} - -/** - * Splits a full dot-delimited key into folder path and entry key. - * E.g. "forms.acceptedFormatsX" → { folderPath: "forms", entryKey: "acceptedFormatsX" } - */ -export function splitKey(fullKey: string): { folderPath: string; entryKey: string } { - const segments = fullKey.split('.'); - return { - folderPath: segments.length > 1 ? segments.slice(0, -1).join('.') : '', - entryKey: segments[segments.length - 1], - }; -} - -function isRelativeNestedKey(key: string, showNestedResources: boolean): boolean { - return showNestedResources && key.includes('.'); -} - -/** - * Resolves the effective folder path for a resource. - * - Search mode: extracts folder from the full key - * - Nested resources shown + key has dots: combines currentFolderPath with relative folder segments - * - Otherwise: returns currentFolderPath - */ -export function resolveEffectiveFolderPath( - translationKey: string, - isSearchMode: boolean, - showNestedResources: boolean, - currentFolderPath: string, -): string { - if (isSearchMode) return splitKey(translationKey).folderPath; - if (isRelativeNestedKey(translationKey, showNestedResources)) { - const { folderPath: relativeFolderPath } = splitKey(translationKey); - return currentFolderPath ? `${currentFolderPath}.${relativeFolderPath}` : relativeFolderPath; - } - return currentFolderPath; -} - -/** - * Resolves the resource DTO for the edit dialog. - * In search mode or nested-resource mode, strips the key to just the entry key portion. - * Otherwise returns the resource unchanged. - */ -export function resolveResourceForDialog( - translation: ResourceSummaryDto, - isSearchMode: boolean, - showNestedResources: boolean, -): ResourceSummaryDto { - if (isSearchMode || isRelativeNestedKey(translation.key, showNestedResources)) { - return { ...translation, key: splitKey(translation.key).entryKey }; - } - return translation; -} diff --git a/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts b/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts index ac83cd9f..d9e66fff 100644 --- a/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts +++ b/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts @@ -10,7 +10,6 @@ import { TranslationEditorLauncher } from '../../../services/translation-editor- import { ConfirmationDialog } from '../../../../shared/components/confirmation-dialog/confirmation-dialog'; import type { ConfirmationDialogData } from '../../../../shared/components/confirmation-dialog/confirmation-dialog-data'; import type { ResourceSummaryDto, TranslateResourceResponseDto } from '@simoncodes-ca/data-transfer'; -import { resolveFullKey, resolveEffectiveFolderPath, resolveResourceForDialog } from './key-resolution'; export function withItemActions() { return signalStoreFeature( @@ -43,23 +42,10 @@ export function withItemActions() { }, editTranslation(translation: ResourceSummaryDto, collectionName: string): void { - const folderPath = resolveEffectiveFolderPath( - translation.key, - browserStore.isSearchMode(), - browserStore.showNestedResources(), - browserStore.currentFolderPath(), - ); - launcher.openEditor({ - resource: resolveResourceForDialog( - translation, - browserStore.isSearchMode(), - browserStore.showNestedResources(), - ), + resource: translation, collectionName, - folderPath, - storeKey: translation.key, - onUpdated: (key) => store.flashRecentlyUpdated(key), + onUpdated: (fullKey) => store.flashRecentlyUpdated(fullKey), }); }, @@ -79,11 +65,7 @@ export function withItemActions() { // deletion the API will refuse is a promise the UI cannot keep. if (browserStore.isReadOnly()) return; - const fullKey = resolveFullKey( - translation.key, - browserStore.isSearchMode(), - browserStore.currentFolderPath(), - ); + const { fullKey } = translation; const dialogData: ConfirmationDialogData = { title: transloco.translate(TRACKER_TOKENS.BROWSER.DIALOG.DELETERESOURCE.TITLE), @@ -127,20 +109,16 @@ export function withItemActions() { }, translateResource(translation: ResourceSummaryDto, collectionName: string): void { - const fullKey = resolveFullKey( - translation.key, - browserStore.isSearchMode(), - browserStore.currentFolderPath(), - ); - store.addTranslatingKey(translation.key); + const { fullKey } = translation; + store.addTranslatingKey(fullKey); browserStore .translateResource(collectionName, fullKey) .pipe(takeUntilDestroyed(destroyRef)) .subscribe({ next: (response: TranslateResourceResponseDto) => { - store.removeTranslatingKey(translation.key); - store.flashRecentlyUpdated(translation.key); + store.removeTranslatingKey(fullKey); + store.flashRecentlyUpdated(fullKey); const { translatedCount, skippedLocales } = response; if (translatedCount > 0) { @@ -163,7 +141,7 @@ export function withItemActions() { } }, error: (error: unknown) => { - store.removeTranslatingKey(translation.key); + store.removeTranslatingKey(fullKey); const message = error instanceof Error ? error.message diff --git a/apps/tracker/src/app/browser/translations/list/store/with-item-ui-state.feature.ts b/apps/tracker/src/app/browser/translations/list/store/with-item-ui-state.feature.ts index 8e2338a7..5ab465a5 100644 --- a/apps/tracker/src/app/browser/translations/list/store/with-item-ui-state.feature.ts +++ b/apps/tracker/src/app/browser/translations/list/store/with-item-ui-state.feature.ts @@ -1,5 +1,6 @@ import { signalStoreFeature, withState, withMethods, withHooks, patchState } from '@ngrx/signals'; +/** Row UI state, keyed by each resource's full key. */ interface ItemUiState { translatingKeys: Set; recentlyUpdatedKey: string | undefined; diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/item-header.html b/apps/tracker/src/app/browser/translations/list/translation-item/item-header.html index aea03f07..0da2ed7e 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/item-header.html +++ b/apps/tracker/src/app/browser/translations/list/translation-item/item-header.html @@ -74,10 +74,9 @@ } - @if (localeStates().length) { + @if (rollupLocales().length) { } diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/item-header.ts b/apps/tracker/src/app/browser/translations/list/translation-item/item-header.ts index aea5f7a4..bd035d55 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/item-header.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/item-header.ts @@ -9,7 +9,8 @@ import { TranslocoPipe } from '@jsverse/transloco'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { KeyMarkupPipe, hasKeyLeaf } from '../../../../shared/pipes/key-markup.pipe'; import { TagList } from '../../../../shared/tag-list/tag-list.component'; -import { TranslationRollup, type LocaleState } from './translation-rollup'; +import { TranslationRollup } from './translation-rollup'; +import type { RowView } from './row-view'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; import { BrowserStore } from '../../../store/browser.store'; import { TranslationListStore } from '../store/translation-list.store'; @@ -50,12 +51,15 @@ export class TranslationItemHeader { /** Active collection name — always set when this component is rendered. */ readonly #collectionName = computed(() => this.#browserStore.selectedCollection() ?? ''); - /** Full translation key to display */ - fullKey = input.required(); - - /** Translation data for deriving comment, tags, and locale states */ + /** The resource: its key, comment and tags, and what the row actions act on. */ translation = input.required(); + /** What the row shows (see `row-view.ts`); the header reads the rollup and the translate verdict. */ + view = input.required(); + + /** Full translation key to display */ + readonly fullKey = computed(() => this.translation().fullKey); + /** * How the header composes itself. * @@ -83,16 +87,8 @@ export class TranslationItemHeader { readonly TOKENS = TRACKER_TOKENS; - /** Non-base locales that carry a status — the rollup's input, derived from the translation status map. */ - readonly localeStates = computed(() => { - const statusMap = this.translation().status || {}; - const base = this.#browserStore.baseLocale(); - - return Object.entries(statusMap).flatMap(([code, status]) => (code !== base && status ? [{ code, status }] : [])); - }); - - /** Base locale code from the browser store */ - readonly baseLocale = this.#browserStore.baseLocale; + /** Target locales that carry a status — the rollup's input. */ + readonly rollupLocales = computed(() => this.view().rollupLocales); /** Whether the active collection is read-only (mutating actions are disabled). */ readonly isReadOnly = this.#browserStore.isReadOnly; @@ -145,21 +141,16 @@ export class TranslationItemHeader { readonly searchQuery = this.#browserStore.searchQuery; /** Tags derived from the translation input */ - readonly tags = computed(() => this.translation().tags ?? []); + readonly tags = computed(() => this.translation().tags); /** Tags inherited from the parent collection */ - readonly inheritedTags = computed(() => this.translation().inheritedTags ?? []); + readonly inheritedTags = computed(() => this.translation().inheritedTags); /** Whether this specific item is currently being auto-translated */ - readonly isTranslating = computed(() => this.#listStore.isTranslating(this.translation().key)); + readonly isTranslating = computed(() => this.#listStore.isTranslating(this.translation().fullKey)); - /** - * Returns true when at least one non-base locale has a 'new' or 'stale' status, - * indicating there is work for the auto-translator to do. - */ - readonly hasTranslatableLocales = computed(() => { - return this.localeStates().some(({ status }) => status === 'new' || status === 'stale'); - }); + /** True when some target locale needs work, so the auto-translator has something to do. */ + readonly hasTranslatableLocales = computed(() => this.view().canTranslate); /** Whether the translate action is disabled */ readonly translateDisabled = computed( diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.ts b/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.ts index d4b5b4fd..28dd53fb 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; import { HighlightPipe } from '../../../../shared/pipes/highlight.pipe'; import { TranslocoPipe } from '@jsverse/transloco'; -import { countByStatus, STATUS_PRECEDENCE, type StatusCounts, type TranslationStatus } from '@simoncodes-ca/domain'; +import { countByStatus, type StatusCounts, type TranslationStatus } from '@simoncodes-ca/domain'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { injectStatusBreakdown } from '../../../../shared/i18n/status-breakdown'; import { @@ -11,32 +11,7 @@ import { statusLabelTokenFor, } from '../../../../shared/translation-status/translation-status-presentation'; import type { DensityMode } from '../../../types/density-mode'; - -export type LocaleTranslation = { - locale: string; - value: string; - status?: TranslationStatus; - /** - * The stored value is byte-identical to the base locale's. The status metadata - * still says `translated` — a checksum cannot tell a deliberate loanword from a - * string nobody touched — so the row says it out loud instead of letting the - * status chip pass source text off as finished work. - */ - isSameAsBase?: boolean; -}; - -/** - * The source string a translator judges every locale row against. - * - * It renders as the first row of the same grid rather than as a heading above it. - * A translation can only be judged against the source when the two sit on one - * baseline, in one measure, at one type size — a bold full-bleed heading and a - * second-column body value are two separate readings of the same sentence. - */ -export type BaseTranslation = { - locale: string; - value: string; -}; +import { type BaseRow, type LocaleRow, sharedStatus } from './row-view'; /** * Displays locale translations in a grid layout. @@ -54,14 +29,19 @@ export type BaseTranslation = { }, }) export class TranslationItemLocales { - /** Array of locale translations to display */ - localeTranslations = input.required(); + /** + * Locale rows to display. A row flagged `isSameAsBase` holds the base value + * verbatim: the status may still say `translated` — a checksum cannot tell a + * deliberate loanword from a string nobody touched — so the row says it out loud. + */ + localeTranslations = input.required(); /** - * The source row, rendered first and in the same grid as the locales. Absent in - * a collection with no base locale, where there is nothing to compare against. + * The source row, rendered first and in the same grid as the locales: a + * translation can only be judged against the source when both share one + * baseline. Absent when there is no base value to compare against. */ - baseRow = input(undefined); + baseRow = input(undefined); /** Density mode affects styling */ densityMode = input('full'); @@ -82,22 +62,8 @@ export class TranslationItemLocales { /** Localized "{n} translated" for the rendered rows. */ private readonly breakdown = injectStatusBreakdown(this.statusCounts); - /** - * The status every rendered row shares, if they share one. - * - * Eleven locales in the same state drew eleven identical chips, which is a - * pattern with nothing to find in it. One chip carrying the count says the same - * thing once. Two or more rows are needed before collapsing wins anything, and - * a row with no status is not a match — so a mixed card keeps its per-row chips, - * which is the case where the column is worth reading. - */ - readonly uniformStatus = computed(() => { - const rowCount = this.localeTranslations().length; - if (rowCount < 2) return undefined; - - const counts = this.statusCounts(); - return STATUS_PRECEDENCE.find((status) => counts[status] === rowCount); - }); + /** The status every rendered row shares, if they share one (see `sharedStatus`); a mixed card keeps per-row chips. */ + readonly uniformStatus = computed(() => sharedStatus(this.localeTranslations())); /** The collapsed chip: the shared status, labelled with its count. */ readonly uniformSummary = computed(() => { diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/row-view.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-item/row-view.spec.ts new file mode 100644 index 00000000..a0c6dae2 --- /dev/null +++ b/apps/tracker/src/app/browser/translations/list/translation-item/row-view.spec.ts @@ -0,0 +1,203 @@ +import type { ResourceSummaryDto, TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { buildResourceSummary } from '@simoncodes-ca/domain'; +import { describe, expect, it } from 'vitest'; +import { LONG_VALUE_THRESHOLD, rowView, type RowViewSelection, sharedStatus } from './row-view'; + +type Target = readonly [value: string | undefined, status: TranslationStatus | undefined]; + +/** A summary of `common.buttons.save` with the given base value and targets (collection order = object order). */ +function summary(base: string, targets: Record): ResourceSummaryDto { + const translations: Record = {}; + const metadata: Record = { en: { checksum: 'base' } }; + for (const [locale, [value, status]] of Object.entries(targets)) { + if (value !== undefined) translations[locale] = value; + if (status !== undefined) metadata[locale] = { checksum: locale, status }; + } + return buildResourceSummary( + 'common.buttons.save', + { source: base, translations, metadata }, + { baseLocale: 'en', targetLocales: Object.keys(targets), tags: [] }, + ); +} + +const save = summary('Save', { es: ['Guardar', 'translated'], fr: ['Enregistrer', 'verified'] }); + +function selection(overrides: Partial = {}): RowViewSelection { + return { visibleLocales: ['en', 'es', 'fr'], compactLocale: 'en', ...overrides }; +} + +describe('rowView', () => { + describe('locale rows', () => { + it('shows the visible target locales and skips the base locale', () => { + expect(rowView(save, selection()).localeRows.map((row) => row.locale)).toEqual(['es', 'fr']); + }); + + it('follows the locale filter', () => { + expect(rowView(save, selection({ visibleLocales: ['en', 'fr'] })).localeRows).toEqual([ + { locale: 'fr', value: 'Enregistrer', status: 'verified', isSameAsBase: false }, + ]); + }); + + it('orders worst status first, then by locale code; a row with no status goes last', () => { + const mixed = summary('Save', { + de: ['Speichern', 'verified'], + it: [undefined, undefined], + fr: ['x', 'stale'], + es: ['y', 'new'], + pt: ['z', 'stale'], + }); + const rows = rowView(mixed, selection({ visibleLocales: ['en', 'de', 'it', 'fr', 'es', 'pt'] })).localeRows; + + expect(rows.map((row) => row.locale)).toEqual(['fr', 'pt', 'es', 'de', 'it']); + }); + + it('shows a missing value as empty', () => { + const missing = summary('Save', { es: [undefined, 'new'] }); + + expect(rowView(missing, selection({ visibleLocales: ['en', 'es'] })).localeRows[0]?.value).toBe(''); + }); + + it('flags a value that is the source text verbatim, whatever the status says', () => { + const copied = summary('Save', { es: ['Save', 'translated'] }); + + expect(rowView(copied, selection({ visibleLocales: ['en', 'es'] })).localeRows[0]?.isSameAsBase).toBe(true); + }); + }); + + describe('base row', () => { + it('is the base locale and its value', () => { + expect(rowView(save, selection()).baseRow).toEqual({ locale: 'en', value: 'Save' }); + }); + + it('is absent when the base value is blank', () => { + expect(rowView(summary(' ', { es: ['x', 'new'] }), selection()).baseRow).toBeUndefined(); + }); + }); + + describe('compact row', () => { + it('shows the base value alone by default, with no status and no marker', () => { + expect(rowView(save, selection()).compact).toEqual({ + locale: 'en', + value: 'Save', + isBase: true, + needsAttention: false, + isSameAsBase: false, + }); + }); + + it('shows the selected locale value in place of the base value', () => { + const compact = rowView(save, selection({ compactLocale: 'es' })).compact; + + expect(compact).toMatchObject({ locale: 'es', value: 'Guardar', isBase: false, status: 'translated' }); + }); + + it.each<[TranslationStatus, boolean]>([ + ['new', true], + ['stale', true], + ['translated', false], + ['verified', false], + ])('asks for attention on %s: %s', (status, expected) => { + const row = summary('Save', { es: ['Guardar', status] }); + + expect(rowView(row, selection({ compactLocale: 'es' })).compact.needsAttention).toBe(expected); + }); + + it('does not ask for attention for a locale with no status to name', () => { + const row = summary('Save', { es: [undefined, undefined] }); + + expect(rowView(row, selection({ compactLocale: 'es' })).compact).toMatchObject({ + value: '', + status: undefined, + needsAttention: false, + isSameAsBase: false, + }); + }); + + it('marks a finished translation that is the source text verbatim', () => { + const row = summary('Save', { es: ['Save', 'translated'] }); + + expect(rowView(row, selection({ compactLocale: 'es' })).compact).toMatchObject({ + needsAttention: false, + isSameAsBase: true, + }); + }); + + it('shows one marker at most: the status chip wins over same-as-source', () => { + const row = summary('Save', { es: ['Save', 'new'] }); + + expect(rowView(row, selection({ compactLocale: 'es' })).compact).toMatchObject({ + needsAttention: true, + isSameAsBase: false, + }); + }); + }); + + describe('rollup', () => { + it('counts every target locale with a status, whatever the filter shows', () => { + const row = summary('Save', { + es: ['a', 'stale'], + fr: ['b', 'verified'], + de: ['c', 'verified'], + it: [undefined, undefined], + }); + const view = rowView(row, selection({ visibleLocales: ['en', 'es'] })); + + expect(view.rollupLocales).toEqual([ + { code: 'es', status: 'stale' }, + { code: 'fr', status: 'verified' }, + { code: 'de', status: 'verified' }, + ]); + expect(view.statusCounts).toEqual({ stale: 1, new: 0, translated: 0, verified: 2 }); + }); + }); + + describe('canTranslate', () => { + it('is true when some target needs work, even outside the filter', () => { + const row = summary('Save', { es: ['a', 'verified'], fr: ['b', 'stale'] }); + + expect(rowView(row, selection({ visibleLocales: ['en', 'es'] })).canTranslate).toBe(true); + }); + + it('is true for a target with no metadata, which the auto-translator would fill', () => { + expect(rowView(summary('Save', { es: [undefined, undefined] }), selection()).canTranslate).toBe(true); + }); + + it('is false when every target is translated or verified', () => { + expect(rowView(save, selection()).canTranslate).toBe(false); + }); + }); + + describe('hasLongValue', () => { + const long = 'x'.repeat(LONG_VALUE_THRESHOLD + 1); + + it('is false for short values', () => { + expect(rowView(save, selection()).hasLongValue).toBe(false); + }); + + it('is true for a long base value', () => { + expect(rowView(summary(long, { es: ['a', 'new'] }), selection()).hasLongValue).toBe(true); + }); + + it('is true for a long visible locale value, not for a hidden one', () => { + const row = summary('Save', { es: [long, 'new'], fr: ['b', 'new'] }); + + expect(rowView(row, selection()).hasLongValue).toBe(true); + expect(rowView(row, selection({ visibleLocales: ['en', 'fr'] })).hasLongValue).toBe(false); + }); + }); +}); + +describe('sharedStatus', () => { + it('is the status every row shares', () => { + expect(sharedStatus([{ status: 'verified' }, { status: 'verified' }])).toBe('verified'); + }); + + it('needs two rows before collapsing wins anything', () => { + expect(sharedStatus([{ status: 'verified' }])).toBeUndefined(); + }); + + it('is undefined for mixed rows and for rows without a status', () => { + expect(sharedStatus([{ status: 'verified' }, { status: 'new' }])).toBeUndefined(); + expect(sharedStatus([{ status: undefined }, { status: undefined }])).toBeUndefined(); + }); +}); diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/row-view.ts b/apps/tracker/src/app/browser/translations/list/translation-item/row-view.ts new file mode 100644 index 00000000..d347e7d5 --- /dev/null +++ b/apps/tracker/src/app/browser/translations/list/translation-item/row-view.ts @@ -0,0 +1,150 @@ +import type { ResourceSummaryDto, TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { countByStatus, STATUS_PRECEDENCE, type StatusCounts, summaryTarget } from '@simoncodes-ca/domain'; + +/* + * Row View: what one translation row shows, as plain data and functions. + * + * The row components own the DOM, focus, overlays and expansion state. Everything + * they decide about the resource itself lives here, with no Angular dependency: + * which locale rows to show and in what order, what compact density says, which + * markers appear, and what the rollup counts. The per-locale verdicts (needs work, + * same as base) come from the Resource Summary; this module only arranges them. + */ + +/** A value longer than this is clipped by the full-density line clamp. */ +export const LONG_VALUE_THRESHOLD = 200; + +/** What the list is currently asking each row to show. */ +export interface RowViewSelection { + /** Locales the locale filter shows, in filter order. The base locale may be included; it is skipped. */ + readonly visibleLocales: readonly string[]; + /** The one locale compact density shows: the base locale until the user picks another. */ + readonly compactLocale: string; +} + +/** The source string every locale row is judged against. */ +export interface BaseRow { + readonly locale: string; + readonly value: string; +} + +/** One target locale in full density. */ +export interface LocaleRow { + readonly locale: string; + /** The stored value; `''` when there is none. */ + readonly value: string; + readonly status?: TranslationStatus; + /** The value is the base value verbatim, whatever the status says. */ + readonly isSameAsBase: boolean; +} + +/** The single line compact density shows. */ +export interface CompactRow { + readonly locale: string; + /** The stored value; `''` when there is none. */ + readonly value: string; + readonly isBase: boolean; + /** `undefined` for the base locale. */ + readonly status?: TranslationStatus; + /** + * Show the status chip. Only a status that asks for work is worth a chip — + * `translated` and `verified` are the quiet states the rollup already reports — + * and a locale with no metadata has no status to name. + */ + readonly needsAttention: boolean; + /** + * Show the same-as-source flag. A row carries at most one marker, so this is + * only set for a translation the chip does not already call unfinished. + */ + readonly isSameAsBase: boolean; +} + +/** One target locale in the rollup ring and its tooltip. */ +export interface RollupLocale { + readonly code: string; + readonly status: TranslationStatus; +} + +export interface RowView { + /** Absent when the base value is blank, so there is nothing to compare against. */ + readonly baseRow: BaseRow | undefined; + /** The visible target locales, worst status first, then by locale code. */ + readonly localeRows: readonly LocaleRow[]; + readonly compact: CompactRow; + /** Every target locale that has a status, whatever the locale filter shows. */ + readonly rollupLocales: readonly RollupLocale[]; + /** Status counts over {@link rollupLocales}. */ + readonly statusCounts: StatusCounts; + /** Some target locale needs work, so the auto-translator has something to do. */ + readonly canTranslate: boolean; + /** The base value or a visible locale value is long enough to be clipped. */ + readonly hasLongValue: boolean; +} + +/** Builds what one row shows for `summary` under the list's current `selection`. */ +export function rowView(summary: ResourceSummaryDto, selection: RowViewSelection): RowView { + const base = summary.base; + + const localeRows = selection.visibleLocales + .filter((locale) => locale !== base.locale) + .map((locale): LocaleRow => { + const target = summaryTarget(summary, locale); + return { + locale, + value: target?.value ?? '', + status: target?.status, + isSameAsBase: target?.sameAsBase ?? false, + }; + }) + .sort((a, b) => statusRank(a.status) - statusRank(b.status) || a.locale.localeCompare(b.locale)); + + const rollupLocales = summary.targets.flatMap((target) => + target.status ? [{ code: target.locale, status: target.status }] : [], + ); + + return { + baseRow: base.value.trim() ? { locale: base.locale, value: base.value } : undefined, + localeRows, + compact: compactRow(summary, selection.compactLocale), + rollupLocales, + statusCounts: countByStatus(rollupLocales.map((locale) => locale.status)), + canTranslate: summary.targets.some((target) => target.needsWork), + hasLongValue: [base.value, ...localeRows.map((row) => row.value)].some( + (value) => value.length > LONG_VALUE_THRESHOLD, + ), + }; +} + +/** + * The status every row shares, when there are at least two rows and they all + * carry the same one. Many identical chips say nothing, so the locale grid shows + * one chip with the count instead. A row with no status is never a match. + */ +export function sharedStatus(rows: readonly { readonly status?: TranslationStatus }[]): TranslationStatus | undefined { + if (rows.length < 2) return undefined; + const counts = countByStatus(rows.map((row) => row.status)); + return STATUS_PRECEDENCE.find((status) => counts[status] === rows.length); +} + +function compactRow(summary: ResourceSummaryDto, locale: string): CompactRow { + if (locale === summary.base.locale) { + return { locale, value: summary.base.value, isBase: true, needsAttention: false, isSameAsBase: false }; + } + + const target = summaryTarget(summary, locale); + const needsAttention = target?.status !== undefined && target.needsWork; + return { + locale, + value: target?.value ?? '', + isBase: false, + status: target?.status, + needsAttention, + isSameAsBase: !needsAttention && (target?.sameAsBase ?? false), + }; +} + +/** Locale row order: worst status first; a row with no known status goes last. */ +function statusRank(status: TranslationStatus | undefined): number { + const rank = status ? STATUS_PRECEDENCE.indexOf(status) : -1; + return rank === -1 ? STATUS_PRECEDENCE.length : rank; +} diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.html b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.html index ed5013f3..47e467d0 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.html +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.html @@ -31,8 +31,8 @@ annotation is a state that asks for work: new, stale, or identical to source. --> @@ -73,8 +73,8 @@ } @else { diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts index 747b3540..126430d0 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts @@ -3,7 +3,8 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import type { ComponentFixture } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; -import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; +import type { ResourceSummaryDto, TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { buildResourceSummary } from '@simoncodes-ca/domain'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; @@ -42,18 +43,32 @@ function renderTranslationItem(): { }; } -const mockTranslation: ResourceSummaryDto = { - key: 'common.buttons.save', - translations: { - en: 'Save', - es: 'Guardar', - fr: 'Enregistrer', - }, - status: { - es: 'translated', - fr: 'verified', - }, -}; +/** + * A Resource Summary with base locale `en`. The collection's target locales are the keys + * of `targets`, in order; each is `[value, status]` (either may be absent). + */ +function summary( + fullKey: string, + base: string, + targets: Record = {}, +): ResourceSummaryDto { + const translations: Record = {}; + const metadata: Record = { en: { checksum: 'base' } }; + for (const [locale, [value, status]] of Object.entries(targets)) { + if (value !== undefined) translations[locale] = value; + if (status !== undefined) metadata[locale] = { checksum: locale, status }; + } + return buildResourceSummary( + fullKey, + { source: base, translations, metadata }, + { baseLocale: 'en', targetLocales: Object.keys(targets), tags: [] }, + ); +} + +const mockTranslation = summary('common.buttons.save', 'Save', { + es: ['Guardar', 'translated'], + fr: ['Enregistrer', 'verified'], +}); describe('TranslationItem', () => { let component: TranslationItem; @@ -77,11 +92,7 @@ describe('TranslationItem', () => { }); it('should render placeholder for empty selected locale value', () => { - const t: ResourceSummaryDto = { - key: 'k-empty', - translations: { en: 'en', es: '' }, - status: { es: 'new' }, - } as any; + const t = summary('k-empty', 'en', { es: ['', 'new'] }); store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); store.setDensityMode('compact'); @@ -147,11 +158,7 @@ describe('TranslationItem', () => { }); it('shows the status chip only for new and stale rows', () => { - const needsWork: ResourceSummaryDto = { - key: 'common.buttons.save', - translations: { en: 'Save', es: 'Guardar viejo', fr: '' }, - status: { es: 'stale', fr: 'new' }, - } as ResourceSummaryDto; + const needsWork = summary('common.buttons.save', 'Save', { es: ['Guardar viejo', 'stale'], fr: ['', 'new'] }); store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); store.setDensityMode('compact'); @@ -169,11 +176,10 @@ describe('TranslationItem', () => { it('falls back to the first locale when the collection carries no base locale', () => { // A vendored collection can ship translations with no base locale at all. - const noBase: ResourceSummaryDto = { - key: 'agGrid.addToLabels', - translations: { ar: 'إضافة', de: 'Hinzufügen' }, - status: { ar: 'translated', de: 'translated' }, - } as ResourceSummaryDto; + const noBase = summary('agGrid.addToLabels', '', { + ar: ['إضافة', 'translated'], + de: ['Hinzufügen', 'translated'], + }); store.setSelectedCollection({ collectionName: 'ds', locales: ['ar', 'de'], baseLocale: 'en' }); store.setDensityMode('compact'); @@ -185,11 +191,7 @@ describe('TranslationItem', () => { }); it('marks a translation that is the source text verbatim', () => { - const untouched: ResourceSummaryDto = { - key: 'common.buttons.add', - translations: { en: 'Add', 'fr-ca': 'Add' }, - status: { 'fr-ca': 'translated' }, - } as ResourceSummaryDto; + const untouched = summary('common.buttons.add', 'Add', { 'fr-ca': ['Add', 'translated'] }); store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'fr-ca'], baseLocale: 'en' }); store.setDensityMode('compact'); @@ -205,11 +207,7 @@ describe('TranslationItem', () => { }); it('shows one marker at most: the status chip wins over same-as-source', () => { - const untouchedNew: ResourceSummaryDto = { - key: 'common.buttons.add', - translations: { en: 'Add', 'fr-ca': 'Add' }, - status: { 'fr-ca': 'new' }, - } as ResourceSummaryDto; + const untouchedNew = summary('common.buttons.add', 'Add', { 'fr-ca': ['Add', 'new'] }); store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'fr-ca'], baseLocale: 'en' }); store.setDensityMode('compact'); @@ -252,27 +250,20 @@ describe('TranslationItem - Compact helpers', () => { ({ fixture, component, store } = renderTranslationItem()); }); - it('should show the base locale in compact until a locale is picked', () => { - store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); - store.setDensityMode('compact'); - fixture.componentRef.setInput('translation', mockTranslation); - fixture.detectChanges(); - - expect(component.compactDisplay().locale).toBe('en'); - expect(component.compactDisplay().isBase).toBe(true); - }); - it('should follow the single compact selection', () => { store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); store.setDensityMode('compact'); fixture.componentRef.setInput('translation', mockTranslation); fixture.detectChanges(); - expect(component.compactDisplay().value).toBe('Save'); + // The store's compact locale (the base locale until one is picked) is what the row view gets. + expect(store.compactDisplayLocale()).toBe('en'); + expect(component.compactDisplay()).toMatchObject({ locale: 'en', isBase: true, value: 'Save' }); store.setSelectedLocales(['es']); fixture.detectChanges(); - expect(component.compactDisplay().value).toBe('Guardar'); + expect(store.compactDisplayLocale()).toBe('es'); + expect(component.compactDisplay()).toMatchObject({ locale: 'es', isBase: false, value: 'Guardar' }); store.setSelectedLocales(['en']); fixture.detectChanges(); @@ -280,11 +271,7 @@ describe('TranslationItem - Compact helpers', () => { }); it('statusBreakdown should return human readable counts in priority order', () => { - const t: ResourceSummaryDto = { - key: 'k3', - translations: { en: 'a', es: 'b', fr: 'c', de: 'd' }, - status: { en: 'stale', es: 'stale', fr: 'verified', de: 'new' }, - } as any; + const t = summary('k3', 'a', { es: ['b', 'stale'], fr: ['c', 'verified'], de: ['d', 'new'] }); store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr', 'de'], baseLocale: 'en' }); store.setDensityMode('full'); @@ -322,11 +309,7 @@ describe('TranslationItem - Full density expansion', () => { }); it('needsExpansion should be false for short values', () => { - const t: ResourceSummaryDto = { - key: 'k-short', - translations: { en: 'short', es: 'corto', fr: 'court' }, - status: {}, - } as any; + const t = summary('k-short', 'short', { es: ['corto', undefined], fr: ['court', undefined] }); fixture.componentRef.setInput('translation', t); fixture.detectChanges(); @@ -342,11 +325,13 @@ describe('TranslationItem - Full density expansion', () => { }); store.clearAllLocales(); - const t: ResourceSummaryDto = { - key: 'k-many-locales', - translations: { en: 'short', es: 'corto', fr: 'court', de: 'kurz', ja: '短い', ru: 'коротко' }, - status: {}, - } as any; + const t = summary('k-many-locales', 'short', { + es: ['corto', undefined], + fr: ['court', undefined], + de: ['kurz', undefined], + ja: ['短い', undefined], + ru: ['коротко', undefined], + }); fixture.componentRef.setInput('translation', t); fixture.detectChanges(); @@ -364,27 +349,9 @@ describe('TranslationItem - Full density expansion', () => { expect(component.needsExpansion()).toBe(true); }); - it('needsExpansion should be true when base long', () => { - const long = 'a'.repeat(201); - const t: ResourceSummaryDto = { - key: 'k-long-base', - translations: { en: long, es: 'es', fr: 'fr' }, - status: {}, - } as any; - - fixture.componentRef.setInput('translation', t); - fixture.detectChanges(); - - expect(component.needsExpansion()).toBe(true); - }); - it('needsExpansion should be true when any locale value long', () => { const long = 'b'.repeat(205); - const t: ResourceSummaryDto = { - key: 'k-long-locale', - translations: { en: 'en', es: long, fr: 'fr' }, - status: {}, - } as any; + const t = summary('k-long-locale', 'en', { es: [long, undefined], fr: ['fr', undefined] }); fixture.componentRef.setInput('translation', t); fixture.detectChanges(); @@ -393,11 +360,7 @@ describe('TranslationItem - Full density expansion', () => { }); it('isExpanded should toggle when toggleExpansion called', () => { - fixture.componentRef.setInput('translation', { - key: 'k', - translations: { en: 'en' }, - status: {}, - } as any); + fixture.componentRef.setInput('translation', summary('k', 'en')); fixture.detectChanges(); expect(component.isExpanded()).toBe(false); @@ -436,11 +399,7 @@ describe('TranslationItem - Full density expansion', () => { }); it('should omit the source row when the collection has no base value', () => { - const t: ResourceSummaryDto = { - key: 'k-no-base', - translations: { es: 'Guardar' }, - status: { es: 'translated' }, - } as any; + const t = summary('k-no-base', '', { es: ['Guardar', 'translated'] }); store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); store.setDensityMode('full'); @@ -562,11 +521,7 @@ describe('TranslationItem - compact key chip', () => { let spectator: Spectator; let store: InstanceType; - const longKey: ResourceSummaryDto = { - key: 'browser.translationEditor.context.allUpToDate', - translations: { en: 'Everything is up to date' }, - status: {}, - }; + const longKey = summary('browser.translationEditor.context.allUpToDate', 'Everything is up to date'); beforeEach(() => { ({ fixture, store, spectator } = renderTranslationItem()); @@ -708,7 +663,7 @@ describe('TranslationItem - compact key chip', () => { expect(tail?.textContent).toBe('.allUpToDate'); expect(head?.textContent).toBe('browser.translationEditor.context'); // Nothing is elided in the string itself — the column width decides. - expect(`${head?.textContent}${tail?.textContent}`).toBe(longKey.key); + expect(`${head?.textContent}${tail?.textContent}`).toBe(longKey.fullKey); }); it('leaves a short key whole, with no ellipsis of its own', () => { @@ -722,7 +677,7 @@ describe('TranslationItem - compact key chip', () => { }); it('renders a key with no separator as a head alone', () => { - render({ key: 'standalone', translations: { en: 'Alone' }, status: {} }); + render(summary('standalone', 'Alone')); expect(fixture.nativeElement.querySelector('.key-text__head')?.textContent).toBe('standalone'); expect(fixture.nativeElement.querySelector('.key-text__tail')).toBeNull(); diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts index 8a57a0f4..3f6db5d2 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts @@ -2,12 +2,12 @@ import { Component, ChangeDetectionStrategy, input, output, computed, effect, in import { MatIconModule } from '@angular/material/icon'; import { CdkDrag, CdkDragPlaceholder } from '@angular/cdk/drag-drop'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; -import { countByStatus, STATUS_PRECEDENCE, type TranslationStatus } from '@simoncodes-ca/domain'; import { BrowserStore } from '../../../store/browser.store'; import { TranslocoPipe } from '@jsverse/transloco'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { TranslationItemHeader } from './item-header'; -import { TranslationItemLocales, type BaseTranslation } from './item-locales'; +import { TranslationItemLocales } from './item-locales'; +import { rowView } from './row-view'; import { HighlightPipe } from '../../../../shared/pipes/highlight.pipe'; import type { DragData } from '../../../types/drag-data'; import { TranslationListStore } from '../store/translation-list.store'; @@ -17,8 +17,6 @@ import { statusLabelTokenFor, } from '../../../../shared/translation-status/translation-status-presentation'; -const EXPAND_THRESHOLD = 200; - /** * Number of locale rows rendered while a full-density item is collapsed. * Kept in sync with the virtual-scroll itemSize estimate in translation-list.ts. @@ -26,26 +24,6 @@ const EXPAND_THRESHOLD = 200; const MAX_VISIBLE_LOCALE_ROWS = 4; const LONG_PRESS_THRESHOLD = 500; -/** - * Whether a locale's stored value is the base value verbatim. - * - * Checksums cannot catch this: copying the source into a locale produces a - * perfectly valid `translated` status, so the row would report finished work on a - * string nobody has touched. Compared trimmed, because trailing whitespace is not - * a translation. An empty value is the `new`/missing case and belongs to the - * status chip, not here. - */ -function isIdenticalToBase(value: string, baseValue: string): boolean { - const trimmed = value.trim(); - return trimmed.length > 0 && trimmed === baseValue.trim(); -} - -/** Locale row order: worst status first; a row with no known status goes last. */ -function statusRank(status: TranslationStatus | undefined): number { - const rank = status ? STATUS_PRECEDENCE.indexOf(status) : -1; - return rank === -1 ? STATUS_PRECEDENCE.length : rank; -} - /** * Displays a single translation entry with key, base value, locale translations, * and action menu. @@ -93,7 +71,7 @@ export class TranslationItem { readonly #collectionName = computed(() => this.#store.selectedCollection() ?? ''); /** Whether this item was recently updated (flash highlight). */ - readonly isRecentlyUpdated = computed(() => this.#listStore.isRecentlyUpdated(this.translation().key)); + readonly isRecentlyUpdated = computed(() => this.#listStore.isRecentlyUpdated(this.translation().fullKey)); /** Current search query from the store */ readonly searchQuery = this.#store.searchQuery; @@ -108,102 +86,32 @@ export class TranslationItem { readonly isTouchPressed = signal(false); /** - * Full translation key (combines folder path with entry key if needed). - * For search results, the key already contains the full path. - * For folder browsing, we prepend the current folder path. - */ - readonly fullKey = computed(() => { - const path = this.#store.isSearchMode() ? '' : this.#store.currentFolderPath(); - const key = this.translation().key; - return path ? `${path}.${key}` : key; - }); - - /** Base locale value (English/source) */ - readonly baseValue = computed(() => { - const base = this.#store.baseLocale(); - return this.translation().translations[base] || ''; - }); - - /** - * The source row for full density, or undefined when the collection has no base - * locale and there is therefore nothing to compare the translations against. + * What this row shows under the list's current locale selection — see `row-view.ts`. + * The computeds below only hand its parts to the template. */ - readonly baseRow = computed(() => { - const value = this.baseValue(); - if (!value.trim()) return undefined; + readonly view = computed(() => + rowView(this.translation(), { + visibleLocales: this.#store.filteredLocales(), + compactLocale: this.#store.compactDisplayLocale(), + }), + ); - return { locale: this.#store.baseLocale(), value }; - }); + /** The source row for full density; absent when there is no base value to compare against. */ + readonly baseRow = computed(() => this.view().baseRow); - /** Locale translations excluding base locale, sorted by status priority then locale code */ - readonly localeTranslations = computed(() => { - const trans = this.translation(); - const base = this.#store.baseLocale(); - const activeLocales = this.#store.filteredLocales(); - - const baseValue = trans.translations[base] || ''; - - return activeLocales - .filter((locale) => locale !== base) - .map((locale) => { - const value = trans.translations[locale] || ''; - - return { - locale, - value, - status: trans.status ? trans.status[locale] : undefined, - isSameAsBase: isIdenticalToBase(value, baseValue), - }; - }) - .sort((a, b) => { - const rankDiff = statusRank(a.status) - statusRank(b.status); - if (rankDiff !== 0) return rankDiff; - return a.locale.localeCompare(b.locale); - }); - }); + /** Visible target locales, worst status first, then by locale code. */ + readonly localeTranslations = computed(() => this.view().localeRows); /** Current density mode (reads from BrowserStore) */ readonly currentDensityMode = computed(() => this.#store.densityMode()); /** - * What the compact row shows: one locale's value, and only what is worth - * saying about it. - * - * The locale is the store's single compact selection — the base locale until - * the user picks another, at which point that locale's value takes the base - * value's place rather than sitting beside it. Compact used to show source and - * translation as a pair, which made the translation an awkward annotation - * hanging off the right-hand end of the row. - * - * `needsAttention` gates the status chip. `translated` and `verified` are the - * quiet states — the rollup already reports them — so a chip on every row saying - * so was noise. Only `new` and `stale` name work to be done, and only they are - * shown. A row carries at most one marker: the chip when the status asks for - * work, otherwise the same-as-source flag when a "finished" translation is - * really the source text. + * What the compact row shows: one locale's value, and at most one marker — the + * status chip when the locale needs work, otherwise the same-as-source flag. + * The locale is the store's single compact selection, the base locale until the + * user picks another, and its value takes the base value's place. */ - readonly compactDisplay = computed(() => { - const locale = this.#store.compactDisplayLocale(); - const base = this.#store.baseLocale(); - const translation = this.translation(); - const value = translation.translations[locale] || ''; - const isBase = locale === base; - const status = isBase ? undefined : translation.status?.[locale]; - const needsAttention = status === 'new' || status === 'stale'; - - return { - locale, - value, - isBase, - status, - needsAttention, - // Source text is trivially the same as itself, so the marker only means - // something for a translation — and only for one the status chip already - // calls finished. A `new` row that still holds the English copy is flagged - // once, by the chip; a second marker saying the same thing is noise. - isSameAsBase: !isBase && !needsAttention && isIdenticalToBase(value, translation.translations[base] || ''), - }; - }); + readonly compactDisplay = computed(() => this.view().compact); /** Material icon for the compact row's status. */ readonly compactStatusIcon = computed(() => statusIconFor(this.compactDisplay().status)); @@ -239,11 +147,7 @@ export class TranslationItem { ); /** True when the base value or any active locale value is clipped by its line clamp. */ - readonly hasClippedValues = computed(() => { - if ((this.baseValue() || '').length > EXPAND_THRESHOLD) return true; - - return this.localeTranslations().some((v) => (v.value || '').length > EXPAND_THRESHOLD); - }); + readonly hasClippedValues = computed(() => this.view().hasLongValue); /** * Whether the expand control is offered. It answers "is anything hidden?" — @@ -271,7 +175,7 @@ export class TranslationItem { toggleExpansion(): void { this.isExpanded.update((v) => !v); this.expansionChanged.emit({ - key: this.translation().key, + key: this.translation().fullKey, expanded: this.isExpanded(), }); } @@ -354,16 +258,8 @@ export class TranslationItem { } } - /** Status counts across every non-base locale of the entry, whatever the locale filter shows. */ - readonly #statusCounts = computed(() => { - const statusMap = this.translation().status || {}; - const base = this.#store.baseLocale(); - return countByStatus( - Object.entries(statusMap) - .filter(([locale]) => locale !== base) - .map(([, status]) => status), - ); - }); + /** Status counts across every target locale of the entry, whatever the locale filter shows. */ + readonly #statusCounts = computed(() => this.view().statusCounts); constructor() { effect(() => { @@ -373,16 +269,13 @@ export class TranslationItem { } /** Returns a stable id for the rollup status element. */ - readonly statusId = computed(() => `rollup-${this.translation().key}`); + readonly statusId = computed(() => `rollup-${this.translation().fullKey}`); - /** - * Drag data for this translation item. - * Contains resource key, folder path, and type identifier. - */ + /** Drag data for this translation item: the resource's full key and the folder it lives in. */ readonly dragData = computed(() => ({ type: 'resource', - key: this.fullKey(), - folderPath: this.#store.isSearchMode() ? '' : this.#store.currentFolderPath(), + key: this.translation().fullKey, + folderPath: this.translation().folderPath, })); /** diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-rollup.ts b/apps/tracker/src/app/browser/translations/list/translation-item/translation-rollup.ts index c31ba7c7..01dfccc7 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-rollup.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-rollup.ts @@ -17,6 +17,7 @@ import { TemplatePortal, PortalModule } from '@angular/cdk/portal'; import { ViewContainerRef, type TemplateRef } from '@angular/core'; import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { countByStatus, type TranslationStatus } from '@simoncodes-ca/domain'; +import type { RollupLocale } from './row-view'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { injectActiveLang, injectStatusBreakdown } from '../../../../shared/i18n/status-breakdown'; import { @@ -25,12 +26,6 @@ import { STATUS_PRESENTATION, } from '../../../../shared/translation-status/translation-status-presentation'; -/** Locale state for rollup display */ -export interface LocaleState { - code: string; - status: TranslationStatus; -} - /** Close delay in ms */ const CLOSE_DELAY = 120; @@ -324,11 +319,8 @@ const RING_ORDER: readonly TranslationStatus[] = [...STATUS_DISPLAY_ORDER].rever }, }) export class TranslationRollup implements OnDestroy { - /** Locale states to display */ - locales = input.required(); - - /** Base locale code (excluded from display) */ - baseLocale = input('en'); + /** Target locales that carry a status (the base locale is never among them). */ + locales = input.required(); /** Renders at the smaller size compact's single-line row can afford. */ compact = input(false); @@ -354,17 +346,11 @@ export class TranslationRollup implements OnDestroy { this.close(); } - /** Effective locales (excluding base locale) */ - private readonly effectiveLocales = computed(() => { - const base = this.baseLocale().toLowerCase(); - return (this.locales() ?? []).filter((l) => (l?.code ?? '').toLowerCase() !== base); - }); - /** Status counts */ - readonly counts = computed(() => countByStatus(this.effectiveLocales().map((l) => l.status))); + readonly counts = computed(() => countByStatus(this.locales().map((l) => l.status))); - /** Total non-base locales */ - readonly total = computed(() => this.effectiveLocales().length); + /** Total target locales with a status */ + readonly total = computed(() => this.locales().length); /** What the centre reports: state and glyph, from `rollupCenter`. */ private readonly center = computed(() => rollupCenter(this.counts())); @@ -430,7 +416,7 @@ export class TranslationRollup implements OnDestroy { readonly tooltipLocaleRows = computed(() => { const orderIndex = (s: TranslationStatus) => STATUS_DISPLAY_ORDER.indexOf(s); - return this.effectiveLocales() + return this.locales() .map((l) => ({ code: l.code, status: l.status, diff --git a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts index c3547dea..80af7335 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts @@ -1,5 +1,6 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import type { Provider } from '@angular/core'; import type { ComponentFixture } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { TranslocoService } from '@jsverse/transloco'; @@ -25,7 +26,24 @@ const createList = createComponentFactory({ detectChanges: false, }); -const renderList = (providers: unknown[] = []): ComponentFixture => createList({ providers }).fixture; +const renderList = (providers: Provider[] = []): ComponentFixture => createList({ providers }).fixture; + +const summary = (fullKey: string, baseValue: string, fr?: [string, 'new' | 'translated']): ResourceSummaryDto => { + const segments = fullKey.split('.'); + const entryKey = segments.pop() ?? ''; + return { + fullKey, + folderPath: segments.join('.'), + entryKey, + base: { locale: 'en', value: baseValue }, + targets: + fr === undefined + ? [] + : [{ locale: 'fr', value: fr[0], status: fr[1], needsWork: fr[1] === 'new', sameAsBase: false }], + tags: [], + inheritedTags: [], + }; +}; describe('TranslationList', () => { let component: TranslationList; @@ -268,10 +286,7 @@ describe('TranslationList - Virtual Scrolling', () => { const folderReq = httpMock.expectOne('/api/collections/test/resources/tree?path=test-folder&includeNested=true'); folderReq.flush({ path: 'test-folder', - resources: [ - { key: 'key1', translations: { en: 'Value 1' }, status: {} }, - { key: 'key2', translations: { en: 'Value 2' }, status: {} }, - ], + resources: [summary('test-folder.key1', 'Value 1'), summary('test-folder.key2', 'Value 2')], children: [], }); @@ -283,16 +298,12 @@ describe('TranslationList - Virtual Scrolling', () => { // Virtual scroll doesn't always render items in test environment // Instead, verify the data is loaded in the store expect(store.translations()).toHaveLength(2); - expect(store.translations()[0].key).toBe('key1'); - expect(store.translations()[1].key).toBe('key2'); + expect(store.translations()[0].fullKey).toBe('test-folder.key1'); + expect(store.translations()[1].fullKey).toBe('test-folder.key2'); }); it('should use trackByKey for performance', () => { - const translation: ResourceSummaryDto = { - key: 'test.key', - translations: { en: 'Test' }, - status: {}, - }; + const translation = summary('test.key', 'Test'); const result = component.trackByKey(0, translation); expect(result).toBe('test.key'); @@ -310,11 +321,7 @@ describe('TranslationList - skippedLocales warning snackbar', () => { let mockDialogRef: { afterClosed: ReturnType }; let mockDialog: { open: ReturnType }; - const mockResource: ResourceSummaryDto = { - key: 'common.test', - translations: { en: 'Test Value', fr: 'Valeur test' }, - status: { fr: 'translated' }, - }; + const mockResource = summary('common.test', 'Test Value', ['Valeur test', 'translated']); beforeEach(async () => { notificationsSpy = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; @@ -361,7 +368,7 @@ describe('TranslationList - skippedLocales warning snackbar', () => { it('should not show warning snackbar when skippedLocales is empty or absent', async () => { vi.useFakeTimers(); - for (const skippedLocales of [[], undefined] as const) { + for (const skippedLocales of [[], undefined] as Array) { notificationsSpy.warning.mockClear(); const result: TranslationEditorResult = { @@ -407,11 +414,11 @@ describe('TranslationList - skippedLocales warning snackbar', () => { const listStore = fixture.debugElement.injector.get(TranslationListStore); listStore.editTranslation(mockResource, 'test-collection'); - expect(listStore.recentlyUpdatedKey()).toBe(mockResource.key); + expect(listStore.recentlyUpdatedKey()).toBe(mockResource.fullKey); }); }); -describe('TranslationList - handleEdit key rewrite', () => { +describe('TranslationList - handleEdit full key', () => { let fixture: ComponentFixture; let notificationsSpy: { success: ReturnType; @@ -437,26 +444,13 @@ describe('TranslationList - handleEdit key rewrite', () => { // The cache itself is patched by BrowserStore.updateResource (see // with-entry-writes.feature.spec.ts); the list only has to flash the right row. - it('should flash the row under the key the list renders, not the bare API key', () => { + it('should flash the row under its full key', () => { const store = fixture.debugElement.injector.get(BrowserStore); - // Activate search mode so the store key contains the full path ("buttons.save") - // while the API returns only the bare entry key ("save"). store.setSearchQuery('buttons'); - // The store-level resource uses the full-path key as it appears in search results. - const storeResource: ResourceSummaryDto = { - key: 'buttons.save', - translations: { en: 'Save', fr: '' }, - status: { fr: 'new' }, - }; - - // The dialog returns the bare entry key that the API echoes back. - const apiResource: ResourceSummaryDto = { - key: 'save', - translations: { en: 'Save', fr: 'Enregistrer' }, - status: { fr: 'translated' }, - }; + const storeResource = summary('buttons.save', 'Save', ['', 'new']); + const apiResource = summary('buttons.save', 'Save', ['Enregistrer', 'translated']); const result: TranslationEditorResult = { key: 'save', @@ -473,7 +467,7 @@ describe('TranslationList - handleEdit key rewrite', () => { const listStore = fixture.debugElement.injector.get(TranslationListStore); listStore.editTranslation(storeResource, 'test-collection'); - expect(listStore.recentlyUpdatedKey()).toBe(storeResource.key); + expect(listStore.recentlyUpdatedKey()).toBe(storeResource.fullKey); }); }); @@ -526,11 +520,7 @@ describe('TranslationList - deleteTranslation', () => { let mockDialog: { open: ReturnType }; let store: InstanceType; - const mockResource: ResourceSummaryDto = { - key: 'button.delete', - translations: { en: 'Delete', fr: 'Supprimer' }, - status: { fr: 'translated' }, - }; + const mockResource = summary('button.delete', 'Delete', ['Supprimer', 'translated']); beforeEach(async () => { notificationsSpy = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; @@ -599,19 +589,9 @@ describe('TranslationList - handleTranslate', () => { let mockBrowserApi: { translateResource: ReturnType; deleteResource: ReturnType }; let store: InstanceType; - const mockResource: ResourceSummaryDto = { - key: 'button.save', - translations: { en: 'Save', fr: '' }, - status: { fr: 'new' }, - }; + const mockResource = summary('button.save', 'Save', ['', 'new']); - // The API returns only the bare entry key ("save"), not the relative-path key - // ("button.save") the list renders. BrowserStore rewrites it when it patches. - const mockUpdatedResource: ResourceSummaryDto = { - key: 'save', - translations: { en: 'Save', fr: 'Enregistrer' }, - status: { fr: 'translated' }, - }; + const mockUpdatedResource = summary('button.save', 'Save', ['Enregistrer', 'translated']); beforeEach(async () => { notificationsSpy = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; @@ -655,9 +635,7 @@ describe('TranslationList - handleTranslate', () => { // The key is removed synchronously from translatingKeys after the observable emits expect(listStore.translatingKeys().has('button.save')).toBe(false); - // Store was updated with the key rewritten from the bare API key ("save") - // back to the relative-path key that the store indexes by ("button.save"). - expect(store.translations()).toEqual([{ ...mockUpdatedResource, key: mockResource.key }]); + expect(store.translations()).toEqual([mockUpdatedResource]); // Success notification shown expect(notificationsSpy.success).toHaveBeenCalledWith('1 locale translated successfully'); @@ -732,10 +710,13 @@ describe('TranslationList - openResourceByKey', () => { it('should open the row editor through the same launcher', () => { const listStore = fixture.debugElement.injector.get(TranslationListStore); - listStore.editTranslation({ key: 'backButton', translations: { en: 'Back' }, status: {} }, 'my-collection'); + listStore.editTranslation(summary('browser.header.backButton', 'Back'), 'my-collection'); expect(launcherSpy.openEditor).toHaveBeenCalledWith( - expect.objectContaining({ collectionName: 'my-collection', storeKey: 'backButton' }), + expect.objectContaining({ + collectionName: 'my-collection', + resource: expect.objectContaining({ fullKey: 'browser.header.backButton' }), + }), ); }); }); diff --git a/apps/tracker/src/app/browser/translations/list/translation-list.ts b/apps/tracker/src/app/browser/translations/list/translation-list.ts index cab5b3a2..81be6193 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-list.ts @@ -141,6 +141,6 @@ export class TranslationList { /** Track function for virtual scroll performance. */ trackByKey(_index: number, item: ResourceSummaryDto): string { - return item.key; + return item.fullKey; } } diff --git a/apps/tracker/src/app/browser/translations/utils/sort-translations.ts b/apps/tracker/src/app/browser/translations/utils/sort-translations.ts index eddc8f5b..1f208b52 100644 --- a/apps/tracker/src/app/browser/translations/utils/sort-translations.ts +++ b/apps/tracker/src/app/browser/translations/utils/sort-translations.ts @@ -1,4 +1,5 @@ -import { countByStatus, type TranslationStatus } from '@simoncodes-ca/domain'; +import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; +import { countByStatus, summaryTarget } from '@simoncodes-ca/domain'; import { STATUS_DISPLAY_ORDER } from '../../../shared/translation-status/translation-status-presentation'; export type SortField = 'key' | 'status'; @@ -10,37 +11,38 @@ const VERIFIED_RANK = STATUS_DISPLAY_ORDER.indexOf('verified'); * Sort rank of an item: the position, in the display order (new first), of the * earliest status its locales carry. Locales with no status rank as verified. */ -function statusRank(statuses: Record, locales: string[]): number { - const counts = countByStatus(locales.map((locale) => statuses[locale])); +function statusRank(item: ResourceSummaryDto, locales: string[]): number { + const counts = countByStatus(locales.map((locale) => summaryTarget(item, locale)?.status)); const rank = STATUS_DISPLAY_ORDER.findIndex((status) => counts[status] > 0); return rank === -1 ? VERIFIED_RANK : rank; } -export function sortTranslations< - T extends { - key: string; - status?: Record; - }, ->(items: T[], field: SortField, direction: SortDirection, selectedLocales: string[]): T[] { +/** Sorts by full key, or by status (then full key). Full keys order a folder list the same as its entry keys. */ +export function sortTranslations( + items: T[], + field: SortField, + direction: SortDirection, + selectedLocales: string[], +): T[] { const sortedItems = [...items]; sortedItems.sort((itemA, itemB) => { if (field === 'key') { - return itemA.key.localeCompare(itemB.key, undefined, { + return itemA.fullKey.localeCompare(itemB.fullKey, undefined, { sensitivity: 'base', }); } // Sort by status - const statusA = statusRank(itemA.status ?? {}, selectedLocales); - const statusB = statusRank(itemB.status ?? {}, selectedLocales); + const statusA = statusRank(itemA, selectedLocales); + const statusB = statusRank(itemB, selectedLocales); if (statusA !== statusB) { return statusA - statusB; } // Secondary sort by key for ties - return itemA.key.localeCompare(itemB.key, undefined, { + return itemA.fullKey.localeCompare(itemB.fullKey, undefined, { sensitivity: 'base', }); }); diff --git a/apps/tracker/src/app/shared/tag-list/tag-list.component.ts b/apps/tracker/src/app/shared/tag-list/tag-list.component.ts index dbe332b7..3b6ed5c8 100644 --- a/apps/tracker/src/app/shared/tag-list/tag-list.component.ts +++ b/apps/tracker/src/app/shared/tag-list/tag-list.component.ts @@ -23,8 +23,8 @@ export class TagList { readonly TOKENS = TRACKER_TOKENS; /** Array of explicit (per-resource) tag strings to display */ - tags = input.required(); + tags = input.required(); /** Tags inherited from the parent collection; shown with a distinct style and tooltip */ - inheritedTags = input([]); + inheritedTags = input([]); } diff --git a/architecture-docs/api.md b/architecture-docs/api.md index 1ae0cd9a..425082a0 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -119,10 +119,10 @@ graph TD end subgraph mappers["Mappers"] - TREEMP["resource-tree.mapper\nResourceTreeNode → ResourceTreeDto\nResourceTreeEntry → ResourceSummaryDto"] + TREEMP["resource-tree.mapper\nResourceTreeNode → ResourceTreeDto\nResourceTreeEntry + Collection → ResourceSummaryDto"] COLMAP["collection.mapper\nLingoTrackerCollectionDto ↔ LingoTrackerCollection"] CFGMAP["config.mapper\nLingoTrackerConfig → LingoTrackerConfigDto"] - SRCHMAP["search-result.mapper\nSearchResult → SearchResultDto"] + SRCHMAP["search-result.mapper\nSearchResult + Collection → SearchResultDto"] end STATIC["Express static middleware\nServes Angular SPA from\ndist/tracker/browser/"] @@ -318,12 +318,14 @@ sequenceDiagram alt Ready Index-->>API: { status: "ready", tree } - API->>API: mapResourceTreeToDto(tree) + API->>API: mapResourceTreeToDto(tree, collection) API-->>UI: 200 OK ResourceTreeDto (404 when the path is not in the tree) end ``` -A 202 Accepted response always means "retry shortly". A 200 OK carries the full or partial tree. The route uses `@Res({ passthrough: true })` only to set the 202 status; Nest serializes the returned DTO. The frontend owns the retry loop; there is no server-sent event or WebSocket. +A 202 Accepted response always means "retry shortly". A 200 OK carries the full or partial tree. The route uses `@Res({ passthrough: true })` only to set the 202 status; Nest serializes the returned DTO. The frontend owns the retry loop, in one place: `BrowserApiService.getResourceTree` asks again (5 times, 1 s apart) and hands its callers only a tree, or a `CollectionIndexNotReadyError` when the index is still not ready. There is no server-sent event or WebSocket. + +Every resource in the tree, in a search result, and in the translate and update responses is a [Resource Summary](glossary.md#resource-summary) (`ResourceSummaryDto`): an explicit address (`fullKey`, `folderPath`, `entryKey`), `base: { locale, value }`, and one `targets` row per target locale of the collection with `value`, `status`, `needsWork` and `sameAsBase`. With `includeNested=true`, `resources` also lists every resource below the folder, each with its own full address. --- @@ -376,12 +378,12 @@ For the entity types that mappers transform, see [domain-and-data-model.md](doma | Mapper file | Direction | Key transformation | |-------------|-----------|-------------------| -| `resource-tree.mapper.ts` | `ResourceTreeNode` → `ResourceTreeDto` | Flattens `folderPathSegments[]` array to a dot-delimited `path` string; merges `source` (base locale value) into the `translations` record keyed by the base locale string; extracts per-locale `status` from the `metadata` record | -| `resource-tree.mapper.ts` | `ResourceTreeEntry` → `ResourceSummaryDto` | Identifies the base locale by the absence of `status` and `baseChecksum` in the metadata entry; produces a flat `{ key, translations, status, comment, tags, inheritedTags }` shape. The `inheritedTags` field carries the parent collection's `tags` so the UI can render them distinctly without re-reading the config. | +| `resource-tree.mapper.ts` | `ResourceTreeNode` + `Collection` → `ResourceTreeDto` | Flattens `folderPathSegments[]` array to a dot-delimited `path` string; turns every resource into a Resource Summary | +| `resource-tree.mapper.ts` | `ResourceTreeEntry` + folder path + `Collection` → `ResourceSummaryDto` | Resolves the entry's full key against the folder it is relative to and calls the domain `buildResourceSummary`. The base locale, the target locales and the `inheritedTags` come from the opened `Collection`; nothing is guessed from the metadata. The translate and update handlers call `buildResourceSummary` directly with the key they already hold. | | `collection.mapper.ts` | `LingoTrackerCollectionDto` ↔ `LingoTrackerCollection` | Bidirectional; shallow clone of `locales[]` and `tags[]` arrays to prevent aliasing. Carries the `protectedTermsFile` setting in both directions. Drops resolved `protectedTerms` on the way back to config, because terms live in a file and the controller writes them there separately. | | `config.mapper.ts` | `LingoTrackerConfig` → `LingoTrackerConfigDto` | Delegates collection mapping to `collection.mapper` and bundle mapping to `bundle.mapper`; shallow clone of `locales[]`. Takes an optional `ResolvedProtectedTerms` and `projectName` (basename of the API's working directory) from the controller, so the mapper itself reads no files. | | `bundle.mapper.ts` | `BundleDefinitionDto` ↔ `BundleDefinition`; `BundlePlan` → `BundleDryRunResultDto`; `GenerateBundleResult` → `BundleGenerateJobResultDto` | Bidirectional definition mapping trims strings and drops empty optionals so nothing spurious is written to the config. The plan mapper drops `absolutePath` and caps `conflictKeys` at 50. The job-result mapper rebuilds written file paths from `localesProcessed` plus the types file. | -| `search-result.mapper.ts` | `SearchResult` → `SearchResultDto` | Structurally identical types; mapper exists for explicit API boundary documentation | +| `search-result.mapper.ts` | `SearchResult` + `Collection` → `SearchResultDto` | The hit's Resource Summary (from its `key`, `source`, `translations` and `metadata`) plus `matchType` and `matchedLocales` | **Why does `config.mapper.ts` take resolved terms as an argument?** Protected terms live in JSON files outside `.lingo-tracker.json`. Building the DTO therefore requires reading the filesystem. @@ -389,4 +391,4 @@ The mapper keeps no file access. Instead `ConfigController.getConfig()` calls `r The resolved terms and their file paths then reach the UI as read-only DTO fields, `protectedTerms` and `protectedTermsFilePath`. The writable `protectedTermsFile` setting travels alongside them. -**Why the base locale detection logic in `resource-tree.mapper.ts`?** The `ResourceTreeEntry` domain model stores the base locale value in a dedicated `source` field and tracks its metadata in the same `metadata` record as translations — distinguished by the absence of `status` and `baseChecksum` fields (the base locale has a checksum but no `baseChecksum` to compare against, and no `status` since it is never `new` or `stale` relative to itself). The DTO flattens this into a single `translations` map for simpler frontend consumption. The mapper performs this denormalization at the API boundary so the domain model stays clean. +**Why is `ResourceSummaryDto` declared in domain?** The summary is JSON-shaped and its rules (`needsWork` is the [staleness rule](glossary.md#staleness-rule)'s `needsTranslation`; `sameAsBase` is `isUntranslatedCopy` on trimmed values) must be the same wherever an entry is shown. So `libs/domain/src/lib/resource-summary.ts` owns the type and the builder, and `data-transfer` re-exports the type as the DTO, like `TranslationStatus`. The mapper only supplies the full key and the `Collection`. The old mapper found the base locale by looking for the metadata entry without `status` and `baseChecksum`; any other locale with that shape was mistaken for it. diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index 1d546cea..2a5d13d7 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -242,7 +242,7 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that | Operations | The entry points the apps call. Resources: `addResource`, `editResource`, `deleteResource`, `moveResource`. Folders: `createFolder`, `deleteFolder`, `moveFolder`. Collections and locales: `addCollection`, `updateCollection`, `deleteCollectionByName`, `addLocaleToCollection`, `removeLocaleFromCollection`, `setGlobal/CollectionProtectedTerms[File]`. Bundles: `generateBundle`, `planBundle`, `add/update/deleteBundleDefinition`, `validateBundleKey`, `validateBundleDefinition`, `getBundleOutputPath`, `hasTypeDistConfigured`. Import: `importResources` and its adapters. Export: `runExport`, `exportTargetLocales`, the export argument checks, `loadResourcesFromCollections`. Also `normalize`, `translateLocale`, `translateExistingResource`, `validateResources`, `generateValidationSummary`, `describePreferredTermRule`. | | Collection & config | `loadConfig`, `openCollection`, `Collection`, `CONFIG_FILENAME`, `DEFAULT_CONFIG`, the config types (`LingoTrackerConfig`, `LingoTrackerCollection`, `TranslationConfig`, `BundleDefinition`, ...), and the protected-terms and preferred-terminology file readers and writers. | | ResourceFolder | `openResourceFolder`, `ResourceFolder` and the types in its methods, `resolveResourcePaths`. | -| Read models | `loadResourceTree`, `extractSubtree`, `extractResourcesRecursively`, `searchTranslations`, `searchResourceTree`, `computeTreeFingerprint`, `treeFingerprintsMatch`, `reindexMutation` and their types. The API's [Collection Index](glossary.md#collection-index) is built from these. | +| Read models | `loadResourceTree`, `extractSubtree`, `extractResourcesRecursively`, `searchTranslations`, `searchResourceTree`, `computeTreeFingerprint`, `treeFingerprintsMatch`, `reindexMutation` and their types. The API's [Collection Index](glossary.md#collection-index) is built from these. A `ResourceTreeEntry` and a `SearchResult` (which carries the entry's `source` and `metadata`) both fit the domain `buildResourceSummary` input, which the API uses to answer with a [Resource Summary](glossary.md#resource-summary). | | Errors | `LingoTrackerError` and every typed subclass, `TranslationError`, `PreferredTerminologyValidationError`. See [Error Model](#error-model). | | Types | Parameter and result types for the operations above (`AddResourceParams`, `GenerateBundleResult`, `ImportResult`, ...). | diff --git a/architecture-docs/domain-and-data-model.md b/architecture-docs/domain-and-data-model.md index da5d8aa0..f82bc7a2 100644 --- a/architecture-docs/domain-and-data-model.md +++ b/architecture-docs/domain-and-data-model.md @@ -248,6 +248,31 @@ erDiagram `ResourceEntries` and `TrackerMetadata` are always paired: one `resource_entries.json` and one `tracker_meta.json` per folder, never one without the other. The relationship between `ResourceEntry` and `ResourceEntryMetadata` is by shared entry key; the relationship between a locale value in `ResourceEntry` and a `LocaleMetadata` object is by shared locale code. +### Read model: Resource Summary + +Readers outside core do not see the stored pair. They see a [Resource Summary](glossary.md#resource-summary) (`libs/domain/src/lib/resource-summary.ts`), built from one entry and its opened `Collection`: + +```typescript +interface ResourceSummary { + fullKey: string; // "apps.common.buttons.ok" + folderPath: string; // "apps.common.buttons" ('' at the root) + entryKey: string; // "ok" + base: { locale: string; value: string }; // the collection's base locale and `source` + targets: Array<{ // every collection target locale, in collection order + locale: string; + value?: string; // absent when the entry has no value + status?: TranslationStatus; // absent when there is no metadata + needsWork: boolean; // needsTranslation(meta): no metadata, new or stale + sameAsBase: boolean; // isUntranslatedCopy on trimmed, non-empty values + }>; + comment?: string; + tags: string[]; // the resource's own tags + inheritedTags: string[]; // the collection's tags +} +``` + +A target locale without a value still has a row. Values for locales the collection does not have are not shown. + --- ## ICU vs Transloco Format diff --git a/architecture-docs/frontend.md b/architecture-docs/frontend.md index a6db354b..87bc806c 100644 --- a/architecture-docs/frontend.md +++ b/architecture-docs/frontend.md @@ -25,6 +25,7 @@ Return to [architecture README](README.md). - [Lazy-Loaded Dialogs](#lazy-loaded-dialogs) - [Translation Editor and the Resource Entry Draft](#translation-editor-and-the-resource-entry-draft) - [Translation Status Summary](#translation-status-summary) + - [Translation Rows and the Row View](#translation-rows-and-the-row-view) - [Writing a Resource Entry](#writing-a-resource-entry) - [Theming System](#theming-system) - [i18n — Transloco Integration](#i18n--transloco-integration) @@ -178,7 +179,7 @@ Root-level methods on `BrowserStore` (not in a feature): | Method | Purpose | |---|---| | `setSelectedCollection` | Switches active collection, restores view preferences from `localStorage`, triggers cache polling | -| `moveResource` | Optimistic remove from `translations` → API call → re-fetch on success, rollback on error | +| `moveResource` | Optimistic remove from `translations` (matched by `fullKey`, so it works for rows in any folder) → API call → re-fetch on success, rollback on error | | `reset` | Clears all state slices back to initial values | | `setBaseLocale`, `setDisabled`, `clearError` | Simple `patchState` helpers | @@ -188,8 +189,8 @@ Root-level methods on `BrowserStore` (not in a feature): `TranslationListStore` is a lightweight store provided at the `TranslationList` component level (not root). It composes two features: -- **`withItemUiState`** — tracks `translatingKeys: Set` (in-progress auto-translate calls) and `recentlyUpdatedKey: string | undefined` (drives the 1.5 s flash highlight after a save). Exposes `addTranslatingKey`, `removeTranslatingKey`, `flashRecentlyUpdated`, `isTranslating(key)`, `isRecentlyUpdated(key)`. Cleans up the flash timer `onDestroy`. -- **`withItemActions`** — exposes `editTranslation`, `deleteTranslation`, `translateResource`, `copyKey`. Edit goes through `TranslationEditorLauncher`; delete opens `ConfirmationDialog`. Delete and translate resolve the row's full key and call `BrowserStore.deleteResource` / `BrowserStore.translateResource`, which update the caches. The feature keeps the per-row feedback: the translating spinner, the flash, and the toasts. +- **`withItemUiState`** — tracks `translatingKeys: Set` (in-progress auto-translate calls) and `recentlyUpdatedKey: string | undefined` (drives the 1.5 s flash highlight after a save). Both are keyed by each resource's `fullKey`. Exposes `addTranslatingKey`, `removeTranslatingKey`, `flashRecentlyUpdated`, `isTranslating(key)`, `isRecentlyUpdated(key)`. Cleans up the flash timer `onDestroy`. +- **`withItemActions`** — exposes `editTranslation`, `deleteTranslation`, `translateResource`, `copyKey`. Edit goes through `TranslationEditorLauncher`; delete opens `ConfirmationDialog`. Delete and translate take the row's `fullKey` and call `BrowserStore.deleteResource` / `BrowserStore.translateResource`, which update the caches. The feature keeps the per-row feedback: the translating spinner, the flash, and the toasts. Because `TranslationListStore` is component-provided, each `TranslationList` instance gets its own store. `TranslationItem` injects it via `inject(TranslationListStore)` — no prop drilling needed. @@ -308,21 +309,42 @@ The dialog also includes a tag chip input (Material `mat-chip-grid` + `mat-autoc | Function | Rule | |---|---| | `absorbDottedKey(rawKey, currentFolder, folderFromKey)` | A dotted key typed in the key field moves its prefix to the folder and keeps the leaf. The next dotted key extends the folder only while the folder is still the one the last absorption set. | -| `folderEntryKeys(folderPath, known)` / `collisionFor(key, folderPath, known, ownKey?)` | Which entry keys a folder holds, from three sources in order: the expanded folder tree, the folder the browser shows, then folders the dialog fetched. Nested keys (with a dot) are not entries of the folder. The match is exact and case-sensitive, the same as `addResource`. The entry being edited never collides with itself. | +| `folderEntryKeys(folderPath, known)` / `collisionFor(key, folderPath, known, ownKey?)` | Which entry keys a folder holds, from three sources in order: the expanded folder tree, the folder the browser shows, then folders the dialog fetched. Only resources whose `folderPath` is the folder count (nested resources the list folds in do not). The match is exact and case-sensitive, the same as `addResource`. The entry being edited never collides with itself. | | `contextTree(input, moreLabel)` | The "Where it lands" tree: the target folder among its siblings, and an 8-entry window of its entries around the key. The remaining entries are one "more" row. | | `addTag` / `removeTag` | Tag list operations. Tags are normalized with `normalizeTag`. Inherited tags cannot be removed. | | `toCreateDto(draft)` | The create request. Every typed translation is sent with status `new`. Locales left empty are not sent; the server seeds them by the collection's rule ([locale seeding](glossary.md#locale-seeding)). The request has no base locale: the collection's applies. | -| `toUpdateDto(draft, original)` / `editedLocales` | The update request. `key` is the entry's full key where it lives now. A change of folder (the collection root included) is sent as `moveTo`, the destination folder. A locale is sent when it has a value or when its status changed. | +| `toUpdateDto(draft, original)` / `editedLocales` | The update request. `original` is the Resource Summary the edit started from; `key` is its `fullKey`. A change of folder (the collection root included) is sent as `moveTo`, the destination folder. A locale is sent when it has a value or when its status changed. | | `hasUnsavedChanges(draft, initial, fieldsEdited)` | Closing loses work when a form field was edited, the folder moved, or the tags changed. | The key field validator is `segmentValidator` (`shared/validators/segment.validator.ts`). It uses the domain `isValidSegment` rule and reports under the `pattern` error key. The bundle name and the inline new-folder name use the same validator. The folder filter in the location popover uses `filterFolderTree` from `browser/store/folder-tree.utils.ts`, the same function as `BrowserStore.filteredFolders`. The dialog reads two things directly from `BrowserApiService`: `searchTranslations` for similar values, and `getResourceTree` for the entries of a folder picked in the popover. Both are dialog-local reads. The store's `selectFolder` would move the browser list behind the dialog, so the dialog does not use it. +Status labels in the editor (the status pill, its menu and the context column dots) come from `statusLabelTokenFor` in the shared translation-status presentation module, the same tokens the rows use. + ### Translation Status Summary Each status roll-up in the browser uses the domain [translation status summary](glossary.md#translation-status-summary) (`countByStatus`, `worstStatus`, `STATUS_PRECEDENCE`). These roll-ups are the `TranslationRollup` ring and its accessible name, the item's screen-reader breakdown, the locale column's single-status chip, the `StatusFilter` counts and `matchesAnyStatus`, and sort by status. The components only render the result. The Tracker keeps the presentation in one table, `shared/translation-status/translation-status-presentation.ts`. `STATUS_PRESENTATION` gives the chip icon, the ring-centre glyph, the label token and the count token for each status. `rollupCenter(counts)` gives the ring centre: the worst status, or `mixed` when `new` and `stale` are both present. The module also has `STATUS_DISPLAY_ORDER` (`new`, `stale`, `translated`, `verified`), which the filter rail, the rollup tooltip rows and sort by status use. The ring draws its arcs in the reverse of this order. The breakdown text and a card's locale rows use the worst-first `STATUS_PRECEDENCE` instead. A per-folder roll-up can use the same functions if `FolderNodeDto` gets status data in the future. +### Translation Rows and the Row View + +Every row in the list shows one [Resource Summary](glossary.md#resource-summary) (`ResourceSummaryDto`). The summary already carries the explicit address (`fullKey`, `folderPath`, `entryKey`), the base locale and value, and one target row per collection locale with `needsWork` and `sameAsBase`. So no row module works out a key, filters out the base locale, or re-implements "new or stale". + +The pure module `browser/translations/list/translation-item/row-view.ts` (no Angular imports, like the Resource Entry Draft) turns a summary and the list's selection (`visibleLocales` from `filteredLocales`, `compactLocale` from `compactDisplayLocale`) into a `RowView`: + +| Field | Rule | +|---|---| +| `baseRow` | The source row for full density; absent when the base value is blank. | +| `localeRows` | The visible target locales, worst status first (`STATUS_PRECEDENCE`), then by locale code. A missing value is `''`. | +| `compact` | The single compact line: the base value, or the chosen locale's value in its place. `needsAttention` (the status chip) is set when the locale needs work and has a status; `isSameAsBase` is set only when there is no chip, so a row has at most one marker. | +| `rollupLocales` / `statusCounts` | Every target locale with a status, whatever the filter shows, and their `countByStatus`. The rollup ring, its tooltip and the screen-reader breakdown read these. | +| `canTranslate` | Some target `needsWork`: the same test `translateExistingResource` uses, so the translate action is enabled exactly when the server has work to do. | +| `hasLongValue` | The base or a visible locale value is longer than `LONG_VALUE_THRESHOLD` (200) and is clipped. | + +`sharedStatus(rows)` gives the locale grid's single chip when every rendered row shares one status. `TranslationItem` computes the view once and passes it to `TranslationItemHeader`; `TranslationItemLocales` and `TranslationRollup` receive rows. The components keep only the DOM parts: expansion, overlays, drag, touch and keyboard handling. The rules are tested in `row-view.spec.ts` as pure functions. + +`BrowserApiService.getResourceTree` hides the collection index's "not ready" answer (HTTP 202): it retries and gives the stores only a tree, so `selectFolder`, `loadRootFolders`, `loadFolderChildren`, `moveFolder`, the launcher and the editor have no retry or shape check of their own. + ### Writing a Resource Entry All UI writes of a resource entry go through `withEntryWritesFeature` on `BrowserStore`: @@ -338,7 +360,7 @@ Each method takes the full dot-delimited key and returns the API `Observable`. T `toUpdateDto` includes `moveTo` only when the entry changes folder, and `''` means the collection root. The server edits the entry, then moves it there (core `editResource` with `moveTo`), so the store drops the row. The store rule and the DTO rule use the same test: the `moveTo` property is present or absent. -The two caches use different keys. `translations` uses the key relative to `currentFolderPath`, so a nested entry keeps its sub-path (`dialog.title`). `searchResults` uses the full key. The store converts the key with `listKeyFor` in one place. The API returns a bare entry key, so the store also replaces the key of the returned resource. Callers do not convert keys. +Both caches (`translations` and `searchResults`) are keyed by each resource's `fullKey`, in folder mode, nested mode and search mode alike. The API returns the updated resource with its own full address, so the store swaps it in by `fullKey`; there is no key conversion anywhere. A drag carries the row's `fullKey` and its real `folderPath`, also for nested rows. `TranslationEditorLauncher` and `TranslationMainHeader` only give feedback after the dialog closes: the row flash and the toasts. diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index f36b4e06..33bde810 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -233,6 +233,14 @@ Explained in context: [`libs-domain.md`](libs-domain.md) --- +### Resource Summary + +One [resource entry](#resource-entry) as the API and the Tracker see it: an explicit address — `fullKey` (`apps.common.buttons.ok`), `folderPath` (`apps.common.buttons`, `''` at the root) and `entryKey` (`ok`) — the base locale and value, and one row per target locale of the [collection](#collection), in collection order, with `value`, `status`, `needsWork` (the [staleness rule](#staleness-rule)'s `needsTranslation`) and `sameAsBase` (`isUntranslatedCopy`, compared trimmed). The base locale and target locales come from the opened `Collection`, never from the metadata. In code, `buildResourceSummary(fullKey, entry, collection)` and `summaryTarget(summary, locale)` in `libs/domain/src/lib/resource-summary.ts`; `ResourceSummaryDto` in `data-transfer` is the same type. The Tracker's pure `row-view.ts` turns a summary into what one list row shows. + +Explained in context: [`api.md`](api.md#mapper-layer), [`frontend.md`](frontend.md#translation-rows-and-the-row-view) + +--- + ### Resolved Key The fully qualified dot-delimited key after combining an input key with an optional [target folder](#target-folder). Resolution is additive: `resolvedKey = targetFolder + "." + key` (or just `key` if no target folder is specified). @@ -359,6 +367,6 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [` ### Translation Status Summary -The roll-up of a set of locale [translation statuses](#translation-status): the number of locales in each status (`StatusCounts`) and the worst status. The pure module `libs/domain/src/lib/translation-status-summary.ts` holds the rules. `countByStatus(statuses)` counts the statuses and ignores a locale with no status. `worstStatus(counts)` applies `STATUS_PRECEDENCE`, which is worst first: `stale` > `new` > `translated` > `verified`. Every roll-up in the Tracker UI uses this module: the rollup ring, the screen-reader breakdown, the locale column, the status filter counts, and sort by status. The glyphs, label tokens and display order are presentation. They are in one Tracker table, `shared/translation-status/translation-status-presentation.ts`. +The roll-up of a set of locale [translation statuses](#translation-status): the number of locales in each status (`StatusCounts`) and the worst status. The pure module `libs/domain/src/lib/translation-status-summary.ts` holds the rules. `countByStatus(statuses)` counts the statuses and ignores a locale with no status. `worstStatus(counts)` applies `STATUS_PRECEDENCE`, which is worst first: `stale` > `new` > `translated` > `verified`. Every roll-up in the Tracker UI uses this module: the rollup ring, the screen-reader breakdown, the locale column, the status filter counts, and sort by status. The glyphs, label tokens and display order are presentation. They are in one Tracker table, `shared/translation-status/translation-status-presentation.ts`, which the rows and the translation editor's status labels both use. Explained in context: [`frontend.md`](frontend.md#translation-status-summary) diff --git a/architecture-docs/user-flows.md b/architecture-docs/user-flows.md index 5cef7eeb..1bf9280a 100644 --- a/architecture-docs/user-flows.md +++ b/architecture-docs/user-flows.md @@ -292,7 +292,7 @@ sequenceDiagram API-->>BS: UpdateResourceResponseDto { resource: ResourceSummaryDto } Note over BS: F. Cache patch (no re-fetch) - BS->>BS: patch translations[] (relative key) and searchResults[] (full key) + BS->>BS: patch translations[] and searchResults[] (both by fullKey) Note right of BS: Uses the API response payload.
No second HTTP request. BS-->>Dialog: response Dialog-->>TLS: afterClosed() → { success: true, resource, folderPath } @@ -409,8 +409,8 @@ sequenceDiagram BS->>API: POST /api/collections/{name}/folders/move API-->>BS: MoveFolderResponseDto BS->>BS: rebaseFolderPaths(sourceNode, destinationFolderPath)
insertFolderIntoTree(rootFolders, rebasedFolder, dest) - BS->>BS: retry GET /tree for movedFolderPath (up to 5×, 1 s delay) - Note right of BS: Folder move clears API cache;
retry waits for READY before loading translations. + BS->>BS: GET /tree for movedFolderPath via BrowserApiService + Note right of BS: Folder move clears API cache;
BrowserApiService retries a 202 (up to 5×, 1 s delay)
before handing the tree over. alt API call fails API-->>BS: HTTP error BS->>BS: patchState({ rootFolders: snapshotFolders })
isDisabled=false, isDeletingFolder=false @@ -465,7 +465,7 @@ flowchart TD TREE_RESPONSE -- "ResourceTreeDto\n(200 OK, cache READY)" --> POPULATE["patchState({\n rootFolders: treeData.children,\n translations: treeData.resources,\n currentFolderPath: ''\n})\nIndexingOverlay hidden"] - TREE_RESPONSE -- "TreeStatusResponseDto\n(202, not ready yet)" --> LOAD_TREE_RETRY["No-op — status still 'indexing'\nNext interval tick will retry"] + TREE_RESPONSE -- "TreeStatusResponseDto\n(202, not ready yet)" --> LOAD_TREE_RETRY["BrowserApiService.getResourceTree\nasks again (up to 5×, 1 s apart);\nthe store only ever receives a tree"] LOAD_TREE_RETRY --> POLL_START diff --git a/libs/core/src/lib/resource/search.ts b/libs/core/src/lib/resource/search.ts index 7c1ccb3b..ce140c0b 100644 --- a/libs/core/src/lib/resource/search.ts +++ b/libs/core/src/lib/resource/search.ts @@ -1,6 +1,7 @@ import { walkFolders } from '../normalize/iterative-folder-walker'; import { openResourceFolder, translationLocales } from './resource-folder'; import type { TranslationStatus } from '@simoncodes-ca/domain'; +import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; import type { ResourceTreeNode } from './load-resource-tree'; /** @@ -20,12 +21,18 @@ export interface SearchResult { /** Full dot-delimited key path */ key: string; + /** Base locale value (the entry's `source`) */ + source: string; + /** Translation values for all locales */ translations: Record; /** Translation status for each locale */ status: Record; + /** The entry's stored metadata, keyed by locale (read by the Resource Summary builder) */ + metadata: ResourceEntryMetadata; + /** Type of match found */ matchType: MatchType; @@ -157,8 +164,10 @@ export function searchTranslations(params: SearchParams): SearchResult[] { results.push({ key: fullKey, + source: entry.source, translations, status, + metadata: meta, matchType, matchedLocales: matchedLocales.length > 0 ? matchedLocales : undefined, comment: entry.comment, @@ -322,8 +331,10 @@ export function searchResourceTree(params: SearchTreeParams): SearchResult[] { results.push({ key: fullKey, + source: entry.source, translations, status, + metadata: entry.metadata, matchType, matchedLocales: matchedLocales.length > 0 ? matchedLocales : undefined, comment: entry.comment, diff --git a/libs/data-transfer/src/lib/resource-tree.dto.ts b/libs/data-transfer/src/lib/resource-tree.dto.ts index 3f90b8a7..a67f1691 100644 --- a/libs/data-transfer/src/lib/resource-tree.dto.ts +++ b/libs/data-transfer/src/lib/resource-tree.dto.ts @@ -1,35 +1,25 @@ -import type { TranslationStatus } from './translation-status'; +import type { ResourceSummary, ResourceSummaryTarget } from '@simoncodes-ca/domain'; export interface ResourceTreeDto { /** Current folder path (dot-delimited, empty string for root) */ path: string; - /** Resources in this folder */ + /** Resources in this folder (with `includeNested`, also every resource below it) */ resources: ResourceSummaryDto[]; /** Child folders (loaded or unloaded based on depth) */ children: FolderNodeDto[]; } -export interface ResourceSummaryDto { - /** Entry key within folder (not full path) */ - key: string; +/** + * One resource entry: explicit address (`fullKey`, `folderPath`, `entryKey`), the base + * value, and one row per target locale of the collection (in collection order) with + * `needsWork` and `sameAsBase` already decided. Declared once, in domain (Resource Summary). + */ +export type ResourceSummaryDto = ResourceSummary; - /** Translation values per locale (includes source locale) */ - translations: Record; - - /** Translation status per locale (undefined for base locale) */ - status: Record; - - /** Optional comment/note for translators */ - comment?: string; - - /** Optional tags for categorization/filtering */ - tags?: string[]; - - /** Tags inherited from the parent collection config (read-only on the resource) */ - inheritedTags?: string[]; -} +/** One target locale of a {@link ResourceSummaryDto}. */ +export type ResourceSummaryTargetDto = ResourceSummaryTarget; export interface FolderNodeDto { /** Folder name (single segment, not full path) */ diff --git a/libs/data-transfer/src/lib/search-result.dto.ts b/libs/data-transfer/src/lib/search-result.dto.ts index d128a4be..b4900bee 100644 --- a/libs/data-transfer/src/lib/search-result.dto.ts +++ b/libs/data-transfer/src/lib/search-result.dto.ts @@ -6,9 +6,7 @@ import type { ResourceSummaryDto } from './resource-tree.dto'; export type MatchType = 'exact-key' | 'partial-key' | 'exact-value' | 'partial-value'; /** - * DTO for a single search result. - * Extends ResourceSummaryDto to maintain compatibility with translation display. - * The key field contains the full dot-delimited path for search results. + * DTO for a single search result: a Resource Summary plus how it matched. */ export interface SearchResultDto extends ResourceSummaryDto { /** Type of match found */ diff --git a/libs/domain/src/index.spec.ts b/libs/domain/src/index.spec.ts index aeb41cf1..d6378fda 100644 --- a/libs/domain/src/index.spec.ts +++ b/libs/domain/src/index.spec.ts @@ -12,6 +12,7 @@ describe('domain public surface', () => { 'applyPreferredTerm', 'autoFixICUPlaceholders', 'autoFixTranslocoPlaceholders', + 'buildResourceSummary', 'classifyICUContent', 'compareIcuArguments', 'countByStatus', @@ -50,6 +51,7 @@ describe('domain public surface', () => { 'resolveResourceKey', 'sortPreferredTermRules', 'splitResolvedKey', + 'summaryTarget', 'translocoToICU', 'validateICUSyntax', 'validateImportKey', diff --git a/libs/domain/src/index.ts b/libs/domain/src/index.ts index e00b41b6..b8528eaf 100644 --- a/libs/domain/src/index.ts +++ b/libs/domain/src/index.ts @@ -29,6 +29,16 @@ export { resolveImportStatus, } from './lib/staleness'; +// Resource Summary: one entry with an explicit address and per-target verdicts +export { + buildResourceSummary, + type ResourceSummary, + type ResourceSummaryCollection, + type ResourceSummaryEntry, + type ResourceSummaryTarget, + summaryTarget, +} from './lib/resource-summary'; + // Status summary: roll-ups over many statuses export { countByStatus, STATUS_PRECEDENCE, type StatusCounts, worstStatus } from './lib/translation-status-summary'; diff --git a/libs/domain/src/lib/resource-summary.spec.ts b/libs/domain/src/lib/resource-summary.spec.ts new file mode 100644 index 00000000..5a8dc1d3 --- /dev/null +++ b/libs/domain/src/lib/resource-summary.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { buildResourceSummary, type ResourceSummaryEntry, summaryTarget } from './resource-summary'; + +const collection = { baseLocale: 'en', targetLocales: ['fr', 'de', 'es'], tags: ['ui'] }; + +function entry(overrides: Partial = {}): ResourceSummaryEntry { + return { + source: 'OK', + translations: { fr: "D'accord", de: 'OK' }, + metadata: { + en: { checksum: 'base' }, + fr: { checksum: 'fr', baseChecksum: 'base', status: 'verified' }, + de: { checksum: 'base', baseChecksum: 'base', status: 'new' }, + }, + ...overrides, + }; +} + +describe('buildResourceSummary', () => { + it('gives the entry an explicit address', () => { + const summary = buildResourceSummary('apps.common.buttons.ok', entry(), collection); + + expect(summary.fullKey).toBe('apps.common.buttons.ok'); + expect(summary.folderPath).toBe('apps.common.buttons'); + expect(summary.entryKey).toBe('ok'); + }); + + it('addresses a root entry with an empty folder path', () => { + const summary = buildResourceSummary('ok', entry(), collection); + + expect(summary.folderPath).toBe(''); + expect(summary.entryKey).toBe('ok'); + }); + + it('takes the base locale from the collection, not from the metadata', () => { + // Metadata whose shape would suggest "fr" is the base (no status, no baseChecksum). + const misleading = entry({ metadata: { fr: { checksum: 'fr' }, en: { checksum: 'base' } } }); + + expect(buildResourceSummary('ok', misleading, collection).base).toEqual({ locale: 'en', value: 'OK' }); + }); + + it('lists every target locale in collection order, even without a value', () => { + const summary = buildResourceSummary('ok', entry(), collection); + + expect(summary.targets.map((target) => target.locale)).toEqual(['fr', 'de', 'es']); + expect(summary.targets[2]).toEqual({ + locale: 'es', + value: undefined, + status: undefined, + needsWork: true, + sameAsBase: false, + }); + }); + + it('ignores values for locales the collection does not target', () => { + const summary = buildResourceSummary('ok', entry({ translations: { fr: 'x', it: 'y', en: 'OK' } }), collection); + + expect(summary.targets.map((target) => target.locale)).toEqual(['fr', 'de', 'es']); + }); + + it('applies the Staleness rule for needsWork', () => { + const summary = buildResourceSummary( + 'ok', + entry({ + metadata: { + fr: { checksum: 'a', status: 'stale' }, + de: { checksum: 'b', status: 'translated' }, + es: { checksum: 'c', status: 'new' }, + }, + }), + collection, + ); + + expect(summary.targets.map((target) => [target.locale, target.status, target.needsWork])).toEqual([ + ['fr', 'stale', true], + ['de', 'translated', false], + ['es', 'new', true], + ]); + }); + + it('flags a value that is the base value verbatim, compared trimmed', () => { + const summary = buildResourceSummary( + 'ok', + entry({ source: 'Save ', translations: { fr: 'Save', de: 'Speichern', es: ' ' } }), + collection, + ); + + expect(summary.targets.map((target) => [target.locale, target.sameAsBase])).toEqual([ + ['fr', true], + ['de', false], + ['es', false], + ]); + }); + + it('keeps own tags and collection tags apart, and the comment when present', () => { + const withDetails = buildResourceSummary('ok', entry({ tags: ['button'], comment: 'Primary action' }), collection); + const plain = buildResourceSummary('ok', entry(), { ...collection, tags: [] }); + + expect(withDetails).toMatchObject({ tags: ['button'], inheritedTags: ['ui'], comment: 'Primary action' }); + expect(plain.tags).toEqual([]); + expect(plain.inheritedTags).toEqual([]); + expect('comment' in plain).toBe(false); + }); +}); + +describe('summaryTarget', () => { + const summary = buildResourceSummary('ok', entry(), collection); + + it('finds a target by locale', () => { + expect(summaryTarget(summary, 'fr')?.status).toBe('verified'); + }); + + it('is undefined for the base locale and for untargeted locales', () => { + expect(summaryTarget(summary, 'en')).toBeUndefined(); + expect(summaryTarget(summary, 'it')).toBeUndefined(); + }); +}); diff --git a/libs/domain/src/lib/resource-summary.ts b/libs/domain/src/lib/resource-summary.ts new file mode 100644 index 00000000..4e3a2397 --- /dev/null +++ b/libs/domain/src/lib/resource-summary.ts @@ -0,0 +1,109 @@ +import type { LocaleMetadata } from './locale-metadata'; +import { splitResolvedKey } from './resource-key'; +import { isUntranslatedCopy, needsTranslation } from './staleness'; +import type { TranslationStatus } from './translation-status'; + +/** + * Resource Summary — one resource entry as every reader (API, Tracker) sees it: + * an explicit address, the base value, and one row per target locale with the + * Staleness rule's verdicts already applied. + * + * The base locale and the target locales come from the collection, never from + * the shape of the entry's metadata. + * + * Pure: no Node.js dependencies. JSON-shaped, so it is also the API's + * `ResourceSummaryDto`. + */ + +/** One target locale of a Resource Summary. */ +export interface ResourceSummaryTarget { + readonly locale: string; + /** The stored value; absent when the entry has none for this locale. */ + readonly value?: string; + /** The stored status; absent when the entry has no metadata for this locale. */ + readonly status?: TranslationStatus; + /** The locale needs (machine or human) translation: the Staleness rule's `needsTranslation`. */ + readonly needsWork: boolean; + /** + * The value is the base value verbatim (`isUntranslatedCopy`, compared trimmed). + * A checksum cannot tell this apart from finished work, so readers say it out loud. + * An empty or missing value is never "same as base". + */ + readonly sameAsBase: boolean; +} + +export interface ResourceSummary { + /** Full dot-delimited key, e.g. `apps.common.buttons.ok`. */ + readonly fullKey: string; + /** Dot-delimited folder the entry lives in, e.g. `apps.common.buttons`; `''` at the collection root. */ + readonly folderPath: string; + /** The entry's own key inside `folderPath`, e.g. `ok`. */ + readonly entryKey: string; + /** The collection's base locale and the entry's value for it. */ + readonly base: { readonly locale: string; readonly value: string }; + /** Every target locale of the collection, in collection order, whether or not the entry has a value for it. */ + readonly targets: readonly ResourceSummaryTarget[]; + readonly comment?: string; + /** The resource's own tags. */ + readonly tags: readonly string[]; + /** Tags inherited from the collection (read-only on the resource). */ + readonly inheritedTags: readonly string[]; +} + +/** What the builder reads from a stored entry. Core's `ResourceTreeEntry` fits. */ +export interface ResourceSummaryEntry { + readonly source: string; + /** Values keyed by locale. Other keys (for example the base locale) are ignored. */ + readonly translations: Readonly>; + readonly metadata: Readonly>; + readonly comment?: string; + readonly tags?: readonly string[]; +} + +/** What the builder reads from the collection. Core's resolved `Collection` fits. */ +export interface ResourceSummaryCollection { + readonly baseLocale: string; + readonly targetLocales: readonly string[]; + readonly tags: readonly string[]; +} + +/** Builds the Resource Summary of the entry stored at `fullKey`. */ +export function buildResourceSummary( + fullKey: string, + entry: ResourceSummaryEntry, + collection: ResourceSummaryCollection, +): ResourceSummary { + const { folderPath, entryKey } = splitResolvedKey(fullKey); + const baseValue = entry.source; + + return { + fullKey, + folderPath: folderPath.join('.'), + entryKey, + base: { locale: collection.baseLocale, value: baseValue }, + targets: collection.targetLocales.map((locale) => { + const value = entry.translations[locale]; + const meta = entry.metadata[locale]; + return { + locale, + value, + status: meta?.status, + needsWork: needsTranslation(meta), + sameAsBase: isSameAsBase(value, baseValue), + }; + }), + ...(entry.comment !== undefined && { comment: entry.comment }), + tags: [...(entry.tags ?? [])], + inheritedTags: [...collection.tags], + }; +} + +/** The summary's row for `locale`; `undefined` for the base locale or a locale the collection does not target. */ +export function summaryTarget(summary: ResourceSummary, locale: string): ResourceSummaryTarget | undefined { + return summary.targets.find((target) => target.locale === locale); +} + +function isSameAsBase(value: string | undefined, baseValue: string): boolean { + const trimmed = value?.trim() ?? ''; + return trimmed.length > 0 && isUntranslatedCopy(trimmed, baseValue.trim()); +} From 51db74de08927a62bc405bc2ce8e640e4592d83e Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 14:14:55 -0700 Subject: [PATCH 11/20] refactor(core): one Collection Reader for every whole-collection read readCollection(collection) in lib/resource/read-collection.ts walks a collection's translations folder through ResourceFolder, opened with the collection's own base locale, and returns StoredResource[] (fullKey, folderPath, entryKey, entry, effectiveTags) plus the folders it could not read. readCollectionFolders is the lazy per-folder walk behind it, also used by loadResourceTree and searchTranslations. It replaces the walkers in export-common, bundle/resource-loader and search. One set of read rules: hidden folders skipped; a missing translations folder is an empty collection; a folder that cannot be listed or parsed is reported as a problem and skipped; an entry with no metadata record is read with metadata {} (counts as new); effective tags are united here and callers no longer recompute them. validateResources(collections, options) validates each collection with its own base locale and target locales and no longer dedupes keys across collections. CLI validate opens collections with openCollection and stops reading the global baseLocale/locales. Bundle and type generation read each collection at its own base locale; COLLECTION_BASE_LOCALE stands in for "each collection's base" in the plan's key set and the debug-keys bundle. Behaviour changes: - validate: per-collection locales (more checks), a key in two collections is validated in both, missing metadata fails as new, unreadable folders fail the run; totalUniqueKeys counts per collection. - export: metadata-less entries exported as new; hidden folders skipped; unreadable folders listed under malformedFiles; a missing translations folder is a warning. - bundle/types/plan: base value from each collection's own baseLocale. - translate-locale: entries with no metadata record are now translated. - tree: entries without metadata appear; an unlistable subfolder is an empty node instead of failing the index. Also: locale-spec-helpers.ts renamed to locale.spec-helpers.ts and excluded from the lib build so vitest is no longer compiled into core; shared temp-dir fixture helper for core specs; reader, loader, export, tree and validate specs use real temp directories instead of fs mocks. Co-Authored-By: Claude Fable 5.1 --- .../cache/collection-index.service.spec.ts | 1 + .../src/app/cache/collection-index.service.ts | 1 + apps/cli/src/commands/glossary.spec.ts | 109 ++- apps/cli/src/commands/glossary.ts | 34 +- apps/cli/src/commands/validate.icu.test.ts | 14 +- apps/cli/src/commands/validate.test.ts | 135 ++-- apps/cli/src/commands/validate.ts | 64 +- architecture-docs/bundle-generation.md | 8 +- architecture-docs/cli.md | 16 +- architecture-docs/core-library.md | 80 +- architecture-docs/glossary.md | 10 +- architecture-docs/user-flows.md | 6 +- docs/cli.md | 16 +- .../add-locale-to-collection.spec.ts | 2 +- ...rs.spec.ts => locale.spec-helpers.spec.ts} | 4 +- ...spec-helpers.ts => locale.spec-helpers.ts} | 0 .../remove-locale-from-collection.spec.ts | 2 +- libs/core/src/index.ts | 11 +- .../src/lib/bundle/generate-bundle.spec.ts | 29 +- libs/core/src/lib/bundle/generate-bundle.ts | 64 +- .../bundle/mixed-base-locale.real-fs.spec.ts | 78 ++ libs/core/src/lib/bundle/plan-bundle.spec.ts | 4 +- libs/core/src/lib/bundle/plan-bundle.ts | 42 +- .../src/lib/bundle/resource-loader.spec.ts | 255 ++----- libs/core/src/lib/bundle/resource-loader.ts | 111 ++- libs/core/src/lib/bundle/tag-filter.ts | 2 +- .../bundle/type-generation/generate-types.ts | 22 +- .../core/src/lib/export/export-common.spec.ts | 184 ++--- libs/core/src/lib/export/export-common.ts | 133 +--- libs/core/src/lib/export/run-export.spec.ts | 55 +- libs/core/src/lib/export/run-export.ts | 29 +- .../iterative-folder-walker.real-fs.spec.ts | 33 + .../lib/normalize/iterative-folder-walker.ts | 10 +- libs/core/src/lib/resource/index.ts | 7 + .../lib/resource/load-resource-tree.spec.ts | 300 +++----- .../src/lib/resource/load-resource-tree.ts | 184 ++--- .../resource/read-collection.real-fs.spec.ts | 195 +++++ libs/core/src/lib/resource/read-collection.ts | 199 +++++ .../src/lib/resource/resource-folder.spec.ts | 14 +- libs/core/src/lib/resource/resource-folder.ts | 12 +- libs/core/src/lib/resource/search.spec.ts | 34 +- libs/core/src/lib/resource/search.ts | 156 ++-- .../src/lib/translation/translate-locale.ts | 2 +- .../generate-validation-summary.spec.ts | 32 + .../validate/generate-validation-summary.ts | 30 + libs/core/src/lib/validate/types.ts | 69 +- .../src/lib/validate/validate-icu.spec.ts | 1 + libs/core/src/lib/validate/validate-icu.ts | 8 +- .../validate/validate-placeholders.spec.ts | 1 + .../lib/validate/validate-resources.spec.ts | 720 +++++------------- .../src/lib/validate/validate-resources.ts | 157 ++-- .../lib/validate/validate-terminology.spec.ts | 1 + .../src/lib/validate/validate-terminology.ts | 11 +- .../core/src/testing/temp-dir.spec-helpers.ts | 107 +++ libs/core/tsconfig.lib.json | 1 + 55 files changed, 1994 insertions(+), 1811 deletions(-) rename libs/core/src/collections-manager/{locale-spec-helpers.spec.ts => locale.spec-helpers.spec.ts} (78%) rename libs/core/src/collections-manager/{locale-spec-helpers.ts => locale.spec-helpers.ts} (100%) create mode 100644 libs/core/src/lib/bundle/mixed-base-locale.real-fs.spec.ts create mode 100644 libs/core/src/lib/resource/read-collection.real-fs.spec.ts create mode 100644 libs/core/src/lib/resource/read-collection.ts create mode 100644 libs/core/src/testing/temp-dir.spec-helpers.ts diff --git a/apps/api/src/app/cache/collection-index.service.spec.ts b/apps/api/src/app/cache/collection-index.service.spec.ts index 05c3933b..513ac112 100644 --- a/apps/api/src/app/cache/collection-index.service.spec.ts +++ b/apps/api/src/app/cache/collection-index.service.spec.ts @@ -70,6 +70,7 @@ describe('CollectionIndex', () => { function expectIndexMatchesDisk(target: Collection = collection()): void { const fromDisk = loadResourceTree({ translationsFolder: target.translationsFolder, + baseLocale: target.baseLocale, depth: Number.POSITIVE_INFINITY, }); expect(normalized(readyTree(target))).toEqual(normalized(fromDisk)); diff --git a/apps/api/src/app/cache/collection-index.service.ts b/apps/api/src/app/cache/collection-index.service.ts index a19d6fcf..d09b2955 100644 --- a/apps/api/src/app/cache/collection-index.service.ts +++ b/apps/api/src/app/cache/collection-index.service.ts @@ -235,6 +235,7 @@ export class CollectionIndex { entry.fingerprint = computeTreeFingerprint({ translationsFolder: collection.translationsFolder }); entry.tree = loadResourceTree({ translationsFolder: collection.translationsFolder, + baseLocale: collection.baseLocale, path: '', depth: Number.POSITIVE_INFINITY, }); diff --git a/apps/cli/src/commands/glossary.spec.ts b/apps/cli/src/commands/glossary.spec.ts index 6791a37b..59758968 100644 --- a/apps/cli/src/commands/glossary.spec.ts +++ b/apps/cli/src/commands/glossary.spec.ts @@ -10,7 +10,7 @@ vi.mock('@simoncodes-ca/core', async (importOriginal) => { ConfigParseError: actual.ConfigParseError, CollectionNotFoundError: actual.CollectionNotFoundError, ReadOnlyCollectionError: actual.ReadOnlyCollectionError, - loadResourcesFromCollections: vi.fn(), + readCollection: vi.fn(), }; }); @@ -40,28 +40,44 @@ vi.mock('fs', async (importOriginal) => { }); import * as fs from 'fs'; -import { type LingoTrackerConfig, loadResourcesFromCollections, openCollection } from '@simoncodes-ca/core'; +import { + type CollectionRead, + type LingoTrackerConfig, + openCollection, + readCollection, + type StoredResource, +} from '@simoncodes-ca/core'; +import type { TranslationStatus } from '@simoncodes-ca/domain'; import { loadConfiguration, resolveCollection } from '../utils'; import { glossaryCommand } from './glossary'; -const LOADED = [ - { - key: 'save', - fullKey: 'save', - source: 'Save', - translations: { fr: 'Enregistrer' }, - status: { fr: 'verified' }, - collection: 'app', - }, - { - key: 'settings', - fullKey: 'settings', - source: 'Settings', - translations: { fr: 'Paramètres' }, - status: { fr: 'translated' }, - collection: 'app', - }, -]; +/** A root-level stored resource with one status per translated locale. */ +function stored( + key: string, + source: string, + translations: Record, + status: Record, +): StoredResource { + const metadata = Object.fromEntries( + Object.entries(status).map(([locale, localeStatus]) => [locale, { checksum: 'x', status: localeStatus }]), + ); + return { + fullKey: key, + folderPath: '', + entryKey: key, + entry: { key, source, translations, metadata }, + effectiveTags: [], + }; +} + +function read(...resources: StoredResource[]): CollectionRead { + return { resources, problems: [] }; +} + +const LOADED = read( + stored('save', 'Save', { fr: 'Enregistrer' }, { fr: 'verified' }), + stored('settings', 'Settings', { fr: 'Paramètres' }, { fr: 'translated' }), +); const LOADED_CONFIG = { config: { @@ -89,13 +105,13 @@ describe('glossaryCommand', () => { }); (process.stdin as unknown as { isTTY: boolean }).isTTY = true; vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG as never); - vi.mocked(loadResourcesFromCollections).mockReturnValue(LOADED as never); + vi.mocked(readCollection).mockReturnValue(LOADED); }); it('returns early when configuration is missing', async () => { vi.mocked(loadConfiguration).mockReturnValue(null); await glossaryCommand({ text: 'Save' }); - expect(loadResourcesFromCollections).not.toHaveBeenCalled(); + expect(readCollection).not.toHaveBeenCalled(); }); it('exits when no input is provided', async () => { @@ -179,16 +195,7 @@ describe('glossaryCommand', () => { }); it('excludes stale/new entries by default but includes them with --include-all', async () => { - vi.mocked(loadResourcesFromCollections).mockReturnValue([ - { - key: 'save', - fullKey: 'save', - source: 'Save', - translations: { fr: 'Enregistrer' }, - status: { fr: 'stale' }, - collection: 'app', - }, - ] as never); + vi.mocked(readCollection).mockReturnValue(read(stored('save', 'Save', { fr: 'Enregistrer' }, { fr: 'stale' }))); await glossaryCommand({ text: 'Save' }); expect(JSON.parse(writtenContent()).matchCount).toBe(0); @@ -207,16 +214,9 @@ describe('glossaryCommand', () => { configPath: '/p/.lingo-tracker.json', cwd: '/p', } as never); - vi.mocked(loadResourcesFromCollections).mockReturnValue([ - { - key: 'save', - fullKey: 'save', - source: 'Enregistrer', - translations: { fr: 'Enregistrer', es: 'Guardar' }, - status: { fr: 'verified', es: 'verified' }, - collection: 'app', - }, - ] as never); + vi.mocked(readCollection).mockReturnValue( + read(stored('save', 'Enregistrer', { fr: 'Enregistrer', es: 'Guardar' }, { fr: 'verified', es: 'verified' })), + ); await glossaryCommand({ text: 'Enregistrer' }); const out = JSON.parse(writtenContent()); @@ -224,6 +224,31 @@ describe('glossaryCommand', () => { expect(out.terms[0].translations).toEqual({ es: 'Guardar' }); }); + it('reports an unreadable folder on stderr and keeps the readable entries', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(readCollection).mockReturnValue({ + resources: LOADED.resources, + problems: [{ folderPath: 'bad', absolutePath: '/project/i18n/bad', message: 'Failed to parse JSON file x' }], + }); + + await glossaryCommand({ text: 'Save', stdout: true }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Collection 'app': skipped unreadable folder")); + const printed = vi.mocked(process.stdout.write).mock.calls[0][0] as string; + expect(JSON.parse(printed).matchCount).toBe(1); + }); + + it('reads a null locale metadata record as no status instead of crashing', async () => { + const entry = stored('save', 'Save', { fr: 'Enregistrer' }, {}); + // A hand-edited tracker_meta.json can hold `"fr": null`. + const withNullMeta: StoredResource = { ...entry, entry: { ...entry.entry, metadata: JSON.parse('{"fr": null}') } }; + vi.mocked(readCollection).mockReturnValue(read(withNullMeta)); + + await glossaryCommand({ text: 'Save', includeAll: true }); + + expect(JSON.parse(writtenContent()).matchCount).toBe(1); + }); + it('exits with a clear error for the unimplemented ai extractor', async () => { await expect(glossaryCommand({ text: 'Save', extractor: 'ai' })).rejects.toThrow('process.exit(1)'); }); diff --git a/apps/cli/src/commands/glossary.ts b/apps/cli/src/commands/glossary.ts index 5863b97f..9040a5fd 100644 --- a/apps/cli/src/commands/glossary.ts +++ b/apps/cli/src/commands/glossary.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { loadResourcesFromCollections, openCollection } from '@simoncodes-ca/core'; +import { openCollection, readCollection } from '@simoncodes-ca/core'; import type { Collection, LingoTrackerConfig } from '@simoncodes-ca/core'; import { ConsoleFormatter, loadConfiguration, parseCommaSeparatedList, resolveCollection } from '../utils'; import { exitWithError } from '../utils/report-error'; @@ -59,9 +59,9 @@ function resolveInputText(options: GlossaryCommandOptions, cwd: string): string } /** - * Loads entries from the requested collection(s) via the shared core loader, - * mapping each `LoadedResource` to the matcher's `FlatEntry`. Each collection's - * effective base locale is stripped from `translations` (it lives in `source`). + * Loads entries from the requested collection(s) through the core Collection Reader, + * mapping each stored resource to the matcher's `FlatEntry`. A folder that cannot be read + * is reported as a warning and its entries are left out. * Returns null if a named collection cannot be resolved. */ function loadEntries(options: GlossaryCommandOptions, config: LingoTrackerConfig, cwd: string): FlatEntry[] | null { @@ -75,18 +75,20 @@ function loadEntries(options: GlossaryCommandOptions, config: LingoTrackerConfig } const entries: FlatEntry[] = []; - for (const { name, translationsFolder, baseLocale } of targets) { - const loaded = loadResourcesFromCollections([{ name, path: translationsFolder }]); - for (const resource of loaded) { - const translations = { ...resource.translations }; - delete translations[baseLocale]; - entries.push({ - key: resource.fullKey, - collection: resource.collection, - source: resource.source, - translations, - status: resource.status, - }); + for (const collection of targets) { + const { resources, problems } = readCollection(collection); + // stderr, so --stdout output stays valid JSON. + for (const problem of problems) { + console.warn(`⚠️ Collection '${collection.name}': skipped unreadable folder: ${problem.message}`); + } + for (const { fullKey, entry } of resources) { + const translations = { ...entry.translations }; + delete translations[collection.baseLocale]; + const status: FlatEntry['status'] = {}; + for (const [locale, meta] of Object.entries(entry.metadata)) { + if (meta?.status) status[locale] = meta.status; + } + entries.push({ key: fullKey, collection: collection.name, source: entry.source, translations, status }); } } return entries; diff --git a/apps/cli/src/commands/validate.icu.test.ts b/apps/cli/src/commands/validate.icu.test.ts index 5d4b4d3f..d6019aba 100644 --- a/apps/cli/src/commands/validate.icu.test.ts +++ b/apps/cli/src/commands/validate.icu.test.ts @@ -49,7 +49,7 @@ const CONFIG = { /** The ICU options `validateResources` was called with. */ function icuOptions() { - return mockValidateResources.mock.calls[0]?.[2].icu; + return mockValidateResources.mock.calls[0]?.[1].icu; } describe('validateCommand ICU options', () => { @@ -89,10 +89,11 @@ describe('validateCommand ICU options', () => { expect(icuOptions()).toBeDefined(); }); - it('includes the base locale, whose value is copied into every translation slot', async () => { + it('leaves base-locale compilation to each collection in core', async () => { await validateCommand({}); - expect(icuOptions()?.baseLocale).toBe('en'); + // Core compiles each collection's base values under that collection's own base locale. + expect(icuOptions()).not.toHaveProperty('baseLocale'); }); it('leaves the portability rule off unless asked', async () => { @@ -131,13 +132,14 @@ describe('validateCommand ICU options', () => { // the ICU pass still covers the source value every translation copies. await validateCommand({ skipLocales: ['en'] }); - expect(icuOptions()?.baseLocale).toBe('en'); - expect(mockValidateResources.mock.calls[0]?.[1]).toEqual(['fr', 'es']); + expect(mockValidateResources.mock.calls[0]?.[1].skippedLocales).toEqual([]); + expect(mockValidateResources.mock.calls[0]?.[0]?.[0]?.targetLocales).toEqual(['fr', 'es']); }); it('does not check a skipped target locale', async () => { await validateCommand({ skipLocales: ['es'] }); - expect(mockValidateResources.mock.calls[0]?.[1]).toEqual(['fr']); + expect(mockValidateResources.mock.calls[0]?.[1].skippedLocales).toEqual(['es']); + expect(mockValidateResources.mock.calls[0]?.[0]?.[0]?.targetLocales).toEqual(['fr', 'es']); }); }); diff --git a/apps/cli/src/commands/validate.test.ts b/apps/cli/src/commands/validate.test.ts index 790cf3fe..7e9e2b2e 100644 --- a/apps/cli/src/commands/validate.test.ts +++ b/apps/cli/src/commands/validate.test.ts @@ -1,5 +1,4 @@ import * as fs from 'node:fs'; -import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { validateCommand } from './validate'; @@ -134,7 +133,9 @@ describe('validateCommand', () => { await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); expect(console.error).toHaveBeenCalledWith('❌ No target locales found in configuration.'); - expect(console.error).toHaveBeenCalledWith('Target locales are all configured locales except the base locale.'); + expect(console.error).toHaveBeenCalledWith( + "Target locales are each collection's locales except its base locale.", + ); }); }); @@ -200,30 +201,32 @@ describe('validateCommand', () => { expect(mockValidateResources).toHaveBeenCalledWith( [ - { + expect.objectContaining({ name: 'common', - path: expect.stringContaining(join('translations', 'common')), - }, - { + baseLocale: 'en', + targetLocales: ['fr', 'es', 'de'], + }), + expect.objectContaining({ name: 'admin', - path: expect.stringContaining(join('translations', 'admin')), - }, + baseLocale: 'en', + targetLocales: ['fr', 'es', 'de'], + }), ], - ['fr', 'es', 'de'], { allowTranslated: false, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }, ); expect(mockGenerateValidationSummary).toHaveBeenCalledWith(successResult, { allowTranslated: false, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }); + expect(mockGenerateValidationSummary.mock.calls[0]?.[1]).toBe(mockValidateResources.mock.calls[0]?.[1]); expect(console.log).toHaveBeenCalledWith('Validation summary output'); expect(process.exit).not.toHaveBeenCalled(); @@ -272,10 +275,12 @@ describe('validateCommand', () => { await validateCommand({}); const validateCall = mockValidateResources.mock.calls[0]; - const targetLocales = validateCall[1]; + const collections = validateCall[0]; - expect(targetLocales).toEqual(['fr', 'es', 'de']); - expect(targetLocales).not.toContain('en'); // Base locale should be excluded + expect(collections).toHaveLength(2); + expect(collections[0]?.targetLocales).toEqual(['fr', 'es', 'de']); + expect(collections[1]?.targetLocales).toEqual(['fr', 'es', 'de']); + expect(collections[0]?.targetLocales).not.toContain('en'); // Base locale should be excluded }); }); @@ -328,8 +333,8 @@ describe('validateCommand', () => { expect(mockGenerateValidationSummary).toHaveBeenCalledWith(failureResult, { allowTranslated: false, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }); }); @@ -381,8 +386,8 @@ describe('validateCommand', () => { expect(mockGenerateValidationSummary).toHaveBeenCalledWith(failureResult, { allowTranslated: false, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }); }); @@ -430,11 +435,11 @@ describe('validateCommand', () => { await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); - expect(mockValidateResources).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), { + expect(mockValidateResources).toHaveBeenCalledWith(expect.any(Array), { allowTranslated: false, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }); }); @@ -690,18 +695,18 @@ describe('validateCommand', () => { await validateCommand({ allowTranslated: true }); - expect(mockValidateResources).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), { + expect(mockValidateResources).toHaveBeenCalledWith(expect.any(Array), { allowTranslated: true, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }); expect(mockGenerateValidationSummary).toHaveBeenCalledWith(warningResult, { allowTranslated: true, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }); expect(console.log).toHaveBeenCalledWith('Validation summary output'); @@ -788,11 +793,11 @@ describe('validateCommand', () => { await validateCommand({}); - expect(mockValidateResources).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), { + expect(mockValidateResources).toHaveBeenCalledWith(expect.any(Array), { allowTranslated: false, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }); }); }); @@ -817,8 +822,7 @@ describe('validateCommand', () => { expect(mockValidateResources).toHaveBeenCalledWith( expect.any(Array), - expect.any(Array), - expect.objectContaining({ placeholders: { baseLocale: 'en' } }), + expect.objectContaining({ placeholders: true }), ); }); @@ -829,8 +833,7 @@ describe('validateCommand', () => { expect(mockValidateResources).toHaveBeenCalledWith( expect.any(Array), - expect.any(Array), - expect.objectContaining({ placeholders: undefined }), + expect.objectContaining({ placeholders: false }), ); }); @@ -843,8 +846,7 @@ describe('validateCommand', () => { expect(mockValidateResources).toHaveBeenCalledWith( expect.any(Array), - expect.any(Array), - expect.objectContaining({ placeholders: { baseLocale: 'en' } }), + expect.objectContaining({ placeholders: true }), ); }); }); @@ -1074,11 +1076,13 @@ describe('validateCommand', () => { await validateCommand({ skipLocales: ['fr'] }); - // fr is excluded from the locales passed to validateResources - const localeArg = mockValidateResources.mock.calls[0][1]; - expect(localeArg).not.toContain('fr'); - expect(localeArg).toContain('es'); - expect(localeArg).toContain('de'); + const validationOptions = mockValidateResources.mock.calls[0]?.[1]; + expect(validationOptions).toBeDefined(); + expect(validationOptions?.skippedLocales).toEqual(['fr']); + + const collections = mockValidateResources.mock.calls[0]?.[0]; + expect(collections?.[0]?.targetLocales).toEqual(['fr', 'es', 'de']); + expect(collections?.[1]?.targetLocales).toEqual(['fr', 'es', 'de']); // skippedLocales is forwarded to generateValidationSummary expect(mockGenerateValidationSummary).toHaveBeenCalledWith( @@ -1094,9 +1098,7 @@ describe('validateCommand', () => { expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Skipping unknown locale 'xx'")); - // All original target locales are still validated - const localeArg = mockValidateResources.mock.calls[0][1]; - expect(localeArg).toEqual(['fr', 'es', 'de']); + expect(mockValidateResources.mock.calls[0]?.[1].skippedLocales).toEqual([]); // skippedLocales in summary is empty (unknown locale was not effectively skipped) expect(mockGenerateValidationSummary).toHaveBeenCalledWith( @@ -1113,9 +1115,31 @@ describe('validateCommand', () => { // No warning logged expect(console.warn).not.toHaveBeenCalled(); - // All target locales still validated (base locale was never a target) - const localeArg = mockValidateResources.mock.calls[0][1]; - expect(localeArg).toEqual(['fr', 'es', 'de']); + expect(mockValidateResources.mock.calls[0]?.[1].skippedLocales).toEqual([]); + }); + + it('should accept a locale from a collection override without an unknown-locale warning', async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + ...mockConfig, + collections: { + ...mockConfig.collections, + admin: { translationsFolder: 'translations/admin', locales: ['en', 'ja'] }, + }, + }), + ); + mockValidateResources.mockReturnValue(successResult); + + await validateCommand({ skipLocales: ['ja'] }); + + expect(console.warn).not.toHaveBeenCalledWith(expect.stringContaining("Skipping unknown locale 'ja'")); + expect(mockValidateResources).toHaveBeenCalledWith( + [ + expect.objectContaining({ name: 'common', targetLocales: ['fr', 'es', 'de'] }), + expect.objectContaining({ name: 'admin', targetLocales: ['ja'] }), + ], + expect.objectContaining({ skippedLocales: ['ja'] }), + ); }); it('should exit with code 1 when all target locales are skipped', async () => { @@ -1270,11 +1294,11 @@ describe('validateCommand', () => { expect.any(String), ); expect(mockValidateResources).toHaveBeenCalledWith( - expect.any(Array), - expect.any(Array), - expect.objectContaining({ - terminology: { rules, loadError: undefined, baseLocaleByCollection: { common: 'en', legacy: 'en-GB' } }, - }), + [ + expect.objectContaining({ name: 'common', baseLocale: 'en' }), + expect.objectContaining({ name: 'legacy', baseLocale: 'en-GB' }), + ], + expect.objectContaining({ terminology: { rules, loadError: undefined } }), ); }); @@ -1313,7 +1337,6 @@ describe('validateCommand', () => { await validateCommand({}); expect(mockValidateResources).toHaveBeenCalledWith( - expect.any(Array), expect.any(Array), expect.objectContaining({ terminology: expect.objectContaining({ rules: [], loadError: 'not valid JSON' }), @@ -1335,7 +1358,7 @@ describe('validateCommand', () => { expect(console.warn).toHaveBeenCalledWith( '⚠️ Preferred terminology file not found: /project/terms.json. Treating as an empty list.', ); - expect(mockValidateResources.mock.calls[0]?.[2].terminology).toBeUndefined(); + expect(mockValidateResources.mock.calls[0]?.[1].terminology).toBeUndefined(); expect(process.exit).not.toHaveBeenCalled(); }); @@ -1344,7 +1367,7 @@ describe('validateCommand', () => { await validateCommand({}); - expect(mockValidateResources.mock.calls[0]?.[2].terminology).toBeUndefined(); + expect(mockValidateResources.mock.calls[0]?.[1].terminology).toBeUndefined(); }); }); }); diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index 564ff1e8..02ed6d32 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -2,6 +2,7 @@ import { generateValidationSummary, loadPreferredTerminology, openCollection, + type ValidationOptions, validateResources, } from '@simoncodes-ca/core'; import { loadConfiguration } from '../utils'; @@ -20,9 +21,9 @@ export interface ValidateCommandOptions { allowTranslated?: boolean; /** - * Locales to exclude from validation. Values that are unknown (not in config.locales) - * emit a warning. The base locale is silently ignored. If all target locales are skipped, - * the command exits with code 1. + * Locales to exclude from validation. Values that are no collection's target locale emit a + * warning, unless they are a collection's base locale (silently ignored). If all target + * locales are skipped, the command exits with code 1. */ skipLocales?: readonly string[]; @@ -68,8 +69,8 @@ export interface ValidateCommandOptions { * * **Validation Process:** * 1. Loads configuration from .lingo-tracker.json - * 2. Identifies all collections and target locales - * 3. Validates EVERY resource in EVERY locale (comprehensive check) + * 2. Opens every collection with its own base locale and target locales + * 3. Validates EVERY resource of each collection in EVERY one of its target locales * 4. Collects ALL failures and warnings * 5. Displays complete validation summary * 6. Exits with code 1 if any failures found, 0 if all passed @@ -80,6 +81,7 @@ export interface ValidateCommandOptions { * - 'translated' status → FAILURE (default) or WARNING (with --allow-translated) * - 'verified' status → SUCCESS (translation reviewed and approved) * - Missing metadata → treated as 'new' (FAILURE) + * - Folder whose files cannot be read (malformed JSON) → FAILURE (its resources are not validated) * - Value does not compile as ICU for its own locale → FAILURE (unless --skip-icu) * - Translation interpolates different placeholders than its base value → FAILURE (unless --skip-placeholders) * - Base-locale value uses a discouraged term from the preferred-terminology file → WARNING (never fails) @@ -151,40 +153,38 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< const { config, cwd } = loaded; const collections = Object.keys(config.collections || {}).map((name) => openCollection(config, name, { cwd })); - const allCollections = collections.map(({ name, translationsFolder }) => ({ name, path: translationsFolder })); - if (allCollections.length === 0) { + if (collections.length === 0) { console.error('❌ No collections found in configuration.'); process.exit(1); } - const targetLocales = (config.locales || []).filter((locale: string) => locale !== config.baseLocale); + // Each collection is validated against its own target locales (its locales without its base locale). + const targetLocales = [...new Set(collections.flatMap((collection) => collection.targetLocales))]; if (targetLocales.length === 0) { console.error('❌ No target locales found in configuration.'); - console.error('Target locales are all configured locales except the base locale.'); + console.error("Target locales are each collection's locales except its base locale."); process.exit(1); } - const configuredLocales = new Set(config.locales || []); + const baseLocales = new Set(collections.map((collection) => collection.baseLocale)); const requestedSkip = options.skipLocales ?? []; const effectiveSkipped: string[] = []; for (const locale of requestedSkip) { - if (locale === config.baseLocale) { - // Base locale is already excluded from targetLocales — silently ignore + if (targetLocales.includes(locale)) { + effectiveSkipped.push(locale); continue; } - if (!configuredLocales.has(locale)) { - console.warn(`⚠️ Skipping unknown locale '${locale}' — not in configured locales`); + if (baseLocales.has(locale)) { + // A base locale is never a target, so there is nothing to skip — silently ignore continue; } - effectiveSkipped.push(locale); + console.warn(`⚠️ Skipping unknown locale '${locale}' — not in configured locales`); } - const localesToValidate = targetLocales.filter((l: string) => !effectiveSkipped.includes(l)); - - if (localesToValidate.length === 0) { + if (targetLocales.every((locale) => effectiveSkipped.includes(locale))) { console.error('❌ All target locales were skipped; nothing to validate.'); process.exit(1); } @@ -195,42 +195,30 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< if (preferredTerminology.warning) { console.warn(`⚠️ ${preferredTerminology.warning}`); } - const baseLocaleByCollection = Object.fromEntries(collections.map(({ name, baseLocale }) => [name, baseLocale])); const compileValues = !options.skipIcu; const requirePortablePlurals = options.requirePortablePlurals ?? false; - const validationOptions = { + const validationOptions: ValidationOptions = { allowTranslated: options.allowTranslated ?? false, skippedLocales: effectiveSkipped, // The portability rule is a static parse, not a compilation, so an explicit - // request for it is honoured even alongside --skip-icu. - icu: - compileValues || requirePortablePlurals - ? { - // The base locale carries the source value copied into every - // translation slot, so ICU checks it alongside the targets. - baseLocale: config.baseLocale, - compileValues, - requirePortablePlurals, - } - : undefined, + // request for it is honoured even alongside --skip-icu. Each collection's + // base values are checked alongside its targets: they are copied into every + // translation slot. + icu: compileValues || requirePortablePlurals ? { compileValues, requirePortablePlurals } : undefined, // A renamed placeholder renders as empty text instead of raising, so the // ICU pass above cannot see it and the status gate has no opinion on it. - placeholders: options.skipPlaceholders ? undefined : { baseLocale: config.baseLocale }, + placeholders: !options.skipPlaceholders, // Omitted when there is nothing to check, so a project without rules sees // no terminology output at all. terminology: preferredTerminology.rules.length > 0 || preferredTerminology.error !== undefined - ? { - rules: preferredTerminology.rules, - loadError: preferredTerminology.error, - baseLocaleByCollection, - } + ? { rules: preferredTerminology.rules, loadError: preferredTerminology.error } : undefined, }; - const validationResult = validateResources(allCollections, localesToValidate, validationOptions); + const validationResult = validateResources(collections, validationOptions); const summary = generateValidationSummary(validationResult, validationOptions); diff --git a/architecture-docs/bundle-generation.md b/architecture-docs/bundle-generation.md index 58c0280d..2fb42552 100644 --- a/architecture-docs/bundle-generation.md +++ b/architecture-docs/bundle-generation.md @@ -42,7 +42,7 @@ Bundle generation is a sub-module of `@simoncodes-ca/core`. The entry point is ` ``` libs/core/src/lib/bundle/ ├── generate-bundle.ts # generateBundle(): main entry point, GenerateBundleParams, GenerateBundleResult -├── resource-loader.ts # loadCollectionResources(): flat FlatResource list per locale +├── resource-loader.ts # loadCollectionResources(): one collection's FlatResource list per locale, via readCollection() ├── hierarchy-builder.ts # buildHierarchy(): dot-keys → nested JSON object ├── pattern-matcher.ts # matchesPattern(): glob-style key filtering ├── tag-filter.ts # matchesTags(): AND/OR tag filter logic @@ -200,7 +200,7 @@ flowchart TD FOR_EACH_COLLECTION --> LOAD_RESOURCES - LOAD_RESOURCES["loadCollectionResources()\nWalk translationsFolder via walkFolders()\nFor each resource_entries.json:\n → base locale: read entry.source\n → other locales: read entry[locale]\n → fall back to base if translation absent\nReturns FlatResource[]\n { key, value, tags }"] + LOAD_RESOURCES["loadCollectionResources(collection, locale)\nreadCollection() once per run (cached)\nFor each StoredResource:\n → collection's base locale: entry.source\n → other locales: entry.translations[locale]\n → no value: left out\nUnreadable folder → warning\nReturns FlatResource[]\n { key, value, tags, collectionTags }"] LOAD_RESOURCES --> RULES_ALL{"entriesSelectionRules\n=== 'All'?"} @@ -390,7 +390,9 @@ Output nested JSON (`en.json`): } ``` -**Locale fallback**: if a [resource entry](glossary.md#resource-entry) has no translation for the target locale, `loadCollectionResources()` omits that key from `FlatResource[]` — it does not silently fall back to the base locale value. The base locale value is read from `entry.source`; all other locale values are read from `entry[locale]`. An entry without a value for the requested locale simply does not appear in the bundle. +**Locale fallback**: if a [resource entry](glossary.md#resource-entry) has no translation for the target locale, `loadCollectionResources()` omits that key from `FlatResource[]` — it does not silently fall back to the base locale value. For the collection's own base locale (each collection is opened with `openCollection()`, so a collection can override the global `baseLocale`) the value is `entry.source`; all other locale values are the stored translations. An entry without a value for the requested locale simply does not appear in the bundle. The debug-keys bundle and the dry-run plan's key set (conflicts, example key, types count) read each collection's own base values instead (`COLLECTION_BASE_LOCALE`), so a collection with its own base locale is never left out of them. + +**Reading**: the entries come from the [Collection Reader](glossary.md#collection-reader) (`readCollection()`), read once per collection per run. Its rules apply: entries without metadata are bundled, hidden folders are skipped, and a folder that cannot be read is left out and reported in the bundle result's `warnings`. Selection rules match against the reader's effective tags (collection tags united with the entry's own). ### Debug Key Bundle diff --git a/architecture-docs/cli.md b/architecture-docs/cli.md index ed28d77d..3c325192 100644 --- a/architecture-docs/cli.md +++ b/architecture-docs/cli.md @@ -49,9 +49,9 @@ All commands are registered in `apps/cli/src/main.ts`. Each row below lists the | `bundle` | `--name`, `--locale`, `--verbose`, `--token-casing`, `--token-constant-name`, `--no-transform-icu-to-transloco`, `--debug-keys` | `generateBundle()` | | `export` | `-f/--format`, `-c/--collection`, `-l/--locale`, `-s/--status`, `-t/--tags`, `-o/--output`, `--structure`, `--rich`, `--include-base`, `--include-status`, `--include-comment`, `--include-tags`, `--base-property-name`, `--filename`, `--no-protect-notes`, `--dry-run`, `--verbose` | `runExport()` | | `import` | `-f/--format`, `-s/--source`, `-l/--locale`, `-c/--collection`, `--strategy`, `--update-comments`, `--update-tags`, `--preserve-status`, `--create-missing`, `--validate-base`, `--dry-run`, `--verbose` | `parseJsonImport()` / `parseXliffImport()` → `importResources()` | -| `validate` | `--allow-translated`, `--skip-locales`, `--skip-icu`, `--require-portable-plurals` | `validateResources()`, `generateValidationSummary()` | +| `validate` | `--allow-translated`, `--skip-locales`, `--skip-icu`, `--skip-placeholders`, `--require-portable-plurals` | `openCollection()` for each collection → `validateResources()`, `generateValidationSummary()` | | `find-similar` | `--collection`, `--value`, `--max-results` | `searchTranslations()` | -| `glossary` | `--text`, `--input`, `--output`, `--stdout`, `--collection`, `--locales`, `--include-all`, `--extractor` | `loadResourcesFromCollections()` (matching/extraction done in the command, not core) | +| `glossary` | `--text`, `--input`, `--output`, `--stdout`, `--collection`, `--locales`, `--include-all`, `--extractor` | `readCollection()` (matching/extraction done in the command, not core) | | `protected-terms` | `--collection`, `--add` (repeatable), `--remove` (repeatable), `--set`, `--list`, `--file` | `setGlobalProtectedTerms()` / `setCollectionProtectedTerms()` / `setGlobalProtectedTermsFile()` / `setCollectionProtectedTermsFile()`, reading via `readGlobalProtectedTerms()` / `readCollectionProtectedTerms()` | | `install-skill` | `--collection ` (repeatable), `--dir`, `--token-casing` | No core call — generates a `.claude/` skill file by template | @@ -68,13 +68,21 @@ Both scopes read through the same core helpers. The command itself parses no ter The core layer raises errors for a malformed file, for a collection with no file, and for a missing parent directory. The command catches each one and calls `exitWithError` (prints `❌ `, exits 1). It writes no partial result. +### `validate` locales + +`validate` opens every collection with `openCollection()` and hands the collections to `validateResources()`. Each collection is validated with its own base locale and target locales (its `locales`, else the global `locales`, without its base locale). The command reads no global `baseLocale` or `locales` itself. When no collection has a target locale, the command exits 1. + +`--skip-locales` removes locales from every collection. A locale that is some collection's target locale is skipped. A locale that is only a base locale is ignored without a message. Any other locale gets an `unknown locale` warning. When every target locale is skipped, the command exits 1. + +A folder whose files cannot be read fails validation. The summary lists it under `Unreadable Folders`. + ### `glossary` pipeline -The `glossary` command is intentionally CLI-only (no new core API surface) but reuses the core loader `loadResourcesFromCollections()` (the same flat loader used by `export` and `validate`). Its logic lives in three sibling modules under `apps/cli/src/commands/`: +The `glossary` command is intentionally CLI-only (no new core API surface) but reads resources through the core [Collection Reader](glossary.md#collection-reader), `readCollection()` (the same reader that `export`, `validate` and `bundle` use). Its logic lives in three sibling modules under `apps/cli/src/commands/`: - `glossary-extractor.ts` — the **extraction seam**. `CandidateExtractor = (block) => Candidate[]`, with a deterministic stopword + unigram/bigram default (`ngramExtractor`). `resolveExtractor(mode)` selects the implementation; `ai` is reserved and throws a clear not-implemented error today. This boundary lets an AI-based extractor replace the n-gram one without touching matching/output. - `glossary-matcher.ts` — matches candidates (over `FlatEntry[]`) against base-locale values only, scores (exact > whole-word containment), keeps top-1 per candidate, dedupes across candidates, and applies the per-locale status filter. -- `glossary.ts` — orchestration: resolve input (`--text` → `--input` → stdin), load each collection's resources via `loadResourcesFromCollections()` (stripping each collection's base locale from `translations`), run extractor → matcher, serialize the header + term-array schema, write to a file or stdout. +- `glossary.ts` — orchestration: resolve input (`--text` → `--input` → stdin), read each collection with `readCollection()` (stripping each collection's base locale from `translations`; an unreadable folder is a warning on stderr, so `--stdout` output stays valid JSON), run extractor → matcher, serialize the header + term-array schema, write to a file or stdout. For the full description of what each core function does internally, see [core-library.md](core-library.md). diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index 2a5d13d7..c024d481 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -19,6 +19,7 @@ Return to [architecture README](README.md). - [edit-resource](#edit-resource) - [delete-resource](#delete-resource) - [move-resource](#move-resource) +- [Collection Reader](#collection-reader) - [Normalization Pipeline](#normalization-pipeline) - [Auto-Translation Pipeline](#auto-translation-pipeline) - [Provider abstraction](#provider-abstraction) @@ -64,7 +65,7 @@ libs/core/src/ └── lib/ # Deeper sub-modules ├── bundle/ # Bundle generation pipeline │ ├── generate-bundle.ts # generateBundle(): main entry point - │ ├── resource-loader.ts # loadCollectionResources(): flat resource list per locale + │ ├── resource-loader.ts # loadCollectionResources(): one collection's values for one locale, via readCollection() │ ├── hierarchy-builder.ts # buildHierarchy(): dot-keys → nested JSON object │ ├── pattern-matcher.ts # matchesPattern(): glob-style key filtering │ ├── tag-filter.ts # matchesTags(): AND/OR tag filter logic @@ -78,7 +79,7 @@ libs/core/src/ │ ├── export/ # The Export run │ ├── run-export.ts # runExport(): the Export run; exportTargetLocales() - │ ├── export-common.ts # loadResourcesFromCollections(): shared resource walker; filterResources(); validateBasePropertyName() + │ ├── export-common.ts # loadResources(): one collection via readCollection(), flattened; filterResources(); validateBasePropertyName() │ ├── export-to-json.ts # JSON exporter (internal to runExport) │ ├── export-to-xliff.ts # XLIFF 1.2 exporter (internal to runExport) │ ├── export-summary.ts # Markdown export summary (internal to runExport) @@ -101,7 +102,7 @@ libs/core/src/ │ └── types.ts # ImportRunOptions, ImportResult, ImportedResource, etc. │ ├── validate/ # CI/CD validation pipeline - │ ├── validate-resources.ts # validateResources(): full cross-collection status check + │ ├── validate-resources.ts # validateResources(): status check per collection, with its own locales │ ├── validate-icu.ts # validateIcuValues(): compiles each value under its own locale │ └── generate-validation-summary.ts # Human-readable validation result summary │ @@ -121,6 +122,14 @@ libs/core/src/ │ ├── placeholder-protector.ts # protectPlaceholders() / restorePlaceholders() │ └── translation-orchestrator.ts # Wraps provider call with placeholder protection │ + ├── resource/ # One folder's files, and the read models built on them + │ ├── resource-folder.ts # openResourceFolder(): the Resource Folder (entries + metadata as a unit) + │ ├── read-collection.ts # readCollection(), readCollectionFolders(): the Collection Reader + │ ├── load-resource-tree.ts # loadResourceTree(): the API's resource tree (built on readCollectionFolders) + │ ├── search.ts # searchTranslations() (disk), searchResourceTree() (in memory) + │ ├── resource-mutation.ts # ResourceMutation: what a write changed + │ └── tree-fingerprint.ts # computeTreeFingerprint(): stat-only change detection + │ ├── folder/ # Folder-level filesystem operations │ ├── create-folder.ts # createFolder(): mkdir with segment validation │ ├── delete-folder.ts # deleteFolder(): recursive removal @@ -161,7 +170,7 @@ graph TD FILEIO["file-io/\nreadJsonFile · writeJsonFile\nensureDirectoryExists"] CONFIG_LIB["config/\nloadConfig · openCollection\ncreateConfigFileOperations"] ERRORS["errors/\nErrorMessages"] - RESOURCE_LIB["resource/\nresource-folder\nresource-file-paths\nload-resource-tree"] + RESOURCE_LIB["resource/\nresource-folder · read-collection\nresource-file-paths\nload-resource-tree · search"] end subgraph domain["@simoncodes-ca/domain (peer)"] @@ -200,7 +209,7 @@ graph TD IMPORT --> DOMAIN EXPORT --> FILEIO - EXPORT --> NORMALIZE + EXPORT --> RESOURCE_LIB EXPORT --> DOMAIN VALIDATE --> EXPORT @@ -235,18 +244,19 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that ## Public Surface -`libs/core/src/index.ts` is the [public surface](glossary.md#public-surface): 172 names, listed one by one and grouped by role. It exports only what the API or CLI uses, plus the types in those names' signatures. It does not re-export `domain` names; callers import `TranslationStatus`, `TokenCasing` and `ImportStrategy` from `@simoncodes-ca/domain`. +`libs/core/src/index.ts` is the [public surface](glossary.md#public-surface): 175 names, listed one by one and grouped by role. It exports only what the API or CLI uses, plus the types in those names' signatures. It does not re-export `domain` names; callers import `TranslationStatus`, `TokenCasing` and `ImportStrategy` from `@simoncodes-ca/domain`. | Group | What it holds | |---|---| -| Operations | The entry points the apps call. Resources: `addResource`, `editResource`, `deleteResource`, `moveResource`. Folders: `createFolder`, `deleteFolder`, `moveFolder`. Collections and locales: `addCollection`, `updateCollection`, `deleteCollectionByName`, `addLocaleToCollection`, `removeLocaleFromCollection`, `setGlobal/CollectionProtectedTerms[File]`. Bundles: `generateBundle`, `planBundle`, `add/update/deleteBundleDefinition`, `validateBundleKey`, `validateBundleDefinition`, `getBundleOutputPath`, `hasTypeDistConfigured`. Import: `importResources` and its adapters. Export: `runExport`, `exportTargetLocales`, the export argument checks, `loadResourcesFromCollections`. Also `normalize`, `translateLocale`, `translateExistingResource`, `validateResources`, `generateValidationSummary`, `describePreferredTermRule`. | +| Operations | The entry points the apps call. Resources: `addResource`, `editResource`, `deleteResource`, `moveResource`. Folders: `createFolder`, `deleteFolder`, `moveFolder`. Collections and locales: `addCollection`, `updateCollection`, `deleteCollectionByName`, `addLocaleToCollection`, `removeLocaleFromCollection`, `setGlobal/CollectionProtectedTerms[File]`. Bundles: `generateBundle`, `planBundle`, `add/update/deleteBundleDefinition`, `validateBundleKey`, `validateBundleDefinition`, `getBundleOutputPath`, `hasTypeDistConfigured`. Import: `importResources` and its adapters. Export: `runExport`, `exportTargetLocales`, the export argument checks. Also `normalize`, `translateLocale`, `translateExistingResource`, `validateResources`, `generateValidationSummary`, `describePreferredTermRule`. | | Collection & config | `loadConfig`, `openCollection`, `Collection`, `CONFIG_FILENAME`, `DEFAULT_CONFIG`, the config types (`LingoTrackerConfig`, `LingoTrackerCollection`, `TranslationConfig`, `BundleDefinition`, ...), and the protected-terms and preferred-terminology file readers and writers. | | ResourceFolder | `openResourceFolder`, `ResourceFolder` and the types in its methods, `resolveResourcePaths`. | +| Collection Reader | `readCollection`, `StoredResource`, `CollectionRead`, `CollectionReadProblem`, `CollectionReadTarget`. See [Collection Reader](#collection-reader). | | Read models | `loadResourceTree`, `extractSubtree`, `extractResourcesRecursively`, `searchTranslations`, `searchResourceTree`, `computeTreeFingerprint`, `treeFingerprintsMatch`, `reindexMutation` and their types. The API's [Collection Index](glossary.md#collection-index) is built from these. A `ResourceTreeEntry` and a `SearchResult` (which carries the entry's `source` and `metadata`) both fit the domain `buildResourceSummary` input, which the API uses to answer with a [Resource Summary](glossary.md#resource-summary). | | Errors | `LingoTrackerError` and every typed subclass, `TranslationError`, `PreferredTerminologyValidationError`. See [Error Model](#error-model). | | Types | Parameter and result types for the operations above (`AddResourceParams`, `GenerateBundleResult`, `ImportResult`, ...). | -Each sub-module with a barrel (`resource/`, `collections-manager/`, and `lib/bundle`, `config`, `errors`, `folder`, `import`, `normalize`, `resource`, `translation`, `validate`) lists its own public names the same way, and the root barrel re-exports from it. `lib/export/` has no barrel, so the root barrel imports its files directly. `lib/file-io/` is internal and has no barrel. Everything else is internal: `ErrorMessages`, `calculateChecksum`, the translation provider classes, the bundle helpers, the normalize walker, `SafeAny`, and the like. Core's specs import these by relative path. Test helpers such as `setupMockFs` (`collections-manager/locale-spec-helpers.ts`) are not in any barrel. +Each sub-module with a barrel (`resource/`, `collections-manager/`, and `lib/bundle`, `config`, `errors`, `folder`, `import`, `normalize`, `resource`, `translation`, `validate`) lists its own public names the same way, and the root barrel re-exports from it. `lib/export/` has no barrel, so the root barrel imports its files directly. `lib/file-io/` is internal and has no barrel. Everything else is internal: `ErrorMessages`, `calculateChecksum`, the translation provider classes, the bundle helpers, the normalize walker, `SafeAny`, and the like. Core's specs import these by relative path. Test helpers live in `*.spec-helpers.ts` files, which `tsconfig.lib.json` excludes from the build: `setupMockFs` (`collections-manager/locale.spec-helpers.ts`) and the real-filesystem fixtures `useTempDir`, `testCollection`, `seedResources`, `writeFolderFiles` (`testing/temp-dir.spec-helpers.ts`). New reader specs use real temp directories rather than a mocked `fs`. --- @@ -301,7 +311,7 @@ Rules: Resource CRUD is implemented across four functions in `libs/core/src/resource/`, each bound to an opened `Collection`. Each function follows the same structural pattern: resolve the dot-delimited [resource key](glossary.md#resource-key) to a filesystem path, load the current JSON files, apply changes, recompute [checksums](glossary.md#checksum) and [translation status](glossary.md#translation-status), then write both files back. Both files are always written together by one call (`ResourceFolder.save()`); the writes are sequential, not atomic. -**All writes go through `ResourceFolder`.** `openResourceFolder(folderPath, { baseLocale })` in `lib/resource/resource-folder.ts` is the only owner of a [resource folder](glossary.md#resource-folder) (`resource_entries.json` + `tracker_meta.json`). Add, edit, delete, move, import, normalize, translate-locale, translate-existing-resource, and add/remove-locale all load the pair through it, change it with `setBase` / `setTranslation` / `setStatus` / `setDetails` / `setEntry` / `seedLocale` / `dropLocale` / `remove`, and persist with `save()` (which deletes both files when the folder becomes empty). `ResourceFolder` computes the checksums and applies the domain [staleness rule](glossary.md#staleness-rule) (`applyBaseChange`, `recordTranslation` in `libs/domain/src/lib/staleness.ts`), so no caller builds `{ checksum, baseChecksum, status }` by hand. Readers (tree loading, search, folder move/delete, folder cleanup) use it too, and `resolveResourcePaths()` is the only function that maps a key to its folder. +**All writes go through `ResourceFolder`.** `openResourceFolder(folderPath, { baseLocale })` in `lib/resource/resource-folder.ts` is the only owner of a [resource folder](glossary.md#resource-folder) (`resource_entries.json` + `tracker_meta.json`). Add, edit, delete, move, import, normalize, translate-locale, translate-existing-resource, and add/remove-locale all load the pair through it, change it with `setBase` / `setTranslation` / `setStatus` / `setDetails` / `setEntry` / `seedLocale` / `dropLocale` / `remove`, and persist with `save()` (which deletes both files when the folder becomes empty). `ResourceFolder` computes the checksums and applies the domain [staleness rule](glossary.md#staleness-rule) (`applyBaseChange`, `recordTranslation` in `libs/domain/src/lib/staleness.ts`), so no caller builds `{ checksum, baseChecksum, status }` by hand. Readers use it too: every whole-collection read goes through the [Collection Reader](#collection-reader), folder move/delete and cleanup open folders directly, and `resolveResourcePaths()` is the only function that maps a key to its folder. **Writes return what changed.** Every write (add, edit, delete, move, translate-existing-resource, folder create/delete/move, add/remove-locale) returns `mutations: ResourceMutation[]` (`lib/resource/resource-mutation.ts`) next to its other results: an `upsert` with the stored entry as `ResourceFolder.treeEntry()` reads it, a `remove`, an `add-folder` / `remove-folder`, or a `reindex` when the change is too broad to describe. Each mutation carries the absolute translations folder it applies to. A move returns an `upsert` at the destination and a `remove` at the source for each moved key, and a folder move adds a `remove-folder` for the deleted source. The API's [Collection Index](glossary.md#collection-index) uses them to follow the disk without reading it again; the CLI ignores them. See [Resource Mutation](glossary.md#resource-mutation). @@ -386,6 +396,43 @@ Two modes: --- +## Collection Reader + +**Entry point:** `readCollection(collection)` in `lib/resource/read-collection.ts` + +The [Collection Reader](glossary.md#collection-reader) is the read side of the [Resource Folder](glossary.md#resource-folder). It walks a collection's `translationsFolder` and opens each folder with `openResourceFolder(folderPath, { baseLocale: collection.baseLocale })`. It returns `{ resources: StoredResource[], problems: CollectionReadProblem[] }`. It takes a `Collection`, or any object with `translationsFolder`, `baseLocale` and `tags` (`CollectionReadTarget`). + +A `StoredResource` holds: + +- the address: `fullKey` (`apps.common.buttons.ok`), `folderPath` (`apps.common.buttons`, `''` at the root) and `entryKey` (`ok`); +- `entry`: the `ResourceTreeEntry` that `ResourceFolder.treeEntry()` returns. It has `source`, `translations`, `metadata` per locale, `comment` and `tags`. `translations` holds every locale property stored besides `source`: normally the target locales, but a hand-written base-locale key is kept as stored. A `tags` value that is not an array reads as no tags; +- `effectiveTags`: the collection tags united with the entry tags ([Tags](glossary.md#tags)). The reader is the one place this union is made: export filtering, bundle selection rules and type generation read `effectiveTags` and do not compute it again. + +`readCollectionFolders(collection, { startPath, maxDepth })` is the same walk, one folder at a time and lazily. `loadResourceTree` builds the tree from it, and `searchTranslations` uses it so that it can stop at `maxResults`. + +The reader applies these rules for every caller: + +| Case | Rule | +|---|---| +| Base locale | Each folder is opened with the collection's base locale. | +| Hidden folder (name starts with `.`) | Skipped. A key segment cannot start with `.`. | +| Missing `translationsFolder` | An empty collection. It is not a problem for the reader. `runExport` adds a `translations folder not found` warning, so a mistyped folder is visible. | +| Folder that exists but cannot be listed (permission denied, or the `translationsFolder` is a file) | Returned as a `CollectionReadProblem`. `walkFolders` reports it through its `onUnlistable` callback. `loadResourceTree` throws when its start folder is a file. | +| Entry without a `tracker_meta.json` record, or folder without the file | Read with `metadata: {}`. Each locale then has no status, and callers treat that as `new`. This is the domain rule (`needsTranslation(undefined)` is true) applied the same way everywhere: `translateLocale` now also machine-translates such entries, which `loadResourceTree` used to leave out. | +| Malformed folder: a file is not valid JSON, or an entry is not an object | None of the folder's entries are read. The folder is returned as a `CollectionReadProblem` (`folderPath`, `absolutePath`, and a `message` that names the file). The walk continues. | + +The caller decides what a problem means: + +| Caller | What it does with a problem | +|---|---| +| `validateResources` | Lists it in `unreadableFolders`, and validation fails. | +| `runExport` | Lists it under `malformedFiles` in the result and the summary. The other resources are exported. | +| Bundle and type generation (`loadCollectionResources`) | Adds a warning to the bundle result, once for each collection. Type generation logs it. | +| `glossary` (CLI) | Writes a warning to stderr. | +| `loadResourceTree`, `searchTranslations` | Log it. The tree keeps the folder, with no resources. | + +--- + ## Normalization Pipeline **Entry point:** `normalize(params)` in `lib/normalize/normalize.ts` @@ -541,7 +588,7 @@ For the full sequence diagram of an import operation, see [user-flows.md — Imp Export writes the resources of one or more collections to one file per target locale. `runExport` is the only entry point; the JSON and XLIFF exporters, the resource filter, and the summary are its internals. The CLI keeps the prompts, the output-directory and `--base-property-name` checks, the console rendering, and the write of the summary file (or, in a dry run, printing it). 1. **Choose the locales** — `exportTargetLocales(collections, options.locales)` lists every collection's target locales (a `Collection`'s `targetLocales`: its locales without its base locale) in order of first appearance, narrowed to the requested ones. The CLI calls it too, to print the plan before the run. The collections must share one base locale, because an export file has one source language; otherwise `runExport` throws. -2. **Load resources** — `loadResourcesFromCollections()` in `export-common.ts` walks each translations folder via `walkFolders()` and reads every `resource_entries.json` with its `tracker_meta.json`. Each entry becomes a `LoadedResource` with `source`, `translations`, `status`, `tags`, `collectionTags`, `collectionProtectedTerms`, and `comment`. +2. **Load resources** — `loadResources(collection, protectedTerms)` in `export-common.ts` reads each collection through the [Collection Reader](#collection-reader). It flattens each `StoredResource` into a `LoadedResource` with `source`, `translations`, `status`, `tags`, `collectionTags`, `collectionProtectedTerms`, and `comment`. An entry without metadata is exported as `new`. A folder that cannot be read goes into `malformedFiles`. 3. **Filter per locale** — for each locale, only the collections that have that locale as a target contribute. `filterResources()` keeps the resources whose status (missing counts as `new`) matches `options.status` and whose effective tags (`effectiveTags(collectionTags, resourceTags)` from `libs/domain/src/lib/effective-tags.ts`) match `options.tags`. A locale with no match is skipped, and `onProgress` reports it. 4. **Annotate protected terms** — `filterResources()` calls `findProtectedTerms(source, effectiveProtectedTerms(global, collection))` on each row and stores the matches on `FilteredResource.protectedTermsFound`. The caller reads the term lists from disk and passes them as `options.protectedTerms` (`global`, and `collections` by name), so the run reads no config. `augmentProtectedTerms: false` (the `--no-protect-notes` flag) leaves the field `undefined`. 5. **Serialize** — the JSON exporter writes a flat or hierarchical file (hierarchical key conflicts are reported separately); the XLIFF exporter writes an XLIFF 1.2 document with `` elements and `` elements for comments. `protectedTermsFound` becomes a `doNotTranslate` array in rich JSON and a `Do not translate: …` note in XLIFF. An exporter that throws fails only its locale; the run continues. @@ -591,7 +638,7 @@ The pointer-setting functions call `assertWritableProtectedTermsPath()` *before* Key steps: 1. **Resolve configuration** — token casing, ICU-to-Transloco transformation flag, and target locales are resolved via a three-level priority chain: CLI override → bundle config → global config → default. -2. **Load resources** — `loadCollectionResources()` reads flat `{key: value}` pairs for the target locale and base locale, falling back to the base locale value when a translation is absent. +2. **Load resources** — each collection is opened with `openCollection(config, name)`. `loadCollectionResources(collection, locale, cache, warnings)` reads it through the [Collection Reader](#collection-reader) once per run (the cache holds each collection's read). It returns one `{ key, value, tags }` for each entry that has a value for the locale, with the reader's effective tags. The value is `source` when the locale is the collection's own base locale, and the stored translation otherwise. An entry with no value for the locale is left out. A folder that cannot be read becomes a warning. The base data of a run — the debug-keys bundle, and the plan's key set, conflicts and types count — passes `COLLECTION_BASE_LOCALE` instead of a locale, so each collection gives its own base values even when it overrides the global base locale. 3. **Filter entries** — `EntrySelectionRule` objects in the `BundleDefinition` combine pattern matching (`matchesPattern()`) and tag filtering (`matchesTags()`) to include only the relevant subset of resources. Collections set to `'All'` skip filtering. 4. **ICU conversion** — when `transformICUToTransloco` is `true` (the default), `icuToTransloco()` from `@simoncodes-ca/domain` is called on each value. Values with malformed ICU syntax are passed through with a warning. 5. **Build hierarchy** — `buildHierarchy()` converts the flat `{dotKey: value}` map into a nested object matching the Angular Transloco expected structure. @@ -604,9 +651,9 @@ For a deep-dive into `BundleDefinition` configuration and the type generation su ## Validation for CI/CD -**Entry point:** `validateResources(collections, targetLocales, options)` in `lib/validate/validate-resources.ts` +**Entry point:** `validateResources(collections, options)` in `lib/validate/validate-resources.ts` -The validation pipeline is designed for headless CI/CD use. It loads all resources from all specified collections via `loadResourcesFromCollections()` (the same shared walker used by the export pipeline), then checks every resource key in every target locale against its stored [translation status](glossary.md#translation-status). +The validation pipeline is designed for headless CI/CD use. It takes the opened collections (`openCollection`) and validates them one by one. It reads each collection through the [Collection Reader](#collection-reader). Then it checks every resource in each of that collection's target locales (its `targetLocales` minus `options.skippedLocales`) against its stored [translation status](glossary.md#translation-status). Nothing is deduplicated across collections: a key in two collections is validated in both. The ICU pass compiles each collection's base values under that collection's base locale. The placeholder pass compares translations with that collection's base value. Terminology findings are reported under the collection's base locale. Categorization rules: @@ -619,16 +666,17 @@ Categorization rules: The function never stops at the first failure — it validates all resources and returns a complete `ResourceValidationResult` so the team has full visibility. The result includes: -- `passed: boolean` — `true` only when `failures.length === 0` +- `passed: boolean` — `true` only when there are no status failures, no unreadable folders, no ICU or placeholder failures, and the terminology file loaded - `failures`, `warnings`, `successes` — `ResourceValidationDetail[]` objects with `key`, `locale`, `collection`, and `status` +- `unreadableFolders` — folders the reader could not read (`collection`, `folderPath`, `message`); their resources were not validated - `statusCounts` — aggregate counts per status type -- `totalResourcesValidated`, `totalUniqueKeys`, `localesValidated`, `collectionsValidated` +- `totalResourcesValidated`, `totalUniqueKeys` (resources checked; a key in two collections counts twice), `localesValidated` (distinct locales across collections), `collectionsValidated` `generateValidationSummary()` in `generate-validation-summary.ts` converts this result into a human-readable string for CLI output. The CLI's `validate` command exits with a non-zero code when `passed` is `false`, making it suitable for use as a blocking step in CI pipelines. The `--allow-translated` flag maps directly to `options.allowTranslated`. -`ValidationOptions` also accepts an optional `skippedLocales: readonly string[]` field. This is **reporting-only** — it does not filter resources inside `validateResources()`. The CLI performs locale filtering before calling the function (removing skipped locales from `targetLocales`) and then passes the skipped list so `generateValidationSummary()` can include a `Skipped Locales: ()` line in the output between "Locales Validated" and "Collections Validated". +`ValidationOptions.skippedLocales` removes locales from every collection's target locales. `generateValidationSummary()` also prints them as a `Skipped Locales: ()` line between "Locales Validated" and "Collections Validated". The other options are `icu` (`{ compileValues, requirePortablePlurals }`), `placeholders` (a boolean) and `terminology` (`{ rules, loadError }`). None of them names a locale: the locales come from the collections. For the [staleness](glossary.md#staleness) detection mechanism that produces `stale` status entries in the first place, see [domain-and-data-model.md — Checksum-Driven Staleness Detection](domain-and-data-model.md#checksum-driven-staleness-detection). diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 33bde810..75d5fc1c 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -65,6 +65,14 @@ Explained in context: [`api.md`](api.md#collection-index) --- +### Collection Reader + +The read side of the [Resource Folder](#resource-folder): the one walk over a [collection's](#collection) `translationsFolder`. In code, `readCollection(collection)` in `libs/core/src/lib/resource/read-collection.ts` opens every folder with the collection's [base locale](#base-locale) and returns `{ resources, problems }`. Each `StoredResource` has an address (`fullKey`, `folderPath`, `entryKey`), the `entry` as `ResourceFolder.treeEntry()` reads it, and `effectiveTags` ([Tags](#tags)). The rules are the same for every caller. Hidden folders are skipped. An entry without metadata is read with `metadata: {}`, so it counts as `new`. A folder whose file is not valid JSON, or that cannot be listed, is left out and returned as a problem, and the caller reports it. Export, validate, bundle, type generation, the resource tree, disk search and the CLI `glossary` all read through it. + +Explained in context: [`core-library.md`](core-library.md#collection-reader) + +--- + ## E ### Export Run @@ -207,7 +215,7 @@ Explained in context: [`frontend.md`](frontend.md#translation-editor-and-the-res ### Resource Folder -One folder of the translation hierarchy, seen as a unit: its `resource_entries.json` ([resource entries](#resource-entry)) and `tracker_meta.json` ([tracker metadata](#tracker-metadata)) are always read and written together. In code, `openResourceFolder()` returns a `ResourceFolder` (`libs/core/src/lib/resource/resource-folder.ts`), and every core operation that changes resources goes through it. It computes checksums and applies the [staleness rule](#staleness-rule). +One folder of the translation hierarchy, seen as a unit: its `resource_entries.json` ([resource entries](#resource-entry)) and `tracker_meta.json` ([tracker metadata](#tracker-metadata)) are always read and written together. In code, `openResourceFolder()` returns a `ResourceFolder` (`libs/core/src/lib/resource/resource-folder.ts`), and every core operation that changes resources goes through it. Whole-collection reads go through it too, by way of the [Collection Reader](#collection-reader). It computes checksums and applies the [staleness rule](#staleness-rule). Explained in context: [`core-library.md`](core-library.md#resource-crud-flows) diff --git a/architecture-docs/user-flows.md b/architecture-docs/user-flows.md index 1bf9280a..b21d4323 100644 --- a/architecture-docs/user-flows.md +++ b/architecture-docs/user-flows.md @@ -84,7 +84,7 @@ sequenceDiagram Note over Dev,FS: 5. Bundle Dev->>CLI: bundle CLI->>Core: generateBundle(params) - Core->>FS: loadCollectionResources() — reads resource_entries.json per folder + Core->>FS: loadCollectionResources() — readCollection(): entries + metadata per folder, once per collection Core->>Domain: icuToTransloco(value) — per entry Core->>Core: buildHierarchy() — dot-keys → nested object Core->>FS: writeBundleFile(dist/i18n/en.json, dist/i18n/fr.json, ...) @@ -118,8 +118,8 @@ sequenceDiagram CLI->>Core: validateOutputDirectory(outputDir) CLI->>Core: exportTargetLocales(collections, ["fr"]) → print the plan CLI->>Core: runExport(collections, options + protected terms) - Core->>Core: loadResourcesFromCollections() - Note right of Core: walkFolders() traverses each translationsFolder
reads resource_entries.json + tracker_meta.json per folder + Core->>Core: loadResources(collection) for each collection + Note right of Core: readCollection() (Collection Reader) walks each translationsFolder
and opens every folder through ResourceFolder Core->>FS: read resource_entries.json + tracker_meta.json (per folder) loop For each target locale Core->>Core: filterResources() — collections with this target locale,
status and tag filters, protected-term annotation diff --git a/docs/cli.md b/docs/cli.md index 10b22f82..5ff460a6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1308,15 +1308,15 @@ lingo-tracker validate [options] **Options:** - `--allow-translated` - Treat 'translated' status as warning instead of failure (default: false) -- `--skip-locales ` - Comma-separated list of target locales to exclude from validation (e.g. `fr` or `fr,de`). Useful when a locale has been added to the config but its translations are still in progress. Unknown locales (not in `config.locales`) emit a warning and are ignored. The base locale is silently ignored, and is still compiled by the ICU check. If all target locales are skipped, the command exits with code `1`. +- `--skip-locales ` - Comma-separated list of target locales to exclude from validation (e.g. `fr` or `fr,de`). Useful when a locale has been added to the config but its translations are still in progress. Locales that are no collection's target locale emit a warning and are ignored. A collection's base locale is silently ignored, and is still compiled by the ICU check. If all target locales are skipped, the command exits with code `1`. - `--skip-icu` - Do not compile values as ICU for their own locale (default: false). Does not disable `--require-portable-plurals`, which parses rather than compiles. - `--require-portable-plurals` - Warn when a base-locale plural selects a branch by category (`one`, `few`, …) instead of an exact `=N` match (default: false). Warnings never fail the run. **What Validate Does:** 1. **Loads all resources** from all configured collections -2. **Checks translation status** for every resource in every target locale -3. **Compiles every value as ICU** under the locale it is stored under, including the base locale (unless `--skip-icu`) +2. **Checks translation status** for every resource in every target locale of its collection. Each collection uses its own `baseLocale` and `locales` when it overrides them. A key that two collections share is checked in both. +3. **Compiles every value as ICU** under the locale it is stored under, including each collection's base locale (unless `--skip-icu`) 4. **Collects all validation results** (does not stop at first error) 5. **Categorizes findings** into failures, warnings, and successes 6. **Reports comprehensive results** grouped by locale @@ -1335,7 +1335,9 @@ A value that does not compile as ICU for its own locale is a failure whatever it **Exit Codes:** - `0` - All validations passed (all resources verified) -- `1` - Validation failures found (new/stale resources, translated without `--allow-translated`, or values that fail to compile as ICU), or the preferred terminology file exists but cannot be loaded +- `1` - Validation failures found (new/stale resources, translated without `--allow-translated`, values that fail to compile as ICU, or a resource folder whose files are not valid JSON), or the preferred terminology file exists but cannot be loaded + +A resource without metadata counts as `new`. A folder whose `resource_entries.json` or `tracker_meta.json` is not valid JSON is listed under **Unreadable Folders**. Its resources are not checked, and validation fails. [Preferred terminology](./features/preferred-terminology.md) findings in base-locale values are warnings. They never change the exit code. @@ -1739,13 +1741,13 @@ Every export generates an `export-summary.md` file in the output directory conta - **Status filtering** is per-locale: a resource is included in a locale if it matches the filter for that locale - **Tag filtering** uses OR logic: resource must have at least one of the specified tags - **Base locale** is never exported (only target locales) -- Resources without metadata are omitted and logged in errors +- Resources without metadata are exported as `new` - Empty export results don't create files (warning logged) **Error Handling:** -- Malformed files: Skipped with detailed warning, export continues -- Missing metadata: Resource omitted, logged in errors +- Malformed files: the folder is skipped and listed under "Malformed Files" in the summary; export continues +- Missing metadata: the resource is exported with status `new` - Hierarchical conflicts: Logged when a key is both a parent and leaf value (JSON hierarchical only) - Non-writable output directory: Fails with clear error message - Empty results: No files created, warning shown diff --git a/libs/core/src/collections-manager/add-locale-to-collection.spec.ts b/libs/core/src/collections-manager/add-locale-to-collection.spec.ts index d36906bd..4a57f1c9 100644 --- a/libs/core/src/collections-manager/add-locale-to-collection.spec.ts +++ b/libs/core/src/collections-manager/add-locale-to-collection.spec.ts @@ -6,7 +6,7 @@ import { calculateChecksum } from '../resource/checksum'; import type { ResourceEntries } from '../resource/resource-entry'; import type { TrackerMetadata } from '../resource/tracker-metadata'; import type { SafeAny } from '../constants'; -import { setupMockFs, makeBaseConfig } from './locale-spec-helpers'; +import { setupMockFs, makeBaseConfig } from './locale.spec-helpers'; import { ReadOnlyCollectionError } from '../lib/errors/lingo-tracker-error'; vi.mock('fs'); diff --git a/libs/core/src/collections-manager/locale-spec-helpers.spec.ts b/libs/core/src/collections-manager/locale.spec-helpers.spec.ts similarity index 78% rename from libs/core/src/collections-manager/locale-spec-helpers.spec.ts rename to libs/core/src/collections-manager/locale.spec-helpers.spec.ts index 8662e2ef..0b47b7a6 100644 --- a/libs/core/src/collections-manager/locale-spec-helpers.spec.ts +++ b/libs/core/src/collections-manager/locale.spec-helpers.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { makeBaseConfig } from './locale-spec-helpers'; +import { makeBaseConfig } from './locale.spec-helpers'; -describe('locale-spec-helpers', () => { +describe('locale.spec-helpers', () => { it('makeBaseConfig returns the expected shape', () => { const config = makeBaseConfig(); expect(config).toMatchObject({ diff --git a/libs/core/src/collections-manager/locale-spec-helpers.ts b/libs/core/src/collections-manager/locale.spec-helpers.ts similarity index 100% rename from libs/core/src/collections-manager/locale-spec-helpers.ts rename to libs/core/src/collections-manager/locale.spec-helpers.ts diff --git a/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts b/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts index 3f29da92..cf25f915 100644 --- a/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts +++ b/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts @@ -6,7 +6,7 @@ import { calculateChecksum } from '../resource/checksum'; import type { ResourceEntries } from '../resource/resource-entry'; import type { TrackerMetadata } from '../resource/tracker-metadata'; import type { SafeAny } from '../constants'; -import { setupMockFs, makeBaseConfig } from './locale-spec-helpers'; +import { setupMockFs, makeBaseConfig } from './locale.spec-helpers'; import { ReadOnlyCollectionError } from '../lib/errors/lingo-tracker-error'; vi.mock('fs'); diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 2e94732c..e4a980a6 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -45,7 +45,6 @@ export { // Operations: export export { - loadResourcesFromCollections, validateBasePropertyName, validateOutputDirectory, } from './lib/export/export-common'; @@ -90,6 +89,15 @@ export { resolveResourcePaths, } from './lib/resource'; +// Collection Reader: every entry of a collection, read through ResourceFolder +export { + type CollectionRead, + type CollectionReadProblem, + type CollectionReadTarget, + readCollection, + type StoredResource, +} from './lib/resource'; + // Read models: the resource tree, search and fingerprints behind the API's CollectionIndex export { computeTreeFingerprint, @@ -160,7 +168,6 @@ export type { UpdateBundleDefinitionOptions, } from './lib/bundle'; export type { LoadConfigOptions, OpenCollectionOptions } from './lib/config'; -export type { LoadedResource } from './lib/export/export-common'; export type { ExportLocaleResult, ExportRunOptions, ExportRunResult } from './lib/export/run-export'; export type { ExportFormat, ExportResult } from './lib/export/types'; export type { diff --git a/libs/core/src/lib/bundle/generate-bundle.spec.ts b/libs/core/src/lib/bundle/generate-bundle.spec.ts index 904d29bc..06c330d7 100644 --- a/libs/core/src/lib/bundle/generate-bundle.spec.ts +++ b/libs/core/src/lib/bundle/generate-bundle.spec.ts @@ -161,8 +161,18 @@ describe('generate-bundle', () => { await generateBundle(params); - expect(loadSpy).toHaveBeenCalledWith('/translations/default', 'en', 'en', expect.any(Map), undefined); - expect(loadSpy).toHaveBeenCalledWith('/translations/admin', 'en', 'en', expect.any(Map), undefined); + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'default', translationsFolder: '/translations/default', baseLocale: 'en' }), + 'en', + expect.any(Map), + expect.any(Array), + ); + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'admin', translationsFolder: '/translations/admin', baseLocale: 'en' }), + 'en', + expect.any(Map), + expect.any(Array), + ); }); it('should process specific collections with selection rules', async () => { @@ -272,7 +282,7 @@ describe('generate-bundle', () => { ], }; - vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation((folder) => { + vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation(({ translationsFolder: folder }) => { if (folder === '/translations/default') { return [{ key: 'shared.title', value: 'Default Title' }]; } @@ -314,7 +324,7 @@ describe('generate-bundle', () => { ], }; - vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation((folder) => { + vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation(({ translationsFolder: folder }) => { if (folder === '/translations/default') { return [{ key: 'shared.title', value: 'Default Title' }]; } @@ -1466,11 +1476,18 @@ describe('generate-bundle', () => { importFolder: 'dist/import', baseLocale: rawConfig.baseLocale, locales: fixtureLocales, - collections: collection ? { [FIXTURE_COLLECTION]: collection } : {}, + collections: collection + ? { + [FIXTURE_COLLECTION]: { + ...collection, + translationsFolder: path.resolve(REPO_ROOT, collection.translationsFolder), + }, + } + : {}, }; vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation( - (translationsFolder, locale, baseLocale) => loadFixtureResources(translationsFolder, locale, baseLocale), + ({ translationsFolder, baseLocale }, locale) => loadFixtureResources(translationsFolder, locale, baseLocale), ); }); diff --git a/libs/core/src/lib/bundle/generate-bundle.ts b/libs/core/src/lib/bundle/generate-bundle.ts index b100a449..0ae70ca1 100644 --- a/libs/core/src/lib/bundle/generate-bundle.ts +++ b/libs/core/src/lib/bundle/generate-bundle.ts @@ -4,13 +4,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - effectiveTags, - hasUnbundlableBranchBody, - icuToTransloco, - type TokenCasing, - validateICUSyntax, -} from '@simoncodes-ca/domain'; +import { hasUnbundlableBranchBody, icuToTransloco, type TokenCasing, validateICUSyntax } from '@simoncodes-ca/domain'; import { type BundleDefinition, type CollectionBundleDefinition, @@ -18,10 +12,16 @@ import { hasTypeDistConfigured, } from '../../config/bundle-definition'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; -import type { ResourceEntries } from '../../resource/resource-entry'; +import { type Collection, openCollection } from '../config/open-collection'; import { buildHierarchy } from './hierarchy-builder'; import { matchesPattern } from './pattern-matcher'; -import { type FlatResource, loadCollectionResources } from './resource-loader'; +import { + type BundleLocale, + COLLECTION_BASE_LOCALE, + type CollectionReadCache, + type FlatResource, + loadCollectionResources, +} from './resource-loader'; import { matchesTags } from './tag-filter'; import { type GenerateTypesResult, generateBundleTypes } from './type-generation/generate-types'; @@ -129,7 +129,7 @@ export async function generateBundle(params: GenerateBundleParams): Promise(); + const resourceCache: CollectionReadCache = new Map(); const totalFiles = targetLocales.length + (debugKeysLocale ? 1 : 0); let progressIndex = 0; @@ -175,10 +175,11 @@ export async function generateBundle(params: GenerateBundleParams): Promise, + cache: CollectionReadCache, trace?: BundleKeyTrace, ): Record { const bundleData: Record = {}; - const baseLocale = config.baseLocale; - if (bundleDefinition.collections === 'All') { - for (const [collectionName, collectionConfig] of Object.entries(config.collections)) { + for (const collectionName of Object.keys(config.collections)) { const collectionBundleDef: CollectionBundleDefinition = { name: collectionName, entriesSelectionRules: 'All', }; processCollection( collectionBundleDef, - collectionConfig.translationsFolder, + openCollection(config, collectionName), locale, - baseLocale, bundleData, transformICUToTransloco, warnings, cache, - collectionConfig.tags, trace, ); } } else { for (const collectionBundleDef of bundleDefinition.collections) { - const collectionConfig = config.collections[collectionBundleDef.name]; - - if (!collectionConfig) { + if (!Object.keys(config.collections).includes(collectionBundleDef.name)) { warnings.push(`Collection '${collectionBundleDef.name}' not found in config`); continue; } processCollection( collectionBundleDef, - collectionConfig.translationsFolder, + openCollection(config, collectionBundleDef.name), locale, - baseLocale, bundleData, transformICUToTransloco, warnings, cache, - collectionConfig.tags, trace, ); } @@ -301,21 +294,20 @@ export function collectBundleData( } /** - * Processes a single collection and adds its entries to bundle data + * Processes a single collection and adds its entries to bundle data. + * The collection's own base locale decides whether `locale` reads the base value or a translation. */ function processCollection( collectionDef: CollectionBundleDefinition, - translationsFolder: string, - locale: string, - baseLocale: string, + collection: Collection, + locale: BundleLocale, bundleData: Record, transformICUToTransloco: boolean, warnings: string[], - cache: Map, - collectionTags?: string[], + cache: CollectionReadCache, trace?: BundleKeyTrace, ): void { - const resources = loadCollectionResources(translationsFolder, locale, baseLocale, cache, collectionTags); + const resources = loadCollectionResources(collection, locale, cache, warnings); const filteredResources = filterResources(resources, collectionDef); const mergeStrategy = collectionDef.mergeStrategy ?? 'merge'; @@ -375,10 +367,14 @@ function filterResources(resources: FlatResource[], collectionDef: CollectionBun * Checks if resource matches any of the selection rules */ function matchesAnyRule(resource: FlatResource, rules: EntrySelectionRule[]): boolean { - const tags = effectiveTags(resource.collectionTags, resource.tags); + const tags = resource.tags; return rules.some((rule) => { const patternMatch = matchesPattern(resource.key, rule.matchingPattern); - const tagMatch = matchesTags(tags.length > 0 ? tags : undefined, rule.matchingTags, rule.matchingTagOperator); + const tagMatch = matchesTags( + tags && tags.length > 0 ? tags : undefined, + rule.matchingTags, + rule.matchingTagOperator, + ); return patternMatch && tagMatch; }); } diff --git a/libs/core/src/lib/bundle/mixed-base-locale.real-fs.spec.ts b/libs/core/src/lib/bundle/mixed-base-locale.real-fs.spec.ts new file mode 100644 index 00000000..1f62240e --- /dev/null +++ b/libs/core/src/lib/bundle/mixed-base-locale.real-fs.spec.ts @@ -0,0 +1,78 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { BundleDefinition } from '../../config/bundle-definition'; +import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { seedResources, testCollection, useTempDir } from '../../testing/temp-dir.spec-helpers'; +import { generateBundle } from './generate-bundle'; +import { planBundle } from './plan-bundle'; + +/** + * A collection whose base locale differs from the global one has no stored value for the global + * base locale. Its keys must still count in the plan's key set and appear in the debug-keys bundle. + */ +describe('bundles with a collection that overrides the base locale (real fs)', () => { + const root = useTempDir('bundle-mixed-base-'); + + function setup(): { config: LingoTrackerConfig; definition: BundleDefinition } { + const commonFolder = join(root(), 'common'); + const frenchFolder = join(root(), 'french'); + seedResources(testCollection(commonFolder, { locales: ['en', 'fr'] }), { + 'shared.title': { source: 'Title', translations: { fr: 'Titre' } }, + 'common.ok': { source: 'OK', translations: { fr: "D'accord" } }, + }); + seedResources(testCollection(frenchFolder, { baseLocale: 'fr', locales: ['fr', 'en'] }), { + 'shared.title': { source: 'Titre (fr)' }, + 'french.bonjour': { source: 'Bonjour' }, + }); + + return { + config: { + exportFolder: 'dist/export', + importFolder: 'dist/import', + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { + common: { translationsFolder: commonFolder }, + french: { translationsFolder: frenchFolder, baseLocale: 'fr', locales: ['fr', 'en'] }, + }, + }, + definition: { + bundleName: '{locale}', + dist: join(root(), 'dist'), + collections: 'All', + typeDistFile: join(root(), 'types', 'tokens.ts'), + }, + }; + } + + it("counts the collection's keys in the plan's key set, types count and conflicts", () => { + const { config, definition } = setup(); + + const plan = planBundle({ bundleKey: 'main', bundleDefinition: definition, config, cwd: root() }); + + expect(plan.files.find((file) => file.kind === 'types')?.keysCount).toBe(3); + expect(plan.conflictKeys).toEqual(['shared.title']); + // The en file itself only holds keys that have an en value. + expect(plan.keysPerLocale).toEqual({ en: 2, fr: 3 }); + }); + + it('includes the collection in the debug-keys bundle', async () => { + const { config, definition } = setup(); + + const result = await generateBundle({ + bundleKey: 'main', + bundleDefinition: { ...definition, typeDistFile: undefined }, + config, + locales: ['en'], + debugKeysLocale: '99', + }); + + expect(result.keysPerLocale['99']).toBe(3); + expect(JSON.parse(readFileSync(join(root(), 'dist', '99.json'), 'utf8'))).toEqual({ + shared: { title: 'shared.title' }, + common: { ok: 'common.ok' }, + french: { bonjour: 'french.bonjour' }, + }); + }); +}); diff --git a/libs/core/src/lib/bundle/plan-bundle.spec.ts b/libs/core/src/lib/bundle/plan-bundle.spec.ts index 988476e0..f4501d1e 100644 --- a/libs/core/src/lib/bundle/plan-bundle.spec.ts +++ b/libs/core/src/lib/bundle/plan-bundle.spec.ts @@ -41,7 +41,9 @@ describe('planBundle', () => { /** Returns resources keyed by translations folder so each collection has distinct content. */ function mockResourcesByFolder(byFolder: Record): void { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation((folder) => byFolder[folder] ?? []); + vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation( + (collection) => byFolder[collection.translationsFolder] ?? [], + ); } const definition: BundleDefinition = { diff --git a/libs/core/src/lib/bundle/plan-bundle.ts b/libs/core/src/lib/bundle/plan-bundle.ts index 84aa4161..445a6f39 100644 --- a/libs/core/src/lib/bundle/plan-bundle.ts +++ b/libs/core/src/lib/bundle/plan-bundle.ts @@ -12,8 +12,8 @@ import * as path from 'node:path'; import { detectHierarchicalConflicts, type TokenCasing } from '@simoncodes-ca/domain'; import { type BundleDefinition, hasTypeDistConfigured } from '../../config/bundle-definition'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; -import type { ResourceEntries } from '../../resource/resource-entry'; import { type BundleKeyTrace, collectBundleData, getBundleOutputPath } from './generate-bundle'; +import { COLLECTION_BASE_LOCALE, type CollectionReadCache } from './resource-loader'; import { bundleKeyToConstantName, segmentToPropertyName, @@ -108,15 +108,9 @@ export function planBundle(params: PlanBundleParams): BundlePlan { const warnings: string[] = []; const keysPerLocale: Record = {}; const files: BundlePlanFile[] = []; - const resourceCache = new Map(); - - // Trace only the base locale: conflicts are a property of the key set, which - // is identical across locales, so tracing every locale would duplicate work. - const trace: BundleKeyTrace = { conflicts: new Set(), origins: new Map() }; - let baseLocaleData: Record | undefined; + const resourceCache: CollectionReadCache = new Map(); for (const locale of targetLocales) { - const isBaseLocale = locale === config.baseLocale; const bundleData = collectBundleData( bundleDefinition, config, @@ -124,13 +118,8 @@ export function planBundle(params: PlanBundleParams): BundlePlan { warnings, resolvedTransformICUToTransloco, resourceCache, - isBaseLocale ? trace : undefined, ); - if (isBaseLocale) { - baseLocaleData = bundleData; - } - const keysCount = Object.keys(bundleData).length; keysPerLocale[locale] = keysCount; @@ -142,19 +131,20 @@ export function planBundle(params: PlanBundleParams): BundlePlan { files.push(describeFile(outputPath, 'bundle', keysCount, cwd, locale)); } - // The base locale drives conflict detection and the example key. When it is - // not among the target locales, collect it once without recording a file. - if (!baseLocaleData) { - baseLocaleData = collectBundleData( - bundleDefinition, - config, - config.baseLocale, - warnings, - resolvedTransformICUToTransloco, - resourceCache, - trace, - ); - } + // The key set drives conflict detection, the example key and the types count. It is read + // once, from every collection's own base values (a collection may override the base locale), + // and traced only here: conflicts are a property of the key set, not of a locale. Its + // warnings were already reported by the locale passes, so they are dropped unless there were none. + const trace: BundleKeyTrace = { conflicts: new Set(), origins: new Map() }; + const baseLocaleData = collectBundleData( + bundleDefinition, + config, + COLLECTION_BASE_LOCALE, + targetLocales.length > 0 ? [] : warnings, + resolvedTransformICUToTransloco, + resourceCache, + trace, + ); const typesConfigured = hasTypeDistConfigured(bundleDefinition); const baseKeysCount = Object.keys(baseLocaleData).length; diff --git a/libs/core/src/lib/bundle/resource-loader.spec.ts b/libs/core/src/lib/bundle/resource-loader.spec.ts index 8ce6c7db..5b959550 100644 --- a/libs/core/src/lib/bundle/resource-loader.spec.ts +++ b/libs/core/src/lib/bundle/resource-loader.spec.ts @@ -1,206 +1,93 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; -import { loadCollectionResources } from './resource-loader'; - -// Mock fs module -vi.mock('fs'); - -describe('resource-loader', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { seedResources, testCollection, useTempDir, writeFolderFiles } from '../../testing/temp-dir.spec-helpers'; +import { type CollectionReadCache, loadCollectionResources } from './resource-loader'; + +describe('loadCollectionResources (real fs)', () => { + const root = useTempDir('bundle-resource-loader-'); afterEach(() => { vi.restoreAllMocks(); }); - describe('loadCollectionResources', () => { - it('should return empty array when folder does not exist', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - - const result = loadCollectionResources('/non/existent/path', 'en', 'en'); - - expect(result).toEqual([]); - }); - - it('should load resources from single folder', () => { - const mockResourceEntries = { - ok: { source: 'OK', en: 'OK', fr: "D'accord" }, - cancel: { source: 'Cancel', en: 'Cancel', fr: 'Annuler' }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockResourceEntries)); - vi.spyOn(fs, 'readdirSync').mockReturnValue([]); - - const result = loadCollectionResources('/translations', 'fr', 'en'); - - expect(result).toEqual([ - { key: 'ok', value: "D'accord", tags: undefined }, - { key: 'cancel', value: 'Annuler', tags: undefined }, - ]); - }); - - it('should include tags when present', () => { - const mockResourceEntries = { - ok: { source: 'OK', en: 'OK', tags: ['ui', 'buttons'] }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockResourceEntries)); - vi.spyOn(fs, 'readdirSync').mockReturnValue([]); - - const result = loadCollectionResources('/translations', 'en', 'en'); + it('returns an empty list when the translations folder does not exist', () => { + expect(loadCollectionResources(testCollection(join(root(), 'missing')), 'en')).toEqual([]); + }); - expect(result).toEqual([{ key: 'ok', value: 'OK', tags: ['ui', 'buttons'] }]); + it('reads the base value for the base locale and the translation for other locales, with full keys', () => { + const collection = testCollection(root()); + seedResources(collection, { + welcome: { source: 'Welcome' }, + 'apps.common.buttons.ok': { source: 'OK', translations: { fr: "D'accord" } }, }); - it('should skip entries missing translation for target locale', () => { - const mockResourceEntries = { - ok: { source: 'OK', en: 'OK', fr: "D'accord" }, - untranslated: { source: 'Untranslated', en: 'Untranslated' }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockResourceEntries)); - vi.spyOn(fs, 'readdirSync').mockReturnValue([]); + expect(loadCollectionResources(collection, 'en')).toEqual([ + { key: 'welcome', value: 'Welcome', tags: [] }, + { key: 'apps.common.buttons.ok', value: 'OK', tags: [] }, + ]); + expect(loadCollectionResources(collection, 'fr')).toEqual([ + { key: 'apps.common.buttons.ok', value: "D'accord", tags: [] }, + ]); + }); - const result = loadCollectionResources('/translations', 'fr', 'en'); + it("uses the collection's own base locale", () => { + const collection = testCollection(root(), { baseLocale: 'fr', locales: ['fr', 'en'] }); + seedResources(collection, { ok: { source: 'Bien', translations: { en: 'OK' } } }); - expect(result).toEqual([{ key: 'ok', value: "D'accord", tags: undefined }]); - }); + expect(loadCollectionResources(collection, 'fr').map((resource) => resource.value)).toEqual(['Bien']); + expect(loadCollectionResources(collection, 'en').map((resource) => resource.value)).toEqual(['OK']); + }); - it('should recursively load from nested folders', () => { - const translationsFolder = '/translations'; - const buttonsFolder = path.join(translationsFolder, 'buttons'); - - const mockRootEntries = { - welcome: { source: 'Welcome', en: 'Welcome' }, - }; - - const mockButtonsEntries = { - ok: { source: 'OK', en: 'OK' }, - cancel: { source: 'Cancel', en: 'Cancel' }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filepath) => { - return ( - filepath === translationsFolder || - filepath === path.join(translationsFolder, RESOURCE_ENTRIES_FILENAME) || - filepath === buttonsFolder || - filepath === path.join(buttonsFolder, RESOURCE_ENTRIES_FILENAME) - ); - }); - - vi.spyOn(fs, 'readFileSync').mockImplementation((filepath) => { - if (filepath === path.join(translationsFolder, RESOURCE_ENTRIES_FILENAME)) { - return JSON.stringify(mockRootEntries); - } - if (filepath === path.join(buttonsFolder, RESOURCE_ENTRIES_FILENAME)) { - return JSON.stringify(mockButtonsEntries); - } - return '{}'; - }); - - vi.spyOn(fs, 'readdirSync').mockImplementation((dirpath) => { - if (dirpath === translationsFolder) { - return [{ name: 'buttons', isDirectory: () => true }] as fs.Dirent[]; - } - return []; - }); - - const result = loadCollectionResources(translationsFolder, 'en', 'en'); - - expect(result).toContainEqual({ - key: 'welcome', - value: 'Welcome', - tags: undefined, - }); - expect(result).toContainEqual({ - key: 'buttons.ok', - value: 'OK', - tags: undefined, - }); - expect(result).toContainEqual({ - key: 'buttons.cancel', - value: 'Cancel', - tags: undefined, - }); - expect(result).toHaveLength(3); - }); + it('carries the effective tags: the collection tags united with the entry tags', () => { + const collection = testCollection(root(), { tags: ['shared'] }); + seedResources(collection, { ok: { source: 'OK', tags: ['ui', 'buttons'] } }); - it('should build correct key paths for deeply nested resources', () => { - const translationsFolder = '/translations'; - const appsFolder = path.join(translationsFolder, 'apps'); - const commonFolder = path.join(appsFolder, 'common'); - const buttonsFolder = path.join(commonFolder, 'buttons'); - - const mockButtonsEntries = { - ok: { source: 'OK', en: 'OK' }, - }; - - vi.spyOn(fs, 'existsSync').mockImplementation((filepath) => { - return [ - translationsFolder, - appsFolder, - commonFolder, - buttonsFolder, - path.join(buttonsFolder, RESOURCE_ENTRIES_FILENAME), - ].includes(filepath as string); - }); - - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockButtonsEntries)); - - vi.spyOn(fs, 'readdirSync').mockImplementation((dirpath) => { - if (dirpath === translationsFolder) { - return [{ name: 'apps', isDirectory: () => true }] as fs.Dirent[]; - } - if (dirpath === appsFolder) { - return [{ name: 'common', isDirectory: () => true }] as fs.Dirent[]; - } - if (dirpath === commonFolder) { - return [{ name: 'buttons', isDirectory: () => true }] as fs.Dirent[]; - } - return []; - }); - - const result = loadCollectionResources(translationsFolder, 'en', 'en'); - - expect(result).toEqual([{ key: 'apps.common.buttons.ok', value: 'OK', tags: undefined }]); - }); + expect(loadCollectionResources(collection, 'en')).toEqual([ + { key: 'ok', value: 'OK', tags: ['shared', 'ui', 'buttons'] }, + ]); + }); - it('should skip invalid JSON files and log warning', () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { - // Empty implementation to suppress console output during tests - }); + it('includes entries without metadata', () => { + writeFolderFiles(root(), 'common', { entries: { ok: { source: 'OK', fr: 'Bien' } } }); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{ invalid json }'); - vi.spyOn(fs, 'readdirSync').mockReturnValue([]); + expect(loadCollectionResources(testCollection(root()), 'fr')).toEqual([ + { key: 'common.ok', value: 'Bien', tags: [] }, + ]); + }); - const result = loadCollectionResources('/translations', 'en', 'en'); + it('skips an unreadable folder and reports it to the warnings once per cached run', () => { + const collection = testCollection(root()); + seedResources(collection, { 'good.ok': { source: 'OK', translations: { fr: 'Bien' } } }); + writeFolderFiles(root(), 'bad', { entries: '{ invalid json }' }); + const cache: CollectionReadCache = new Map(); + const warnings: string[] = []; + + expect(loadCollectionResources(collection, 'en', cache, warnings).map((resource) => resource.key)).toEqual([ + 'good.ok', + ]); + loadCollectionResources(collection, 'fr', cache, warnings); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Collection 'main': skipped unreadable folder"); + expect(warnings[0]).toContain('resource_entries.json'); + }); - expect(result).toEqual([]); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid JSON')); - }); + it('logs an unreadable folder when no warnings list is given', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + writeFolderFiles(root(), 'bad', { entries: '{ invalid json }' }); - it('should ignore non-directory entries when scanning folders', () => { - const mockResourceEntries = { - ok: { source: 'OK', en: 'OK' }, - }; + expect(loadCollectionResources(testCollection(root()), 'en')).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped unreadable folder')); + }); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockResourceEntries)); - vi.spyOn(fs, 'readdirSync').mockReturnValue([ - { name: RESOURCE_ENTRIES_FILENAME, isDirectory: () => false }, - { name: TRACKER_META_FILENAME, isDirectory: () => false }, - ] as fs.Dirent[]); + it('reads the disk once per collection for a shared cache', () => { + const collection = testCollection(root()); + seedResources(collection, { ok: { source: 'OK' } }); + const cache: CollectionReadCache = new Map(); - const result = loadCollectionResources('/translations', 'en', 'en'); + loadCollectionResources(collection, 'en', cache); + seedResources(collection, { later: { source: 'Later' } }); - expect(result).toEqual([{ key: 'ok', value: 'OK', tags: undefined }]); - }); + expect(loadCollectionResources(collection, 'en', cache).map((resource) => resource.key)).toEqual(['ok']); }); }); diff --git a/libs/core/src/lib/bundle/resource-loader.ts b/libs/core/src/lib/bundle/resource-loader.ts index a6110049..49a8a263 100644 --- a/libs/core/src/lib/bundle/resource-loader.ts +++ b/libs/core/src/lib/bundle/resource-loader.ts @@ -1,86 +1,69 @@ /** - * Utilities for loading translation resources from collections + * The bundle's view of a collection: one value per key for one locale, read through the + * Collection Reader. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { RESOURCE_ENTRIES_FILENAME } from '../../constants'; -import { walkFolders } from '../normalize/iterative-folder-walker'; -import type { ResourceEntries } from '../../resource/resource-entry'; -import { readJsonFile } from '../file-io/json-file-operations'; +import type { Collection } from '../config/open-collection'; +import { type CollectionRead, readCollection } from '../resource/read-collection'; export interface FlatResource { readonly key: string; readonly value: string; - readonly tags?: string[]; - readonly collectionTags?: string[]; + /** The entry's effective tags (collection tags united with its own), from the Collection Reader. */ + readonly tags?: readonly string[]; } /** - * Loads all resources from a collection's translations folder. - * - * Returns a flat list of resources with full keys. When a `cache` map is - * provided, parsed `ResourceEntries` objects are stored in it by file path - * so subsequent calls for the same folder (e.g., different locales in the - * same bundle generation) avoid redundant disk reads and JSON parsing. - * The cache is intentionally short-lived: callers should create it per - * invocation and discard it afterwards to avoid stale data. - * - * @param translationsFolder - Path to collection's translations folder - * @param locale - Target locale to extract values for - * @param baseLocale - Base locale (source values use 'source' property) - * @param cache - Optional per-invocation cache of parsed ResourceEntries by file path - * @returns Array of flat resources with keys, values, and tags + * Stands in for a locale: "each collection's own base locale". The base data of a run (debug keys, + * the plan's key set) reads every collection's base value, whatever the global base locale is. */ -export function loadCollectionResources( - translationsFolder: string, - locale: string, - baseLocale: string, - cache?: Map, - collectionTags?: string[], -): FlatResource[] { - const resources: FlatResource[] = []; +export const COLLECTION_BASE_LOCALE: unique symbol = Symbol('collection base locale'); - if (!fs.existsSync(translationsFolder)) { - return resources; - } - - for (const visit of walkFolders(translationsFolder, { skipHidden: false })) { - const resourceEntriesPath = path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME); - - if (!fs.existsSync(resourceEntriesPath)) continue; +/** A locale code, or {@link COLLECTION_BASE_LOCALE}. */ +export type BundleLocale = string | typeof COLLECTION_BASE_LOCALE; - try { - let entries: ResourceEntries; +/** + * Collections already read in one bundle run, by collection name. Create one per run and discard + * it afterwards, so every locale of a run reads the same data without reading the disk again. + */ +export type CollectionReadCache = Map; - const cached = cache?.get(resourceEntriesPath); - if (cached) { - entries = cached; +/** + * Lists a collection's values for `locale`: the base value (`source`) when `locale` is the + * collection's base locale (or {@link COLLECTION_BASE_LOCALE}), otherwise the stored translation. + * An entry with no value for `locale` is left out. + * + * Folders the reader could not read are reported once per collection and run: pushed to + * `warnings` when given, otherwise logged. + */ +export function loadCollectionResources( + collection: Collection, + locale: BundleLocale, + cache?: CollectionReadCache, + warnings?: string[], +): FlatResource[] { + let read = cache?.get(collection.name); + if (!read) { + read = readCollection(collection); + cache?.set(collection.name, read); + for (const problem of read.problems) { + const message = `Collection '${collection.name}': skipped unreadable folder: ${problem.message}`; + if (warnings) { + warnings.push(message); } else { - entries = readJsonFile({ filePath: resourceEntriesPath }); - cache?.set(resourceEntriesPath, entries); + console.warn(`⚠️ ${message}`); } + } + } - for (const [entryKey, entry] of Object.entries(entries)) { - const fullKey = visit.keyPrefix ? `${visit.keyPrefix}.${entryKey}` : entryKey; + const isBase = locale === COLLECTION_BASE_LOCALE || locale === collection.baseLocale; + const resources: FlatResource[] = []; - // For base locale, use 'source' property; for others, use locale key - const value = locale === baseLocale ? entry.source : entry[locale]; + for (const { fullKey, entry, effectiveTags } of read.resources) { + const value = isBase ? entry.source : entry.translations[locale]; + if (typeof value !== 'string') continue; - // Extract translation value (skip if missing) - if (typeof value === 'string') { - resources.push({ - key: fullKey, - value, - tags: entry.tags, - collectionTags, - }); - } - } - } catch { - // Skip invalid JSON files - console.warn(`⚠️ Skipping invalid JSON: ${resourceEntriesPath}`); - } + resources.push({ key: fullKey, value, tags: effectiveTags }); } return resources; diff --git a/libs/core/src/lib/bundle/tag-filter.ts b/libs/core/src/lib/bundle/tag-filter.ts index b89936a2..c191c8dc 100644 --- a/libs/core/src/lib/bundle/tag-filter.ts +++ b/libs/core/src/lib/bundle/tag-filter.ts @@ -11,7 +11,7 @@ * @returns true if entry matches tag criteria */ export function matchesTags( - entryTags: string[] | undefined, + entryTags: readonly string[] | undefined, matchingTags: string[] | undefined, matchingTagOperator: 'All' | 'Any' = 'Any', ): boolean { diff --git a/libs/core/src/lib/bundle/type-generation/generate-types.ts b/libs/core/src/lib/bundle/type-generation/generate-types.ts index c161d85e..b1073716 100644 --- a/libs/core/src/lib/bundle/type-generation/generate-types.ts +++ b/libs/core/src/lib/bundle/type-generation/generate-types.ts @@ -2,10 +2,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { LingoTrackerConfig } from '../../../config/lingo-tracker-config'; import { type BundleDefinition, hasTypeDistConfigured } from '../../../config/bundle-definition'; +import { openCollection } from '../../config/open-collection'; import { loadCollectionResources } from '../resource-loader'; import { matchesPattern } from '../pattern-matcher'; import { matchesTags } from '../tag-filter'; -import { effectiveTags, type TokenCasing } from '@simoncodes-ca/domain'; +import type { TokenCasing } from '@simoncodes-ca/domain'; import { buildTypeHierarchy, serializeHierarchy } from './hierarchy-builder'; import { generateFileHeader } from './file-header'; import { bundleKeyToConstantName, validateJavaScriptIdentifier } from './key-transformer'; @@ -90,21 +91,14 @@ export async function generateBundleTypes( : bundleDef.collections; for (const collectionDef of collections) { - const collectionConfig = config.collections[collectionDef.name]; - - if (!collectionConfig) { + if (!Object.keys(config.collections).includes(collectionDef.name)) { console.warn(`Collection '${collectionDef.name}' not found in configuration`); continue; } - // Load resources (using base locale as source of truth for keys) - const resources = loadCollectionResources( - collectionConfig.translationsFolder, - config.baseLocale, - config.baseLocale, - undefined, - collectionConfig.tags, - ); + // Load resources (the collection's base values are the source of truth for keys) + const collection = openCollection(config, collectionDef.name); + const resources = loadCollectionResources(collection, collection.baseLocale); for (const resource of resources) { // Apply filters @@ -113,11 +107,11 @@ export async function generateBundleTypes( if (collectionDef.entriesSelectionRules === 'All') { isMatch = true; } else { - const tags = effectiveTags(resource.collectionTags, resource.tags); + const tags = resource.tags; for (const rule of collectionDef.entriesSelectionRules) { if ( matchesPattern(resource.key, rule.matchingPattern) && - matchesTags(tags.length > 0 ? tags : undefined, rule.matchingTags, rule.matchingTagOperator) + matchesTags(tags && tags.length > 0 ? tags : undefined, rule.matchingTags, rule.matchingTagOperator) ) { isMatch = true; break; diff --git a/libs/core/src/lib/export/export-common.spec.ts b/libs/core/src/lib/export/export-common.spec.ts index 19a33b33..2c1a0010 100644 --- a/libs/core/src/lib/export/export-common.spec.ts +++ b/libs/core/src/lib/export/export-common.spec.ts @@ -1,140 +1,102 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'fs'; -import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; +import { chmodSync, existsSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { seedResources, testCollection, useTempDir, writeFolderFiles } from '../../testing/temp-dir.spec-helpers'; import { - loadResourcesFromCollections, filterResources, - validateOutputDirectory, - validateBasePropertyName, type LoadedResource, + loadResources, + validateBasePropertyName, + validateOutputDirectory, } from './export-common'; -// Mock fs module -vi.mock('fs'); - describe('export-common', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + const root = useTempDir('export-common-'); - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('validateOutputDirectory', () => { + describe('validateOutputDirectory (real fs)', () => { it('should create directory if it does not exist', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); - vi.spyOn(fs, 'accessSync').mockReturnValue(undefined); + const directory = join(root(), 'dist', 'export'); - validateOutputDirectory('/dist/export'); + validateOutputDirectory(directory); - expect(fs.mkdirSync).toHaveBeenCalledWith('/dist/export', { - recursive: true, - }); + expect(statSync(directory).isDirectory()).toBe(true); }); it('should throw error if directory cannot be created', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => { - throw new Error('Permission denied'); - }); + writeFileSync(join(root(), 'file'), ''); - expect(() => validateOutputDirectory('/dist/export')).toThrow('Could not create output directory'); + expect(() => validateOutputDirectory(join(root(), 'file', 'export'))).toThrow( + 'Could not create output directory', + ); }); - it('should throw error if directory is not writable', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'accessSync').mockImplementation(() => { - throw new Error('Not writable'); - }); + it.skipIf(process.getuid?.() === 0)('should throw error if directory is not writable', () => { + const directory = join(root(), 'read-only'); + validateOutputDirectory(directory); + chmodSync(directory, 0o500); - expect(() => validateOutputDirectory('/dist/export')).toThrow('not writable'); + try { + expect(() => validateOutputDirectory(directory)).toThrow('not writable'); + } finally { + chmodSync(directory, 0o700); + } }); }); - describe('loadResourcesFromCollections', () => { - it('should load resources from multiple collections', () => { - const collections = [ - { name: 'Core', path: '/libs/core' }, - { name: 'App', path: '/apps/app' }, - ]; - - vi.spyOn(fs, 'existsSync').mockImplementation((p) => { - if (typeof p === 'string') { - return ( - p.includes('translations') || - p.endsWith(RESOURCE_ENTRIES_FILENAME) || - p.endsWith(TRACKER_META_FILENAME) || - p === '/libs/core' || - p === '/apps/app' - ); - } - return false; + describe('loadResources (real fs)', () => { + it('flattens each entry with its collection, translations, status and tags', () => { + const collection = testCollection(root(), { name: 'Core', tags: ['shared'] }); + seedResources(collection, { + 'button.ok': { + source: 'OK', + comment: 'Confirm', + tags: ['ui'], + translations: { es: { value: 'Vale', status: 'translated' } }, + }, }); - vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { - if (typeof p === 'string') { - if (p.includes('core')) { - if (p.endsWith(RESOURCE_ENTRIES_FILENAME)) { - return JSON.stringify({ - 'button.ok': { source: 'OK', es: 'Vale' }, - }); - } - if (p.endsWith(TRACKER_META_FILENAME)) { - return JSON.stringify({ - 'button.ok': { es: { status: 'translated' } }, - }); - } - } - if (p.includes('app')) { - if (p.endsWith(RESOURCE_ENTRIES_FILENAME)) { - return JSON.stringify({ - title: { source: 'Title', es: 'Título' }, - }); - } - if (p.endsWith(TRACKER_META_FILENAME)) { - return JSON.stringify({ - title: { es: { status: 'new' } }, - }); - } - } - } - return '{}'; - }); + const { resources, problems } = loadResources(collection, ['Acme']); + + expect(problems).toEqual([]); + expect(resources).toEqual([ + { + key: 'ok', + fullKey: 'button.ok', + source: 'OK', + translations: { es: 'Vale' }, + tags: ['ui'], + effectiveTags: ['shared', 'ui'], + collectionProtectedTerms: ['Acme'], + comment: 'Confirm', + status: { es: 'translated' }, + collection: 'Core', + }, + ]); + }); + + it('includes an entry without metadata, with no status', () => { + writeFolderFiles(root(), '', { entries: { key: { source: 'val' } }, meta: {} }); + + const { resources } = loadResources(testCollection(root())); - vi.spyOn(fs, 'readdirSync').mockReturnValue([]); + expect(resources).toHaveLength(1); + expect(resources[0]?.status).toEqual({}); + }); - const result = loadResourcesFromCollections(collections); + it('returns unreadable folders as problems', () => { + writeFolderFiles(root(), 'bad', { entries: '{ nope' }); - expect(result).toHaveLength(2); - const okBtn = result.find((r) => r.key === 'button.ok'); - expect(okBtn).toBeDefined(); - expect(okBtn?.collection).toBe('Core'); - expect(okBtn?.translations['es']).toBe('Vale'); + const { resources, problems } = loadResources(testCollection(root())); - const title = result.find((r) => r.key === 'title'); - expect(title).toBeDefined(); - expect(title?.collection).toBe('App'); + expect(resources).toEqual([]); + expect(problems.map((problem) => problem.folderPath)).toEqual(['bad']); }); - it('should handle missing metadata gracefully (skip resource)', () => { - const collections = [{ name: 'Core', path: '/libs/core' }]; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { - if (typeof p === 'string' && p.endsWith(RESOURCE_ENTRIES_FILENAME)) { - return JSON.stringify({ key: { source: 'val' } }); - } - if (typeof p === 'string' && p.endsWith(TRACKER_META_FILENAME)) { - return JSON.stringify({}); // No metadata for 'key' - } - return '{}'; - }); - vi.spyOn(fs, 'readdirSync').mockReturnValue([]); + it('reads nothing from a missing translations folder', () => { + const missing = join(root(), 'missing'); - const result = loadResourcesFromCollections(collections); - expect(result).toHaveLength(0); + expect(loadResources(testCollection(missing))).toEqual({ resources: [], problems: [] }); + expect(existsSync(missing)).toBe(false); }); }); @@ -148,6 +110,7 @@ describe('export-common', () => { status: { es: 'translated' }, collection: 'Core', tags: ['ui'], + effectiveTags: ['ui'], }, { key: 'key2', @@ -157,6 +120,7 @@ describe('export-common', () => { status: { es: 'new' }, collection: 'Core', tags: ['backend'], + effectiveTags: ['backend'], }, { key: 'key3', @@ -165,6 +129,7 @@ describe('export-common', () => { translations: { es: 'Val 3' }, status: { es: 'verified' }, collection: 'App', + effectiveTags: [], }, ]; @@ -200,6 +165,7 @@ describe('export-common', () => { translations: { es: 'Bienvenido' }, status: { es: 'new' }, collection: 'Core', + effectiveTags: [], collectionProtectedTerms: ['iPhone'], }, ]; @@ -221,6 +187,7 @@ describe('export-common', () => { translations: { es: 'Bienvenido' }, status: { es: 'new' }, collection: 'Core', + effectiveTags: [], collectionProtectedTerms: ['iPhone'], }, ]; @@ -250,6 +217,7 @@ describe('export-common', () => { translations: { es: 'Bienvenido' }, status: { es: 'new' }, collection: 'Core', + effectiveTags: [], }, ]; diff --git a/libs/core/src/lib/export/export-common.ts b/libs/core/src/lib/export/export-common.ts index 330fc4b1..13938b19 100644 --- a/libs/core/src/lib/export/export-common.ts +++ b/libs/core/src/lib/export/export-common.ts @@ -1,25 +1,19 @@ import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { readJsonFile } from '../file-io/json-file-operations'; -import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; -import { walkFolders } from '../normalize/iterative-folder-walker'; -import type { ResourceEntries } from '../../resource/resource-entry'; -import type { TrackerMetadata } from '../../resource/tracker-metadata'; -import { - effectiveTags, - effectiveProtectedTerms, - findProtectedTerms, - type TranslationStatus, -} from '@simoncodes-ca/domain'; +import type { Collection } from '../config/open-collection'; +import { type CollectionReadProblem, readCollection } from '../resource/read-collection'; +import { effectiveProtectedTerms, findProtectedTerms, type TranslationStatus } from '@simoncodes-ca/domain'; import type { FilteredResource } from './types'; +/** A stored resource flattened for the export and validate passes, with the collection it came from. */ export interface LoadedResource { key: string; fullKey: string; source: string; translations: Record; + /** The entry's own tags. */ tags?: string[]; - collectionTags?: string[]; + /** The collection's tags united with the entry's own (from the Collection Reader). */ + effectiveTags: readonly string[]; collectionProtectedTerms?: string[]; comment?: string; status: Record; @@ -57,89 +51,37 @@ export function validateOutputDirectory(directory: string): void { } /** - * Loads all resources and metadata from a list of collections. + * Reads one collection through the Collection Reader and flattens each entry for the export and + * validate passes. Folders the reader could not read are returned as `problems`. */ -export function loadResourcesFromCollections( - collections: { name: string; path: string; tags?: string[]; protectedTerms?: string[] }[], -): LoadedResource[] { - const allResources: Map = new Map(); - - for (const collection of collections) { - if (!fs.existsSync(collection.path)) { - console.warn(`Collection path not found: ${collection.path}`); - continue; - } - - // The collection.path should point directly to where translations are stored - loadFolderResources(collection.path, collection.name, allResources, collection.tags, collection.protectedTerms); - } - - return Array.from(allResources.values()); -} - -function loadFolderResources( - collectionPath: string, - collectionName: string, - allResources: Map, - collectionTags?: string[], - collectionProtectedTerms?: string[], -): void { - for (const visit of walkFolders(collectionPath, { skipHidden: false })) { - const entriesPath = path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME); - const metaPath = path.join(visit.absolutePath, TRACKER_META_FILENAME); - - if (!fs.existsSync(entriesPath) || !fs.existsSync(metaPath)) continue; - - try { - const entries = readJsonFile({ filePath: entriesPath }); - const metadata = readJsonFile({ filePath: metaPath }); - - for (const [key, entry] of Object.entries(entries)) { - const fullKey = visit.keyPrefix ? `${visit.keyPrefix}.${key}` : key; - const meta = metadata[key]; - - if (!meta) { - // Skip if no metadata (will be reported as omitted in summary if we track it) - continue; - } - - const loadedResource: LoadedResource = { - key, - fullKey, - source: entry.source, - translations: {}, - tags: entry.tags, - collectionTags, - collectionProtectedTerms, - comment: entry.comment, - status: {}, - collection: collectionName, - }; - - // Extract translations and status - // We assume the entry has keys for locales like 'es', 'fr', etc. - // But ResourceEntry type is flexible. - // We'll iterate over keys that are not source, tags, comment - for (const [prop, value] of Object.entries(entry)) { - if (prop !== 'source' && prop !== 'tags' && prop !== 'comment' && typeof value === 'string') { - loadedResource.translations[prop] = value; - } - } - - // Extract status - for (const [locale, localeMeta] of Object.entries(meta)) { - if (localeMeta && typeof localeMeta === 'object' && 'status' in localeMeta) { - loadedResource.status[locale] = localeMeta.status as TranslationStatus; - } - } - - // Add to map (last write wins for same fullKey) - allResources.set(fullKey, loadedResource); +export function loadResources( + collection: Pick, + protectedTerms?: string[], +): { resources: LoadedResource[]; problems: CollectionReadProblem[] } { + const { resources, problems } = readCollection(collection); + + return { + resources: resources.map(({ fullKey, entryKey, entry, effectiveTags }) => { + const status: Record = {}; + for (const [locale, localeMeta] of Object.entries(entry.metadata)) { + if (localeMeta?.status) status[locale] = localeMeta.status; } - } catch (e) { - console.warn(`Error loading files in ${visit.absolutePath}: ${(e as Error).message}`); - } - } + + return { + key: entryKey, + fullKey, + source: entry.source, + translations: { ...entry.translations }, + tags: entry.tags, + effectiveTags, + collectionProtectedTerms: protectedTerms, + comment: entry.comment, + status, + collection: collection.name, + }; + }), + problems, + }; } /** @@ -173,8 +115,7 @@ export function filterResources( // Tag filter — use effective tags (collection-level union resource-level) if (tagFilter && tagFilter.length > 0) { - const tags = effectiveTags(res.collectionTags, res.tags); - const hasMatch = tagFilter.some((tag) => tags.includes(tag)); + const hasMatch = tagFilter.some((tag) => res.effectiveTags.includes(tag)); if (!hasMatch) { return false; } diff --git a/libs/core/src/lib/export/run-export.spec.ts b/libs/core/src/lib/export/run-export.spec.ts index 9e86babd..4ff4df4c 100644 --- a/libs/core/src/lib/export/run-export.spec.ts +++ b/libs/core/src/lib/export/run-export.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -270,6 +270,59 @@ describe('runExport', () => { expect(result.malformedFiles).toEqual(['k/resource_entries.json']); }); + it('lists an unreadable folder under malformed files and exports the rest', async () => { + const common = open('common'); + seed(common, 'good', { ok: { source: 'OK', translations: { fr: 'Bien' } } }); + mkdirSync(join(common.translationsFolder, 'bad'), { recursive: true }); + writeFileSync(join(common.translationsFolder, 'bad', 'resource_entries.json'), '{ nope'); + + const result = await runExport([common], { + format: 'json', + outputDirectory, + jsonStructure: 'flat', + locales: ['fr'], + }); + + expect(readJson('fr.json')).toEqual({ 'good.ok': 'Bien' }); + expect(result.malformedFiles).toHaveLength(1); + expect(result.malformedFiles[0]).toContain(join('bad', 'resource_entries.json')); + expect(result.summary).toContain('### Malformed Files'); + }); + + it('warns about a collection whose translations folder does not exist', async () => { + const common = open('common'); + seed(common, 'k', { ok: { source: 'OK' } }); + const missing = { ...open('frOnly'), translationsFolder: join(projectDir, 'translations', 'typo') }; + + const result = await runExport([common, missing], { format: 'json', outputDirectory, locales: ['fr'] }); + + expect(result.warnings).toContain( + `Collection 'frOnly': translations folder not found: ${join(projectDir, 'translations', 'typo')}`, + ); + expect(result.summary).toContain('translations folder not found'); + expect(result.resourcesExported).toBe(1); + }); + + it('exports an entry without metadata as new', async () => { + const common = open('common'); + mkdirSync(join(common.translationsFolder, 'loose'), { recursive: true }); + writeFileSync( + join(common.translationsFolder, 'loose', 'resource_entries.json'), + JSON.stringify({ x: { source: 'X' } }), + ); + + const result = await runExport([common], { + format: 'json', + outputDirectory, + jsonStructure: 'flat', + locales: ['fr'], + status: ['new'], + }); + + expect(result.resourcesExported).toBe(1); + expect(readJson('fr.json')).toEqual({ 'loose.x': '' }); + }); + it('passes each locale and the shared base locale to the exporter', async () => { const common = open('common'); seed(common, 'j', { ok: { source: 'OK' } }); diff --git a/libs/core/src/lib/export/run-export.ts b/libs/core/src/lib/export/run-export.ts index 6a49fca2..907ef32f 100644 --- a/libs/core/src/lib/export/run-export.ts +++ b/libs/core/src/lib/export/run-export.ts @@ -1,5 +1,6 @@ +import { existsSync } from 'node:fs'; import type { Collection } from '../config/open-collection'; -import { filterResources, loadResourcesFromCollections } from './export-common'; +import { filterResources, loadResources } from './export-common'; import { generateExportSummary } from './export-summary'; import { exportToJson } from './export-to-json'; import { exportToXliff } from './export-to-xliff'; @@ -79,19 +80,21 @@ export async function runExport( const localeResults: ExportLocaleResult[] = []; if (targetLocales.length > 0) { - // Loaded per collection so a key shared by two collections survives in each one's own locales. + // Read per collection so a key shared by two collections survives in each one's own locales. const resourcesByCollection = new Map( - collections.map((collection) => [ - collection.name, - loadResourcesFromCollections([ - { - name: collection.name, - path: collection.translationsFolder, - tags: [...collection.tags], - protectedTerms: protectedTerms?.collections?.[collection.name], - }, - ]), - ]), + collections.map((collection) => { + // The reader reads a missing folder as an empty collection; say so, since a mistyped + // translationsFolder would otherwise export nothing without a word. + if (!existsSync(collection.translationsFolder)) { + totals.warnings.push( + `Collection '${collection.name}': translations folder not found: ${collection.translationsFolder}`, + ); + } + const { resources, problems } = loadResources(collection, protectedTerms?.collections?.[collection.name]); + // A folder the reader could not read is left out of every locale file; the summary lists it. + totals.malformedFiles.push(...problems.map((problem) => problem.message)); + return [collection.name, resources]; + }), ); for (const locale of targetLocales) { diff --git a/libs/core/src/lib/normalize/iterative-folder-walker.real-fs.spec.ts b/libs/core/src/lib/normalize/iterative-folder-walker.real-fs.spec.ts index 869c3ab8..a18ee066 100644 --- a/libs/core/src/lib/normalize/iterative-folder-walker.real-fs.spec.ts +++ b/libs/core/src/lib/normalize/iterative-folder-walker.real-fs.spec.ts @@ -143,4 +143,37 @@ describe('walkFolders (real filesystem)', () => { expect(keyPrefixByPath[path.join(tempDir, 'apps', 'common')]).toBe('apps.common'); expect(keyPrefixByPath[path.join(tempDir, 'apps', 'common', 'buttons')]).toBe('apps.common.buttons'); }); + + it('reports a root that is a file through onUnlistable and yields nothing', () => { + const file = path.join(tempDir, 'file'); + fs.writeFileSync(file, ''); + const unlistable: string[] = []; + + const visits = [...walkFolders(file, { onUnlistable: (absolutePath) => unlistable.push(absolutePath) })]; + + expect(visits).toEqual([]); + expect(unlistable).toEqual([file]); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'reports a subdirectory it cannot list through onUnlistable and keeps walking', + () => { + const locked = path.join(tempDir, 'locked'); + fs.mkdirSync(locked); + fs.mkdirSync(path.join(tempDir, 'open')); + fs.chmodSync(locked, 0o000); + const unlistable: string[] = []; + + try { + const visited = [ + ...walkFolders(tempDir, { onUnlistable: (absolutePath) => unlistable.push(absolutePath) }), + ].map((visit) => visit.keyPrefix); + + expect(visited.sort()).toEqual(['', 'open']); + expect(unlistable).toEqual([locked]); + } finally { + fs.chmodSync(locked, 0o700); + } + }, + ); }); diff --git a/libs/core/src/lib/normalize/iterative-folder-walker.ts b/libs/core/src/lib/normalize/iterative-folder-walker.ts index 09299ed4..16e9917c 100644 --- a/libs/core/src/lib/normalize/iterative-folder-walker.ts +++ b/libs/core/src/lib/normalize/iterative-folder-walker.ts @@ -8,6 +8,11 @@ export interface WalkFoldersOptions { maxDepth?: number; /** When provided, realpath of each directory is checked against this set for cycle detection. */ visitedPaths?: Set; + /** + * Called for a directory (the root included) that exists but cannot be listed — permission + * denied, or a file where a directory was expected. The directory is skipped either way. + */ + onUnlistable?: (absolutePath: string, error: unknown) => void; } export interface FolderVisit { @@ -85,8 +90,9 @@ export function* walkFolders(rootPath: string, options?: WalkFoldersOptions): Ge let dirEntries: fs.Dirent[]; try { dirEntries = fs.readdirSync(current.absolutePath, { withFileTypes: true }); - } catch { - // Directory became inaccessible between existence check and read — skip + } catch (error) { + // Cannot be listed (permission denied, not a directory, or gone since the check) — skip + options?.onUnlistable?.(current.absolutePath, error); continue; } diff --git a/libs/core/src/lib/resource/index.ts b/libs/core/src/lib/resource/index.ts index d6fcd0ad..b4b3f7f5 100644 --- a/libs/core/src/lib/resource/index.ts +++ b/libs/core/src/lib/resource/index.ts @@ -8,6 +8,13 @@ export { type ResourceTreeEntry, type ResourceTreeNode, } from './load-resource-tree'; +export { + type CollectionRead, + type CollectionReadProblem, + type CollectionReadTarget, + readCollection, + type StoredResource, +} from './read-collection'; export { type ResolvedResourcePaths, type ResourcePathResolutionParams, diff --git a/libs/core/src/lib/resource/load-resource-tree.spec.ts b/libs/core/src/lib/resource/load-resource-tree.spec.ts index 8064980d..3cf40099 100644 --- a/libs/core/src/lib/resource/load-resource-tree.spec.ts +++ b/libs/core/src/lib/resource/load-resource-tree.spec.ts @@ -1,117 +1,13 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdirSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTempDir, writeFolderFiles } from '../../testing/temp-dir.spec-helpers'; import { loadResourceTree } from './load-resource-tree'; -import type * as fs from 'node:fs'; -const mockFs = vi.hoisted(() => { - return new Map([ - // Root directory and files - ['/test/translations', 'directory'], - [ - '/test/translations/resource_entries.json', - JSON.stringify({ - title: { - source: 'App Title', - es: 'Título de la Aplicación', - fr: "Titre de l'Application", - }, - }), - ], - [ - '/test/translations/tracker_meta.json', - JSON.stringify({ - title: { - en: { checksum: 'abc123' }, - es: { - status: 'translated', - checksum: 'def456', - baseChecksum: 'abc123', - }, - fr: { status: 'stale', checksum: 'ghi789', baseChecksum: 'abc123' }, - }, - }), - ], - - // apps directory - ['/test/translations/apps', 'directory'], +const FIXTURE_ROOT = '/test/translations'; - // apps/common directory and files - ['/test/translations/apps/common', 'directory'], - [ - '/test/translations/apps/common/resource_entries.json', - JSON.stringify({ - header: { - source: 'Common Header', - es: 'Encabezado Común', - fr: 'En-tête Commun', - tags: ['ui', 'common'], - comment: 'Main header text', - }, - }), - ], - [ - '/test/translations/apps/common/tracker_meta.json', - JSON.stringify({ - header: { - en: { checksum: 'aaa111' }, - es: { - status: 'verified', - checksum: 'bbb222', - baseChecksum: 'aaa111', - }, - fr: { - status: 'translated', - checksum: 'ccc333', - baseChecksum: 'aaa111', - }, - }, - }), - ], - - // apps/common/buttons directory and files - ['/test/translations/apps/common/buttons', 'directory'], - [ - '/test/translations/apps/common/buttons/resource_entries.json', - JSON.stringify({ - ok: { - source: 'OK', - es: 'Aceptar', - fr: "D'accord", - }, - cancel: { - source: 'Cancel', - es: 'Cancelar', - fr: 'Annuler', - tags: ['button'], - }, - }), - ], - [ - '/test/translations/apps/common/buttons/tracker_meta.json', - JSON.stringify({ - ok: { - en: { checksum: 'ok111' }, - es: { status: 'verified', checksum: 'ok222', baseChecksum: 'ok111' }, - fr: { - status: 'translated', - checksum: 'ok333', - baseChecksum: 'ok111', - }, - }, - cancel: { - en: { checksum: 'can111' }, - es: { - status: 'translated', - checksum: 'can222', - baseChecksum: 'can111', - }, - fr: { status: 'new', checksum: '', baseChecksum: 'can111' }, - }, - }), - ], - ]); -}); - -const createMockFileSystem = () => { +/** The fixture tree, keyed by POSIX path under `/test/translations`; written to a temp dir per test. */ +const createFixture = () => { return new Map([ // Root directory and files ['/test/translations', 'directory'], @@ -220,84 +116,36 @@ const createMockFileSystem = () => { ]); }; -/** - * The fixture map in this suite is keyed on POSIX paths, while the code under test resolves to - * platform-native form. Stripping a drive letter and backslashes is the identity on POSIX. - */ -const toFixtureKey = vi.hoisted( - () => - (p: { toString(): string }): string => - p - .toString() - .replace(/^[A-Za-z]:/, '') - .replace(/\\/g, '/'), -); - -vi.mock('node:fs', () => ({ - existsSync: vi.fn((filePath: fs.PathLike) => { - return mockFs.has(toFixtureKey(filePath)); - }), - readFileSync: vi.fn((filePath: fs.PathLike) => { - const content = mockFs.get(toFixtureKey(filePath)); - if (content === 'directory' || content === undefined) { - throw new Error(`ENOENT: no such file or directory, open '${filePath}'`); - } - return content; - }), - realpathSync: vi.fn((filePath: fs.PathLike) => { - return filePath.toString(); - }), - readdirSync: vi.fn((dirPath: fs.PathLike, _options?: any) => { - const dirPathStr = toFixtureKey(dirPath); - const entries: fs.Dirent[] = []; - - for (const [fsPath, type] of mockFs.entries()) { - const pathParts = fsPath.split('/').filter(Boolean); - const dirParts = dirPathStr.split('/').filter(Boolean); - - // Check if this is a direct child of dirPath - if (pathParts.length === dirParts.length + 1 && fsPath.startsWith(`${dirPathStr}/`)) { - const name = pathParts[pathParts.length - 1]; - const isDirectory = type === 'directory'; - - entries.push({ - name, - isDirectory: () => isDirectory, - isFile: () => !isDirectory, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isSymbolicLink: () => false, - isFIFO: () => false, - isSocket: () => false, - parentPath: dirPathStr, - path: dirPathStr, - } as fs.Dirent); +describe('loadResourceTree (real fs)', () => { + const tempDir = useTempDir('load-resource-tree-'); + let translationsFolder: string; + + /** Writes fixture entries (keyed under `/test/translations`) into the temp translations folder. */ + function materialize(entries: Iterable<[string, string]>): void { + for (const [fixturePath, content] of entries) { + const target = join(translationsFolder, ...fixturePath.replace(FIXTURE_ROOT, '').split('/').filter(Boolean)); + if (content === 'directory') { + mkdirSync(target, { recursive: true }); + } else { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); } } - - return entries; - }), -})); - -describe('loadResourceTree', () => { - const translationsFolder = '/test/translations'; + } beforeEach(() => { - // Reset the mock filesystem to initial state - mockFs.clear(); - const initialFs = createMockFileSystem(); - for (const [key, value] of initialFs.entries()) { - mockFs.set(key, value); - } + translationsFolder = join(tempDir(), 'translations'); + materialize(createFixture()); }); describe('depth=0 (current folder only)', () => { it('should load root folder resources with children marked unloaded', () => { const result = loadResourceTree({ translationsFolder, + baseLocale: 'en', path: '', depth: 0, - cwd: '/', + cwd: tempDir(), }); // Should have root resources @@ -323,9 +171,10 @@ describe('loadResourceTree', () => { it('should recursively load folders up to depth 2', () => { const result = loadResourceTree({ translationsFolder, + baseLocale: 'en', path: '', depth: 2, - cwd: '/', + cwd: tempDir(), }); // Root level @@ -371,9 +220,10 @@ describe('loadResourceTree', () => { it('should load from nested folder path', () => { const result = loadResourceTree({ translationsFolder, + baseLocale: 'en', path: 'apps.common', depth: 1, - cwd: '/', + cwd: tempDir(), }); // Should load apps/common as root @@ -402,38 +252,40 @@ describe('loadResourceTree', () => { // Add two sibling subdirectories under apps/common: buttons and forms // They are inserted in alphabetical (readdir) order so the mock returns them // in that order — the iterative implementation must preserve it. - mockFs.set('/test/translations/apps/common/forms', 'directory'); - mockFs.set( - '/test/translations/apps/common/forms/resource_entries.json', - JSON.stringify({ - email: { source: 'Email', es: 'Correo', fr: 'E-mail' }, - }), - ); - mockFs.set( - '/test/translations/apps/common/forms/tracker_meta.json', - JSON.stringify({ - email: { - en: { checksum: 'form111' }, - es: { status: 'translated', checksum: 'form222', baseChecksum: 'form111' }, - fr: { status: 'new', checksum: '', baseChecksum: 'form111' }, - }, - }), - ); + materialize([ + ['/test/translations/apps/common/forms', 'directory'], + [ + '/test/translations/apps/common/forms/resource_entries.json', + JSON.stringify({ email: { source: 'Email', es: 'Correo', fr: 'E-mail' } }), + ], + [ + '/test/translations/apps/common/forms/tracker_meta.json', + JSON.stringify({ + email: { + en: { checksum: 'form111' }, + es: { status: 'translated', checksum: 'form222', baseChecksum: 'form111' }, + fr: { status: 'new', checksum: '', baseChecksum: 'form111' }, + }, + }), + ], + ]); + const readdirOrder = readdirSync(join(translationsFolder, 'apps', 'common'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); const result = loadResourceTree({ translationsFolder, + baseLocale: 'en', path: 'apps.common', depth: 1, - cwd: '/', + cwd: tempDir(), }); // Both children should be loaded expect(result.children).toHaveLength(2); - // The Map iterates insertion order: buttons was inserted before forms, - // so readdir returns ["buttons", "forms"] — the tree must preserve that order. - expect(result.children[0].name).toBe('buttons'); - expect(result.children[1].name).toBe('forms'); + // The tree keeps the order the filesystem lists the folders in. + expect(result.children.map((child) => child.name)).toEqual(readdirOrder); }); }); @@ -442,9 +294,10 @@ describe('loadResourceTree', () => { expect(() => loadResourceTree({ translationsFolder, + baseLocale: 'en', path: 'nonexistent.folder', depth: 1, - cwd: '/', + cwd: tempDir(), }), ).toThrow('Folder not found'); }); @@ -453,9 +306,10 @@ describe('loadResourceTree', () => { // Use the apps folder which has no resource files at the root level const result = loadResourceTree({ translationsFolder, + baseLocale: 'en', path: 'apps', depth: 0, - cwd: '/', + cwd: tempDir(), }); expect(result.resources).toHaveLength(0); @@ -467,9 +321,10 @@ describe('loadResourceTree', () => { it('should extract metadata for all locales', () => { const result = loadResourceTree({ translationsFolder, + baseLocale: 'en', path: '', depth: 0, - cwd: '/', + cwd: tempDir(), }); const titleResource = result.resources[0]; @@ -488,4 +343,41 @@ describe('loadResourceTree', () => { expect(titleResource.metadata['fr'].status).toBe('stale'); }); }); + describe('Collection Reader rules', () => { + it('includes an entry without metadata, with empty metadata', () => { + writeFolderFiles(translationsFolder, 'loose', { entries: { orphan: { source: 'Orphan' } } }); + + const result = loadResourceTree({ translationsFolder, baseLocale: 'en', path: 'loose', depth: 0 }); + + expect(result.resources).toEqual([{ key: 'orphan', source: 'Orphan', translations: {}, metadata: {} }]); + }); + + it('keeps an unreadable folder in the tree without resources, and logs it', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + writeFolderFiles(translationsFolder, 'apps.broken', { entries: '{ nope' }); + + const result = loadResourceTree({ translationsFolder, baseLocale: 'en', path: 'apps', depth: 1 }); + + const broken = result.children.find((child) => child.name === 'broken'); + expect(broken?.loaded).toBe(true); + expect(broken?.tree?.resources).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('resource_entries.json')); + warn.mockRestore(); + }); + + it('throws when the translations folder is a file', () => { + const file = join(tempDir(), 'file'); + writeFileSync(file, 'not a folder'); + + expect(() => loadResourceTree({ translationsFolder: file, baseLocale: 'en' })).toThrow('Not a folder'); + }); + + it('treats a missing translations folder as an empty tree', () => { + expect(loadResourceTree({ translationsFolder: join(tempDir(), 'missing'), baseLocale: 'en' })).toEqual({ + folderPathSegments: [], + resources: [], + children: [], + }); + }); + }); }); diff --git a/libs/core/src/lib/resource/load-resource-tree.ts b/libs/core/src/lib/resource/load-resource-tree.ts index e5011630..2790670f 100644 --- a/libs/core/src/lib/resource/load-resource-tree.ts +++ b/libs/core/src/lib/resource/load-resource-tree.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; -import { openResourceFolder } from './resource-folder'; +import { readCollectionFolders } from './read-collection'; export interface ResourceTreeNode { /** Folder path segments (empty array for root) */ @@ -34,6 +34,9 @@ export interface LoadResourceTreeOptions { /** Root translations folder path */ translationsFolder: string; + /** The collection's base locale; folders are opened with it. */ + baseLocale: string; + /** Folder to start from (dot-delimited, empty for root) */ path?: string; @@ -44,26 +47,21 @@ export interface LoadResourceTreeOptions { cwd?: string; } -interface StackEntry { - readonly folderPath: string; - readonly pathSegments: string[]; - readonly depth: number; - readonly parentChildren: FolderChild[]; -} - +/** + * Loads the resource tree of a translations folder (or of the subfolder at `path`), `depth` levels + * deep; deeper folders are listed as not loaded. Folders are read through the Collection Reader, + * so its rules apply: an entry without metadata has `metadata: {}`, and a folder that cannot be + * read has no resources (the problem is logged). + * + * @throws Error when `path` names a folder that does not exist, or the start folder is not a folder. + * A missing translations folder is an empty tree. + */ export function loadResourceTree(options: LoadResourceTreeOptions): ResourceTreeNode { - const { translationsFolder, path: folderPath = '', depth = 2, cwd = process.cwd() } = options; - - // Parse folder path into segments + const { baseLocale, path: folderPath = '', depth = 2, cwd = process.cwd() } = options; + const translationsFolder = path.resolve(cwd, options.translationsFolder); const pathSegments = folderPath ? folderPath.split('.').filter(Boolean) : []; + const absoluteFolderPath = path.join(translationsFolder, ...pathSegments); - // Resolve absolute folder path - const absoluteFolderPath = - pathSegments.length > 0 - ? path.resolve(cwd, translationsFolder, ...pathSegments) - : path.resolve(cwd, translationsFolder); - - // Check if folder exists if (!fs.existsSync(absoluteFolderPath)) { if (pathSegments.length === 0) { // Root translations folder doesn't exist yet (e.g. fresh project) — treat as empty @@ -71,139 +69,47 @@ export function loadResourceTree(options: LoadResourceTreeOptions): ResourceTree } throw new Error(`Folder not found: ${absoluteFolderPath}`); } - - // Initialize visited paths for cycle detection - const visitedPaths = new Set(); - - return loadFolderIterative(absoluteFolderPath, pathSegments, depth, visitedPaths); -} - -function loadResourcesFromFolder(folderPath: string): ResourceTreeEntry[] { - const resources: ResourceTreeEntry[] = []; - - try { - const folder = openResourceFolder(folderPath); - for (const key of folder.keys()) { - // Entries without metadata are skipped (a folder without tracker_meta.json has no resources) - const resource = folder.treeEntry(key); - if (resource) resources.push(resource); - } - } catch (error) { - // Malformed JSON, skip this folder's resources - console.warn(`Error loading resources from ${folderPath}:`, error); + if (!fs.statSync(absoluteFolderPath).isDirectory()) { + throw new Error(`Not a folder: ${absoluteFolderPath}`); } - return resources; -} + const nodes = new Map(); + let rootNode: ResourceTreeNode = { folderPathSegments: pathSegments, resources: [], children: [] }; -function loadFolderIterative( - rootFolderPath: string, - rootPathSegments: string[], - maxDepth: number, - visitedPaths: Set, -): ResourceTreeNode { - const rootRealPath = fs.realpathSync(rootFolderPath); - visitedPaths.add(rootRealPath); - - const rootNode: ResourceTreeNode = { - folderPathSegments: rootPathSegments, - resources: loadResourcesFromFolder(rootFolderPath), - children: [], - }; - - const stack: StackEntry[] = []; - - const rootDirEntries = fs.readdirSync(rootFolderPath, { withFileTypes: true }); - - if (maxDepth === 0) { - // Children are pushed directly (not onto the stack), so forward iteration preserves readdir order. - for (const dirEntry of rootDirEntries) { - if (!dirEntry.isDirectory() || dirEntry.name.startsWith('.')) continue; - const childName = dirEntry.name; - rootNode.children.push({ - name: childName, - fullPathSegments: [...rootPathSegments, childName], - loaded: false, - }); - } - } else { - // Push in reverse so the stack pops entries in forward (readdir) order. - for (let i = rootDirEntries.length - 1; i >= 0; i--) { - const dirEntry = rootDirEntries[i]; - if (!dirEntry.isDirectory() || dirEntry.name.startsWith('.')) continue; - const childName = dirEntry.name; - stack.push({ - folderPath: path.join(rootFolderPath, childName), - pathSegments: [...rootPathSegments, childName], - depth: 1, - parentChildren: rootNode.children, - }); - } - } + const folders = readCollectionFolders( + { translationsFolder, baseLocale, tags: [] }, + { startPath: pathSegments.join('.'), maxDepth: depth }, + ); - while (stack.length > 0) { - const { folderPath, pathSegments, depth, parentChildren } = stack.pop() as StackEntry; - - let realPath: string; - try { - realPath = fs.realpathSync(folderPath); - } catch { - parentChildren.push({ - name: pathSegments[pathSegments.length - 1], - fullPathSegments: pathSegments, - loaded: false, - }); - continue; - } - if (visitedPaths.has(realPath)) { - // Cycle detected — push an empty loaded node so the child is represented - parentChildren.push({ - name: pathSegments[pathSegments.length - 1], - fullPathSegments: pathSegments, - loaded: true, - tree: { folderPathSegments: pathSegments, resources: [], children: [] }, - }); - continue; + // Parents are visited before their children, and siblings in directory order. + for (const folder of folders) { + if (folder.problem) { + console.warn(`Error loading resources from ${folder.absolutePath}: ${folder.problem.message}`); } - visitedPaths.add(realPath); + const segments = [...folder.segments]; const node: ResourceTreeNode = { - folderPathSegments: pathSegments, - resources: loadResourcesFromFolder(folderPath), - children: [], + folderPathSegments: segments, + resources: folder.resources.map((resource) => resource.entry), + children: + folder.depth >= depth + ? folder.subfolderNames.map((name) => ({ name, fullPathSegments: [...segments, name], loaded: false })) + : [], }; + nodes.set(folder.absolutePath, node); - parentChildren.push({ - name: pathSegments[pathSegments.length - 1], - fullPathSegments: pathSegments, + if (folder.depth === 0) { + rootNode = node; + continue; + } + + const parent = nodes.get(path.dirname(folder.absolutePath)); + parent?.children.push({ + name: segments[segments.length - 1], + fullPathSegments: segments, loaded: true, tree: node, }); - - const dirEntries = fs.readdirSync(folderPath, { withFileTypes: true }); - for (let i = dirEntries.length - 1; i >= 0; i--) { - const dirEntry = dirEntries[i]; - if (!dirEntry.isDirectory() || dirEntry.name.startsWith('.')) continue; - - const childName = dirEntry.name; - const childPathSegments = [...pathSegments, childName]; - const childFolderPath = path.join(folderPath, childName); - - if (depth < maxDepth) { - stack.push({ - folderPath: childFolderPath, - pathSegments: childPathSegments, - depth: depth + 1, - parentChildren: node.children, - }); - } else { - node.children.push({ - name: childName, - fullPathSegments: childPathSegments, - loaded: false, - }); - } - } } return rootNode; diff --git a/libs/core/src/lib/resource/read-collection.real-fs.spec.ts b/libs/core/src/lib/resource/read-collection.real-fs.spec.ts new file mode 100644 index 00000000..15e88aec --- /dev/null +++ b/libs/core/src/lib/resource/read-collection.real-fs.spec.ts @@ -0,0 +1,195 @@ +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { seedResources, testCollection, useTempDir, writeFolderFiles } from '../../testing/temp-dir.spec-helpers'; +import { readCollection, readCollectionFolders } from './read-collection'; + +describe('readCollection (real fs)', () => { + const root = useTempDir('read-collection-'); + + it('reads every entry with its address, stored values, metadata and effective tags', () => { + const collection = testCollection(root(), { tags: ['shared'] }); + seedResources(collection, { + title: { source: 'App' }, + 'apps.common.buttons.ok': { + source: 'OK', + comment: 'Confirm button', + tags: ['ui'], + translations: { fr: { value: "D'accord", status: 'verified' } }, + }, + }); + + const { resources, problems } = readCollection(collection); + + expect(problems).toEqual([]); + expect(resources.map((resource) => resource.fullKey)).toEqual(['title', 'apps.common.buttons.ok']); + + const ok = resources.find((resource) => resource.fullKey === 'apps.common.buttons.ok'); + expect(ok).toBeDefined(); + expect(ok?.folderPath).toBe('apps.common.buttons'); + expect(ok?.entryKey).toBe('ok'); + expect(ok?.entry).toMatchObject({ + key: 'ok', + source: 'OK', + translations: { fr: "D'accord" }, + comment: 'Confirm button', + tags: ['ui'], + }); + expect(ok?.entry.metadata.fr?.status).toBe('verified'); + expect(ok?.entry.metadata.en?.checksum).toBeDefined(); + expect(ok?.effectiveTags).toEqual(['shared', 'ui']); + + const title = resources.find((resource) => resource.fullKey === 'title'); + expect(title?.folderPath).toBe(''); + expect(title?.effectiveTags).toEqual(['shared']); + }); + + it('reads an entry without a metadata record with empty metadata', () => { + writeFolderFiles(root(), 'common', { + entries: { ok: { source: 'OK', fr: 'OK' }, cancel: { source: 'Cancel' } }, + meta: { ok: { en: { checksum: 'a' }, fr: { checksum: 'b', baseChecksum: 'a', status: 'translated' } } }, + }); + + const { resources } = readCollection(testCollection(root())); + + expect(resources.map((resource) => [resource.fullKey, resource.entry.metadata])).toEqual([ + ['common.ok', { en: { checksum: 'a' }, fr: { checksum: 'b', baseChecksum: 'a', status: 'translated' } }], + ['common.cancel', {}], + ]); + }); + + it('reads a folder without tracker_meta.json with empty metadata', () => { + writeFolderFiles(root(), 'common', { entries: { ok: { source: 'OK', fr: 'Bien' } } }); + + const { resources, problems } = readCollection(testCollection(root())); + + expect(problems).toEqual([]); + expect(resources).toHaveLength(1); + expect(resources[0]?.entry).toMatchObject({ source: 'OK', translations: { fr: 'Bien' }, metadata: {} }); + }); + + it('reports a folder with malformed JSON as a problem, skips its entries and keeps reading', () => { + const collection = testCollection(root()); + seedResources(collection, { 'good.ok': { source: 'OK' }, 'zz.later': { source: 'Later' } }); + writeFolderFiles(root(), 'bad', { entries: { broken: { source: 'Broken' } }, meta: '{ not json' }); + + const { resources, problems } = readCollection(collection); + + expect(resources.map((resource) => resource.fullKey).sort()).toEqual(['good.ok', 'zz.later']); + expect(problems).toHaveLength(1); + expect(problems[0]).toMatchObject({ folderPath: 'bad', absolutePath: join(root(), 'bad') }); + expect(problems[0]?.message).toContain('tracker_meta.json'); + }); + + it('reports a folder whose entry is not an object', () => { + writeFolderFiles(root(), 'odd', { entries: { ok: { source: 'OK' }, bare: null } }); + + const { resources, problems } = readCollection(testCollection(root())); + + expect(resources).toEqual([]); + expect(problems.map((problem) => problem.folderPath)).toEqual(['odd']); + expect(problems[0]?.message).toContain('"bare"'); + }); + + it('skips hidden folders', () => { + const collection = testCollection(root()); + seedResources(collection, { 'visible.ok': { source: 'OK' } }); + mkdirSync(join(root(), '.hidden')); + writeFolderFiles(join(root(), '.hidden'), '', { entries: { secret: { source: 'Secret' } } }); + + expect(readCollection(collection).resources.map((resource) => resource.fullKey)).toEqual(['visible.ok']); + }); + + it('reads a missing translations folder as an empty collection without problems', () => { + expect(readCollection(testCollection(join(root(), 'missing')))).toEqual({ resources: [], problems: [] }); + }); + + it('reports a translations folder that is a file', () => { + const file = join(root(), 'translations'); + writeFileSync(file, 'not a folder'); + + const { resources, problems } = readCollection(testCollection(file)); + + expect(resources).toEqual([]); + expect(problems).toHaveLength(1); + expect(problems[0]).toMatchObject({ folderPath: '', absolutePath: file }); + expect(problems[0]?.message).toContain('Cannot list folder'); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'reports a subfolder that cannot be listed and keeps reading the others', + () => { + const collection = testCollection(root()); + seedResources(collection, { 'public.ok': { source: 'OK' }, 'private.secret': { source: 'Secret' } }); + const privateFolder = join(root(), 'private'); + chmodSync(privateFolder, 0o000); + + try { + const { resources, problems } = readCollection(collection); + + expect(resources.map((resource) => resource.fullKey)).toEqual(['public.ok']); + expect(problems).toHaveLength(1); + expect(problems[0]).toMatchObject({ folderPath: 'private', absolutePath: privateFolder }); + expect(problems[0]?.message).toContain('Cannot list folder'); + } finally { + chmodSync(privateFolder, 0o700); + } + }, + ); + + it('reads non-array tags as no tags instead of failing the folder', () => { + writeFolderFiles(root(), '', { entries: { a: { source: 'A', tags: null }, b: { source: 'B', tags: 5 } } }); + + const { resources, problems } = readCollection(testCollection(root(), { tags: ['shared'] })); + + expect(problems).toEqual([]); + expect(resources.map((resource) => [resource.entry.tags, resource.effectiveTags])).toEqual([ + [undefined, ['shared']], + [undefined, ['shared']], + ]); + }); + + it('keeps a stored base-locale value in translations as stored', () => { + writeFolderFiles(root(), '', { entries: { ok: { source: 'OK', en: 'Okay', fr: 'Bien' } } }); + + const { resources } = readCollection(testCollection(root())); + + expect(resources[0]?.entry.source).toBe('OK'); + expect(resources[0]?.entry.translations).toEqual({ en: 'Okay', fr: 'Bien' }); + }); +}); + +describe('readCollectionFolders (real fs)', () => { + const root = useTempDir('read-collection-folders-'); + + it('starts at a subfolder, stops at maxDepth and lists the subfolders it did not enter', () => { + const collection = testCollection(root()); + seedResources(collection, { + 'apps.title': { source: 'Title' }, + 'apps.common.ok': { source: 'OK' }, + 'apps.common.deep.leaf': { source: 'Leaf' }, + 'other.x': { source: 'X' }, + }); + + const folders = [...readCollectionFolders(collection, { startPath: 'apps', maxDepth: 1 })]; + + expect(folders.map((folder) => [folder.folderPath, folder.depth, folder.subfolderNames])).toEqual([ + ['apps', 0, ['common']], + ['apps.common', 1, ['deep']], + ]); + expect(folders[1]?.segments).toEqual(['apps', 'common']); + expect(folders[1]?.resources.map((resource) => resource.fullKey)).toEqual(['apps.common.ok']); + }); + + it('is lazy, so a caller can stop early', () => { + const collection = testCollection(root()); + seedResources(collection, { 'a.one': { source: '1' }, 'b.two': { source: '2' } }); + + const iterator = readCollectionFolders(collection); + const first = iterator.next(); + + expect(first.done).toBe(false); + expect(first.value?.folderPath).toBe(''); + iterator.return(undefined); + }); +}); diff --git a/libs/core/src/lib/resource/read-collection.ts b/libs/core/src/lib/resource/read-collection.ts new file mode 100644 index 00000000..fbbf71d8 --- /dev/null +++ b/libs/core/src/lib/resource/read-collection.ts @@ -0,0 +1,199 @@ +import { join, relative, sep } from 'node:path'; +import { effectiveTags } from '@simoncodes-ca/domain'; +import type { Collection } from '../config/open-collection'; +import { walkFolders } from '../normalize/iterative-folder-walker'; +import type { ResourceTreeEntry } from './load-resource-tree'; +import { openResourceFolder } from './resource-folder'; + +/** + * Collection Reader — the read side of the Resource Folder. + * + * Every read of a whole collection (export, validate, bundle, type generation, search, the + * resource tree, glossary) walks the translations folder here, so one set of rules applies: + * + * - Folders are opened with the collection's base locale, through `openResourceFolder`. + * - Hidden folders (name starts with `.`) are skipped: a key segment cannot start with `.`. + * - A missing translations folder is an empty collection, not a problem. A folder that exists but + * cannot be listed (permission denied, or the translations "folder" is a file) is a problem. + * - **Missing metadata**: an entry without a `tracker_meta.json` record (or a folder without the + * file) is read with `metadata: {}`. Every locale then has no status, which readers treat as `new`. + * - **Malformed folder**: when a folder cannot be read (a file is not valid JSON, or an entry is + * not an object), none of its entries are read and the folder is reported as a + * {@link CollectionReadProblem}. The walk continues with the other folders. The caller decides + * what a problem means: validate fails, export lists it under malformed files, bundle warns. + */ + +/** What the reader needs from a collection. A resolved `Collection` fits. */ +export type CollectionReadTarget = Pick; + +/** One resource entry as stored, with its address in the collection. */ +export interface StoredResource { + /** Full dot-delimited key, e.g. `apps.common.buttons.ok`. */ + readonly fullKey: string; + /** Folder part of the key, e.g. `apps.common.buttons`; `''` at the collection root. */ + readonly folderPath: string; + /** Last key segment, e.g. `ok` (the same as `entry.key`). */ + readonly entryKey: string; + /** + * The entry as the Resource Folder reads it (`ResourceFolder.treeEntry`): `source` is the base value, + * `translations` every locale property stored besides `source` (normally the target locales; a + * hand-written base-locale key is kept as stored), `metadata` the stored record per locale + * (`{}` when there is none), and the entry's own `comment` and `tags` (non-array tags read as none). + */ + readonly entry: ResourceTreeEntry; + /** The collection's tags united with the entry's own tags (see `effectiveTags` in domain). */ + readonly effectiveTags: readonly string[]; +} + +/** A folder the reader could not read. Its entries are missing from the result. */ +export interface CollectionReadProblem { + /** Dot-delimited folder path relative to the translations folder; `''` for the root. */ + readonly folderPath: string; + /** Absolute path of the folder. */ + readonly absolutePath: string; + /** Why the folder could not be read; names the file. */ + readonly message: string; +} + +/** Everything the reader found in a collection. */ +export interface CollectionRead { + /** Every readable entry, folder by folder (parents before children, directory order). */ + readonly resources: StoredResource[]; + /** Folders that could not be read. */ + readonly problems: CollectionReadProblem[]; +} + +/** One folder visited by {@link readCollectionFolders}. */ +export interface CollectionFolderRead { + /** Folder path segments relative to the translations folder; empty for the root. */ + readonly segments: readonly string[]; + /** `segments` joined with `.`. */ + readonly folderPath: string; + readonly absolutePath: string; + /** Depth below the folder the walk started at (which is 0). */ + readonly depth: number; + /** Subfolders (hidden ones excluded), whether or not the walk descends into them. */ + readonly subfolderNames: readonly string[]; + /** The folder's entries; empty when `problem` is set. */ + readonly resources: readonly StoredResource[]; + readonly problem?: CollectionReadProblem; +} + +export interface ReadCollectionFoldersOptions { + /** Dot-delimited folder to start at. Default: the collection root. */ + readonly startPath?: string; + /** How deep to descend below the start folder (0 = the start folder only). Default: no limit. */ + readonly maxDepth?: number; +} + +/** + * Reads every resource entry of a collection. + * Never throws for a folder it cannot read; see the module rules above. + */ +export function readCollection(collection: CollectionReadTarget): CollectionRead { + const resources: StoredResource[] = []; + const problems: CollectionReadProblem[] = []; + + for (const folder of readCollectionFolders(collection)) { + if (folder.problem) { + problems.push(folder.problem); + } else { + resources.push(...folder.resources); + } + } + + return { resources, problems }; +} + +/** + * Walks a collection folder by folder (parents before children, in directory order), reading + * each folder by the rules above. Lazy, so a caller can stop early. `readCollection` and the + * resource tree loader are built on it. + */ +export function* readCollectionFolders( + collection: CollectionReadTarget, + options: ReadCollectionFoldersOptions = {}, +): Generator { + const root = collection.translationsFolder; + const startSegments = (options.startPath ?? '').split('.').filter((segment) => segment.length > 0); + const collectionTags = [...collection.tags]; + + // The walker skips a folder it cannot list (the start folder included); each one becomes a + // problem, so an unreadable folder is never read as an empty one. + const unlistable: CollectionFolderRead[] = []; + const onUnlistable = (absolutePath: string, error: unknown): void => { + const segments = segmentsOf(root, absolutePath); + const folderPath = segments.join('.'); + const message = error instanceof Error ? error.message : String(error); + unlistable.push({ + segments, + folderPath, + absolutePath, + depth: segments.length - startSegments.length, + subfolderNames: [], + resources: [], + problem: { folderPath, absolutePath, message: `Cannot list folder ${absolutePath}: ${message}` }, + }); + }; + + const walk = walkFolders(join(root, ...startSegments), { maxDepth: options.maxDepth, onUnlistable }); + for (const visit of walk) { + yield* unlistable.splice(0); + const segments = segmentsOf(root, visit.absolutePath); + const folderPath = segments.join('.'); + const base = { + segments, + folderPath, + absolutePath: visit.absolutePath, + depth: visit.depth, + subfolderNames: visit.subdirectoryNames, + }; + + let resources: StoredResource[]; + try { + resources = readFolder(visit.absolutePath, folderPath, collection.baseLocale, collectionTags); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + yield { ...base, resources: [], problem: { folderPath, absolutePath: visit.absolutePath, message } }; + continue; + } + + yield { ...base, resources }; + } + yield* unlistable.splice(0); +} + +function segmentsOf(root: string, absolutePath: string): string[] { + return relative(root, absolutePath) + .split(sep) + .filter((segment) => segment.length > 0); +} + +function readFolder( + absolutePath: string, + folderPath: string, + baseLocale: string, + collectionTags: string[], +): StoredResource[] { + const folder = openResourceFolder(absolutePath, { baseLocale }); + const resources: StoredResource[] = []; + + for (const entryKey of folder.keys()) { + const stored = folder.get(entryKey); + if (typeof stored?.entry !== 'object' || stored.entry === null) { + throw new Error(`Resource entry "${entryKey}" in ${folder.entriesPath} is not an object`); + } + const entry = folder.treeEntry(entryKey); + if (!entry) continue; + + resources.push({ + fullKey: folderPath ? `${folderPath}.${entryKey}` : entryKey, + folderPath, + entryKey, + entry, + effectiveTags: effectiveTags(collectionTags, entry.tags), + }); + } + + return resources; +} diff --git a/libs/core/src/lib/resource/resource-folder.spec.ts b/libs/core/src/lib/resource/resource-folder.spec.ts index 7646a62b..bc74d3b4 100644 --- a/libs/core/src/lib/resource/resource-folder.spec.ts +++ b/libs/core/src/lib/resource/resource-folder.spec.ts @@ -262,9 +262,19 @@ describe('ResourceFolder', () => { }); }); - it('returns undefined when metadata is missing', () => { + it('returns the entry with empty metadata when its metadata record is missing', () => { + writePair({ ok: { source: 'OK', fr: 'Bien' } }, {}); + expect(openResourceFolder(folderPath).treeEntry('ok')).toEqual({ + key: 'ok', + source: 'OK', + translations: { fr: 'Bien' }, + metadata: {}, + }); + }); + + it('returns undefined when the entry is missing', () => { writePair({ ok: { source: 'OK' } }, {}); - expect(openResourceFolder(folderPath).treeEntry('ok')).toBeUndefined(); + expect(openResourceFolder(folderPath).treeEntry('missing')).toBeUndefined(); }); }); diff --git a/libs/core/src/lib/resource/resource-folder.ts b/libs/core/src/lib/resource/resource-folder.ts index 59c695d0..e67acb46 100644 --- a/libs/core/src/lib/resource/resource-folder.ts +++ b/libs/core/src/lib/resource/resource-folder.ts @@ -30,7 +30,10 @@ export interface ResourceFolder { get(key: string): ResourceFolderEntry | undefined; keys(): string[]; isEmpty(): boolean; - /** The entry as the API/UI sees it. `undefined` when the entry or its metadata is missing. */ + /** + * The entry as the API/UI sees it. `undefined` when the entry is missing. + * An entry without a metadata record gets `metadata: {}` (no locale has a status). + */ treeEntry(key: string): ResourceTreeEntry | undefined; /** @@ -170,7 +173,7 @@ class FileResourceFolder implements ResourceFolder { treeEntry(key: string): ResourceTreeEntry | undefined { const stored = this.get(key); - if (!stored?.meta) return undefined; + if (!stored) return undefined; const { entry, meta } = stored; const translations: Record = {}; @@ -182,9 +185,10 @@ class FileResourceFolder implements ResourceFolder { key, source: entry.source, translations, - metadata: meta, + metadata: meta ?? {}, ...(entry.comment !== undefined && { comment: entry.comment }), - ...(entry.tags !== undefined && entry.tags.length > 0 && { tags: entry.tags }), + // A hand-edited non-array `tags` reads as no tags, so one bad value does not make the folder unreadable. + ...(Array.isArray(entry.tags) && entry.tags.length > 0 && { tags: entry.tags }), }; } diff --git a/libs/core/src/lib/resource/search.spec.ts b/libs/core/src/lib/resource/search.spec.ts index fbd60b8c..32799279 100644 --- a/libs/core/src/lib/resource/search.spec.ts +++ b/libs/core/src/lib/resource/search.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { searchTranslations, searchResourceTree, type SearchParams } from './search'; import type { ResourceTreeNode } from './load-resource-tree'; import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; @@ -440,6 +440,38 @@ describe('searchTranslations', () => { expect(results).toEqual([]); }); }); + + describe('Collection Reader rules', () => { + it('skips an unreadable folder, logs it, and searches the others', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + createTestResource('good', { save: { source: 'Save' } }, { save: { en: { checksum: 'a' } } }); + mkdirSync(join(testDir, 'bad'), { recursive: true }); + writeFileSync(join(testDir, 'bad', 'resource_entries.json'), '{ nope'); + + const results = searchTranslations({ translationsFolder: testDir, query: 'save', baseLocale: 'en' }); + + expect(results.map((result) => result.key)).toEqual(['good.save']); + expect(error).toHaveBeenCalledWith(expect.stringContaining('resource_entries.json')); + error.mockRestore(); + }); + + it('finds an entry without metadata, with empty metadata', () => { + mkdirSync(join(testDir, 'loose'), { recursive: true }); + writeFileSync(join(testDir, 'loose', 'resource_entries.json'), JSON.stringify({ save: { source: 'Save' } })); + + const results = searchTranslations({ translationsFolder: testDir, query: 'save', baseLocale: 'en' }); + + expect(results).toHaveLength(1); + expect(results[0]?.metadata).toEqual({}); + }); + + it('skips hidden folders', () => { + mkdirSync(join(testDir, '.hidden'), { recursive: true }); + writeFileSync(join(testDir, '.hidden', 'resource_entries.json'), JSON.stringify({ save: { source: 'Save' } })); + + expect(searchTranslations({ translationsFolder: testDir, query: 'save', baseLocale: 'en' })).toEqual([]); + }); + }); }); describe('searchResourceTree', () => { diff --git a/libs/core/src/lib/resource/search.ts b/libs/core/src/lib/resource/search.ts index ce140c0b..a197ec8a 100644 --- a/libs/core/src/lib/resource/search.ts +++ b/libs/core/src/lib/resource/search.ts @@ -1,6 +1,6 @@ -import { walkFolders } from '../normalize/iterative-folder-walker'; -import { openResourceFolder, translationLocales } from './resource-folder'; import type { TranslationStatus } from '@simoncodes-ca/domain'; +import { DEFAULT_CONFIG } from '../../constants'; +import { readCollectionFolders } from './read-collection'; import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; import type { ResourceTreeNode } from './load-resource-tree'; @@ -95,93 +95,93 @@ export function searchTranslations(params: SearchParams): SearchResult[] { const normalizedQuery = query.toLowerCase().trim(); const results: SearchResult[] = []; - for (const visit of walkFolders(translationsFolder, { skipHidden: false })) { - try { - const folder = openResourceFolder(visit.absolutePath); - - // Search each entry - for (const entryKey of folder.keys()) { - const stored = folder.get(entryKey); - if (!stored) continue; - const { entry } = stored; - const fullKey = visit.keyPrefix ? `${visit.keyPrefix}.${entryKey}` : entryKey; - const normalizedKey = fullKey.toLowerCase(); - - // Check key matches - let matchType: MatchType | null = null; - const matchedLocales: string[] = []; - - if (normalizedKey === normalizedQuery) { - matchType = 'exact-key'; - } else if (normalizedKey.includes(normalizedQuery)) { - matchType = 'partial-key'; - } + // Folders are read through the Collection Reader; the base locale only matters for writes, + // so the default stands in when the caller does not name one. + const folders = readCollectionFolders({ + translationsFolder, + baseLocale: baseLocale ?? DEFAULT_CONFIG.baseLocale, + tags: [], + }); - // Check value matches if no key match - if (!matchType) { - // Search source field if baseLocale is provided - if (baseLocale && entry.source && typeof entry.source === 'string') { - const normalizedValue = entry.source.toLowerCase(); - if (normalizedValue === normalizedQuery) { - matchType = 'exact-value'; - matchedLocales.push(baseLocale); - } else if (normalizedValue.includes(normalizedQuery)) { - matchType = 'partial-value'; - matchedLocales.push(baseLocale); - } + for (const folder of folders) { + if (folder.problem) { + // Skip folders that cannot be read + console.error(`Error reading resources in ${folder.absolutePath}: ${folder.problem.message}`); + continue; + } + + for (const { fullKey, entry } of folder.resources) { + const normalizedKey = fullKey.toLowerCase(); + + // Check key matches + let matchType: MatchType | null = null; + const matchedLocales: string[] = []; + + if (normalizedKey === normalizedQuery) { + matchType = 'exact-key'; + } else if (normalizedKey.includes(normalizedQuery)) { + matchType = 'partial-key'; + } + + // Check value matches if no key match + if (!matchType) { + // Search source field if baseLocale is provided + if (baseLocale && entry.source && typeof entry.source === 'string') { + const normalizedValue = entry.source.toLowerCase(); + if (normalizedValue === normalizedQuery) { + matchType = 'exact-value'; + matchedLocales.push(baseLocale); + } else if (normalizedValue.includes(normalizedQuery)) { + matchType = 'partial-value'; + matchedLocales.push(baseLocale); } + } - // Search all other locale translations - for (const locale of translationLocales(entry)) { - const normalizedValue = (entry[locale] as string).toLowerCase(); - if (normalizedValue === normalizedQuery) { - matchType = 'exact-value'; - matchedLocales.push(locale); - } else if (normalizedValue.includes(normalizedQuery)) { - if (matchType !== 'exact-value') { - matchType = 'partial-value'; - } - matchedLocales.push(locale); + // Search all other locale translations + for (const [locale, value] of Object.entries(entry.translations)) { + const normalizedValue = value.toLowerCase(); + if (normalizedValue === normalizedQuery) { + matchType = 'exact-value'; + matchedLocales.push(locale); + } else if (normalizedValue.includes(normalizedQuery)) { + if (matchType !== 'exact-value') { + matchType = 'partial-value'; } + matchedLocales.push(locale); } } + } - // Add to results if match found - if (matchType) { - const meta = stored.meta ?? {}; - const status: Record = {}; - const translations: Record = {}; + // Add to results if match found + if (matchType) { + const status: Record = {}; + const translations: Record = { ...entry.translations }; - for (const locale of translationLocales(entry)) { - translations[locale] = entry[locale] as string; - status[locale] = meta[locale]?.status; - } + for (const locale of Object.keys(entry.translations)) { + status[locale] = entry.metadata[locale]?.status; + } - // Include base locale value from source field - if (baseLocale && entry.source) { - translations[baseLocale] = entry.source; - } + // Include base locale value from source field + if (baseLocale && entry.source) { + translations[baseLocale] = entry.source; + } - results.push({ - key: fullKey, - source: entry.source, - translations, - status, - metadata: meta, - matchType, - matchedLocales: matchedLocales.length > 0 ? matchedLocales : undefined, - comment: entry.comment, - tags: entry.tags, - }); - - if (results.length >= maxResults) { - break; - } + results.push({ + key: fullKey, + source: entry.source, + translations, + status, + metadata: entry.metadata, + matchType, + matchedLocales: matchedLocales.length > 0 ? matchedLocales : undefined, + comment: entry.comment, + tags: entry.tags, + }); + + if (results.length >= maxResults) { + break; } } - } catch (error) { - // Skip folders with invalid JSON - console.error(`Error reading resources in ${visit.absolutePath}:`, error); } if (results.length >= maxResults) { @@ -242,7 +242,7 @@ export interface SearchTreeParams { * @returns Array of search results sorted by relevance * * @example - * const tree = loadResourceTree({ translationsFolder: './translations', depth: Infinity }); + * const tree = loadResourceTree({ translationsFolder: './translations', baseLocale: 'en', depth: Infinity }); * const results = searchResourceTree({ * tree, * query: 'button', diff --git a/libs/core/src/lib/translation/translate-locale.ts b/libs/core/src/lib/translation/translate-locale.ts index b0133a89..4977c615 100644 --- a/libs/core/src/lib/translation/translate-locale.ts +++ b/libs/core/src/lib/translation/translate-locale.ts @@ -150,7 +150,7 @@ export async function translateLocale(params: TranslateLocaleParams): Promise { allowTranslated: false, }; + describe('unreadable folders', () => { + it('lists each unreadable folder with its message and counts them in the summary', () => { + const result: ResourceValidationResult = { + totalResourcesValidated: 0, + totalUniqueKeys: 0, + localesValidated: 1, + collectionsValidated: 1, + statusCounts: { new: 0, translated: 0, stale: 0, verified: 0 }, + failures: [], + warnings: [], + successes: [], + unreadableFolders: [ + { + collection: 'main', + folderPath: 'apps.bad', + message: 'Failed to parse JSON file /t/apps/bad/tracker_meta.json', + }, + { collection: 'main', folderPath: '', message: 'Failed to parse JSON file /t/resource_entries.json' }, + ], + passed: false, + }; + + const summary = generateValidationSummary(result, defaultOptions); + + expect(summary).toContain('❌ Unreadable Folders (2):'); + expect(summary).toContain(' [main] apps.bad\n Failed to parse JSON file /t/apps/bad/tracker_meta.json'); + expect(summary).toContain(' [main] (root)'); + expect(summary).toContain('Unreadable Folders: 2'); + expect(summary).toContain('❌ Validation failed.'); + }); + }); + describe('successful validation', () => { it('should generate summary for all verified resources', () => { const result: ResourceValidationResult = { diff --git a/libs/core/src/lib/validate/generate-validation-summary.ts b/libs/core/src/lib/validate/generate-validation-summary.ts index 329d36af..e84bb9a2 100644 --- a/libs/core/src/lib/validate/generate-validation-summary.ts +++ b/libs/core/src/lib/validate/generate-validation-summary.ts @@ -4,6 +4,7 @@ import type { ResourceValidationDetail, ResourceValidationResult, TerminologyValidationResult, + UnreadableFolderDetail, ValidationOptions, } from './types'; @@ -55,6 +56,10 @@ export function generateValidationSummary(result: ResourceValidationResult, opti sections.push(buildStatisticsSection(result, options)); sections.push(buildStatusBreakdownSection(result)); + if (result.unreadableFolders && result.unreadableFolders.length > 0) { + sections.push(buildUnreadableFoldersSection(result.unreadableFolders)); + } + if (result.failures.length > 0) { sections.push(buildFailuresSection(result.failures)); } @@ -265,6 +270,28 @@ function buildUnsupportedLocalesSection(locales: readonly string[]): string { ].join('\n'); } +/** + * Builds the list of folders the Collection Reader could not read. + * + * Each one fails validation: its resources were never checked, so a clean + * result would otherwise hide them. + * + * @param folders - The unreadable folders, with their collection and message + * @returns Formatted section string + * @internal + */ +function buildUnreadableFoldersSection(folders: readonly UnreadableFolderDetail[]): string { + const lines = [`❌ Unreadable Folders (${folders.length}):`, '─'.repeat(50)]; + + for (const folder of folders) { + lines.push(` [${folder.collection}] ${folder.folderPath || '(root)'}`); + lines.push(` ${folder.message}`); + } + + lines.push(' The resources in these folders were not validated. Fix the files and run validate again.'); + return lines.join('\n'); +} + /** * Builds the status breakdown section showing counts by translation status. * @@ -388,6 +415,9 @@ function buildFooterSection(result: ResourceValidationResult, options: Validatio if (result.placeholders && result.placeholders.failures.length > 0) { lines.push(` Total Placeholder Failures: ${result.placeholders.failures.length}`); } + if (result.unreadableFolders && result.unreadableFolders.length > 0) { + lines.push(` Unreadable Folders: ${result.unreadableFolders.length}`); + } if (result.terminology?.configError !== undefined) { lines.push(' Preferred Terminology File: failed to load'); } diff --git a/libs/core/src/lib/validate/types.ts b/libs/core/src/lib/validate/types.ts index 6cf74293..f7e349eb 100644 --- a/libs/core/src/lib/validate/types.ts +++ b/libs/core/src/lib/validate/types.ts @@ -14,8 +14,8 @@ export interface ValidationOptions { readonly allowTranslated: boolean; /** - * Locales that were excluded from validation by the caller. - * Used only for reporting — not for filtering (filtering happens before validateResources is called). + * Locales not to validate. Each collection validates its own target locales minus these. + * The summary also lists them. */ readonly skippedLocales?: readonly string[]; @@ -31,8 +31,8 @@ export interface ValidationOptions { readonly icu?: IcuValidationOptions; /** - * When present, every translation is checked against its base value for the - * arguments it interpolates. + * When true, every translation is checked against its base value (in the + * collection's base locale) for the arguments it interpolates. * * A third question again: a translation can be approved by a reviewer and * compile cleanly while interpolating an argument nobody passes, because a @@ -41,7 +41,7 @@ export interface ValidationOptions { * * Omit to skip the check. */ - readonly placeholders?: PlaceholderValidationOptions; + readonly placeholders?: boolean; /** * When present, base-locale values are scanned for discouraged terms from the @@ -71,13 +71,6 @@ export interface TerminologyValidationOptions { * failure: a broken file means no value was checked. */ readonly loadError?: string; - - /** - * Effective base locale of each collection, by collection name. Findings are - * reported under this locale; a collection missing from the map is reported - * under an empty locale. - */ - readonly baseLocaleByCollection: Readonly>; } /** @@ -141,19 +134,6 @@ export interface TerminologyValidationResult { readonly valuesChecked: number; } -/** - * Options controlling the placeholder-agreement pass. - */ -export interface PlaceholderValidationOptions { - /** - * The locale whose value defines the arguments a translation must interpolate. - * - * The base value is the contract: it is what the calling code passes - * arguments for, so it is what every translation has to agree with. - */ - readonly baseLocale: string; -} - /** * A translation whose interpolated arguments disagree with its base value. */ @@ -209,18 +189,11 @@ export interface PlaceholderValidationResult { * Options controlling the per-locale ICU compilation pass. */ export interface IcuValidationOptions { - /** - * The base locale, whose `source` values are compiled alongside the targets. - * - * The base value is the one copied into every translation slot, so leaving - * it unchecked misses the failures that propagate furthest. Omit to check - * target locales only. - */ - readonly baseLocale?: string; - /** * When true, every stored value is compiled under the locale it is stored - * under, and any value that fails to compile is a validation failure. + * under, and any value that fails to compile is a validation failure. Base + * values are compiled under their collection's base locale: the base value is + * the one copied into every translation slot, so its failures propagate furthest. * * Set false to run the portability rule alone, without compiling. */ @@ -326,6 +299,18 @@ export interface StatusCounts { verified: number; } +/** + * A folder that could not be read, so none of its resources were validated. + */ +export interface UnreadableFolderDetail { + /** The collection the folder belongs to. */ + readonly collection: string; + /** Dot-delimited folder path in the collection; `''` for the translations folder itself. */ + readonly folderPath: string; + /** Why the folder could not be read; names the file. */ + readonly message: string; +} + /** * Comprehensive validation result containing counts, categorized failures, and warnings. */ @@ -336,12 +321,13 @@ export interface ResourceValidationResult { readonly totalResourcesValidated: number; /** - * Total number of unique resource keys checked (before multiplying by locale count). + * Number of resources checked (before multiplying by locale count). Keys are unique within a + * collection; a key present in two collections counts once for each. */ readonly totalUniqueKeys: number; /** - * Number of locales validated. + * Number of distinct locales validated across all collections. */ readonly localesValidated: number; @@ -390,9 +376,16 @@ export interface ResourceValidationResult { */ readonly terminology?: TerminologyValidationResult; + /** + * Folders that could not be read (malformed JSON, or an entry that is not an object). + * Their resources were not validated, so any entry here fails validation. + * Absent or empty when every folder was read. + */ + readonly unreadableFolders?: readonly UnreadableFolderDetail[]; + /** * Whether the validation passed overall (no status failures, no ICU compile - * failures, no placeholder mismatches, and no unreadable terminology file). + * failures, no placeholder mismatches, no unreadable folder, and no unreadable terminology file). * Note: warnings, including terminology findings, do not cause validation to fail. */ readonly passed: boolean; diff --git a/libs/core/src/lib/validate/validate-icu.spec.ts b/libs/core/src/lib/validate/validate-icu.spec.ts index 7a692dc9..62ee94a5 100644 --- a/libs/core/src/lib/validate/validate-icu.spec.ts +++ b/libs/core/src/lib/validate/validate-icu.spec.ts @@ -10,6 +10,7 @@ function resource(overrides: Partial = {}): LoadedResource { translations: {}, status: {}, collection: 'main', + effectiveTags: [], ...overrides, }; } diff --git a/libs/core/src/lib/validate/validate-icu.ts b/libs/core/src/lib/validate/validate-icu.ts index 4f7bd654..f234ed41 100644 --- a/libs/core/src/lib/validate/validate-icu.ts +++ b/libs/core/src/lib/validate/validate-icu.ts @@ -2,6 +2,12 @@ import { findIcuCompileError, findUnportablePluralCases, isIcuLocaleSupported } import type { LoadedResource } from '../export/export-common'; import type { IcuValidationOptions, IcuValidationDetail, IcuValidationResult } from './types'; +/** {@link IcuValidationOptions} for one collection. */ +export interface IcuPassOptions extends IcuValidationOptions { + /** The collection's base locale, whose `source` values are compiled alongside the targets. Omit to check targets only. */ + readonly baseLocale?: string; +} + /** * Compiles every stored value under the locale it is stored under. * @@ -28,7 +34,7 @@ import type { IcuValidationOptions, IcuValidationDetail, IcuValidationResult } f export function validateIcuValues( resources: readonly LoadedResource[], targetLocales: readonly string[], - options: IcuValidationOptions, + options: IcuPassOptions, ): IcuValidationResult { const { baseLocale } = options; diff --git a/libs/core/src/lib/validate/validate-placeholders.spec.ts b/libs/core/src/lib/validate/validate-placeholders.spec.ts index fcd8a09a..30e98053 100644 --- a/libs/core/src/lib/validate/validate-placeholders.spec.ts +++ b/libs/core/src/lib/validate/validate-placeholders.spec.ts @@ -9,6 +9,7 @@ const resource = (overrides: Partial = {}): LoadedResource => ({ translations: {}, status: {}, collection: 'trackerResources', + effectiveTags: [], ...overrides, }); diff --git a/libs/core/src/lib/validate/validate-resources.spec.ts b/libs/core/src/lib/validate/validate-resources.spec.ts index 5964918f..a8a81f58 100644 --- a/libs/core/src/lib/validate/validate-resources.spec.ts +++ b/libs/core/src/lib/validate/validate-resources.spec.ts @@ -1,596 +1,260 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { LoadedResource } from '../export/export-common'; -import * as exportCommon from '../export/export-common'; +import { join } from 'node:path'; +import type { TranslationStatus } from '@simoncodes-ca/domain'; +import { describe, expect, it } from 'vitest'; +import type { Collection } from '../config/open-collection'; +import { + type SeedResource, + seedResources, + testCollection, + useTempDir, + writeFolderFiles, +} from '../../testing/temp-dir.spec-helpers'; import { validateResources } from './validate-resources'; -// Mock the export-common module -vi.mock('../export/export-common', async () => { - const actual = await vi.importActual('../export/export-common'); - return { - ...actual, - loadResourcesFromCollections: vi.fn(), - }; -}); - -describe('validateResources', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('success cases', () => { - it('should pass validation when all resources are verified', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: { es: 'Vale', fr: 'OK' }, - status: { es: 'verified', fr: 'verified' }, - collection: 'main', - }, - { - key: 'cancel', - fullKey: 'common.cancel', - source: 'Cancel', - translations: { es: 'Cancelar', fr: 'Annuler' }, - status: { es: 'verified', fr: 'verified' }, - collection: 'main', - }, - ]; - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources([{ name: 'main', path: '/translations' }], ['es', 'fr'], { - allowTranslated: false, +describe('validateResources (real fs)', () => { + const root = useTempDir('validate-resources-'); + + /** A collection in its own subfolder of the temp dir; targets `es` and `fr` unless told otherwise. */ + function collection(name = 'main', overrides: Partial = {}): Collection { + return testCollection(join(root(), name), { name, locales: ['en', 'es', 'fr'], ...overrides }); + } + + /** A resource whose translations all have the given status. */ + function withStatus(source: string, statuses: Record): SeedResource { + return { + source, + translations: Object.fromEntries( + Object.entries(statuses).map(([locale, status]) => [locale, { value: `${source} (${locale})`, status }]), + ), + }; + } + + describe('status', () => { + it('passes when every resource is verified in every target locale', () => { + const main = collection(); + seedResources(main, { + 'common.ok': withStatus('OK', { es: 'verified', fr: 'verified' }), + 'common.cancel': withStatus('Cancel', { es: 'verified', fr: 'verified' }), }); + const result = validateResources([main], { allowTranslated: false }); + expect(result.passed).toBe(true); + expect(result.successes).toHaveLength(4); expect(result.failures).toHaveLength(0); expect(result.warnings).toHaveLength(0); - expect(result.successes).toHaveLength(4); // 2 resources × 2 locales expect(result.totalResourcesValidated).toBe(4); expect(result.totalUniqueKeys).toBe(2); expect(result.localesValidated).toBe(2); expect(result.collectionsValidated).toBe(1); - expect(result.statusCounts.verified).toBe(4); - expect(result.statusCounts.new).toBe(0); - expect(result.statusCounts.stale).toBe(0); - expect(result.statusCounts.translated).toBe(0); + expect(result.statusCounts).toEqual({ new: 0, translated: 0, stale: 0, verified: 4 }); + expect(result.unreadableFolders).toEqual([]); }); - it('should pass validation with empty collections', () => { - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue([]); - - const result = validateResources([{ name: 'main', path: '/translations' }], ['es'], { allowTranslated: false }); + it('passes for a collection without resources', () => { + const result = validateResources([collection()], { allowTranslated: false }); expect(result.passed).toBe(true); - expect(result.failures).toHaveLength(0); - expect(result.warnings).toHaveLength(0); - expect(result.successes).toHaveLength(0); expect(result.totalResourcesValidated).toBe(0); expect(result.totalUniqueKeys).toBe(0); }); - }); - - describe('failure cases - new resources', () => { - it('should fail validation when resources have new status', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: {}, - status: { es: 'new' }, - collection: 'main', - }, - ]; - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); + it('fails new and stale translations', () => { + const main = collection(); + seedResources(main, { 'common.ok': withStatus('OK', { es: 'new', fr: 'stale' }) }); - const result = validateResources([{ name: 'main', path: '/translations' }], ['es'], { allowTranslated: false }); + const result = validateResources([main], { allowTranslated: false }); expect(result.passed).toBe(false); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]).toEqual({ - key: 'common.ok', - locale: 'es', - collection: 'main', - status: 'new', - }); - expect(result.warnings).toHaveLength(0); - expect(result.successes).toHaveLength(0); - expect(result.statusCounts.new).toBe(1); + expect(result.failures).toEqual([ + { key: 'common.ok', locale: 'es', collection: 'main', status: 'new' }, + { key: 'common.ok', locale: 'fr', collection: 'main', status: 'stale' }, + ]); + expect(result.statusCounts).toEqual({ new: 1, translated: 0, stale: 1, verified: 0 }); }); - it('should treat missing status as new and fail validation', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: {}, - status: {}, // No status for 'es' locale - collection: 'main', - }, - ]; + it('treats a locale without a status as new, including an entry without metadata', () => { + const main = collection(); + seedResources(main, { 'common.ok': withStatus('OK', { es: 'verified' }) }); + writeFolderFiles(main.translationsFolder, 'loose', { entries: { orphan: { source: 'Orphan' } } }); - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); + const result = validateResources([main], { allowTranslated: false }); - const result = validateResources([{ name: 'main', path: '/translations' }], ['es'], { allowTranslated: false }); - - expect(result.passed).toBe(false); - expect(result.failures).toHaveLength(1); - expect(result.failures[0].status).toBe('new'); - expect(result.statusCounts.new).toBe(1); + expect(result.failures.map((failure) => `${failure.key}/${failure.locale}/${failure.status}`)).toEqual([ + 'common.ok/fr/new', + 'loose.orphan/es/new', + 'loose.orphan/fr/new', + ]); }); - }); - - describe('failure cases - stale resources', () => { - it('should fail validation when resources have stale status', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: { es: 'Vale' }, - status: { es: 'stale' }, - collection: 'main', - }, - ]; - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); + it('fails translated resources by default and warns with allowTranslated', () => { + const main = collection(); + seedResources(main, { 'common.ok': withStatus('OK', { es: 'translated', fr: 'verified' }) }); - const result = validateResources([{ name: 'main', path: '/translations' }], ['es'], { allowTranslated: false }); + const strict = validateResources([main], { allowTranslated: false }); + const relaxed = validateResources([main], { allowTranslated: true }); - expect(result.passed).toBe(false); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]).toEqual({ - key: 'common.ok', - locale: 'es', - collection: 'main', - status: 'stale', - }); - expect(result.statusCounts.stale).toBe(1); + expect(strict.passed).toBe(false); + expect(strict.failures).toEqual([{ key: 'common.ok', locale: 'es', collection: 'main', status: 'translated' }]); + expect(relaxed.passed).toBe(true); + expect(relaxed.warnings).toEqual([{ key: 'common.ok', locale: 'es', collection: 'main', status: 'translated' }]); + expect(relaxed.successes).toHaveLength(1); }); - }); - - describe('failure cases - translated resources (default behavior)', () => { - it('should fail validation when resources have translated status and allowTranslated is false', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: { es: 'Vale' }, - status: { es: 'translated' }, - collection: 'main', - }, - ]; - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); + it('collects every failure without stopping early', () => { + const main = collection('main', { locales: ['en', 'es', 'fr', 'de'] }); + seedResources( + main, + Object.fromEntries( + Array.from({ length: 20 }, (_, index) => [`ns.key${index}`, withStatus(`V${index}`, { es: 'new' })]), + ), + ); - const result = validateResources([{ name: 'main', path: '/translations' }], ['es'], { allowTranslated: false }); + const result = validateResources([main], { allowTranslated: false }); - expect(result.passed).toBe(false); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]).toEqual({ - key: 'common.ok', - locale: 'es', - collection: 'main', - status: 'translated', - }); - expect(result.warnings).toHaveLength(0); - expect(result.statusCounts.translated).toBe(1); + // es is new; fr and de have no status at all + expect(result.failures).toHaveLength(60); + expect(result.statusCounts.new).toBe(60); }); }); - describe('warning cases - translated resources with allowTranslated flag', () => { - it('should generate warnings when resources have translated status and allowTranslated is true', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: { es: 'Vale' }, - status: { es: 'translated' }, - collection: 'main', - }, - ]; - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources([{ name: 'main', path: '/translations' }], ['es'], { allowTranslated: true }); + describe('per collection', () => { + it('validates each collection against its own target locales and counts distinct locales', () => { + const common = collection('common'); + const admin = collection('admin', { locales: ['en', 'de'] }); + seedResources(common, { ok: withStatus('OK', { es: 'verified', fr: 'verified' }) }); + seedResources(admin, { users: withStatus('Users', { de: 'new' }) }); - expect(result.passed).toBe(true); // No failures, only warnings - expect(result.failures).toHaveLength(0); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toEqual({ - key: 'common.ok', - locale: 'es', - collection: 'main', - status: 'translated', - }); - expect(result.statusCounts.translated).toBe(1); - }); - }); - - describe('mixed statuses', () => { - it('should correctly categorize resources with mixed statuses across multiple locales', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: { es: 'Vale', fr: 'OK', de: 'OK' }, - status: { - es: 'verified', - fr: 'translated', - de: 'new', - }, - collection: 'main', - }, - { - key: 'cancel', - fullKey: 'common.cancel', - source: 'Cancel', - translations: { es: 'Cancelar', fr: 'Annuler', de: 'Abbrechen' }, - status: { - es: 'stale', - fr: 'verified', - de: 'translated', - }, - collection: 'main', - }, - ]; - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources([{ name: 'main', path: '/translations' }], ['es', 'fr', 'de'], { - allowTranslated: false, - }); + const result = validateResources([common, admin], { allowTranslated: false }); - expect(result.passed).toBe(false); - expect(result.totalResourcesValidated).toBe(6); // 2 resources × 3 locales - expect(result.totalUniqueKeys).toBe(2); + expect(result.successes.map((detail) => `${detail.collection}/${detail.locale}`)).toEqual([ + 'common/es', + 'common/fr', + ]); + expect(result.failures).toEqual([{ key: 'users', locale: 'de', collection: 'admin', status: 'new' }]); + expect(result.totalResourcesValidated).toBe(3); expect(result.localesValidated).toBe(3); + expect(result.collectionsValidated).toBe(2); + }); - // Failures: new (de/ok), stale (es/cancel), translated (fr/ok, de/cancel) - expect(result.failures).toHaveLength(4); - expect(result.failures.filter((f) => f.status === 'new')).toHaveLength(1); - expect(result.failures.filter((f) => f.status === 'stale')).toHaveLength(1); - expect(result.failures.filter((f) => f.status === 'translated')).toHaveLength(2); - - // Successes: verified (es/ok, fr/cancel) - expect(result.successes).toHaveLength(2); + it('validates a key present in two collections in both', () => { + const first = collection('first'); + const second = collection('second'); + seedResources(first, { 'shared.title': withStatus('Title', { es: 'verified', fr: 'verified' }) }); + seedResources(second, { 'shared.title': withStatus('Title', { es: 'new', fr: 'verified' }) }); - // No warnings with allowTranslated=false - expect(result.warnings).toHaveLength(0); + const result = validateResources([first, second], { allowTranslated: false }); - // Status counts - expect(result.statusCounts.new).toBe(1); - expect(result.statusCounts.stale).toBe(1); - expect(result.statusCounts.translated).toBe(2); - expect(result.statusCounts.verified).toBe(2); + expect(result.totalUniqueKeys).toBe(2); + expect(result.totalResourcesValidated).toBe(4); + expect(result.failures).toEqual([{ key: 'shared.title', locale: 'es', collection: 'second', status: 'new' }]); }); - it('should correctly categorize with allowTranslated=true', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: { es: 'Vale', fr: 'OK' }, - status: { - es: 'verified', - fr: 'translated', - }, - collection: 'main', - }, - { - key: 'cancel', - fullKey: 'common.cancel', - source: 'Cancel', - translations: { es: 'Cancelar', fr: 'Annuler' }, - status: { - es: 'new', - fr: 'stale', - }, - collection: 'main', - }, - ]; - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources([{ name: 'main', path: '/translations' }], ['es', 'fr'], { - allowTranslated: true, - }); + it('treats the base locale of a collection as its source, not as a target', () => { + const french = collection('french', { baseLocale: 'fr', locales: ['fr', 'en'] }); + seedResources(french, { ok: withStatus('Bien', { en: 'verified' }) }); - expect(result.passed).toBe(false); // Still fails due to new/stale - expect(result.failures).toHaveLength(2); // new and stale only - expect(result.warnings).toHaveLength(1); // translated - expect(result.successes).toHaveLength(1); // verified + const result = validateResources([french], { allowTranslated: false }); - expect(result.failures.filter((f) => f.status === 'new')).toHaveLength(1); - expect(result.failures.filter((f) => f.status === 'stale')).toHaveLength(1); - expect(result.warnings.filter((w) => w.status === 'translated')).toHaveLength(1); + expect(result.passed).toBe(true); + expect(result.successes).toEqual([{ key: 'ok', locale: 'en', collection: 'french', status: 'verified' }]); }); - }); - - describe('multiple collections', () => { - it('should validate resources across multiple collections', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: { es: 'Vale' }, - status: { es: 'verified' }, - collection: 'main', - }, - { - key: 'submit', - fullKey: 'forms.submit', - source: 'Submit', - translations: { es: 'Enviar' }, - status: { es: 'new' }, - collection: 'app', - }, - ]; - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources( - [ - { name: 'main', path: '/translations/main' }, - { name: 'app', path: '/translations/app' }, - ], - ['es'], - { allowTranslated: false }, - ); - expect(result.passed).toBe(false); - expect(result.collectionsValidated).toBe(2); - expect(result.failures).toHaveLength(1); - expect(result.failures[0].collection).toBe('app'); - expect(result.successes).toHaveLength(1); - expect(result.successes[0].collection).toBe('main'); - }); - }); + it('leaves skipped locales out of every collection', () => { + const main = collection(); + seedResources(main, { ok: withStatus('OK', { es: 'verified', fr: 'new' }) }); - describe('comprehensive validation - no early exit', () => { - it('should collect ALL failures across all resources and locales', () => { - // Create 100 resources with new status across 2 locales - const mockResources: LoadedResource[] = Array.from({ length: 100 }, (_, i) => ({ - key: `key${i}`, - fullKey: `namespace.key${i}`, - source: `Source ${i}`, - translations: {}, - status: { es: 'new', fr: 'new' }, - collection: 'main', - })); - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources([{ name: 'main', path: '/translations' }], ['es', 'fr'], { - allowTranslated: false, - }); + const result = validateResources([main], { allowTranslated: false, skippedLocales: ['fr'] }); - // Should report ALL 200 failures (100 resources × 2 locales) - expect(result.passed).toBe(false); - expect(result.failures).toHaveLength(200); - expect(result.totalResourcesValidated).toBe(200); - expect(result.totalUniqueKeys).toBe(100); - expect(result.statusCounts.new).toBe(200); + expect(result.passed).toBe(true); + expect(result.localesValidated).toBe(1); + expect(result.totalResourcesValidated).toBe(1); }); - it('should collect ALL failures of different types across multiple locales', () => { - const mockResources: LoadedResource[] = [ - // Resource 1: new in es, stale in fr - { - key: 'key1', - fullKey: 'ns.key1', - source: 'Source 1', - translations: { es: '', fr: 'Val 1' }, - status: { es: 'new', fr: 'stale' }, - collection: 'main', - }, - // Resource 2: translated in both (will be failure) - { - key: 'key2', - fullKey: 'ns.key2', - source: 'Source 2', - translations: { es: 'Val 2', fr: 'Val 2' }, - status: { es: 'translated', fr: 'translated' }, - collection: 'main', - }, - // Resource 3: new in es, verified in fr - { - key: 'key3', - fullKey: 'ns.key3', - source: 'Source 3', - translations: { es: '', fr: 'Val 3' }, - status: { es: 'new', fr: 'verified' }, - collection: 'main', - }, - ]; - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources([{ name: 'main', path: '/translations' }], ['es', 'fr'], { - allowTranslated: false, - }); - - expect(result.passed).toBe(false); - expect(result.totalResourcesValidated).toBe(6); // 3 resources × 2 locales + it('validates nothing for a collection whose target locales are all skipped', () => { + const main = collection('main', { locales: ['en', 'es'] }); + seedResources(main, { ok: withStatus('OK', { es: 'new' }) }); - // Should collect all failures: 2 new, 1 stale, 2 translated = 5 total - expect(result.failures).toHaveLength(5); - expect(result.failures.filter((f) => f.status === 'new')).toHaveLength(2); - expect(result.failures.filter((f) => f.status === 'stale')).toHaveLength(1); - expect(result.failures.filter((f) => f.status === 'translated')).toHaveLength(2); + const result = validateResources([main], { allowTranslated: false, skippedLocales: ['es'] }); - // Should have 1 success - expect(result.successes).toHaveLength(1); - expect(result.successes[0].status).toBe('verified'); + expect(result.passed).toBe(true); + expect(result.totalResourcesValidated).toBe(0); + expect(result.localesValidated).toBe(0); }); + }); - it('should validate across multiple collections and collect all failures', () => { - const mockResources: LoadedResource[] = [ - // Collection 1 - 2 resources with failures - { - key: 'key1', - fullKey: 'c1.key1', - source: 'Source 1', - translations: { es: '' }, - status: { es: 'new' }, - collection: 'collection1', - }, - { - key: 'key2', - fullKey: 'c1.key2', - source: 'Source 2', - translations: { es: 'Val 2' }, - status: { es: 'stale' }, - collection: 'collection1', - }, - // Collection 2 - 2 resources with failures - { - key: 'key1', - fullKey: 'c2.key1', - source: 'Source 3', - translations: { es: 'Val 3' }, - status: { es: 'translated' }, - collection: 'collection2', - }, - { - key: 'key2', - fullKey: 'c2.key2', - source: 'Source 4', - translations: { es: '' }, - status: { es: 'new' }, - collection: 'collection2', - }, - ]; - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources( - [ - { name: 'collection1', path: '/c1' }, - { name: 'collection2', path: '/c2' }, - ], - ['es'], - { allowTranslated: false }, - ); + describe('unreadable folders', () => { + it('fails validation and names the folder and file', () => { + const main = collection(); + seedResources(main, { 'good.ok': withStatus('OK', { es: 'verified', fr: 'verified' }) }); + writeFolderFiles(main.translationsFolder, 'bad', { entries: { x: { source: 'X' } }, meta: '{ broken' }); - expect(result.passed).toBe(false); - expect(result.collectionsValidated).toBe(2); - expect(result.failures).toHaveLength(4); + const result = validateResources([main], { allowTranslated: false }); - // Verify all collections are represented - const collection1Failures = result.failures.filter((f) => f.collection === 'collection1'); - const collection2Failures = result.failures.filter((f) => f.collection === 'collection2'); - expect(collection1Failures).toHaveLength(2); - expect(collection2Failures).toHaveLength(2); + expect(result.passed).toBe(false); + expect(result.failures).toHaveLength(0); + expect(result.successes).toHaveLength(2); + expect(result.unreadableFolders).toHaveLength(1); + expect(result.unreadableFolders?.[0]).toMatchObject({ collection: 'main', folderPath: 'bad' }); + expect(result.unreadableFolders?.[0]?.message).toContain('tracker_meta.json'); }); }); - describe('edge cases', () => { - it('should handle resources with no translations in any locale', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: {}, - status: {}, - collection: 'main', - }, - ]; - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources([{ name: 'main', path: '/translations' }], ['es', 'fr'], { + describe('ICU and placeholders', () => { + it("compiles each collection's base values under its own base locale", () => { + const main = collection('main', { locales: ['en', 'es'] }); + const japanese = collection('japanese', { baseLocale: 'ja', locales: ['ja', 'en'] }); + const plural = '{count, plural, one {# item} other {# items}}'; + seedResources(main, { count: { source: plural, translations: { es: { value: plural, status: 'verified' } } } }); + seedResources(japanese, { + count: { source: plural, translations: { en: { value: plural, status: 'verified' } } }, + }); + + const result = validateResources([main, japanese], { allowTranslated: false, + icu: { compileValues: true, requirePortablePlurals: true }, }); - expect(result.passed).toBe(false); - expect(result.failures).toHaveLength(2); // Both locales default to 'new' - expect(result.failures.every((f) => f.status === 'new')).toBe(true); + expect(result.icu?.valuesChecked).toBe(4); + // The portability rule reads base values only, each under its own collection's base locale. + expect(result.icu?.warnings.map((warning) => `${warning.collection}/${warning.locale}`)).toEqual([ + 'main/en', + 'japanese/ja', + ]); }); - it('should handle validation with no target locales', () => { - const mockResources: LoadedResource[] = [ - { - key: 'ok', - fullKey: 'common.ok', - source: 'OK', - translations: {}, - status: {}, - collection: 'main', - }, - ]; - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); + it("compares each translation with its collection's base value", () => { + const french = collection('french', { baseLocale: 'fr', locales: ['fr', 'en'] }); + seedResources(french, { + greeting: { source: 'Bonjour {name}', translations: { en: { value: 'Hello {nom}', status: 'verified' } } }, + }); - const result = validateResources([{ name: 'main', path: '/translations' }], [], { allowTranslated: false }); + const result = validateResources([french], { allowTranslated: false, placeholders: true }); - expect(result.passed).toBe(true); // No locales to validate - expect(result.failures).toHaveLength(0); - expect(result.totalResourcesValidated).toBe(0); - expect(result.localesValidated).toBe(0); + expect(result.passed).toBe(false); + expect(result.placeholders?.valuesChecked).toBe(1); + expect(result.placeholders?.failures).toMatchObject([ + { key: 'greeting', locale: 'en', collection: 'french', missing: ['name'], unexpected: ['nom'] }, + ]); }); - it('should handle large numbers of resources efficiently', () => { - // Create 1000 resources - const mockResources: LoadedResource[] = Array.from({ length: 1000 }, (_, i) => ({ - key: `key${i}`, - fullKey: `ns.key${i}`, - source: `Source ${i}`, - translations: { es: `Value ${i}` }, - status: { es: 'verified' }, - collection: 'main', - })); - - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue(mockResources); - - const result = validateResources([{ name: 'main', path: '/translations' }], ['es'], { allowTranslated: false }); + it('leaves the ICU and placeholder results undefined when not requested', () => { + const result = validateResources([collection()], { allowTranslated: false }); - expect(result.passed).toBe(true); - expect(result.totalResourcesValidated).toBe(1000); - expect(result.successes).toHaveLength(1000); - expect(result.statusCounts.verified).toBe(1000); + expect(result.icu).toBeUndefined(); + expect(result.placeholders).toBeUndefined(); }); }); describe('preferred terminology', () => { const rules = [{ discouraged: 'Expenditure', preferred: 'Investment' }]; - const collections = [ - { name: 'main', path: '/translations/main' }, - { name: 'legacy', path: '/translations/legacy' }, - ]; - - const verified = (overrides: Partial): LoadedResource => ({ - key: 'title', - fullKey: 'budget.title', - source: 'Capital expenditure', - translations: { es: 'Gasto de capital', fr: 'Dépenses en capital' }, - status: { es: 'verified', fr: 'verified' }, - collection: 'main', - ...overrides, - }); + const budget: SeedResource = withStatus('Capital expenditure', { es: 'verified', fr: 'verified' }); it('reports a finding once across every target locale and still passes', () => { - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue([verified({})]); + const main = collection(); + seedResources(main, { 'budget.title': budget }); - const result = validateResources(collections, ['es', 'fr'], { - allowTranslated: false, - terminology: { rules, baseLocaleByCollection: { main: 'en', legacy: 'en' } }, - }); + const result = validateResources([main], { allowTranslated: false, terminology: { rules } }); expect(result.passed).toBe(true); expect(result.terminology?.warnings).toHaveLength(1); @@ -598,29 +262,27 @@ describe('validateResources', () => { expect(result.terminology?.valuesChecked).toBe(1); }); - it('scans each collection under its own base locale', () => { - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue([ - verified({}), - verified({ collection: 'legacy', fullKey: 'legacy.title' }), - ]); + it('reports each collection under its own base locale', () => { + const main = collection(); + const legacy = collection('legacy', { baseLocale: 'en-GB', locales: ['en-GB', 'es', 'fr'] }); + seedResources(main, { 'budget.title': budget }); + seedResources(legacy, { 'legacy.title': budget }); - const result = validateResources(collections, ['es', 'fr'], { - allowTranslated: false, - terminology: { rules, baseLocaleByCollection: { main: 'en', legacy: 'en-GB' } }, - }); + const result = validateResources([main, legacy], { allowTranslated: false, terminology: { rules } }); - expect(result.terminology?.warnings.map((w) => `${w.collection}:${w.locale}`)).toEqual([ + expect(result.terminology?.warnings.map((warning) => `${warning.collection}:${warning.locale}`)).toEqual([ 'main:en', 'legacy:en-GB', ]); }); it('fails when the rule file could not be loaded', () => { - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue([verified({})]); + const main = collection(); + seedResources(main, { 'budget.title': budget }); - const result = validateResources(collections, ['es', 'fr'], { + const result = validateResources([main], { allowTranslated: false, - terminology: { rules: [], loadError: 'broken', baseLocaleByCollection: {} }, + terminology: { rules: [], loadError: 'broken' }, }); expect(result.passed).toBe(false); @@ -629,11 +291,7 @@ describe('validateResources', () => { }); it('leaves the terminology result undefined when not requested', () => { - vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue([verified({})]); - - const result = validateResources(collections, ['es', 'fr'], { allowTranslated: false }); - - expect(result.terminology).toBeUndefined(); + expect(validateResources([collection()], { allowTranslated: false }).terminology).toBeUndefined(); }); }); }); diff --git a/libs/core/src/lib/validate/validate-resources.ts b/libs/core/src/lib/validate/validate-resources.ts index 56ec2c62..6f8bcdee 100644 --- a/libs/core/src/lib/validate/validate-resources.ts +++ b/libs/core/src/lib/validate/validate-resources.ts @@ -1,32 +1,44 @@ import type { TranslationStatus } from '@simoncodes-ca/domain'; -import { type LoadedResource, loadResourcesFromCollections } from '../export/export-common'; -import type { ResourceValidationDetail, ResourceValidationResult, StatusCounts, ValidationOptions } from './types'; +import type { Collection } from '../config/open-collection'; +import { type LoadedResource, loadResources } from '../export/export-common'; +import type { + IcuValidationResult, + PlaceholderValidationResult, + ResourceValidationDetail, + ResourceValidationResult, + StatusCounts, + UnreadableFolderDetail, + ValidationOptions, +} from './types'; import { validateIcuValues } from './validate-icu'; import { validatePlaceholders } from './validate-placeholders'; import { validateTerminology } from './validate-terminology'; /** - * Validates translation resources across all collections and locales. + * Validates translation resources collection by collection. * - * This function performs comprehensive validation by: - * 1. Loading ALL resources from ALL specified collections - * 2. Checking translation status for EVERY resource in EVERY target locale - * 3. Collecting ALL validation results (does NOT stop at first error) - * 4. Categorizing resources into failures, warnings, and successes + * Each collection is read through the Collection Reader and validated with its own + * base locale and target locales (minus `options.skippedLocales`). A key present in + * two collections is validated in both. * - * Validation logic: + * Status validation, per resource and target locale: * - 'new' status → failure (resource not yet translated) * - 'stale' status → failure (translation out of sync with source) * - 'translated' status → failure (default) or warning (if allowTranslated=true) * - 'verified' status → success (translation reviewed and approved) * - Missing status/metadata → treated as 'new' (failure) * + * A folder the reader could not read (malformed JSON, or an entry that is not an + * object) is listed in `unreadableFolders` and fails validation: its resources were + * not checked. + * * When `options.icu` is provided, a second pass compiles every stored value - * under the locale it is stored under. Any value that fails to compile is a - * failure regardless of its status — plural categories are per-language, so a - * value approved by a reviewer can still throw for its own locale. + * under the locale it is stored under, base values under the collection's base + * locale. Any value that fails to compile is a failure regardless of its status — + * plural categories are per-language, so a value approved by a reviewer can still + * throw for its own locale. * - * When `options.placeholders` is provided, a third pass checks that every + * When `options.placeholders` is set, a third pass checks that every * translation interpolates the same arguments as its base value. A renamed * argument renders as empty text rather than raising, so neither of the other * two passes can see it. @@ -35,54 +47,37 @@ import { validateTerminology } from './validate-terminology'; * base-locale values for discouraged terms. Its findings are advisory and never * fail validation; only a rule file that could not be loaded does. * - * The function validates ALL resources comprehensively before returning results. - * This ensures teams have complete visibility into translation status across - * their entire project. + * Every resource is validated before returning; the function never stops at the first failure. * - * @param collections - Array of collections to validate with name and path - * @param targetLocales - Array of locale codes to validate (e.g., ['es', 'fr', 'de']) + * @param collections - The opened collections to validate (see `openCollection`) * @param options - Validation configuration options * @returns Comprehensive validation result with counts, failures, warnings, and successes * * @example * ```typescript - * // Validate all resources with strict requirements (translated = failure) - * const result = validateResources( - * [{ name: 'main', path: '/project/src/translations' }], - * ['es', 'fr'], - * { allowTranslated: false } - * ); + * const collections = Object.keys(config.collections).map((name) => openCollection(config, name, { cwd })); + * const result = validateResources(collections, { allowTranslated: false, skippedLocales: ['de'] }); * * if (!result.passed) { * console.error(`Validation failed: ${result.failures.length} failures`); - * for (const failure of result.failures) { - * console.error(` ${failure.locale}/${failure.key}: ${failure.status}`); - * } * } - * - * // Validate with relaxed requirements (translated = warning) - * const relaxedResult = validateResources( - * collections, - * targetLocales, - * { allowTranslated: true } - * ); - * - * console.log(`Warnings: ${relaxedResult.warnings.length}`); - * console.log(`Passed: ${relaxedResult.passed}`); * ``` */ export function validateResources( - collections: Array<{ name: string; path: string }>, - targetLocales: readonly string[], + collections: readonly Collection[], options: ValidationOptions, ): ResourceValidationResult { - // Load all resources from all collections - const loadedResources = loadResourcesFromCollections(collections); + const skipped = new Set(options.skippedLocales ?? []); // Initialize result accumulators const failures: ResourceValidationDetail[] = []; const warnings: ResourceValidationDetail[] = []; const successes: ResourceValidationDetail[] = []; + const unreadableFolders: UnreadableFolderDetail[] = []; + const icuResults: IcuValidationResult[] = []; + const placeholderResults: PlaceholderValidationResult[] = []; + const allResources: LoadedResource[] = []; + const validatedLocales = new Set(); const statusCounts: StatusCounts = { new: 0, @@ -93,37 +88,56 @@ export function validateResources( let totalResourcesValidated = 0; - // Validate each resource for each target locale - for (const resource of loadedResources) { - for (const locale of targetLocales) { - totalResourcesValidated++; - - const validationDetail = validateSingleResourceInLocale(resource, locale); + for (const collection of collections) { + const { resources, problems } = loadResources(collection); + const targetLocales = collection.targetLocales.filter((locale) => !skipped.has(locale)); + allResources.push(...resources); + for (const locale of targetLocales) validatedLocales.add(locale); + unreadableFolders.push( + ...problems.map(({ folderPath, message }) => ({ collection: collection.name, folderPath, message })), + ); + + // Validate each resource for each of the collection's target locales + for (const resource of resources) { + for (const locale of targetLocales) { + totalResourcesValidated++; + + const validationDetail = validateSingleResourceInLocale(resource, locale); + statusCounts[validationDetail.status]++; + categorizeValidationDetail(validationDetail, options, failures, warnings, successes); + } + } - // Update status counts - statusCounts[validationDetail.status]++; + // ICU compilation is a separate question from translation status: it asks + // whether the stored value renders at all, not whether anyone approved it. + if (options.icu) { + icuResults.push( + validateIcuValues(resources, targetLocales, { ...options.icu, baseLocale: collection.baseLocale }), + ); + } - // Categorize based on status and options - categorizeValidationDetail(validationDetail, options, failures, warnings, successes); + // And placeholder agreement is a third: a value can be approved and compile + // cleanly while interpolating an argument the caller never passes. + if (options.placeholders) { + placeholderResults.push(validatePlaceholders(resources, targetLocales, collection.baseLocale)); } } - // ICU compilation is a separate question from translation status: it asks - // whether the stored value renders at all, not whether anyone approved it. - const icu = options.icu ? validateIcuValues(loadedResources, targetLocales, options.icu) : undefined; - - // And placeholder agreement is a third: a value can be approved and compile - // cleanly while interpolating an argument the caller never passes. - const placeholders = options.placeholders - ? validatePlaceholders(loadedResources, targetLocales, options.placeholders.baseLocale) - : undefined; + const icu = options.icu ? mergeIcuResults(icuResults) : undefined; + const placeholders = options.placeholders ? mergePlaceholderResults(placeholderResults) : undefined; // Terminology is advisory: findings suggest wording and never block. A rule // file that failed to load does block, because then nothing was checked. - const terminology = options.terminology ? validateTerminology(loadedResources, options.terminology) : undefined; + const terminology = options.terminology + ? validateTerminology(allResources, { + ...options.terminology, + baseLocaleByCollection: Object.fromEntries(collections.map(({ name, baseLocale }) => [name, baseLocale])), + }) + : undefined; const passed = failures.length === 0 && + unreadableFolders.length === 0 && (icu?.failures.length ?? 0) === 0 && (placeholders?.failures.length ?? 0) === 0 && terminology?.configError === undefined; @@ -132,9 +146,10 @@ export function validateResources( icu, placeholders, terminology, + unreadableFolders, totalResourcesValidated, - totalUniqueKeys: loadedResources.length, - localesValidated: targetLocales.length, + totalUniqueKeys: allResources.length, + localesValidated: validatedLocales.size, collectionsValidated: collections.length, statusCounts, failures, @@ -144,6 +159,22 @@ export function validateResources( }; } +function mergeIcuResults(results: readonly IcuValidationResult[]): IcuValidationResult { + return { + failures: results.flatMap((result) => result.failures), + warnings: results.flatMap((result) => result.warnings), + unsupportedLocales: [...new Set(results.flatMap((result) => result.unsupportedLocales))], + valuesChecked: results.reduce((total, result) => total + result.valuesChecked, 0), + }; +} + +function mergePlaceholderResults(results: readonly PlaceholderValidationResult[]): PlaceholderValidationResult { + return { + failures: results.flatMap((result) => result.failures), + valuesChecked: results.reduce((total, result) => total + result.valuesChecked, 0), + }; +} + /** * Validates a single resource for a specific locale. * diff --git a/libs/core/src/lib/validate/validate-terminology.spec.ts b/libs/core/src/lib/validate/validate-terminology.spec.ts index 1fe19f2d..e62ddf10 100644 --- a/libs/core/src/lib/validate/validate-terminology.spec.ts +++ b/libs/core/src/lib/validate/validate-terminology.spec.ts @@ -10,6 +10,7 @@ const resource = (overrides: Partial = {}): LoadedResource => ({ translations: { fr: 'Dépenses en capital', de: 'Investitionsausgaben' }, status: { fr: 'verified', de: 'verified' }, collection: 'main', + effectiveTags: [], ...overrides, }); diff --git a/libs/core/src/lib/validate/validate-terminology.ts b/libs/core/src/lib/validate/validate-terminology.ts index 212dafec..771ad476 100644 --- a/libs/core/src/lib/validate/validate-terminology.ts +++ b/libs/core/src/lib/validate/validate-terminology.ts @@ -2,6 +2,15 @@ import { findPreferredTermFindings, type PreferredTermRule } from '@simoncodes-c import type { LoadedResource } from '../export/export-common'; import type { TerminologyValidationDetail, TerminologyValidationOptions, TerminologyValidationResult } from './types'; +/** {@link TerminologyValidationOptions} plus where to report each collection's findings. */ +export interface TerminologyPassOptions extends TerminologyValidationOptions { + /** + * Base locale of each collection, by collection name. Findings are reported under this + * locale; a collection missing from the map is reported under an empty locale. + */ + readonly baseLocaleByCollection: Readonly>; +} + /** * Scans every base-locale value for discouraged terms from the preferred-terminology file. * @@ -24,7 +33,7 @@ import type { TerminologyValidationDetail, TerminologyValidationOptions, Termino */ export function validateTerminology( resources: readonly LoadedResource[], - options: TerminologyValidationOptions, + options: TerminologyPassOptions, ): TerminologyValidationResult { if (options.loadError !== undefined) { return { warnings: [], configError: options.loadError, valuesChecked: 0 }; diff --git a/libs/core/src/testing/temp-dir.spec-helpers.ts b/libs/core/src/testing/temp-dir.spec-helpers.ts new file mode 100644 index 00000000..8053a4ad --- /dev/null +++ b/libs/core/src/testing/temp-dir.spec-helpers.ts @@ -0,0 +1,107 @@ +/** + * Real-filesystem fixtures for core specs: a fresh temp directory per test, collections that + * point into it, and helpers that write resources through the Resource Folder or as raw files. + * + * Spec-only (imports vitest): `*.spec-helpers.ts` files are excluded from the library build. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { TranslationStatus } from '@simoncodes-ca/domain'; +import { afterEach, beforeEach } from 'vitest'; +import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../constants'; +import type { Collection } from '../lib/config/open-collection'; +import { openResourceFolder } from '../lib/resource/resource-folder'; + +/** + * Creates a temp directory before each test and removes it after. Call inside `describe`; + * read the path with the returned getter (it changes per test). + * + * Temp directories live outside the workspace so fixture writes never reach the Nx file watcher. + */ +export function useTempDir(prefix = 'lingo-core-'): () => string { + let dir = ''; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), prefix)); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + }); + + return () => dir; +} + +/** A resolved collection for tests. `locales` defaults to the base locale plus `fr` and `es`. */ +export function testCollection(translationsFolder: string, overrides: Partial = {}): Collection { + const baseLocale = overrides.baseLocale ?? 'en'; + const locales = overrides.locales ?? [baseLocale, 'fr', 'es']; + return { + name: 'main', + translationsFolder, + translationConfig: undefined, + tags: [], + readOnly: false, + config: { translationsFolder }, + ...overrides, + baseLocale, + locales, + targetLocales: overrides.targetLocales ?? locales.filter((locale) => locale !== baseLocale), + }; +} + +/** A translation value, optionally with its status (default `translated`). */ +export type SeedTranslation = string | { readonly value: string; readonly status: TranslationStatus }; + +export interface SeedResource { + readonly source: string; + readonly translations?: Readonly>; + readonly comment?: string; + readonly tags?: readonly string[]; +} + +/** + * Writes resources by full key (`apps.common.ok`) through the Resource Folder, so checksums and + * metadata are what core itself would store. + */ +export function seedResources(collection: Collection, resources: Readonly>): void { + for (const [fullKey, resource] of Object.entries(resources)) { + const segments = fullKey.split('.'); + const entryKey = segments.pop() ?? fullKey; + const folder = openResourceFolder(join(collection.translationsFolder, ...segments), { + baseLocale: collection.baseLocale, + }); + + folder.setBase(entryKey, resource.source); + if (resource.comment !== undefined || resource.tags !== undefined) { + folder.setDetails(entryKey, { comment: resource.comment, tags: resource.tags }); + } + for (const [locale, translation] of Object.entries(resource.translations ?? {})) { + const { value, status } = + typeof translation === 'string' ? { value: translation, status: undefined } : translation; + folder.setTranslation(entryKey, locale, value, status); + } + folder.save(); + } +} + +/** + * Writes one folder's files as given, bypassing the Resource Folder — for malformed or hand-edited + * data. A string is written verbatim; an object as JSON; `undefined` leaves the file out. + */ +export function writeFolderFiles( + translationsFolder: string, + folderPath: string, + files: { readonly entries?: object | string; readonly meta?: object | string }, +): string { + const folder = join(translationsFolder, ...folderPath.split('.').filter((segment) => segment.length > 0)); + mkdirSync(folder, { recursive: true }); + const write = (name: string, content: object | string | undefined): void => { + if (content === undefined) return; + writeFileSync(join(folder, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2)); + }; + write(RESOURCE_ENTRIES_FILENAME, files.entries); + write(TRACKER_META_FILENAME, files.meta); + return folder; +} diff --git a/libs/core/tsconfig.lib.json b/libs/core/tsconfig.lib.json index 00531bd5..cd41c04e 100644 --- a/libs/core/tsconfig.lib.json +++ b/libs/core/tsconfig.lib.json @@ -14,6 +14,7 @@ "vitest.config.mts", "src/**/*.test.ts", "src/**/*.spec.ts", + "src/**/*.spec-helpers.ts", "src/**/*.test.tsx", "src/**/*.spec.tsx", "src/**/*.test.js", From 7930e3d199121f66a10c05bc0538c4aab8e18032 Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 17:41:05 -0700 Subject: [PATCH 12/20] refactor(core): one Translator behind the machine-translation seam openTranslator(collection, { provider?, protectedTerms? }) returns translate(entries, locales) -> { values, skipped }. It owns the enabled check, the API key, provider construction, the complex-ICU skip, the placeholder guard, the protected-term guard and translocoToICU normalisation. seedLocales (add/edit), translateExistingResource and translateLocale only pick what needs work by the Staleness rule. - translateLocale(collection, { targetLocale, onProgress?, provider? }) reads via the Collection Reader; result gains `warnings` for folders it could not read (CLI prints them, API job logs them) - Collection gains protectedTermsFiles (resolved paths, no I/O); readProtectedTermsInForce(collection) reads them through the cache - InMemoryTranslationProvider: second adapter, used by specs instead of vi.mock of core internals; add/edit/translate* accept { provider? } - ProtectedTermsFileError (typed) replaces plain Error for a malformed terms file - deleted auto-translate-resources, translation-orchestrator (translateBatch), icu-classifier shim and their specs Behaviour changes: - an auto-translation that drops a protected term is skipped, not stored; on add/edit that locale gets a base copy as `new` (a real translation is kept as `stale`) - translateExistingResource and translateLocale store ICU-normalised values - CLI translate-locale label "Skipped (ICU)" -> "Skipped (needs human translation)"; prints reader warnings - a malformed protected-terms file now fails add/edit (auto-translation on), translateExistingResource and translateLocale (500 with message) Co-Authored-By: Claude Fable 5.1 --- .../cache/collection-index.service.spec.ts | 8 +- .../resources/resources.controller.spec.ts | 4 + .../resources/resources.controller.ts | 10 +- .../lingo-tracker-exception.filter.spec.ts | 10 + .../app/mappers/resource-tree.mapper.spec.ts | 1 + .../translation-job.service.spec.ts | 64 ++- .../translation-job.service.ts | 31 +- apps/cli/src/commands/add-locale.spec.ts | 1 + apps/cli/src/commands/normalize.test.ts | 1 + apps/cli/src/commands/remove-locale.spec.ts | 1 + apps/cli/src/commands/translate-locale.ts | 18 +- architecture-docs/api.md | 15 +- architecture-docs/cli.md | 2 +- architecture-docs/core-library.md | 156 +++--- architecture-docs/domain-and-data-model.md | 2 +- architecture-docs/glossary.md | 14 +- docs/auto-translation.md | 28 +- docs/cli.md | 5 +- docs/features/protected-terms.md | 15 +- libs/core/src/index.ts | 7 + libs/core/src/lib/config/index.ts | 8 +- .../src/lib/config/open-collection.spec.ts | 22 + libs/core/src/lib/config/open-collection.ts | 30 +- .../lib/config/protected-terms-file.spec.ts | 32 ++ .../src/lib/config/protected-terms-file.ts | 42 +- libs/core/src/lib/errors/index.ts | 1 + .../src/lib/errors/lingo-tracker-error.ts | 13 + .../core/src/lib/folder/create-folder.spec.ts | 1 + .../lib/folder/move-folder.real-fs.spec.ts | 1 + libs/core/src/lib/folder/move-folder.spec.ts | 1 + .../resource-mutation.real-fs.spec.ts | 1 + .../auto-translate-resources.spec.ts | 274 ---------- .../translation/auto-translate-resources.ts | 89 --- .../lib/translation/icu-classifier.spec.ts | 119 ---- .../src/lib/translation/icu-classifier.ts | 1 - .../in-memory-translation-provider.ts | 37 ++ libs/core/src/lib/translation/index.ts | 25 +- .../translate-existing-resource.spec.ts | 351 +++++------- .../translate-existing-resource.ts | 40 +- .../lib/translation/translate-locale.spec.ts | 508 +++++------------- .../src/lib/translation/translate-locale.ts | 202 +++---- .../translation-orchestrator.spec.ts | 418 -------------- .../translation/translation-orchestrator.ts | 251 --------- .../src/lib/translation/translator.spec.ts | 270 ++++++++++ libs/core/src/lib/translation/translator.ts | 196 +++++++ libs/core/src/resource/add-resource.spec.ts | 155 ++++-- libs/core/src/resource/add-resource.ts | 13 +- .../core/src/resource/delete-resource.spec.ts | 1 + libs/core/src/resource/edit-resource.spec.ts | 105 ++-- libs/core/src/resource/edit-resource.ts | 28 +- libs/core/src/resource/locale-seeding.ts | 42 +- .../resource/move-resource.real-fs.spec.ts | 1 + libs/core/src/resource/move-resource.spec.ts | 1 + .../core/src/testing/temp-dir.spec-helpers.ts | 7 +- libs/domain/src/lib/protected-terms.ts | 8 +- 55 files changed, 1502 insertions(+), 2185 deletions(-) delete mode 100644 libs/core/src/lib/translation/auto-translate-resources.spec.ts delete mode 100644 libs/core/src/lib/translation/auto-translate-resources.ts delete mode 100644 libs/core/src/lib/translation/icu-classifier.spec.ts delete mode 100644 libs/core/src/lib/translation/icu-classifier.ts create mode 100644 libs/core/src/lib/translation/in-memory-translation-provider.ts delete mode 100644 libs/core/src/lib/translation/translation-orchestrator.spec.ts delete mode 100644 libs/core/src/lib/translation/translation-orchestrator.ts create mode 100644 libs/core/src/lib/translation/translator.spec.ts create mode 100644 libs/core/src/lib/translation/translator.ts diff --git a/apps/api/src/app/cache/collection-index.service.spec.ts b/apps/api/src/app/cache/collection-index.service.spec.ts index 513ac112..dca15fb7 100644 --- a/apps/api/src/app/cache/collection-index.service.spec.ts +++ b/apps/api/src/app/cache/collection-index.service.spec.ts @@ -36,7 +36,7 @@ describe('CollectionIndex', () => { } function collection(name = 'main'): Collection { - return openCollection(config(name), name); + return openCollection(config(name), name, { cwd: root }); } /** Writes one entry (with metadata) the way core stores it. */ @@ -128,7 +128,7 @@ describe('CollectionIndex', () => { it('reports an indexing failure and retries it on the next tree read', () => { // A file where the translations folder should be cannot be read as a folder. fs.writeFileSync(path.join(root, 'broken'), 'not a folder'); - const broken = openCollection(config('broken'), 'broken'); + const broken = openCollection(config('broken'), 'broken', { cwd: root }); expect(index.tree(broken)).toEqual({ status: 'not-started' }); expect(index.status(broken)).toEqual(expect.objectContaining({ status: 'error', error: expect.any(String) })); @@ -190,7 +190,7 @@ describe('CollectionIndex', () => { it('moves a resource to another collection and updates both', async () => { writeEntry('other', 'existing', 'Existing'); - const other = openCollection(config('other'), 'other'); + const other = openCollection(config('other'), 'other', { cwd: root }); readyTree(other); const result = await moveResource(collection(), { @@ -312,7 +312,7 @@ describe('CollectionIndex', () => { const capped = new CollectionIndex(); const [first, second, third] = ['first', 'second', 'third'].map((name) => { writeEntry(name, 'ok', 'OK'); - return openCollection(config(name), name); + return openCollection(config(name), name, { cwd: root }); }); capped.tree(first); diff --git a/apps/api/src/app/collections/resources/resources.controller.spec.ts b/apps/api/src/app/collections/resources/resources.controller.spec.ts index da19d626..dbe36f4c 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -1284,6 +1284,10 @@ describe('ResourcesController', () => { await resourcesController.translateLocale('test-collection', { locale: 'fr-ca' }, mockResponse as any); + expect(translationJobService.startJob).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection', translationConfig: configWithTranslation.translation }), + 'fr-ca', + ); expect(mockResponse.status).toHaveBeenCalledWith(202); expect(mockResponse.json).toHaveBeenCalledWith(mockJobDto); }); diff --git a/apps/api/src/app/collections/resources/resources.controller.ts b/apps/api/src/app/collections/resources/resources.controller.ts index 76a7612a..b249148a 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -335,15 +335,7 @@ export class ResourcesController { ); } - const jobId = this.#translationJobService.startJob({ - collectionName: collection.name, - translationsFolder: collection.translationsFolder, - translationConfig, - targetLocale: dto.locale, - baseLocale, - allLocales, - cwd: process.cwd(), - }); + const jobId = this.#translationJobService.startJob(collection, dto.locale); const job = this.#translationJobService.getJob(jobId); (response as unknown as import('express').Response).status(HttpStatus.ACCEPTED).json(job); diff --git a/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts b/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts index d0a444f4..8544cec8 100644 --- a/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts +++ b/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts @@ -16,6 +16,7 @@ import { LingoTrackerError, LocaleAlreadyExistsError, LocaleNotFoundError, + ProtectedTermsFileError, ReadOnlyCollectionError, ResourceAlreadyExistsError, ResourceNotFoundError, @@ -117,6 +118,15 @@ describe('toHttpException', () => { 400, { message: 'Invalid bundle definition: a; b', error: 'Bad Request', statusCode: 400 }, ], + [ + new ProtectedTermsFileError('/p/terms.json', 'Protected terms file is not valid JSON: /p/terms.json'), + 500, + { + message: 'Protected terms file is not valid JSON: /p/terms.json', + error: 'Internal Server Error', + statusCode: 500, + }, + ], [ new LingoTrackerError('Unmapped', 'UNMAPPED'), 500, diff --git a/apps/api/src/app/mappers/resource-tree.mapper.spec.ts b/apps/api/src/app/mappers/resource-tree.mapper.spec.ts index ee569f7b..ce8a02a5 100644 --- a/apps/api/src/app/mappers/resource-tree.mapper.spec.ts +++ b/apps/api/src/app/mappers/resource-tree.mapper.spec.ts @@ -11,6 +11,7 @@ function collectionWith(overrides: Partial = {}): Collection { targetLocales: ['es', 'fr'], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder: '/t' }, ...overrides, diff --git a/apps/api/src/app/translation-job/translation-job.service.spec.ts b/apps/api/src/app/translation-job/translation-job.service.spec.ts index 9a248149..82e31825 100644 --- a/apps/api/src/app/translation-job/translation-job.service.spec.ts +++ b/apps/api/src/app/translation-job/translation-job.service.spec.ts @@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common'; import { TranslationJobService } from './translation-job.service'; import type { CollectionIndex } from '../cache/collection-index.service'; import { TranslationError } from '@simoncodes-ca/core'; -import type { TranslateLocaleResult, TranslateLocaleProgress } from '@simoncodes-ca/core'; +import type { Collection, TranslateLocaleResult, TranslateLocaleProgress } from '@simoncodes-ca/core'; const mockTranslateLocale = jest.fn(); @@ -10,7 +10,7 @@ jest.mock('@simoncodes-ca/core', () => { const actual = jest.requireActual('@simoncodes-ca/core'); return { ...actual, - translateLocale: (params: unknown) => mockTranslateLocale(params), + translateLocale: (collection: unknown, params: unknown) => mockTranslateLocale(collection, params), }; }); @@ -21,18 +21,24 @@ const makeSuccessResult = (overrides: Partial = {}): Tran skippedCount: 0, failures: [{ key: 'apps.button.ok', error: 'Rate limit exceeded' }], skippedKeys: [], + warnings: [], ...overrides, }); -const makeStartJobParams = () => ({ - collectionName: 'my-collection', +const collection: Collection = { + name: 'my-collection', translationsFolder: '/path/to/translations', - translationConfig: { enabled: true, provider: 'google', apiKeyEnv: 'GOOGLE_API_KEY' }, - targetLocale: 'fr', baseLocale: 'en', - allLocales: ['en', 'fr', 'de'], - cwd: '/workspace', -}); + locales: ['en', 'fr', 'de'], + targetLocales: ['fr', 'de'], + translationConfig: { enabled: true, provider: 'google', apiKeyEnv: 'GOOGLE_API_KEY' }, + tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, + readOnly: false, + config: { translationsFolder: '/path/to/translations' }, +}; + +const startJob = (service: TranslationJobService): string => service.startJob(collection, 'fr'); describe('TranslationJobService', () => { let service: TranslationJobService; @@ -45,10 +51,21 @@ describe('TranslationJobService', () => { service = new TranslationJobService(mockLogger as unknown as Logger, mockIndex as unknown as CollectionIndex); }); + it('runs translateLocale on the opened collection for the target locale', () => { + mockTranslateLocale.mockReturnValue(new Promise(() => {})); // never resolves + + startJob(service); + + expect(mockTranslateLocale).toHaveBeenCalledWith( + collection, + expect.objectContaining({ targetLocale: 'fr', onProgress: expect.any(Function) }), + ); + }); + it('startJob returns a non-empty job ID', () => { mockTranslateLocale.mockReturnValue(new Promise(() => {})); // never resolves - const jobId = service.startJob(makeStartJobParams()); + const jobId = startJob(service); expect(jobId).toBeTruthy(); expect(typeof jobId).toBe('string'); @@ -63,7 +80,7 @@ describe('TranslationJobService', () => { it('getJob returns a running job immediately after startJob (before async completes)', () => { mockTranslateLocale.mockReturnValue(new Promise(() => {})); // never resolves - const jobId = service.startJob(makeStartJobParams()); + const jobId = startJob(service); const job = service.getJob(jobId); expect(job).toBeDefined(); @@ -77,7 +94,7 @@ describe('TranslationJobService', () => { const result = makeSuccessResult(); mockTranslateLocale.mockResolvedValue(result); - const jobId = service.startJob(makeStartJobParams()); + const jobId = startJob(service); // Wait for the microtask queue to flush the resolved promise await Promise.resolve(); @@ -97,7 +114,7 @@ describe('TranslationJobService', () => { it('job status becomes failed when translateLocale throws a TranslationError', async () => { mockTranslateLocale.mockRejectedValue(new TranslationError('API quota exceeded', 'QUOTA_EXCEEDED', false)); - const jobId = service.startJob(makeStartJobParams()); + const jobId = startJob(service); await Promise.resolve(); await Promise.resolve(); @@ -111,7 +128,7 @@ describe('TranslationJobService', () => { it('job status becomes failed when translateLocale throws a generic Error', async () => { mockTranslateLocale.mockRejectedValue(new Error('Unexpected network failure')); - const jobId = service.startJob(makeStartJobParams()); + const jobId = startJob(service); await Promise.resolve(); await Promise.resolve(); @@ -127,7 +144,7 @@ describe('TranslationJobService', () => { ])('drops the collection index for the translations folder when the job %s', async (_outcome, arrange) => { arrange(); - service.startJob(makeStartJobParams()); + startJob(service); expect(mockIndex.apply).not.toHaveBeenCalled(); await Promise.resolve(); @@ -136,10 +153,21 @@ describe('TranslationJobService', () => { expect(mockIndex.apply).toHaveBeenCalledWith([{ kind: 'reindex', translationsFolder: '/path/to/translations' }]); }); + it('logs the folders translateLocale could not read', async () => { + mockTranslateLocale.mockResolvedValue(makeSuccessResult({ warnings: ["Folder 'broken' was not translated: bad"] })); + + const jobId = startJob(service); + + await Promise.resolve(); + await Promise.resolve(); + + expect(mockLogger.warn).toHaveBeenCalledWith(`Translation job ${jobId}: Folder 'broken' was not translated: bad`); + }); + it('getJob omits optional fields when there are no failures or skipped keys', async () => { mockTranslateLocale.mockResolvedValue(makeSuccessResult({ failures: [], skippedKeys: [] })); - const jobId = service.startJob(makeStartJobParams()); + const jobId = startJob(service); await Promise.resolve(); await Promise.resolve(); @@ -154,7 +182,7 @@ describe('TranslationJobService', () => { let resolveTranslation!: (result: TranslateLocaleResult) => void; mockTranslateLocale.mockImplementationOnce( - (params: { onProgress?: (p: TranslateLocaleProgress) => void }) => + (_collection: Collection, params: { onProgress?: (p: TranslateLocaleProgress) => void }) => new Promise((resolve) => { resolveTranslation = resolve; params.onProgress?.({ @@ -168,7 +196,7 @@ describe('TranslationJobService', () => { }), ); - const jobId = service.startJob(makeStartJobParams()); + const jobId = startJob(service); // Give the microtask queue a tick so the async function runs up to its first await // (the Promise constructor callback runs synchronously, so onProgress has already been called) diff --git a/apps/api/src/app/translation-job/translation-job.service.ts b/apps/api/src/app/translation-job/translation-job.service.ts index 60db5d8a..c5079ffb 100644 --- a/apps/api/src/app/translation-job/translation-job.service.ts +++ b/apps/api/src/app/translation-job/translation-job.service.ts @@ -1,8 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { resolve } from 'node:path'; -import { reindexMutation, translateLocale, TranslationError } from '@simoncodes-ca/core'; -import type { TranslateLocaleParams, TranslateLocaleProgress } from '@simoncodes-ca/core'; +import { reindexMutation, translateLocale } from '@simoncodes-ca/core'; +import type { Collection, TranslateLocaleProgress } from '@simoncodes-ca/core'; import type { TranslateLocaleJobDto } from '@simoncodes-ca/data-transfer'; import { CollectionIndex } from '../cache/collection-index.service'; @@ -34,16 +33,16 @@ export class TranslationJobService { } /** - * Kicks off an async translate-locale job and returns its ID immediately. + * Kicks off an async translate-locale job for an opened collection and returns its ID immediately. * The caller can poll `getJob(jobId)` to track progress. */ - startJob(params: TranslateLocaleParams & { collectionName: string }): string { + startJob(collection: Collection, targetLocale: string): string { const jobId = randomUUID(); const job: TranslationJob = { jobId, - collectionName: params.collectionName, - targetLocale: params.targetLocale, + collectionName: collection.name, + targetLocale, status: 'pending', totalResources: 0, translatedCount: 0, @@ -55,7 +54,7 @@ export class TranslationJobService { this.#jobs.set(jobId, job); - this.#runJob(jobId, params); + this.#runJob(jobId, collection, targetLocale); return jobId; } @@ -71,9 +70,7 @@ export class TranslationJobService { return this.#toDto(job); } - #runJob(jobId: string, params: TranslateLocaleParams & { collectionName: string }): void { - const { collectionName: _collectionName, ...translateParams } = params; - + #runJob(jobId: string, collection: Collection, targetLocale: string): void { const onProgress = (progress: TranslateLocaleProgress): void => { const job = this.#jobs.get(jobId); @@ -93,14 +90,14 @@ export class TranslationJobService { runningJob.startedAt = new Date(); // translateLocale writes resource files (even when it fails part-way), so the index is dropped either way. - const reindex = (): void => - this.#index.apply([ - reindexMutation(resolve(translateParams.cwd ?? process.cwd(), translateParams.translationsFolder)), - ]); + const reindex = (): void => this.#index.apply([reindexMutation(collection.translationsFolder)]); - translateLocale({ ...translateParams, onProgress }) + translateLocale(collection, { targetLocale, onProgress }) .then((result) => { reindex(); + for (const warning of result.warnings) { + this.#logger.warn(`Translation job ${jobId}: ${warning}`); + } const completedJob = this.#jobs.get(jobId); if (!completedJob) { @@ -127,7 +124,7 @@ export class TranslationJobService { failedJob.status = 'failed'; failedJob.completedAt = new Date(); - if (error instanceof TranslationError || error instanceof Error) { + if (error instanceof Error) { failedJob.error = error.message; } else { failedJob.error = 'An unexpected error occurred'; diff --git a/apps/cli/src/commands/add-locale.spec.ts b/apps/cli/src/commands/add-locale.spec.ts index 32bca17f..e52b3900 100644 --- a/apps/cli/src/commands/add-locale.spec.ts +++ b/apps/cli/src/commands/add-locale.spec.ts @@ -45,6 +45,7 @@ const RESOLVED_COLLECTION: Collection = { targetLocales: ['fr'], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder: 'src/i18n' }, }; diff --git a/apps/cli/src/commands/normalize.test.ts b/apps/cli/src/commands/normalize.test.ts index d45e5899..7a96835a 100644 --- a/apps/cli/src/commands/normalize.test.ts +++ b/apps/cli/src/commands/normalize.test.ts @@ -58,6 +58,7 @@ function resolved(name: string, readOnly: boolean): Collection { targetLocales: ['fr'], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly, config: { translationsFolder: `path/${name}`, ...(readOnly ? { readOnly: true } : {}) }, }; diff --git a/apps/cli/src/commands/remove-locale.spec.ts b/apps/cli/src/commands/remove-locale.spec.ts index 206f9bb8..a9c6aeca 100644 --- a/apps/cli/src/commands/remove-locale.spec.ts +++ b/apps/cli/src/commands/remove-locale.spec.ts @@ -45,6 +45,7 @@ const RESOLVED_COLLECTION: Collection = { targetLocales: ['fr', 'de'], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder: 'src/i18n', locales: ['en', 'fr', 'de'] }, }; diff --git a/apps/cli/src/commands/translate-locale.ts b/apps/cli/src/commands/translate-locale.ts index bc3b4e68..b7edbcb7 100644 --- a/apps/cli/src/commands/translate-locale.ts +++ b/apps/cli/src/commands/translate-locale.ts @@ -113,19 +113,12 @@ export async function translateLocaleCommand(options: TranslateLocaleOptions): P // Run translation // ------------------------------------------------------------------------- - const { translationsFolder } = collection; - console.log(''); ConsoleFormatter.progress(`Translating locale '${targetLocale}' in collection '${collectionName}'...`); try { - const result = await translateLocale({ - translationsFolder, - translationConfig, + const result = await translateLocale(collection, { targetLocale, - baseLocale, - allLocales, - cwd, onProgress: options.verbose ? (progress) => { ConsoleFormatter.indent( @@ -143,9 +136,16 @@ export async function translateLocaleCommand(options: TranslateLocaleOptions): P console.log(''); ConsoleFormatter.success(`Translated locale '${targetLocale}' in collection '${collectionName}'`); ConsoleFormatter.keyValue('Translated', result.translatedCount); - ConsoleFormatter.keyValue('Skipped (ICU)', result.skippedCount); + ConsoleFormatter.keyValue('Skipped (needs human translation)', result.skippedCount); ConsoleFormatter.keyValue('Failed', result.failedCount); + if (result.warnings.length > 0) { + console.log(''); + for (const warning of result.warnings) { + ConsoleFormatter.warning(warning); + } + } + if (result.failures.length > 0) { console.log(''); ConsoleFormatter.section('Failures'); diff --git a/architecture-docs/api.md b/architecture-docs/api.md index 425082a0..6d4d1157 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -56,7 +56,7 @@ All paths are relative to the `/api` global prefix. URL path parameters that con | `PATCH` | `/collections/:collectionName/resources` | Update a resource's base value, translations, comment, or tags. `key` is the full, existing key; `moveTo` (a folder path, `''` for the root) moves the entry there, 409 when the destination already has that entry key. | `UpdateResourceDto` | `UpdateResourceResponseDto` | | `DELETE` | `/collections/:collectionName/resources` | Delete one or more resources by key | `DeleteResourceDto` | `DeleteResourceResponseDto` | | `POST` | `/collections/:collectionName/resources/move` | Move or rename resources (single key or wildcard pattern, cross-collection supported) | `MoveResourceDto` | `MoveResourceResponseDto` | -| `POST` | `/collections/:collectionName/resources/translate` | Auto-translate a single resource via the configured provider (422 when the collection has auto-translation off) | `TranslateResourceDto` | `TranslateResourceResponseDto` | +| `POST` | `/collections/:collectionName/resources/translate` | Auto-translate a single resource through the [Translator](glossary.md#translator) (422 when the collection has auto-translation off). Values are stored in ICU format; `skippedLocales` lists the locales it did not store (complex ICU, a lost placeholder, a dropped protected term) | `TranslateResourceDto` | `TranslateResourceResponseDto` | | `GET` | `/collections/:collectionName/resources/tree` | Fetch the resource [tree](glossary.md#resource-tree) (or subtree) from the Collection Index | query: `path`, `includeNested` | `ResourceTreeDto \| TreeStatusResponseDto` | | `GET` | `/collections/:collectionName/resources/cache/status` | Poll the [Collection Index](glossary.md#collection-index) state (starts indexing) | — | `CacheStatusDto` | | `GET` | `/collections/:collectionName/resources/search` | Full-text search across the collection | query: `SearchTranslationsDto` | `SearchResultsDto` | @@ -189,6 +189,7 @@ Controllers are the only layer that knows HTTP. They read the config from `Confi | `TranslationError` with code `MISSING_API_KEY`, `UNKNOWN_PROVIDER`, or `AUTH_ERROR` (server misconfiguration) | 500 (`InternalServerErrorException`) | `Translation provider error: ` | | `TranslationError` with code `RATE_LIMIT` | 429 (`HttpException`, error `Too Many Requests`) | `Translation provider error: ` | | `TranslationError` with any other code (for example `SERVER_ERROR`) | 502 (`BadGatewayException`) | `Translation provider error: ` | +| `ProtectedTermsFileError` (a malformed protected-terms file on the server) | 500 (`InternalServerErrorException`) | error message (names the file) | | any other `LingoTrackerError` | 500 (`InternalServerErrorException`) | error message | | any other `Error` (message and stack logged on the server) | 500 (`InternalServerErrorException`) | `Internal server error` | | an error with its own numeric `statusCode` (for example from body-parser) | Nest default | Nest default | @@ -341,10 +342,10 @@ sequenceDiagram participant Core as @simoncodes-ca/core UI->>RC: POST /translate-locale { locale: "fr" } - RC->>JS: startJob(params) + RC->>JS: startJob(collection, locale) JS->>JS: generate UUID jobId JS->>JS: store job (status: "pending") - JS->>Core: translateLocale() [no await — runs in background] + JS->>Core: translateLocale(collection, { targetLocale, onProgress }) [no await — runs in background] JS-->>RC: jobId RC-->>UI: 202 Accepted TranslateLocaleJobDto\n{ jobId, status: "pending", ... } @@ -362,11 +363,17 @@ sequenceDiagram RC-->>UI: 200 OK\n{ status: "completed", translatedCount: N, skippedCount: M } ``` +**Starting a job.** The handler opens the collection with `openRouteCollection`, answers 422 when its translation config is not enabled and 400 when the locale is the base locale or not one of its locales, and then calls `startJob(collection, locale)`. The job runs core `translateLocale(collection, { targetLocale, onProgress })`, which translates through the [Translator](glossary.md#translator). When the job ends, successfully or not, the service applies `reindexMutation(collection.translationsFolder)` to the [Collection Index](glossary.md#collection-index), because `translateLocale` may have written files. + **Job lifecycle states:** `pending` → `running` → `completed` | `failed`. `TranslationJobService` stores jobs in a plain `Map` in process memory. Jobs are never evicted — this is appropriate for a single-user development tool. If the process restarts, all jobs are lost and the UI must re-issue any in-progress operations. **Progress reporting.** `translateLocale()` in `@simoncodes-ca/core` accepts an `onProgress` callback. `TranslationJobService` subscribes to this callback and updates the in-memory job's `translatedCount`, `failedCount`, and `skippedCount` fields on each tick. Polling clients see live progress, not just a final result. -**Error handling.** If `translateLocale()` rejects with a `TranslationError` (API key issue, provider timeout) or any other error, the job transitions to `failed` and the `error` field is set. No retry is attempted. The UI can display the error and offer a manual re-trigger. +**Unreadable folders.** `translateLocale` returns a `warnings` line for each folder the Collection Reader could not read (its resources are not translated). The service logs each one with `Logger.warn`; the DTO does not carry them. + +**Skips.** `skippedCount` and `skippedKeys` cover every resource the Translator did not store: complex ICU, a lost placeholder, or a translation that dropped a [protected term](glossary.md#protected-term). The DTO does not carry the reason. + +**Error handling.** If `translateLocale()` rejects with a `TranslationError` (a missing API key, which is only checked when some resource needs work) or any other error, the job transitions to `failed` and the `error` field is set. A provider failure in one batch does not reject: that batch's resources are listed in `failures`. No retry is attempted. The UI can display the error and offer a manual re-trigger. --- diff --git a/architecture-docs/cli.md b/architecture-docs/cli.md index 3c325192..bde050ca 100644 --- a/architecture-docs/cli.md +++ b/architecture-docs/cli.md @@ -45,7 +45,7 @@ All commands are registered in `apps/cli/src/main.ts`. Each row below lists the | `delete-resource` | `--collection`, `--key`, `--yes` | `deleteResource()` | | `move` | `--collection`, `--source`, `--dest`, `--override`, `--verbose` | `moveResource()` | | `normalize` | `--collection`, `--all`, `--dry-run`, `--json` | `normalize()` | -| `translate-locale` | `--collection`, `--locale`, `--verbose` | `translateLocale()` | +| `translate-locale` | `--collection`, `--locale`, `--verbose` | `translateLocale(collection, { targetLocale, onProgress })` (through the [Translator](glossary.md#translator)); the summary prints `Skipped (needs human translation)` for complex ICU, lost placeholders and dropped protected terms | | `bundle` | `--name`, `--locale`, `--verbose`, `--token-casing`, `--token-constant-name`, `--no-transform-icu-to-transloco`, `--debug-keys` | `generateBundle()` | | `export` | `-f/--format`, `-c/--collection`, `-l/--locale`, `-s/--status`, `-t/--tags`, `-o/--output`, `--structure`, `--rich`, `--include-base`, `--include-status`, `--include-comment`, `--include-tags`, `--base-property-name`, `--filename`, `--no-protect-notes`, `--dry-run`, `--verbose` | `runExport()` | | `import` | `-f/--format`, `-s/--source`, `-l/--locale`, `-c/--collection`, `--strategy`, `--update-comments`, `--update-tags`, `--preserve-status`, `--create-missing`, `--validate-base`, `--dry-run`, `--verbose` | `parseJsonImport()` / `parseXliffImport()` → `importResources()` | diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index c024d481..d7bae6ce 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -113,14 +113,15 @@ libs/core/src/ │ ├── iterative-folder-walker.ts # walkFolders(): depth-ordered directory traversal │ └── folder-utils.ts # Path helpers for the walker │ - ├── translation/ # Auto-translation provider abstraction - │ ├── translation-provider.ts # TranslationProvider interface, TranslationError - │ ├── translation-provider-factory.ts # createTranslationProvider(): factory by name - │ ├── google-translate-v2.provider.ts # GoogleTranslateV2Provider implementation - │ ├── auto-translate-resources.ts # autoTranslateResource(): orchestrate per-locale calls - │ ├── translate-existing-resource.ts # translateExistingResource(): translate new/stale entries - │ ├── placeholder-protector.ts # protectPlaceholders() / restorePlaceholders() - │ └── translation-orchestrator.ts # Wraps provider call with placeholder protection + ├── translation/ # Machine translation: the Translator and the operations that use it + │ ├── translator.ts # openTranslator(): setup, ICU skip, placeholder + protected-term guards, ICU normalisation + │ ├── translation-provider.ts # TranslationProvider interface (the seam), TranslationError + │ ├── translation-provider-factory.ts # createTranslationProvider(): the Google adapter's constructor site + │ ├── google-translate-v2.provider.ts # GoogleTranslateV2Provider adapter + │ ├── in-memory-translation-provider.ts # InMemoryTranslationProvider adapter (internal; core specs, no network) + │ ├── translate-existing-resource.ts # translateExistingResource(): translate one entry's new/stale locales + │ ├── translate-locale.ts # translateLocale(): translate one locale of a collection in batches + │ └── placeholder-protector.ts # protectPlaceholders() / restorePlaceholders() │ ├── resource/ # One folder's files, and the read models built on them │ ├── resource-folder.ts # openResourceFolder(): the Resource Folder (entries + metadata as a unit) @@ -165,7 +166,7 @@ graph TD IMPORT["import/\nparseJsonImport · parseXliffImport\nimportResources"] VALIDATE["validate/\nvalidateResources"] NORMALIZE["normalize/\nnormalize"] - TRANSLATION["translation/\nautoTranslateResource\ntranslateExistingResource"] + TRANSLATION["translation/\nopenTranslator\ntranslateExistingResource\ntranslateLocale"] FOLDER["folder/\ncreateFolder · deleteFolder\nmoveFolder"] FILEIO["file-io/\nreadJsonFile · writeJsonFile\nensureDirectoryExists"] CONFIG_LIB["config/\nloadConfig · openCollection\ncreateConfigFileOperations"] @@ -218,7 +219,7 @@ graph TD NORMALIZE --> DOMAIN TRANSLATION --> DOMAIN - TRANSLATION --> FILEIO + TRANSLATION --> RESOURCE_LIB FOLDER --> FILEIO FOLDER --> DOMAIN @@ -244,11 +245,12 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that ## Public Surface -`libs/core/src/index.ts` is the [public surface](glossary.md#public-surface): 175 names, listed one by one and grouped by role. It exports only what the API or CLI uses, plus the types in those names' signatures. It does not re-export `domain` names; callers import `TranslationStatus`, `TokenCasing` and `ImportStrategy` from `@simoncodes-ca/domain`. +`libs/core/src/index.ts` is the [public surface](glossary.md#public-surface): 185 names, listed one by one and grouped by role. It exports only what the API or CLI uses, plus the types in those names' signatures. It does not re-export `domain` names; callers import `TranslationStatus`, `TokenCasing` and `ImportStrategy` from `@simoncodes-ca/domain`. | Group | What it holds | |---|---| | Operations | The entry points the apps call. Resources: `addResource`, `editResource`, `deleteResource`, `moveResource`. Folders: `createFolder`, `deleteFolder`, `moveFolder`. Collections and locales: `addCollection`, `updateCollection`, `deleteCollectionByName`, `addLocaleToCollection`, `removeLocaleFromCollection`, `setGlobal/CollectionProtectedTerms[File]`. Bundles: `generateBundle`, `planBundle`, `add/update/deleteBundleDefinition`, `validateBundleKey`, `validateBundleDefinition`, `getBundleOutputPath`, `hasTypeDistConfigured`. Import: `importResources` and its adapters. Export: `runExport`, `exportTargetLocales`, the export argument checks. Also `normalize`, `translateLocale`, `translateExistingResource`, `validateResources`, `generateValidationSummary`, `describePreferredTermRule`. | +| Translator | Only the types in the translate operations' signatures: `OpenTranslatorOptions` (the optional `{ provider?, protectedTerms? }` of `addResource`, `editResource`, `translateExistingResource`, `translateLocale`) and the `TranslationProvider` seam (`TranslateRequest`, `TranslateResult`, `ProviderCapabilities`). `openTranslator`, the `Translator` types and `InMemoryTranslationProvider` stay internal to core (the translation barrel), because no app uses them. See [Auto-Translation Pipeline](#auto-translation-pipeline). | | Collection & config | `loadConfig`, `openCollection`, `Collection`, `CONFIG_FILENAME`, `DEFAULT_CONFIG`, the config types (`LingoTrackerConfig`, `LingoTrackerCollection`, `TranslationConfig`, `BundleDefinition`, ...), and the protected-terms and preferred-terminology file readers and writers. | | ResourceFolder | `openResourceFolder`, `ResourceFolder` and the types in its methods, `resolveResourcePaths`. | | Collection Reader | `readCollection`, `StoredResource`, `CollectionRead`, `CollectionReadProblem`, `CollectionReadTarget`. See [Collection Reader](#collection-reader). | @@ -256,7 +258,7 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that | Errors | `LingoTrackerError` and every typed subclass, `TranslationError`, `PreferredTerminologyValidationError`. See [Error Model](#error-model). | | Types | Parameter and result types for the operations above (`AddResourceParams`, `GenerateBundleResult`, `ImportResult`, ...). | -Each sub-module with a barrel (`resource/`, `collections-manager/`, and `lib/bundle`, `config`, `errors`, `folder`, `import`, `normalize`, `resource`, `translation`, `validate`) lists its own public names the same way, and the root barrel re-exports from it. `lib/export/` has no barrel, so the root barrel imports its files directly. `lib/file-io/` is internal and has no barrel. Everything else is internal: `ErrorMessages`, `calculateChecksum`, the translation provider classes, the bundle helpers, the normalize walker, `SafeAny`, and the like. Core's specs import these by relative path. Test helpers live in `*.spec-helpers.ts` files, which `tsconfig.lib.json` excludes from the build: `setupMockFs` (`collections-manager/locale.spec-helpers.ts`) and the real-filesystem fixtures `useTempDir`, `testCollection`, `seedResources`, `writeFolderFiles` (`testing/temp-dir.spec-helpers.ts`). New reader specs use real temp directories rather than a mocked `fs`. +Each sub-module with a barrel (`resource/`, `collections-manager/`, and `lib/bundle`, `config`, `errors`, `folder`, `import`, `normalize`, `resource`, `translation`, `validate`) lists its own public names the same way, and the root barrel re-exports from it. `lib/export/` has no barrel, so the root barrel imports its files directly. `lib/file-io/` is internal and has no barrel. Everything else is internal: `ErrorMessages`, `calculateChecksum`, the Translator, the provider classes and `createTranslationProvider`, the bundle helpers, the normalize walker, `SafeAny`, and the like. Core's specs import these by relative path. Test helpers live in `*.spec-helpers.ts` files, which `tsconfig.lib.json` excludes from the build: `setupMockFs` (`collections-manager/locale.spec-helpers.ts`) and the real-filesystem fixtures `useTempDir`, `testCollection`, `seedResources`, `writeFolderFiles` (`testing/temp-dir.spec-helpers.ts`). New reader specs use real temp directories rather than a mocked `fs`. --- @@ -265,7 +267,7 @@ Each sub-module with a barrel (`resource/`, `collections-manager/`, and `lib/bun Core owns the config file and the rule that turns a collection's config entry into its effective settings. The adapters (CLI, API) call two functions in `lib/config/` once per command or request, then pass the results to the per-resource operations. - **`loadConfig({ cwd? })`** is the only reader of `.lingo-tracker.json`. It returns the file as written, with no validation and no fallbacks. It throws `ConfigNotFoundError` when the file does not exist and `ConfigParseError` when the file is not a JSON object; other I/O errors pass through. The CLI passes its `INIT_CWD`-aware directory, the API passes `process.cwd()`, and `createConfigFileOperations().read()` (used by the config writers) reads through it too. -- **`openCollection(config, name, { cwd?, writable? })`** returns a `Collection`: `name`, the absolute `translationsFolder` (resolved against `cwd`), `baseLocale` (collection, else global, else `en`; an empty string counts as unset), `locales` (collection, else global, else `[]`), `targetLocales` (`locales` without `baseLocale`), `translationConfig` (collection, else global; not merged), normalized `tags`, `readOnly`, and the raw entry as `config`. It throws `CollectionNotFoundError` for an unknown name and, when `writable` is set, `ReadOnlyCollectionError` for a read-only collection. +- **`openCollection(config, name, { cwd?, writable? })`** returns a `Collection`: `name`, the absolute `translationsFolder` (resolved against `cwd`), `baseLocale` (collection, else global, else `en`; an empty string counts as unset), `locales` (collection, else global, else `[]`), `targetLocales` (`locales` without `baseLocale`), `translationConfig` (collection, else global; not merged), normalized `tags`, `protectedTermsFiles` (the absolute paths of the global and collection protected-terms files, resolved but not read; see [Protected Terms Resolution](#protected-terms-resolution)), `readOnly`, and the raw entry as `config`. It throws `CollectionNotFoundError` for an unknown name and, when `writable` is set, `ReadOnlyCollectionError` for a read-only collection. The fallback rule lives only in `openCollection`. The collection operations in `collections-manager/` (`addLocaleToCollection`, `removeLocaleFromCollection`, `updateCollection`) use it for their locale checks. The [Import run](glossary.md#import-run) and the [Export run](glossary.md#export-run) take `Collection` objects, so they read the base locale and locales from there and never read the config file. The resource and folder operations (`addResource`, `editResource`, `deleteResource`, `moveResource`, `translateExistingResource`, `createFolder`, `deleteFolder`, `moveFolder`) take the opened `Collection` as their first parameter too, so no caller passes a base locale, a locale list, a translation config, or a `cwd`. See [Collection-bound operations](#collection-bound-operations). The typed errors extend `LingoTrackerError`; see [Error Model](#error-model). @@ -279,6 +281,7 @@ Core raises a [typed error](glossary.md#typed-errors) for every failure that an |---|---|---|---| | `ConfigNotFoundError` | `CONFIG_NOT_FOUND` | `configPath` | `loadConfig` | | `ConfigParseError` | `CONFIG_PARSE_FAILED` | `configPath`, `reason` | `loadConfig` | +| `ProtectedTermsFileError` | `INVALID_PROTECTED_TERMS_FILE` | `filePath` | `readProtectedTermsFile` and every reader built on it: the protected-terms commands, import/export callers, `resolveProtectedTermsForConfig`, and `openTranslator` (so `addResource` / `editResource` with auto-translation on, `translateExistingResource` and `translateLocale`, when there is work). The API answers 500 with the message. | | `CollectionNotFoundError` | `COLLECTION_NOT_FOUND` | `collectionName` | `openCollection`, `deleteCollectionByName`, `updateCollection`, `setCollectionProtectedTerms`, `setCollectionProtectedTermsFile` | | `CollectionAlreadyExistsError` | `COLLECTION_ALREADY_EXISTS` | `collectionName` | `addCollection`, `updateCollection` (rename) | | `ReadOnlyCollectionError` | `COLLECTION_READ_ONLY` | `collectionName` | `openCollection` with `{ writable: true }` | @@ -292,11 +295,11 @@ Core raises a [typed error](glossary.md#typed-errors) for every failure that an | `InvalidFolderPathError` | `INVALID_FOLDER_PATH` | `part`, `segment` | `createFolder`, `deleteFolder`, `moveFolder` | | `FolderNotFoundError` | `FOLDER_NOT_FOUND` | `folderPath` | `deleteFolder`, `moveFolder` (source missing or not a directory) | | `FolderMoveIntoDescendantError` | `FOLDER_MOVE_INTO_DESCENDANT` | `sourceFolderPath`, `destinationFolderPath` | `moveFolder` (same collection) | -| `AutoTranslationDisabledError` | `AUTO_TRANSLATION_DISABLED` | `collectionName` | `translateExistingResource` | +| `AutoTranslationDisabledError` | `AUTO_TRANSLATION_DISABLED` | `collectionName` | `openTranslator` (so `translateExistingResource`, and `translateLocale` when there is work) | | `BundleNotFoundError` | `BUNDLE_NOT_FOUND` | `bundleName` | `updateBundleDefinition`, `deleteBundleDefinition` | | `BundleAlreadyExistsError` | `BUNDLE_ALREADY_EXISTS` | `bundleName` | `addBundleDefinition`, `updateBundleDefinition` (rename) | | `InvalidBundleDefinitionError` | `INVALID_BUNDLE_DEFINITION` | `errors[]` | bundle definition add / update | -| `TranslationError` | provider code (`MISSING_API_KEY`, `RATE_LIMIT`, `INVALID_REQUEST`, …) | `retryable`, `providerErrorCode` | translation providers, `autoTranslateResource` | +| `TranslationError` | provider code (`MISSING_API_KEY`, `UNKNOWN_PROVIDER`, `INVALID_RESPONSE`, `RATE_LIMIT`, `INVALID_REQUEST`, …) | `retryable`, `providerErrorCode` | translation providers, `openTranslator` / `Translator.translate` (so every operation that auto-translates) | | `PreferredTerminologyValidationError` | `INVALID_PREFERRED_TERMINOLOGY` | `errors[]` | `writePreferredTerminology` | Rules: @@ -324,13 +327,14 @@ addResource(collection, { key, baseValue, comment?, tags?, targetFolder?, transl editResource(collection, key, { baseValue?, comment?, tags?, translations?, moveTo? }) deleteResource(collection, { keys }) moveResource(collection, { source, destination, override?, destinationCollection? }) -translateExistingResource(collection, key) +translateExistingResource(collection, key, { provider?, protectedTerms? }?) +translateLocale(collection, { targetLocale, onProgress?, provider?, protectedTerms? }) createFolder(collection, { folderName, parentPath? }) deleteFolder(collection, { folderPath }) moveFolder(collection, { sourceFolderPath, destinationFolderPath, override?, nestUnderDestination?, destinationCollection? }) ``` -The base locale, the target locales, and the translation config come only from the `Collection`; there is no `'en'` fallback and no `cwd` (the `translationsFolder` is absolute). A cross-collection move takes the destination as a second `Collection`. +`addResource` and `editResource` take the same optional `{ provider?, protectedTerms? }` as a last parameter, for [locale seeding](#locale-seeding). The base locale, the target locales, the translation config and the protected-terms files come only from the `Collection`; there is no `'en'` fallback and no `cwd` (the `translationsFolder` is absolute). A cross-collection move takes the destination as a second `Collection`. **Key placement.** `addResource` stores `targetFolder.key` (`resolveResourceKey`, applied by `validateAndResolvePaths`). `editResource` takes the entry's full, existing key. Its `moveTo` is a destination folder (`''` is the collection root): the entry keeps its entry key (the last segment) and moves there, as a lossless copy, after the edit is saved. The destination must not already have that entry key (`ResourceAlreadyExistsError`). This is checked before anything is written, and again on a fresh read of the destination just before the move, because auto-translation may run in between; a collision found then throws with the edit already saved in the source folder. The destination is written before the source entry is removed. @@ -339,8 +343,8 @@ The base locale, the target locales, and the translation config come only from t [Locale seeding](glossary.md#locale-seeding) (`seedLocales` in `resource/locale-seeding.ts`) decides what each of `collection.targetLocales` gets when a base value is written: 1. A translation the caller supplied → the caller's value and status. -2. Else, when `collection.translationConfig` is enabled → `autoTranslateResource()` (status `translated`). -3. Else, or when the provider skipped the locale (ICU) → a copy of the base value with status `new`. +2. Else, when `collection.translationConfig` is enabled → the [Translator](#auto-translation-pipeline)'s value (status `translated`). Locale seeding checks `enabled` itself before it opens the Translator, so a disabled config never throws here. +3. Else, or when the Translator skipped the locale (complex ICU, a lost placeholder, or a dropped protected term) → a copy of the base value with status `new`. On edit, this applies only to a locale with no value or an untranslated copy of the old base; a locale that holds a real translation keeps it, marked `stale` by the staleness rule. `addResource` applies it to every target locale. `editResource` applies it after a base value change, to the locales that need work by the [staleness rule](glossary.md#staleness-rule) (`needsTranslation` after `setBase`), with one limit: step 3 never overwrites a real translation. Only a missing locale, or one that held an untranslated copy of the old base, gets the copy; a real translation stays, marked `stale`. A supplied translation for a locale that is not in the collection throws `LocaleNotFoundError`; a value for the base locale is ignored. @@ -430,6 +434,7 @@ The caller decides what a problem means: | Bundle and type generation (`loadCollectionResources`) | Adds a warning to the bundle result, once for each collection. Type generation logs it. | | `glossary` (CLI) | Writes a warning to stderr. | | `loadResourceTree`, `searchTranslations` | Log it. The tree keeps the folder, with no resources. | +| `translateLocale` | Does not translate the folder's resources and adds one line to `warnings` in the result (`Folder '' was not translated: `). The CLI prints the warnings after the summary; the API translation job logs them with `Logger.warn`. | --- @@ -456,69 +461,60 @@ Returns a `NormalizeResult` with counts: `entriesProcessed`, `localesAdded`, `va ## Auto-Translation Pipeline -**Entry point:** `autoTranslateResource(params)` in `lib/translation/auto-translate-resources.ts` +**Entry point:** `openTranslator(collection, { provider?, protectedTerms? })` in `lib/translation/translator.ts`, which returns a `Translator` with one method, `translate(entries, locales) → { values, skipped }`. -This pipeline is called by [locale seeding](#locale-seeding) (so from `addResource()` and from `editResource()` on a base value change), and also from the standalone `translateExistingResource()` function which targets only entries with `new` or `stale` status. +The [Translator](glossary.md#translator) is the only way core machine-translates text. Its three callers only choose what needs work, by the [staleness rule](glossary.md#staleness-rule), and store what comes back: + +| Caller | Entries → locales | Stores | +|---|---|---| +| [Locale seeding](#locale-seeding) (`addResource`, `editResource` on a base value change) | the base value → the target locales that need work and were not supplied | values as `translated`; a skipped locale gets a copy of the base as `new`, except on edit where it holds a real translation (kept, `stale`) | +| `translateExistingResource(collection, key)` | the entry → its target locales with `needsTranslation` | values as `translated`; skipped locales stay as they are | +| `translateLocale(collection, { targetLocale })` | every entry with `needsTranslation` for the locale (read with the [Collection Reader](#collection-reader)), in batches of `batchSize` with `delayMs` between them → `[targetLocale]` | values as `translated`, one save per folder per batch; skipped keys in `skippedKeys`; folders the reader could not read in `warnings` | ```mermaid flowchart TD - START([Caller: addResource / editResource\nor translateExistingResource]) --> CHECK_ENABLED - - CHECK_ENABLED{"translationConfig.enabled?"} - CHECK_ENABLED -- No --> SKIP_ALL([Return empty translations]) - CHECK_ENABLED -- Yes --> READ_API_KEY - - READ_API_KEY["Read API key from\nprocess.env[translationConfig.apiKeyEnv]"] - READ_API_KEY --> KEY_MISSING{"Key present?"} - KEY_MISSING -- No --> THROW_KEY([Throw TranslationError\nMISSING_API_KEY]) - KEY_MISSING -- Yes --> CREATE_PROVIDER - - CREATE_PROVIDER["createTranslationProvider(providerName, apiKey)\n→ GoogleTranslateV2Provider"] - - CREATE_PROVIDER --> FOR_EACH_LOCALE - - FOR_EACH_LOCALE["For each target locale (parallel Promise.all):\norchestrator.translateText(baseValue, srcLocale, tgtLocale)"] - - FOR_EACH_LOCALE --> CLASSIFY - - subgraph orchestrator["TranslationOrchestrator (per locale)"] - CLASSIFY["classifyICUContent(baseValue)\n→ plain | simple-placeholders | complex-icu"] - - CLASSIFY --> IS_COMPLEX{"complex-icu?"} - IS_COMPLEX -- Yes --> SKIP_LOCALE(["kind: 'skipped'\n(cannot safely translate ICU)"]) - IS_COMPLEX -- No --> HAS_PLACEHOLDERS - - HAS_PLACEHOLDERS{"simple-placeholders?"} - HAS_PLACEHOLDERS -- Yes --> PROTECT["protectPlaceholders()\nWraps {varName} in\n__PHn__"] - HAS_PLACEHOLDERS -- No --> CALL_PROVIDER - - PROTECT --> CALL_PROVIDER - - CALL_PROVIDER["provider.translate(request)\n→ Google Translate API v2"] - CALL_PROVIDER --> RESTORE + OPEN(["openTranslator(collection, { provider? })"]) --> ENABLED{"translationConfig.enabled?"} + ENABLED -- No --> DISABLED([Throw AutoTranslationDisabledError]) + ENABLED -- Yes --> INJECTED{"provider injected?"} + READY -.- TERMSREAD["protectedTerms option, else readProtectedTermsInForce(collection)\n(ProtectedTermsFileError when a file is malformed)"] + INJECTED -- Yes --> READY + INJECTED -- No --> KEY{"process.env[apiKeyEnv] set?"} + KEY -- No --> THROW_KEY([Throw TranslationError\nMISSING_API_KEY]) + KEY -- Yes --> FACTORY["createTranslationProvider(provider, apiKey)\n→ GoogleTranslateV2Provider"] + FACTORY --> READY + + READY(["translate(entries, locales)"]) --> CLASSIFY + + CLASSIFY["Once per entry: classifyICUContent(source)"] + CLASSIFY --> IS_COMPLEX{"complex-icu?"} + IS_COMPLEX -- Yes --> SKIP_ICU(["skipped: complex-icu\n(never sent)"]) + IS_COMPLEX -- No --> PROTECT["simple placeholders → protectPlaceholders()\n__PHn__"] + + PROTECT --> CALL["Per locale (in parallel), base locale ignored:\none provider.translate() call with every sendable entry"] + CALL --> RESTORE{"restorePlaceholders():\nevery marker exactly once?"} + RESTORE -- No --> SKIP_PH(["skipped: placeholder-mismatch"]) + RESTORE -- Yes --> NORMALISE["translocoToICU(value)"] + NORMALISE --> TERMS{"findProtectedTermViolations(\nsource, value, protectedTerms)"} + TERMS -- "terms dropped" --> SKIP_TERM(["skipped: protected-term\n(terms listed)"]) + TERMS -- none --> VALUE(["values: { key, locale, value }"]) + + style DISABLED fill:#f8d7da,stroke:#dc3545,color:#000 + style THROW_KEY fill:#f8d7da,stroke:#dc3545,color:#000 + style SKIP_ICU fill:#fff3cd,stroke:#ffc107,color:#000 + style SKIP_PH fill:#fff3cd,stroke:#ffc107,color:#000 + style SKIP_TERM fill:#fff3cd,stroke:#ffc107,color:#000 + style VALUE fill:#d4edda,stroke:#28a745,color:#000 +``` - RESTORE{"restorePlaceholders()\nAll markers present\nexactly once?"} - RESTORE -- Yes --> TRANSLATED_VALUE(["kind: 'translated'\nvalue: restored string"]) - RESTORE -- No --> SKIP_MISMATCH(["kind: 'skipped'\nmarker-count-mismatch"]) - end +**What the Translator owns.** Setup (the enabled check, the API key, the provider), the ICU skip, the placeholder guard, the protected-term guard, and normalisation. Each happens in one place, for every caller. There is one code path: a single text is a batch of one. A provider failure (`TranslationError`) propagates; locale seeding passes it on, and `translateLocale` marks the batch as failed and goes on with the next one. - SKIP_LOCALE --> COLLECT - SKIP_MISMATCH --> COLLECT - TRANSLATED_VALUE --> COLLECT +**Skip reasons.** `SkippedTranslation.reason` is `complex-icu`, `placeholder-mismatch` or `protected-term` (with the dropped `terms`). A translation that drops a protected term would be rejected by import, so it is not stored. The callers report skipped locales (`skippedLocales`) or keys (`skippedKeys`) without the reason. - COLLECT["Collect results:\n- translations[]: { locale, value, status: 'translated' }\n- skippedLocales[]: locales with kind 'skipped'"] +**When the Translator is opened.** Only when there is work, so "nothing to translate" never needs an API key or a readable terms file. `translateExistingResource` checks `translationConfig.enabled` first (`AutoTranslationDisabledError`, 422 in the API), reads the entry, and opens the Translator only when a locale needs work. `translateLocale` opens it only when at least one resource needs work. Locale seeding opens it only when the config is enabled and a locale needs work. Opening reads the protected terms once (unless the `protectedTerms` option is passed), so with auto-translation on, `addResource`, `editResource` (on a base value change), `translateExistingResource` and `translateLocale` fail with `ProtectedTermsFileError` when a terms file is malformed. - COLLECT --> CALLER_WRITES["Caller writes results to\nresource_entries.json + tracker_meta.json\nvia writeJsonFile()"] - - style SKIP_ALL fill:#f8d7da,stroke:#dc3545,color:#000 - style THROW_KEY fill:#f8d7da,stroke:#dc3545,color:#000 - style SKIP_LOCALE fill:#fff3cd,stroke:#ffc107,color:#000 - style SKIP_MISMATCH fill:#fff3cd,stroke:#ffc107,color:#000 - style TRANSLATED_VALUE fill:#d4edda,stroke:#28a745,color:#000 - style orchestrator fill:#e8f4fd,stroke:#17a2b8,color:#000 -``` +**The provider seam.** The `provider` option replaces the configured provider, and the `protectedTerms` option replaces the terms files. `InMemoryTranslationProvider` (`in-memory-translation-provider.ts`) is the second adapter, internal to core: it translates each text with a function (default `[locale] text`) and records every call in `calls`, so specs can assert batching. It imports nothing from vitest. The core specs for the Translator, add, edit, translate-existing and translate-locale use it with real temp directories; none of them mocks a core module. ### Provider abstraction @@ -531,19 +527,13 @@ interface TranslationProvider { } ``` -`createTranslationProvider(providerName, apiKey)` in `translation-provider-factory.ts` is the single switch-point that maps a provider name string to a concrete implementation. Today only `'google-translate'` is supported, instantiating `GoogleTranslateV2Provider`. Adding a new provider (e.g. DeepL) requires: +`createTranslationProvider(providerName, apiKey)` in `translation-provider-factory.ts` is the single switch-point that maps a configured provider name to a concrete implementation; `openTranslator` calls it when no provider is injected. Today only `'google-translate'` is supported, instantiating `GoogleTranslateV2Provider`. Adding a new provider (e.g. DeepL) requires: 1. Implementing `TranslationProvider`. 2. Adding one `case` branch in `createTranslationProvider()`. 3. Updating `TranslationConfig` to accept the new provider name. -**Why a single factory instead of a plugin registry?** LingoTracker currently has one provider. A plugin-registry pattern (dynamic module loading, registration maps) would add indirection and surface area for no concrete benefit. The factory switch is O(1), statically typed, and the full provider list is visible at a glance. If a second provider ships, the factory grows by four lines. This is "extensible without over-engineering" — the abstraction boundary (`TranslationProvider`) is clean; the wiring (`createTranslationProvider`) is simple until it needs to be otherwise. - -The `TranslationOrchestrator` class sits between `autoTranslateResource()` and the provider. It is responsible for: - -- Calling `classifyICUContent()` from `@simoncodes-ca/domain` to decide whether the string is safe to send to the provider. -- Calling `protectPlaceholders()` before the provider call and `restorePlaceholders()` after, to prevent the translation engine from mutating ICU variable names. -- Returning a discriminated union (`kind: 'translated' | 'skipped'`) so callers can distinguish success from graceful skip without exception handling. +**Why a single factory instead of a plugin registry?** LingoTracker has one configurable provider (the in-memory one is only injected). A plugin-registry pattern (dynamic module loading, registration maps) would add indirection and surface area for no concrete benefit. The factory switch is O(1), statically typed, and the full provider list is visible at a glance. If a second provider ships, the factory grows by four lines. This is "extensible without over-engineering" — the abstraction boundary (`TranslationProvider`) is clean; the wiring (`createTranslationProvider`) is simple until it needs to be otherwise. The [ICU format](glossary.md#icu-format) classification determines safety: `plain` and `simple-placeholders` strings are sent (with placeholder protection for the latter); `complex-icu` strings (containing `plural`, `select`, or `selectordinal`) are skipped entirely because machine translation cannot reliably preserve nested ICU syntax. @@ -617,11 +607,13 @@ Core resolves both settings against the directory that holds `.lingo-tracker.jso `readEffectiveProtectedTerms(config, collection, cwd)` returns the combined list. `resolveProtectedTermsForConfig(config, cwd)` reads every scope in one pass, which suits read-only consumers such as the API. +`openCollection` resolves the two paths, without reading them, into `Collection.protectedTermsFiles`: `global` (the pointer, else the default file), `globalExplicit` (whether the config names it), and `collection` (only when the collection names a file). `readProtectedTermsInForce(collection)` reads them through the cache and returns the same list as `readEffectiveProtectedTerms(config, raw, cwd)`. Opening a collection therefore reads no files, and a malformed terms file fails only the operations that use the terms. Today that is the [Translator](#auto-translation-pipeline), which reads them once when it is opened. Import and export still take `protectedTerms` from their caller. + Core caches reads in a module-level `Map` keyed by absolute path. It hands back a copy of each entry, so a caller that mutates the result leaves the cache intact. `writeProtectedTermsFile()` refreshes the entry it wrote, and `clearProtectedTermsFileCache()` drops every entry. -The two failure modes differ on purpose. An **absent** file reads as an empty list, and this is the normal state before the first term is added. Core warns only when the path came from an explicit setting. +The two failure modes differ on purpose. An **absent** file reads as an empty list, and this is the normal state before the first term is added. Core warns only when the path came from an explicit setting, and only once per path (the Translator reads the files on every operation that auto-translates). -**Malformed** content throws. That covers invalid JSON, a payload that is not an array, and an element that is not a string. An empty list here would protect nothing, and altered brand names would reach the resources through import with nobody seeing it. +**Malformed** content throws `ProtectedTermsFileError` (`INVALID_PROTECTED_TERMS_FILE`, with `filePath`). That covers invalid JSON, a payload that is not an array, and an element that is not a string. An empty list here would protect nothing, and altered brand names would reach the resources through import or auto-translation with nobody seeing it. Writes normalize the list, sort it alphabetically, and end the file with a newline. Adding a term therefore produces a one-line diff. diff --git a/architecture-docs/domain-and-data-model.md b/architecture-docs/domain-and-data-model.md index f82bc7a2..ad3abc7e 100644 --- a/architecture-docs/domain-and-data-model.md +++ b/architecture-docs/domain-and-data-model.md @@ -408,7 +408,7 @@ Two functions use the regular expression, with opposite case sensitivity: | Function | Case handling | Used by | |---|---|---| | `findProtectedTerms(value, terms)` | Case-**insensitive** — returns the stored canonical term, not the matched substring | Export annotation | -| `findProtectedTermViolations(source, translation, terms)` | Source matched case-insensitively; translation matched case-**sensitively** | Import verification | +| `findProtectedTermViolations(source, translation, terms)` | Source matched case-insensitively; translation matched case-**sensitively** | Import verification; the [Translator](glossary.md#translator)'s protected-term guard | That difference is the mechanism. LingoTracker flags a source string however it was typed. It then requires the translation to hold the term exactly as stored. This is what catches `iPhone` returning from a translation service as `iphone` or as `Iphone`. diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 75d5fc1c..346a57c4 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -51,7 +51,7 @@ Collections may declare a `tags?: string[]` array. These are **collection-level Example collections from the project's own config: `trackerResources` (the Tracker UI's own strings), `TestDataPlayground`, and `mockDesignSystem`. -**Collection (resolved).** Code outside the config module never reads a collection's raw entry to get its settings. `openCollection(config, name)` in `@simoncodes-ca/core` returns a `Collection` with the effective values: `baseLocale` (collection, else global, else `en`), `locales` (collection, else global, else none), `targetLocales` (the locales without the base locale), `translationConfig` (collection, else global; the two are not merged), the absolute `translationsFolder`, normalized `tags`, and `readOnly`. It throws `CollectionNotFoundError` for an unknown name, and `ReadOnlyCollectionError` when `{ writable: true }` is set on a read-only collection. The CLI and the API both open collections this way. Every resource and folder operation (`addResource`, `editResource`, `deleteResource`, `moveResource`, `translateExistingResource`, `createFolder`, `deleteFolder`, `moveFolder`) takes the opened `Collection` as its first parameter, like the [Import run](#import-run), so the base locale and locales come only from it. +**Collection (resolved).** Code outside the config module never reads a collection's raw entry to get its settings. `openCollection(config, name)` in `@simoncodes-ca/core` returns a `Collection` with the effective values: `baseLocale` (collection, else global, else `en`), `locales` (collection, else global, else none), `targetLocales` (the locales without the base locale), `translationConfig` (collection, else global; the two are not merged), the absolute `translationsFolder`, normalized `tags`, `protectedTermsFiles` (the paths of the global and collection [protected-terms](#protected-term) files, resolved but not read), and `readOnly`. It throws `CollectionNotFoundError` for an unknown name, and `ReadOnlyCollectionError` when `{ writable: true }` is set on a read-only collection. The CLI and the API both open collections this way. Every resource and folder operation (`addResource`, `editResource`, `deleteResource`, `moveResource`, `translateExistingResource`, `translateLocale`, `createFolder`, `deleteFolder`, `moveFolder`) takes the opened `Collection` as its first parameter, like the [Import run](#import-run), so the base locale and locales come only from it. Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [`cli.md`](cli.md), [`core-library.md`](core-library.md#config-and-collection-resolution) @@ -107,7 +107,7 @@ Explained in context: [`core-library.md`](core-library.md#import-pipeline) ### Locale Seeding -What each of a [collection's](#collection) target locales gets when a resource's base value is written: the translation the caller supplied, else an auto-translation when the collection enables it, else a copy of the base value with status `new`. In code, `seedLocales(collection, request)` in `libs/core/src/resource/locale-seeding.ts`. `addResource` applies it to every target locale; `editResource` applies it after a base value change, to the locales that need work by the [staleness rule](#staleness-rule), and never replaces a real translation with a copy. The API, the CLI and the Tracker do not decide this themselves. +What each of a [collection's](#collection) target locales gets when a resource's base value is written: the translation the caller supplied, else an auto-translation from the [Translator](#translator) when the collection enables it, else (or when the Translator skipped the locale) a copy of the base value with status `new`, except that on edit a real translation is kept (and is `stale`). In code, `seedLocales(collection, request)` in `libs/core/src/resource/locale-seeding.ts`. `addResource` applies it to every target locale; `editResource` applies it after a base value change, to the locales that need work by the [staleness rule](#staleness-rule), and never replaces a real translation with a copy. The API, the CLI and the Tracker do not decide this themselves. Explained in context: [`core-library.md`](core-library.md#locale-seeding) @@ -163,7 +163,7 @@ Example file: ] ``` -A term matches only as a whole word. LingoTracker uses the list in two places. Export marks each string with the terms found in its source, as a `doNotTranslate` array in JSON and as a `Do not translate:` note in XLIFF. Import rejects any translation that omits a term present in the source. +A term matches only as a whole word. LingoTracker uses the list in three places. Export marks each string with the terms found in its source, as a `doNotTranslate` array in JSON and as a `Do not translate:` note in XLIFF. Import rejects any translation that omits a term present in the source. The [Translator](#translator) skips (does not store) a machine translation that omits one. The terms in force for a collection are read from `Collection.protectedTermsFiles` with `readProtectedTermsInForce`. Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md#protected-terms), [`core-library.md`](core-library.md#protected-terms-resolution) @@ -378,3 +378,11 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [` The roll-up of a set of locale [translation statuses](#translation-status): the number of locales in each status (`StatusCounts`) and the worst status. The pure module `libs/domain/src/lib/translation-status-summary.ts` holds the rules. `countByStatus(statuses)` counts the statuses and ignores a locale with no status. `worstStatus(counts)` applies `STATUS_PRECEDENCE`, which is worst first: `stale` > `new` > `translated` > `verified`. Every roll-up in the Tracker UI uses this module: the rollup ring, the screen-reader breakdown, the locale column, the status filter counts, and sort by status. The glyphs, label tokens and display order are presentation. They are in one Tracker table, `shared/translation-status/translation-status-presentation.ts`, which the rows and the translation editor's status labels both use. Explained in context: [`frontend.md`](frontend.md#translation-status-summary) + +--- + +### Translator + +The one way core machine-translates text for a [collection](#collection). In code, `openTranslator(collection, { provider?, protectedTerms? })` in `libs/core/src/lib/translation/translator.ts` returns a `Translator` with one method, `translate(entries, locales) → { values, skipped }`. Opening it checks that the collection's translation config is enabled (`AutoTranslationDisabledError`) and, unless a provider is injected, reads the API key (`TranslationError` `MISSING_API_KEY`) and builds the configured provider. It reads the collection's protected terms once, unless they are passed (`ProtectedTermsFileError` for a malformed file). `translate` makes one provider call per locale and ignores the base locale. It never sends complex [ICU](#icu-format). It protects simple placeholders and skips a translation that lost a marker. It skips a translation that dropped a [protected term](#protected-term) present in the source. It returns every value normalized to ICU. Each skip carries a reason: `complex-icu`, `placeholder-mismatch` or `protected-term`. [Locale seeding](#locale-seeding), `translateExistingResource` and `translateLocale` all translate through it; they only choose what needs work (the [staleness rule](#staleness-rule)) and store the values. The provider is the seam: `GoogleTranslateV2Provider` in production, `InMemoryTranslationProvider` (a deterministic transform that records its calls, internal to core) in core's specs. + +Explained in context: [`core-library.md`](core-library.md#auto-translation-pipeline) diff --git a/docs/auto-translation.md b/docs/auto-translation.md index a3362dc6..3e37ee15 100644 --- a/docs/auto-translation.md +++ b/docs/auto-translation.md @@ -15,7 +15,9 @@ When auto-translation is enabled, adding or editing a resource automatically tri - **Simple placeholders** (`{name}`, `{{ count }}`) are protected with markers before translation, then restored afterward. - **Complex ICU** (plural, select, number, date, time) is skipped entirely -- these strings cannot be translated reliably with Google Cloud Translate as of yet. -Translated entries receive the `translated` status. Skipped entries retain their current status (typically `new` or `stale`) so they surface clearly in the UI and CLI for manual handling. +A translation is also skipped when the provider loses a placeholder marker, or when it drops a [protected term](./features/protected-terms.md) that is in the source (the same check that import applies). + +Translated entries receive the `translated` status and are stored in ICU format. Skipped entries retain their current status (typically `new` or `stale`) so they surface clearly in the UI and CLI for manual handling. ## Supported Providers @@ -101,19 +103,19 @@ If the environment variable is not set when a translation is requested, the syst ## How It Works -### Translation Orchestrator +### The Translator -The translation orchestrator sits between callers and the translation provider. For each string, it: +Every auto-translation (add-resource, edit-resource, translate one resource, translate a locale) goes through one Translator, opened for the collection. It reads the API key, and then for each string it: 1. **Classifies** the string using the ICU classifier (see below). 2. **Routes** the string based on classification: 1. `plain` -- sends to the provider directly. 2. `simple-placeholders` -- protects placeholders, sends, then restores. - 3. `complex-icu` -- returns the original value unchanged with `kind: 'skipped'`. -3. **Returns** a result with `kind` indicating what happened: - - `'translated'` -- plain text was translated. - - `'translated-with-placeholders'` -- simple-placeholder text was translated with marker protection. - - `'skipped'` -- complex ICU or a marker restoration failure; the value is unchanged. + 3. `complex-icu` -- does not send it; the string is skipped. +3. **Checks** the translation: a lost or duplicated placeholder marker, or a dropped protected term, skips the string. +4. **Normalizes** the translation to ICU (`{{ name }}` becomes `{name}`). + +For add-resource, edit-resource and translating one resource, all strings for one locale go to the provider in one request. `translate-locale` sends one request per batch of `batchSize` resources (default 5), with `delayMs` between batches. The provider may split a request further (Google: chunks of 128). ### Placeholder Protection @@ -181,7 +183,7 @@ For this reason, complex ICU strings are always skipped and left for human trans #### Transloco double-brace format -The classifier normalizes Transloco's double-brace format (`{{ name }}`) to single-brace (`{name}`) before analysis. Both formats are treated identically for classification purposes. The placeholder protector preserves the original format in the translated output -- if your source uses `{{ name }}`, the translated string will too. +The classifier normalizes Transloco's double-brace format (`{{ name }}`) to single-brace (`{name}`) before analysis. Both formats are treated identically for classification purposes. Translations are always stored in ICU format: if your source uses `{{ name }}`, the stored translation uses `{name}`. ## Usage @@ -192,7 +194,7 @@ Auto-translation runs automatically during the `add-resource` and `edit-resource - **add-resource**: When no explicit translations are provided (interactively or via flags), the command delegates all target locales to the translation provider instead of populating them with the base value. - **edit-resource**: When the base value is updated, the command triggers translation for any locale whose status is `new` or `stale`. -Skipped locales (complex ICU strings) are left at their current status and surfaced in the CLI output for manual handling. +Skipped locales (complex ICU, a lost placeholder, or a dropped protected term) are surfaced in the CLI output for manual handling. On add, a skipped locale gets a copy of the base value with status `new`. On edit, a skipped locale that has no value, or only an untranslated copy of the old base value, gets the new base value with status `new`; a locale that holds a real translation keeps it, marked `stale`. ### Translating existing resources via the API @@ -217,7 +219,7 @@ The response includes the updated resource, the number of locales translated, an } ``` -If the base value uses complex ICU, `skippedLocales` will list the locales that could not be auto-translated: +If the base value uses complex ICU (or a translation lost a placeholder or a protected term), `skippedLocales` will list the locales that could not be auto-translated: ```json { @@ -250,7 +252,7 @@ Translating locale 'fr' in collection 'playground'... Done. Translated: 45 resources -Skipped (ICU): 3 resources +Skipped (needs human translation): 3 resources Failed: 0 resources ``` @@ -314,7 +316,7 @@ The Google Translate v2 provider: 1. **Always review auto-translated strings.** Machine translation provides a starting point, not a final result. Use the `translated` status to distinguish auto-translated strings from human-`verified` ones. -2. **Translate complex ICU strings manually.** When `skippedLocales` is non-empty, those strings need a human translator who understands plural rules, gender selection, and other ICU constructs for the target language. +2. **Translate skipped strings manually.** When `skippedLocales` is non-empty, those strings need a human translator: complex ICU needs someone who understands plural rules, gender selection, and other ICU constructs for the target language, and a translation that lost a protected term must keep it. 3. **Use environment variables for API keys.** Never commit API keys to `.lingo-tracker.json` or any other file in version control. diff --git a/docs/cli.md b/docs/cli.md index 5ff460a6..ed38d8fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -720,7 +720,7 @@ Translating locale 'fr' in collection 'playground'... Done. Translated: 45 resources -Skipped (ICU): 3 resources +Skipped (needs human translation): 3 resources Failed: 0 resources ``` @@ -734,7 +734,8 @@ Failed: 0 resources **Notes:** - Only resources with status `new` or `stale` are translated; `translated` and `verified` resources are left unchanged -- Resources whose base value uses complex ICU syntax are skipped and reported in the "Skipped (ICU)" count +- Resources whose base value uses complex ICU syntax, and translations that lose a placeholder or drop a [protected term](./features/protected-terms.md), are skipped and reported in the "Skipped (needs human translation)" count +- Translations are stored in ICU format (`{{ name }}` becomes `{name}`) - Throttling is controlled by `batchSize` and `delayMs` in the `translation` config block; see [Auto-Translation](./auto-translation.md) for recommended settings - Requires `translation.enabled: true` in `.lingo-tracker.json` diff --git a/docs/features/protected-terms.md b/docs/features/protected-terms.md index f302903c..bac74e9c 100644 --- a/docs/features/protected-terms.md +++ b/docs/features/protected-terms.md @@ -7,10 +7,11 @@ sidebar_position: 6 A protected term is a word that must stay unchanged through translation. Brand names, product names, and technical jargon all qualify. `iPhone` stays `iPhone` in every locale, and `Node.js` stays `Node.js`. -LingoTracker applies the list in two places. +LingoTracker applies the list in three places. - **Export** marks each exported string with the protected terms found in its source. Translators and machine-translation services then see which words to leave alone. - **Import** rejects an incoming translation when a protected term from the source is missing from it. +- **Auto-translation** does not store a machine translation when a protected term from the source is missing from it. The locale is reported as skipped. See [Auto-Translation](../auto-translation.md). Protected terms are not [preferred terminology](./preferred-terminology.md). Preferred terminology suggests better wording for your base-locale text, and it only warns. Protected terms keep words intact in translations, and import enforces them. @@ -158,6 +159,14 @@ Base-locale imports skip the check. A base-locale import defines the source rath The [Import](./import.md) page explains how LingoTracker reports failures. +## Auto-translation behavior + +Auto-translation applies the same check as import to every machine translation. A translation that is missing a protected term from the source is not stored. The locale is reported in `skippedLocales` (or in the skipped count of `translate-locale`). When a resource is added, that locale gets a copy of the base value with status `new`. When a base value is edited, a locale with no value or an untranslated copy gets the new base value as `new`, and a locale with a real translation keeps it, marked `stale`. + +Because it is the same rule as import, the source is matched in any case, but the translation must hold the term exactly as stored. So a source that has the term in a different case (for example `IPHONE` for the term `iPhone`) is always skipped, even when the provider keeps `IPHONE` unchanged. + +A malformed terms file makes auto-translation fail with an error (for add-resource and edit-resource only when auto-translation is on), just as it does for import. + ## Web UI **Settings** edits the global list as chips. It names the file underneath the field. @@ -169,9 +178,9 @@ The **collection dialog** does the same for one collection. It requires that col | Situation | Behavior | |-----------|----------| | The file is absent at the default path | LingoTracker reads an empty list and stays quiet. This is the normal state before you add your first term. | -| The file is absent at a path you configured | LingoTracker reads an empty list and warns you. A pointer at nothing is usually a typo. | +| The file is absent at a path you configured | LingoTracker reads an empty list and warns you once. A pointer at nothing is usually a typo. | | The JSON is malformed | LingoTracker reports an error. | | The JSON holds something other than an array of strings | LingoTracker reports an error. | | The parent directory of the target path is absent | LingoTracker reports an error. It creates no directories for you, and it leaves your configuration unchanged. | -A corrupt file is a hard error rather than an empty list, and this is deliberate. An empty list would protect nothing. Altered brand names would then reach your resources through import, and nobody would see it happen. +A corrupt file is a hard error rather than an empty list, and this is deliberate. An empty list would protect nothing. Altered brand names would then reach your resources through import or auto-translation, and nobody would see it happen. diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index e4a980a6..a55082a6 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -67,6 +67,7 @@ export { loadConfig, loadPreferredTerminology, openCollection, + type ProtectedTermsFiles, type ResolvedProtectedTerms, readCollectionProtectedTerms, readEffectiveProtectedTerms, @@ -138,6 +139,7 @@ export { LingoTrackerError, LocaleAlreadyExistsError, LocaleNotFoundError, + ProtectedTermsFileError, ReadOnlyCollectionError, ResourceAlreadyExistsError, ResourceNotFoundError, @@ -201,10 +203,15 @@ export type { SearchTreeParams, } from './lib/resource'; export type { + OpenTranslatorOptions, + ProviderCapabilities, TranslateExistingResourceResult, TranslateLocaleParams, TranslateLocaleProgress, TranslateLocaleResult, + TranslateRequest, + TranslateResult, + TranslationProvider, } from './lib/translation'; export type { ResourceValidationResult, ValidationOptions } from './lib/validate'; export type { diff --git a/libs/core/src/lib/config/index.ts b/libs/core/src/lib/config/index.ts index fb51c5c6..af0e6117 100644 --- a/libs/core/src/lib/config/index.ts +++ b/libs/core/src/lib/config/index.ts @@ -1,7 +1,12 @@ // Config and collections: load .lingo-tracker.json, open a collection, and read or write its terminology files. export { type LoadConfigOptions, loadConfig } from './load-config'; -export { type Collection, type OpenCollectionOptions, openCollection } from './open-collection'; +export { + type Collection, + type OpenCollectionOptions, + openCollection, + type ProtectedTermsFiles, +} from './open-collection'; export { type LoadPreferredTerminologyResult, loadPreferredTerminology, @@ -14,6 +19,7 @@ export { readCollectionProtectedTerms, readEffectiveProtectedTerms, readGlobalProtectedTerms, + readProtectedTermsInForce, resolveCollectionProtectedTermsFilePath, resolveGlobalProtectedTermsFilePath, resolveProtectedTermsForConfig, diff --git a/libs/core/src/lib/config/open-collection.spec.ts b/libs/core/src/lib/config/open-collection.spec.ts index f2fa14a1..3d8817d5 100644 --- a/libs/core/src/lib/config/open-collection.spec.ts +++ b/libs/core/src/lib/config/open-collection.spec.ts @@ -7,6 +7,7 @@ import { CONFIG_FILENAME } from '../../constants'; import { CollectionNotFoundError, ReadOnlyCollectionError } from '../errors/lingo-tracker-error'; import { loadConfig } from './load-config'; import { openCollection } from './open-collection'; +import { DEFAULT_PROTECTED_TERMS_FILENAME } from './protected-terms-file'; const globalTranslation = { enabled: true, provider: 'google-translate', apiKeyEnv: 'GLOBAL_KEY' }; const collectionTranslation = { enabled: false, provider: 'google-translate', apiKeyEnv: 'OWN_KEY' }; @@ -58,6 +59,7 @@ describe('openCollection', () => { targetLocales: ['fr', 'de'], translationConfig: globalTranslation, tags: [], + protectedTermsFiles: { global: join(dir, DEFAULT_PROTECTED_TERMS_FILENAME), globalExplicit: false }, readOnly: false, config: { translationsFolder: 'src/i18n' }, }); @@ -109,6 +111,26 @@ describe('openCollection', () => { expect(openCollection(config, 'inherits').translationsFolder).toBe(resolve(dir, 'src/i18n')); }); + it('resolves the protected-terms file paths against cwd without reading them', () => { + const withTerms: LingoTrackerConfig = { + ...config, + protectedTermsFile: 'terms/global.json', + collections: { own: { translationsFolder: 'x', protectedTermsFile: '/abs/own-terms.json' } }, + }; + + expect(openCollection(withTerms, 'own', { cwd: dir }).protectedTermsFiles).toEqual({ + global: join(dir, 'terms', 'global.json'), + globalExplicit: true, + collection: resolve('/abs/own-terms.json'), + }); + }); + + it('opens a collection whose protected-terms file is malformed', () => { + writeFileSync(join(dir, DEFAULT_PROTECTED_TERMS_FILENAME), '["iPhone",', 'utf8'); + + expect(openCollection(config, 'inherits', { cwd: dir }).baseLocale).toBe('en'); + }); + it('opens a read-only collection for reading and reports readOnly', () => { const collection = openCollection(config, 'vendor', { cwd: dir }); diff --git a/libs/core/src/lib/config/open-collection.ts b/libs/core/src/lib/config/open-collection.ts index 30bada81..c69e15b0 100644 --- a/libs/core/src/lib/config/open-collection.ts +++ b/libs/core/src/lib/config/open-collection.ts @@ -5,6 +5,7 @@ import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; import type { TranslationConfig } from '../../config/translation-config'; import { DEFAULT_CONFIG } from '../../constants'; import { CollectionNotFoundError, ReadOnlyCollectionError } from '../errors/lingo-tracker-error'; +import { resolveCollectionProtectedTermsFilePath, resolveGlobalProtectedTermsFilePath } from './protected-terms-file'; /** * A collection with every setting resolved: the collection's own value where it has one, @@ -25,13 +26,31 @@ export interface Collection { readonly translationConfig: TranslationConfig | undefined; /** Collection-level tags (normalized), inherited by every resource in the collection. */ readonly tags: readonly string[]; + /** + * Where the collection's protected terms live (absolute paths, resolved but not read). Read them + * with `readProtectedTermsInForce`; opening a collection does no file I/O. + */ + readonly protectedTermsFiles: ProtectedTermsFiles; readonly readOnly: boolean; /** The collection's raw config entry, for settings not modelled here. */ readonly config: LingoTrackerCollection; } +/** The protected-terms files in force for a collection: the global file and the collection's own. */ +export interface ProtectedTermsFiles { + /** The global file: `protectedTermsFile` from the config, else the default file beside it. */ + readonly global: string; + /** True when the config names the global file (a missing named file is warned about). */ + readonly globalExplicit: boolean; + /** The collection's own file, when it names one. */ + readonly collection?: string; +} + export interface OpenCollectionOptions { - /** Directory a relative `translationsFolder` resolves against. Default: `process.cwd()`. */ + /** + * Directory a relative `translationsFolder` or protected-terms file pointer resolves against + * (the directory holding `.lingo-tracker.json`). Default: `process.cwd()`. + */ readonly cwd?: string; /** Refuse a read-only collection. Set this for operations that change resources. */ readonly writable?: boolean; @@ -60,17 +79,24 @@ export function openCollection( throw new ReadOnlyCollectionError(name); } + const cwd = options.cwd ?? process.cwd(); const baseLocale = raw.baseLocale || config.baseLocale || DEFAULT_CONFIG.baseLocale; const locales = raw.locales ?? config.locales ?? []; + const collectionTermsFile = resolveCollectionProtectedTermsFilePath(raw, cwd); return { name, - translationsFolder: resolve(options.cwd ?? process.cwd(), raw.translationsFolder), + translationsFolder: resolve(cwd, raw.translationsFolder), baseLocale, locales, targetLocales: locales.filter((locale) => locale !== baseLocale), translationConfig: raw.translation ?? config.translation, tags: normalizeTags(raw.tags ?? []), + protectedTermsFiles: { + global: resolveGlobalProtectedTermsFilePath(config, cwd), + globalExplicit: config.protectedTermsFile !== undefined, + ...(collectionTermsFile !== undefined && { collection: collectionTermsFile }), + }, readOnly, config: raw, }; diff --git a/libs/core/src/lib/config/protected-terms-file.spec.ts b/libs/core/src/lib/config/protected-terms-file.spec.ts index 8a97dc7c..229ffc9b 100644 --- a/libs/core/src/lib/config/protected-terms-file.spec.ts +++ b/libs/core/src/lib/config/protected-terms-file.spec.ts @@ -3,6 +3,8 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { ProtectedTermsFileError } from '../errors/lingo-tracker-error'; +import { openCollection } from './open-collection'; import { DEFAULT_PROTECTED_TERMS_FILENAME, clearProtectedTermsFileCache, @@ -10,6 +12,7 @@ import { readEffectiveProtectedTerms, readGlobalProtectedTerms, readProtectedTermsFile, + readProtectedTermsInForce, resolveCollectionProtectedTermsFilePath, resolveGlobalProtectedTermsFilePath, resolveProtectedTermsFilePath, @@ -89,10 +92,23 @@ describe('protected-terms-file', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('absent.json')); }); + it('warns only once per missing explicit file', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + readProtectedTermsFile(join(cwd, 'absent.json'), { explicit: true }); + readProtectedTermsFile(join(cwd, 'absent.json'), { explicit: true }); + + expect(warn).toHaveBeenCalledTimes(1); + }); + it('throws on malformed JSON', () => { const filePath = write('terms.json', '["iPhone",'); expect(() => readProtectedTermsFile(filePath)).toThrow('not valid JSON'); + expect(() => readProtectedTermsFile(filePath)).toThrow( + expect.objectContaining({ code: 'INVALID_PROTECTED_TERMS_FILE', filePath }), + ); + expect(() => readProtectedTermsFile(filePath)).toThrow(ProtectedTermsFileError); }); it('throws when the payload is not an array', () => { @@ -182,6 +198,22 @@ describe('protected-terms-file', () => { expect(readEffectiveProtectedTerms(baseConfig(), { translationsFolder: './i18n' }, cwd)).toEqual(['SimonCodes']); }); + it("reads an opened collection's terms from its resolved files, like readEffectiveProtectedTerms", () => { + write('global.json', '["SimonCodes", "iPhone"]'); + write('collection-terms.json', '["iPhone", "Node.js"]'); + const config = baseConfig({ + protectedTermsFile: 'global.json', + collections: { app: { translationsFolder: './i18n', protectedTermsFile: 'collection-terms.json' } }, + }); + + const collection = openCollection(config, 'app', { cwd }); + + expect(readProtectedTermsInForce(collection)).toEqual(['SimonCodes', 'iPhone', 'Node.js']); + expect(readProtectedTermsInForce(collection)).toEqual( + readEffectiveProtectedTerms(config, config.collections.app, cwd), + ); + }); + it('honours an explicit global pointer over the default path', () => { write(DEFAULT_PROTECTED_TERMS_FILENAME, '["Ignored"]'); write('custom.json', '["Used"]'); diff --git a/libs/core/src/lib/config/protected-terms-file.ts b/libs/core/src/lib/config/protected-terms-file.ts index 4da77456..6875d744 100644 --- a/libs/core/src/lib/config/protected-terms-file.ts +++ b/libs/core/src/lib/config/protected-terms-file.ts @@ -3,6 +3,8 @@ import { dirname, isAbsolute, resolve } from 'node:path'; import { effectiveProtectedTerms, normalizeProtectedTerms } from '@simoncodes-ca/domain'; import type { LingoTrackerCollection } from '../../config/lingo-tracker-collection'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { ProtectedTermsFileError } from '../errors/lingo-tracker-error'; +import type { Collection } from './open-collection'; /** * Default location of the global protected-terms file, resolved against the @@ -14,10 +16,16 @@ export const DEFAULT_PROTECTED_TERMS_FILENAME = '.lingo-tracker-protected-terms. /** In-process cache keyed by absolute file path; cleared whenever a file is written. */ const cache = new Map(); +/** + * Explicit pointers already warned about as missing. The Translator reads the files on every + * operation that auto-translates (for the API, on every such request), so warn once per path. + */ +const warnedMissing = new Set(); /** Drops every cached protected-terms file. Exported for tests and for callers that write out-of-band. */ export function clearProtectedTermsFileCache(): void { cache.clear(); + warnedMissing.clear(); } /** @@ -50,10 +58,11 @@ export function resolveCollectionProtectedTermsFilePath( * * A missing file reads as an empty list — the normal state before any term has been * added. When the path came from an explicit pointer rather than the default, the - * absence is also warned about, since a pointer at nothing is usually a typo. - * Malformed JSON, a non-array payload, or a non-string element throws: silently + * absence is also warned about (once per path), since a pointer at nothing is usually a typo. + * Malformed JSON, a non-array payload, or a non-string element throws + * {@link ProtectedTermsFileError}: silently * treating a corrupt file as "no protected terms" would let bad translations - * through import unnoticed. + * through import or auto-translation unnoticed. */ export function readProtectedTermsFile(filePath: string, options: { explicit?: boolean } = {}): string[] { const cached = cache.get(filePath); @@ -62,7 +71,8 @@ export function readProtectedTermsFile(filePath: string, options: { explicit?: b } if (!existsSync(filePath)) { - if (options.explicit) { + if (options.explicit && !warnedMissing.has(filePath)) { + warnedMissing.add(filePath); console.warn(`Protected terms file not found: ${filePath}. Treating as an empty list.`); } return []; @@ -73,14 +83,17 @@ export function readProtectedTermsFile(filePath: string, options: { explicit?: b parsed = JSON.parse(readFileSync(filePath, 'utf8')); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Protected terms file is not valid JSON: ${filePath} (${detail})`); + throw new ProtectedTermsFileError(filePath, `Protected terms file is not valid JSON: ${filePath} (${detail})`); } if (!Array.isArray(parsed)) { - throw new Error(`Protected terms file must contain a JSON array of strings: ${filePath}`); + throw new ProtectedTermsFileError( + filePath, + `Protected terms file must contain a JSON array of strings: ${filePath}`, + ); } if (parsed.some((term) => typeof term !== 'string')) { - throw new Error(`Protected terms file must contain only strings: ${filePath}`); + throw new ProtectedTermsFileError(filePath, `Protected terms file must contain only strings: ${filePath}`); } const terms = normalizeProtectedTerms(parsed as string[]); @@ -145,6 +158,21 @@ export function readEffectiveProtectedTerms( ); } +/** + * The protected terms in force for an opened collection: its global file united with its own file, + * read from the paths `openCollection` resolved (`Collection.protectedTermsFiles`). The same result + * as `readEffectiveProtectedTerms(config, collectionConfig, cwd)`. + * + * @throws {ProtectedTermsFileError} A file exists but is not a JSON array of strings. + */ +export function readProtectedTermsInForce(collection: Pick): string[] { + const files = collection.protectedTermsFiles; + return effectiveProtectedTerms( + readProtectedTermsFile(files.global, { explicit: files.globalExplicit }), + files.collection === undefined ? undefined : readProtectedTermsFile(files.collection, { explicit: true }), + ); +} + /** Resolved protected terms for a whole config — what each scope's file actually contains. */ export interface ResolvedProtectedTerms { /** Terms in the global file. */ diff --git a/libs/core/src/lib/errors/index.ts b/libs/core/src/lib/errors/index.ts index e5eafbb6..4324efd3 100644 --- a/libs/core/src/lib/errors/index.ts +++ b/libs/core/src/lib/errors/index.ts @@ -19,6 +19,7 @@ export { LingoTrackerError, LocaleAlreadyExistsError, LocaleNotFoundError, + ProtectedTermsFileError, ReadOnlyCollectionError, ResourceAlreadyExistsError, ResourceNotFoundError, diff --git a/libs/core/src/lib/errors/lingo-tracker-error.ts b/libs/core/src/lib/errors/lingo-tracker-error.ts index 4c2e123e..0bb3dd8a 100644 --- a/libs/core/src/lib/errors/lingo-tracker-error.ts +++ b/libs/core/src/lib/errors/lingo-tracker-error.ts @@ -42,6 +42,19 @@ export class ConfigParseError extends LingoTrackerError { } } +/** + * A protected-terms file exists but cannot be used: it is not valid JSON, or not a JSON array of + * strings. A corrupt list is an error, not an empty list, so it never protects nothing silently. + */ +export class ProtectedTermsFileError extends LingoTrackerError { + readonly filePath: string; + + constructor(filePath: string, message: string) { + super(message, 'INVALID_PROTECTED_TERMS_FILE'); + this.filePath = filePath; + } +} + // --- Collections ------------------------------------------------------------- /** The config has no collection with this name. */ diff --git a/libs/core/src/lib/folder/create-folder.spec.ts b/libs/core/src/lib/folder/create-folder.spec.ts index 88a64d2c..1149ae2a 100644 --- a/libs/core/src/lib/folder/create-folder.spec.ts +++ b/libs/core/src/lib/folder/create-folder.spec.ts @@ -14,6 +14,7 @@ function collection(translationsFolder: string): Collection { targetLocales: [], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder }, }; diff --git a/libs/core/src/lib/folder/move-folder.real-fs.spec.ts b/libs/core/src/lib/folder/move-folder.real-fs.spec.ts index a14907fb..a23337fe 100644 --- a/libs/core/src/lib/folder/move-folder.real-fs.spec.ts +++ b/libs/core/src/lib/folder/move-folder.real-fs.spec.ts @@ -16,6 +16,7 @@ function collection(translationsFolder: string): Collection { targetLocales: [], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder }, }; diff --git a/libs/core/src/lib/folder/move-folder.spec.ts b/libs/core/src/lib/folder/move-folder.spec.ts index 716000ac..6a4bd480 100644 --- a/libs/core/src/lib/folder/move-folder.spec.ts +++ b/libs/core/src/lib/folder/move-folder.spec.ts @@ -19,6 +19,7 @@ function collection(translationsFolder: string, name = 'main'): Collection { targetLocales: ['fr'], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder }, }; diff --git a/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts b/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts index 1dee6f89..949f687a 100644 --- a/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts +++ b/libs/core/src/lib/resource/resource-mutation.real-fs.spec.ts @@ -25,6 +25,7 @@ describe('mutations returned by core writes (real fs)', () => { targetLocales: ['fr'], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder }, }); diff --git a/libs/core/src/lib/translation/auto-translate-resources.spec.ts b/libs/core/src/lib/translation/auto-translate-resources.spec.ts deleted file mode 100644 index f2b10133..00000000 --- a/libs/core/src/lib/translation/auto-translate-resources.spec.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { autoTranslateResource } from './auto-translate-resources'; -import { TranslationError } from './translation-provider'; -import type { TranslationConfig } from '../../config/translation-config'; -import type { TranslateTextResult } from './translation-orchestrator'; - -vi.mock('./translation-provider-factory'); -vi.mock('./translation-orchestrator'); - -import { createTranslationProvider } from './translation-provider-factory'; -import { TranslationOrchestrator } from './translation-orchestrator'; - -const mockTranslateText = vi.fn(); - -/** Shorthand for a result that was sent to the provider and translated. */ -function translated(value: string): TranslateTextResult { - return { kind: 'translated', value }; -} - -/** Shorthand for a result that was skipped due to ICU placeholders. */ -function skipped(value: string): TranslateTextResult { - return { kind: 'skipped', value }; -} - -describe('autoTranslateResource', () => { - const enabledConfig: TranslationConfig = { - enabled: true, - provider: 'google-translate', - apiKeyEnv: 'GOOGLE_TRANSLATE_API_KEY', - }; - - const disabledConfig: TranslationConfig = { - enabled: false, - provider: 'google-translate', - apiKeyEnv: 'GOOGLE_TRANSLATE_API_KEY', - }; - - beforeEach(() => { - vi.clearAllMocks(); - - vi.mocked(createTranslationProvider).mockReturnValue({ - translate: vi.fn(), - getCapabilities: vi.fn(), - }); - - // Vitest 4 requires a constructable implementation for `new TranslationOrchestrator(...)`. - // biome-ignore lint/complexity/useArrowFunction: this mock must remain constructable - vi.mocked(TranslationOrchestrator).mockImplementation(function () { - return { - translateText: mockTranslateText, - translateBatch: vi.fn(), - } as unknown as TranslationOrchestrator; - }); - - process.env.GOOGLE_TRANSLATE_API_KEY = 'test-api-key'; - }); - - afterEach(() => { - delete process.env.GOOGLE_TRANSLATE_API_KEY; - }); - - it('should return empty translations and skippedLocales when translation is disabled', async () => { - const result = await autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['fr', 'de'], - translationConfig: disabledConfig, - }); - - expect(result).toEqual({ translations: [], skippedLocales: [] }); - expect(createTranslationProvider).not.toHaveBeenCalled(); - }); - - it('should translate to all non-base target locales', async () => { - mockTranslateText.mockResolvedValueOnce(translated('Bonjour')).mockResolvedValueOnce(translated('Hallo')); - - const result = await autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['fr', 'de'], - translationConfig: enabledConfig, - }); - - expect(result.translations).toEqual([ - { locale: 'fr', value: 'Bonjour', status: 'translated' }, - { locale: 'de', value: 'Hallo', status: 'translated' }, - ]); - expect(result.skippedLocales).toEqual([]); - expect(mockTranslateText).toHaveBeenCalledTimes(2); - expect(mockTranslateText).toHaveBeenCalledWith('Hello', 'en', 'fr'); - expect(mockTranslateText).toHaveBeenCalledWith('Hello', 'en', 'de'); - }); - - it('should skip the base locale when it appears in targetLocales', async () => { - mockTranslateText.mockResolvedValueOnce(translated('Bonjour')); - - const result = await autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['en', 'fr'], // 'en' is the base locale — should be skipped - translationConfig: enabledConfig, - }); - - expect(result.translations).toHaveLength(1); - expect(result.translations[0].locale).toBe('fr'); - expect(result.skippedLocales).toEqual([]); - expect(mockTranslateText).toHaveBeenCalledTimes(1); - expect(mockTranslateText).not.toHaveBeenCalledWith('Hello', 'en', 'en'); - }); - - it('should return empty translations when targetLocales contains only the base locale', async () => { - const result = await autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['en'], - translationConfig: enabledConfig, - }); - - expect(result).toEqual({ translations: [], skippedLocales: [] }); - expect(mockTranslateText).not.toHaveBeenCalled(); - }); - - it('should return empty translations when targetLocales is empty', async () => { - const result = await autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: [], - translationConfig: enabledConfig, - }); - - expect(result).toEqual({ translations: [], skippedLocales: [] }); - expect(mockTranslateText).not.toHaveBeenCalled(); - }); - - it('should populate skippedLocales when the orchestrator returns kind: skipped (ICU messages)', async () => { - const icuMessage = 'You have {count, plural, one {# item} other {# items}}'; - - mockTranslateText.mockResolvedValue(skipped(icuMessage)); - - const result = await autoTranslateResource({ - baseValue: icuMessage, - baseLocale: 'en', - targetLocales: ['fr', 'de'], - translationConfig: enabledConfig, - }); - - expect(result.translations).toEqual([]); - expect(result.skippedLocales).toEqual(['fr', 'de']); - expect(mockTranslateText).toHaveBeenCalledTimes(2); - }); - - it('should include only translated locales and report skipped locales in a mixed batch', async () => { - // 'de' gets a real translation; 'fr' is skipped (e.g. the orchestrator - // detected ICU content for that locale). Uses a plain-text baseValue to - // reflect a realistic scenario where the orchestrator's ICU detection - // determines the skip, not the equality of the returned value. - mockTranslateText.mockResolvedValueOnce(translated('Hallo')).mockResolvedValueOnce(skipped('Hello')); - - const result = await autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['de', 'fr'], - translationConfig: enabledConfig, - }); - - expect(result.translations).toEqual([{ locale: 'de', value: 'Hallo', status: 'translated' }]); - expect(result.skippedLocales).toEqual(['fr']); - }); - - it('should not discard a translated value that happens to equal the base value', async () => { - // "OK" is a valid translation in many languages — the old equality guard - // would have incorrectly dropped this entry. - mockTranslateText.mockResolvedValueOnce(translated('OK')); - - const result = await autoTranslateResource({ - baseValue: 'OK', - baseLocale: 'en', - targetLocales: ['de'], - translationConfig: enabledConfig, - }); - - expect(result.translations).toEqual([{ locale: 'de', value: 'OK', status: 'translated' }]); - expect(result.skippedLocales).toEqual([]); - }); - - it('should throw TranslationError with MISSING_API_KEY when the env var is not set', async () => { - delete process.env.GOOGLE_TRANSLATE_API_KEY; - - await expect( - autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['fr'], - translationConfig: enabledConfig, - }), - ).rejects.toMatchObject({ code: 'MISSING_API_KEY', retryable: false }); - }); - - it('should include the env var name in the missing API key error message', async () => { - delete process.env.GOOGLE_TRANSLATE_API_KEY; - - await expect( - autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['fr'], - translationConfig: enabledConfig, - }), - ).rejects.toThrow('GOOGLE_TRANSLATE_API_KEY'); - }); - - it('should propagate TranslationError from the provider', async () => { - const providerError = new TranslationError('Rate limit exceeded', 'RATE_LIMIT', true); - mockTranslateText.mockRejectedValue(providerError); - - await expect( - autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['fr'], - translationConfig: enabledConfig, - }), - ).rejects.toMatchObject({ code: 'RATE_LIMIT', retryable: true }); - }); - - it('should use the API key from the configured environment variable', async () => { - process.env.CUSTOM_TRANSLATE_KEY = 'custom-api-key'; - mockTranslateText.mockResolvedValue(translated('Translated')); - - const customConfig: TranslationConfig = { - enabled: true, - provider: 'google-translate', - apiKeyEnv: 'CUSTOM_TRANSLATE_KEY', - }; - - await autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['fr'], - translationConfig: customConfig, - }); - - expect(createTranslationProvider).toHaveBeenCalledWith('google-translate', 'custom-api-key'); - - delete process.env.CUSTOM_TRANSLATE_KEY; - }); - - it('should mark all returned entries with status "translated"', async () => { - mockTranslateText.mockResolvedValueOnce(translated('Bonjour')).mockResolvedValueOnce(translated('Hola')); - - const result = await autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['fr', 'es'], - translationConfig: enabledConfig, - }); - - for (const entry of result.translations) { - expect(entry.status).toBe('translated'); - } - }); - - it('should not call the provider when translation is disabled even if API key exists', async () => { - await autoTranslateResource({ - baseValue: 'Hello', - baseLocale: 'en', - targetLocales: ['fr', 'de'], - translationConfig: disabledConfig, - }); - - expect(createTranslationProvider).not.toHaveBeenCalled(); - expect(mockTranslateText).not.toHaveBeenCalled(); - }); -}); diff --git a/libs/core/src/lib/translation/auto-translate-resources.ts b/libs/core/src/lib/translation/auto-translate-resources.ts deleted file mode 100644 index 353524db..00000000 --- a/libs/core/src/lib/translation/auto-translate-resources.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { TranslationConfig } from '../../config/translation-config'; -import { createTranslationProvider } from './translation-provider-factory'; -import { TranslationOrchestrator } from './translation-orchestrator'; -import { TranslationError } from './translation-provider'; - -export interface AutoTranslateParams { - readonly baseValue: string; - readonly baseLocale: string; - readonly targetLocales: string[]; - readonly translationConfig: TranslationConfig; -} - -export interface AutoTranslatedEntry { - readonly locale: string; - readonly value: string; - readonly status: 'translated'; -} - -export interface AutoTranslateResult { - readonly translations: AutoTranslatedEntry[]; - readonly skippedLocales: string[]; -} - -/** - * Auto-translates a base value to multiple target locales using the configured provider. - * - * Reads the API key from the environment variable named in `translationConfig.apiKeyEnv`. - * Skips the base locale if it appears in `targetLocales`. - * Skips locales where the orchestrator reports the text was not translated (e.g. ICU messages). - * Returns empty `translations` and `skippedLocales` arrays when translation is disabled. - * - * Throws {@link TranslationError} on any failure — callers are responsible for deciding - * whether to propagate or handle the error. - * - * @param params - Translation parameters including the source text, locales, and config. - * @returns Result containing translated entries and the locales that were skipped due to ICU format. - */ -export async function autoTranslateResource(params: AutoTranslateParams): Promise { - const { baseValue, baseLocale, targetLocales, translationConfig } = params; - - if (!translationConfig.enabled) { - return { translations: [], skippedLocales: [] }; - } - - const apiKey = process.env[translationConfig.apiKeyEnv]; - if (!apiKey) { - throw new TranslationError( - `Translation API key not found. Set the ${translationConfig.apiKeyEnv} environment variable.`, - 'MISSING_API_KEY', - false, - ); - } - - const provider = createTranslationProvider(translationConfig.provider, apiKey); - const orchestrator = new TranslationOrchestrator(provider); - - const nonBaseLocales = targetLocales.filter((locale) => locale !== baseLocale); - - // Translate all locales in parallel for better performance. - const localeResults = await Promise.all( - nonBaseLocales.map(async (targetLocale) => { - const result = await orchestrator.translateText(baseValue, baseLocale, targetLocale); - return { targetLocale, result }; - }), - ); - - const translations: AutoTranslatedEntry[] = []; - const skippedLocales: string[] = []; - - for (const { targetLocale, result } of localeResults) { - // The orchestrator signals complex ICU messages or marker mismatches with - // kind: 'skipped'. Leave those entries out so they retain their default - // 'new' status rather than being incorrectly marked as 'translated'. - // Both 'translated' and 'translated-with-placeholders' are treated as - // successful translations. - if (result.kind === 'skipped') { - skippedLocales.push(targetLocale); - continue; - } - - translations.push({ - locale: targetLocale, - value: result.value, - status: 'translated', - }); - } - - return { translations, skippedLocales }; -} diff --git a/libs/core/src/lib/translation/icu-classifier.spec.ts b/libs/core/src/lib/translation/icu-classifier.spec.ts deleted file mode 100644 index 3d515757..00000000 --- a/libs/core/src/lib/translation/icu-classifier.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { classifyICUContent } from './icu-classifier'; - -describe('classifyICUContent', () => { - // --------------------------------------------------------------------------- - // Plain strings (no ICU syntax) - // --------------------------------------------------------------------------- - - describe("'plain' classification", () => { - it("classifies an empty string as 'plain'", () => { - expect(classifyICUContent('')).toBe('plain'); - }); - - it("classifies a string with no braces as 'plain'", () => { - expect(classifyICUContent('Hello world')).toBe('plain'); - }); - - it("classifies a string with only whitespace as 'plain'", () => { - expect(classifyICUContent(' ')).toBe('plain'); - }); - - it("classifies a string with ICU-escaped braces as 'plain'", () => { - // Single-quoted text is escaped in ICU — '{literal}' is not a placeholder. - expect(classifyICUContent("Use the '{' character")).toBe('plain'); - }); - }); - - // --------------------------------------------------------------------------- - // Simple placeholders only - // --------------------------------------------------------------------------- - - describe("'simple-placeholders' classification", () => { - it("classifies a single-brace variable as 'simple-placeholders'", () => { - expect(classifyICUContent('Hello {name}')).toBe('simple-placeholders'); - }); - - it("classifies a numeric index placeholder as 'simple-placeholders'", () => { - expect(classifyICUContent('Value: {0}')).toBe('simple-placeholders'); - }); - - it("classifies a Transloco double-brace placeholder as 'simple-placeholders'", () => { - expect(classifyICUContent('Hello {{ name }}')).toBe('simple-placeholders'); - }); - - it("classifies a double-brace placeholder without surrounding spaces as 'simple-placeholders'", () => { - expect(classifyICUContent('Hello {{name}}')).toBe('simple-placeholders'); - }); - - it("classifies multiple simple single-brace placeholders as 'simple-placeholders'", () => { - expect(classifyICUContent('File {fileA} is newer than {fileB}')).toBe('simple-placeholders'); - }); - - it("classifies a double-brace placeholder mid-sentence as 'simple-placeholders'", () => { - expect(classifyICUContent('Changed filename to {{ newFileName }}')).toBe('simple-placeholders'); - }); - - it("classifies adjacent simple placeholders as 'simple-placeholders'", () => { - expect(classifyICUContent('{first}{second}')).toBe('simple-placeholders'); - }); - - it("classifies a mix of single and double-brace simple placeholders as 'simple-placeholders'", () => { - expect(classifyICUContent('{a} and {{ b }}')).toBe('simple-placeholders'); - }); - }); - - // --------------------------------------------------------------------------- - // Complex ICU - // --------------------------------------------------------------------------- - - describe("'complex-icu' classification", () => { - it("classifies a plural form as 'complex-icu'", () => { - expect(classifyICUContent('{count, plural, one {# item} other {# items}}')).toBe('complex-icu'); - }); - - it("classifies a plural form with surrounding text as 'complex-icu'", () => { - expect(classifyICUContent('You have {count, plural, one {# item} other {# items}} in cart')).toBe('complex-icu'); - }); - - it("classifies a select statement as 'complex-icu'", () => { - expect(classifyICUContent('{gender, select, male {he} female {she} other {they}}')).toBe('complex-icu'); - }); - - it("classifies a number formatter as 'complex-icu'", () => { - expect(classifyICUContent('{price, number, currency}')).toBe('complex-icu'); - }); - - it("classifies a date formatter as 'complex-icu'", () => { - expect(classifyICUContent('{date, date, short}')).toBe('complex-icu'); - }); - - it("classifies a time formatter as 'complex-icu'", () => { - expect(classifyICUContent('{time, time, medium}')).toBe('complex-icu'); - }); - - it("classifies a selectordinal form as 'complex-icu'", () => { - expect(classifyICUContent('{rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}')).toBe( - 'complex-icu', - ); - }); - - it("classifies a mix of a simple double-brace placeholder and a plural block as 'complex-icu'", () => { - expect(classifyICUContent('Hello {{ name }}, {count, plural, one {# item} other {# items}}')).toBe('complex-icu'); - }); - - it("classifies a mix of a simple single-brace placeholder and a plural block as 'complex-icu'", () => { - expect(classifyICUContent('{name} has {count, plural, one {# message} other {# messages}}')).toBe('complex-icu'); - }); - - it("classifies a standalone plural block with no surrounding text as 'complex-icu'", () => { - expect(classifyICUContent('{count, plural, one {# item} other {# items}}')).toBe('complex-icu'); - }); - - it("classifies an unclosed brace as 'complex-icu' for safety", () => { - // Malformed input with no closing brace must not silently pass through - // as a simple placeholder — skipping is always safer than corrupting output. - expect(classifyICUContent('Hello {name')).toBe('complex-icu'); - }); - }); -}); diff --git a/libs/core/src/lib/translation/icu-classifier.ts b/libs/core/src/lib/translation/icu-classifier.ts deleted file mode 100644 index d3bb344e..00000000 --- a/libs/core/src/lib/translation/icu-classifier.ts +++ /dev/null @@ -1 +0,0 @@ -export { ICUClassification, classifyICUContent } from '@simoncodes-ca/domain'; diff --git a/libs/core/src/lib/translation/in-memory-translation-provider.ts b/libs/core/src/lib/translation/in-memory-translation-provider.ts new file mode 100644 index 00000000..966de6a2 --- /dev/null +++ b/libs/core/src/lib/translation/in-memory-translation-provider.ts @@ -0,0 +1,37 @@ +import type { + ProviderCapabilities, + TranslateRequest, + TranslateResult, + TranslationProvider, +} from './translation-provider'; + +/** Produces the translation of one request. May throw (for example a `TranslationError`) to simulate a failure. */ +export type InMemoryTranslate = (request: TranslateRequest) => string; + +/** Default transform: prefixes the text with the target locale, e.g. `Save` → `[fr] Save`. */ +const prefixWithLocale: InMemoryTranslate = ({ text, targetLocale }) => `[${targetLocale}] ${text}`; + +/** + * A translation provider that runs in memory: each text is translated by a function (default: + * `[locale] text`), and every `translate` call is recorded in {@link calls}. Inject it with + * `openTranslator(collection, { provider })`, or through the `provider` option of the operations + * that translate, to run them without a network or an API key. + */ +export class InMemoryTranslationProvider implements TranslationProvider { + /** The requests of every `translate` call, in call order. */ + readonly calls: TranslateRequest[][] = []; + readonly #translate: InMemoryTranslate; + + constructor(translate: InMemoryTranslate = prefixWithLocale) { + this.#translate = translate; + } + + async translate(requests: TranslateRequest[]): Promise { + this.calls.push([...requests]); + return requests.map((request) => ({ translatedText: this.#translate(request), provider: 'in-memory' })); + } + + getCapabilities(): ProviderCapabilities { + return { supportsBatch: true, maxBatchSize: Number.MAX_SAFE_INTEGER, supportsFormality: false }; + } +} diff --git a/libs/core/src/lib/translation/index.ts b/libs/core/src/lib/translation/index.ts index fb3dec35..1dc15a4a 100644 --- a/libs/core/src/lib/translation/index.ts +++ b/libs/core/src/lib/translation/index.ts @@ -1,5 +1,10 @@ -// The translation module: machine-translate one resource or a whole locale through the configured provider. +// The translation module: the Translator (one seam to the machine-translation provider) and the +// operations that translate one resource or a whole locale through it. +export { + type InMemoryTranslate, + InMemoryTranslationProvider, +} from './in-memory-translation-provider'; export { type TranslateExistingResourceResult, translateExistingResource, @@ -10,4 +15,20 @@ export { type TranslateLocaleResult, translateLocale, } from './translate-locale'; -export { TranslationError } from './translation-provider'; +export { + type ProviderCapabilities, + type TranslateRequest, + type TranslateResult, + TranslationError, + type TranslationProvider, +} from './translation-provider'; +export { + type OpenTranslatorOptions, + openTranslator, + type SkippedTranslation, + type TranslatedValue, + type TranslationOutcome, + type TranslationSkipReason, + type Translator, + type TranslatorEntry, +} from './translator'; diff --git a/libs/core/src/lib/translation/translate-existing-resource.spec.ts b/libs/core/src/lib/translation/translate-existing-resource.spec.ts index 1a061655..0a81c6b8 100644 --- a/libs/core/src/lib/translation/translate-existing-resource.spec.ts +++ b/libs/core/src/lib/translation/translate-existing-resource.spec.ts @@ -1,284 +1,177 @@ -import * as fs from 'node:fs'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; import type { TranslationConfig } from '../../config/translation-config'; -import { RESOURCE_ENTRIES_FILENAME, type SafeAny, TRACKER_META_FILENAME } from '../../constants'; +import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; +import { seedResources, testCollection, useTempDir } from '../../testing/temp-dir.spec-helpers'; import type { Collection } from '../config/open-collection'; -import { AutoTranslationDisabledError } from '../errors/lingo-tracker-error'; +import { AutoTranslationDisabledError, ResourceNotFoundError } from '../errors/lingo-tracker-error'; +import { openResourceFolder } from '../resource/resource-folder'; +import { InMemoryTranslationProvider } from './in-memory-translation-provider'; import { translateExistingResource } from './translate-existing-resource'; +import { TranslationError } from './translation-provider'; -vi.mock('node:fs'); -vi.mock('./auto-translate-resources'); - -import { autoTranslateResource } from './auto-translate-resources'; +const AUTO: TranslationConfig = { + enabled: true, + provider: 'google-translate', + apiKeyEnv: 'TRANSLATE_EXISTING_SPEC_KEY', +}; describe('translateExistingResource', () => { - const translationsFolder = '/test/translations'; - - const enabledTranslationConfig: TranslationConfig = { - enabled: true, - provider: 'google-translate', - apiKeyEnv: 'GOOGLE_TRANSLATE_API_KEY', - }; - - function collection(locales: readonly string[], translationConfig = enabledTranslationConfig): Collection { - return { - name: 'main', - translationsFolder, - baseLocale: 'en', - locales, - targetLocales: locales.filter((locale) => locale !== 'en'), - translationConfig, - tags: [], - readOnly: false, - config: { translationsFolder }, - }; + const dir = useTempDir('translate-existing-'); + + function collection(overrides: Partial = {}): Collection { + return testCollection(dir(), { locales: ['en', 'fr', 'es', 'de'], translationConfig: AUTO, ...overrides }); } - const baseResourceEntries = { - save: { source: 'Save', 'fr-ca': 'Sauvegarder' }, - }; - - const _baseTrackerMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'base_hash', status: 'translated' }, - es: { checksum: 'es_empty_hash', baseChecksum: 'base_hash', status: 'new' }, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); - - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [], - skippedLocales: [], - }); - }); + function read(file: string, ...segments: string[]) { + return JSON.parse(readFileSync(join(dir(), ...segments, file), 'utf8')); + } - function mockFileSystem(resourceEntries: SafeAny, trackerMeta: SafeAny) { - vi.mocked(fs.readFileSync).mockImplementation((filePath: SafeAny) => { - if ((filePath as string).includes(RESOURCE_ENTRIES_FILENAME)) { - return JSON.stringify(resourceEntries); - } - if ((filePath as string).includes(TRACKER_META_FILENAME)) { - return JSON.stringify(trackerMeta); - } - return '{}'; + /** `common.save`: fr translated, es new (copy), de missing. */ + function seedSave(target: Collection, source = 'Save'): void { + seedResources(target, { + 'common.save': { + source, + translations: { fr: 'Sauvegarder', es: { value: source, status: 'new' } }, + }, }); } - it('should throw when the resource files do not exist', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + it('throws AutoTranslationDisabledError when auto-translation is disabled', async () => { + const disabled = collection({ translationConfig: { ...AUTO, enabled: false } }); + seedSave(disabled); - await expect(translateExistingResource(collection(['en', 'fr-ca', 'es']), 'buttons.save')).rejects.toThrow( - /Resource not found/, - ); + await expect(translateExistingResource(disabled, 'common.save')).rejects.toThrow(AutoTranslationDisabledError); }); - it('should throw when the entry key is not present in the files', async () => { - mockFileSystem({}, {}); + it('throws MISSING_API_KEY when no provider is injected and the env var is unset', async () => { + const target = collection(); + seedSave(target); - await expect(translateExistingResource(collection(['en', 'fr-ca']), 'buttons.save')).rejects.toThrow( - /Resource not found/, - ); + await expect(translateExistingResource(target, 'common.save')).rejects.toThrow(TranslationError); }); - it('should return translatedCount 0 when no locales have new or stale status', async () => { - const allTranslatedMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'base_hash', status: 'translated' }, - es: { checksum: 'es_hash', baseChecksum: 'base_hash', status: 'verified' }, - }, - }; - mockFileSystem(baseResourceEntries, allTranslatedMeta); + it('returns without an API key when no locale needs work', async () => { + const target = collection({ locales: ['en', 'fr'] }); + seedResources(target, { 'common.save': { source: 'Save', translations: { fr: 'Sauvegarder' } } }); - const result = await translateExistingResource(collection(['en', 'fr-ca', 'es']), 'buttons.save'); + const result = await translateExistingResource(target, 'common.save'); - expect(result.translatedCount).toBe(0); - expect(result.skippedLocales).toEqual([]); - expect(autoTranslateResource).not.toHaveBeenCalled(); - expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(result).toMatchObject({ translatedCount: 0, skippedLocales: [], mutations: [] }); }); - it('should return the current entry when no locales need translation', async () => { - const resourceEntries = { save: { source: 'Save', 'fr-ca': 'Sauvegarder' } }; - const allVerifiedMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'base_hash', status: 'verified' }, - }, - }; - mockFileSystem(resourceEntries, allVerifiedMeta); - - const result = await translateExistingResource(collection(['en', 'fr-ca']), 'buttons.save'); + it('throws ResourceNotFoundError when the folder or the entry does not exist', async () => { + const target = collection(); + seedSave(target); + const provider = new InMemoryTranslationProvider(); - expect(result.entry.key).toBe('save'); - expect(result.entry.source).toBe('Save'); - expect(result.entry.translations['fr-ca']).toBe('Sauvegarder'); + await expect(translateExistingResource(target, 'nowhere.save', { provider })).rejects.toThrow( + ResourceNotFoundError, + ); + await expect(translateExistingResource(target, 'common.missing', { provider })).rejects.toThrow( + ResourceNotFoundError, + ); }); - it('should translate locales with new status', async () => { - const newLocaleMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'base_hash', status: 'new' }, - es: { checksum: 'es_hash', baseChecksum: 'base_hash', status: 'new' }, + it('translates only the locales that need work (new, stale, or no metadata), with status translated', async () => { + const target = collection(); + seedResources(target, { + 'common.save': { + source: 'Save', + translations: { + fr: { value: 'Sauvegarder', status: 'verified' }, + es: { value: 'Save', status: 'new' }, + }, }, - }; - const resourceEntries = { save: { source: 'Save', 'fr-ca': 'Save', es: 'Save' } }; - mockFileSystem(resourceEntries, newLocaleMeta); - - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [ - { locale: 'fr-ca', value: 'Sauvegarder', status: 'translated' }, - { locale: 'es', value: 'Guardar', status: 'translated' }, - ], - skippedLocales: [], }); + const provider = new InMemoryTranslationProvider(); - const result = await translateExistingResource(collection(['en', 'fr-ca', 'es']), 'buttons.save'); + const result = await translateExistingResource(target, 'common.save', { provider }); + expect(provider.calls.map((call) => call.map(({ targetLocale }) => targetLocale))).toEqual([['es'], ['de']]); expect(result.translatedCount).toBe(2); expect(result.skippedLocales).toEqual([]); - expect(result.entry.translations['fr-ca']).toBe('Sauvegarder'); - expect(result.entry.translations.es).toBe('Guardar'); + expect(read(RESOURCE_ENTRIES_FILENAME, 'common').save).toEqual({ + source: 'Save', + fr: 'Sauvegarder', + es: '[es] Save', + de: '[de] Save', + }); + const meta = read(TRACKER_META_FILENAME, 'common').save; + expect(meta.fr.status).toBe('verified'); + expect(meta.es.status).toBe('translated'); + expect(meta.de.status).toBe('translated'); + expect(result.entry.translations).toMatchObject({ es: '[es] Save', de: '[de] Save' }); + expect(result.mutations).toEqual([ + { kind: 'upsert', translationsFolder: dir(), key: 'common.save', entry: result.entry }, + ]); }); - it('should translate locales with stale status', async () => { - const staleMeta = { - save: { - en: { checksum: 'new_base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'old_base_hash', status: 'stale' }, - }, - }; - const resourceEntries = { save: { source: 'Save It', 'fr-ca': 'Sauvegarder' } }; - mockFileSystem(resourceEntries, staleMeta); + it('translates a stale locale', async () => { + const target = collection({ locales: ['en', 'fr'] }); + seedResources(target, { 'common.save': { source: 'Save', translations: { fr: 'Sauvegarder' } } }); + const folder = openResourceFolder(join(dir(), 'common'), { baseLocale: 'en' }); + folder.setBase('save', 'Save all'); + folder.save(); + expect(read(TRACKER_META_FILENAME, 'common').save.fr.status).toBe('stale'); + const provider = new InMemoryTranslationProvider(() => 'Tout sauvegarder'); - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [{ locale: 'fr-ca', value: 'Enregistrer', status: 'translated' }], - skippedLocales: [], - }); - - const result = await translateExistingResource(collection(['en', 'fr-ca']), 'buttons.save'); + const result = await translateExistingResource(target, 'common.save', { provider }); expect(result.translatedCount).toBe(1); - expect(result.entry.translations['fr-ca']).toBe('Enregistrer'); - expect(autoTranslateResource).toHaveBeenCalledWith( - expect.objectContaining({ - targetLocales: ['fr-ca'], - }), - ); + expect(read(RESOURCE_ENTRIES_FILENAME, 'common').save.fr).toBe('Tout sauvegarder'); + expect(read(TRACKER_META_FILENAME, 'common').save.fr.status).toBe('translated'); }); - it('should skip verified and translated locales and only translate new/stale ones', async () => { - const mixedMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'base_hash', status: 'verified' }, - es: { checksum: 'es_hash', baseChecksum: 'base_hash', status: 'translated' }, - de: { checksum: 'de_hash', baseChecksum: 'base_hash', status: 'new' }, - }, - }; - const resourceEntries = { save: { source: 'Save', 'fr-ca': 'Sauvegarder', es: 'Guardar', de: 'Save' } }; - mockFileSystem(resourceEntries, mixedMeta); - - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [{ locale: 'de', value: 'Speichern', status: 'translated' }], - skippedLocales: [], - }); + it('returns the current entry and writes nothing when no locale needs work', async () => { + const target = collection({ locales: ['en', 'fr'] }); + seedResources(target, { 'common.save': { source: 'Save', translations: { fr: 'Sauvegarder' } } }); + const before = readFileSync(join(dir(), 'common', TRACKER_META_FILENAME), 'utf8'); + const provider = new InMemoryTranslationProvider(); - const result = await translateExistingResource(collection(['en', 'fr-ca', 'es', 'de']), 'buttons.save'); + const result = await translateExistingResource(target, 'common.save', { provider }); - expect(autoTranslateResource).toHaveBeenCalledWith( - expect.objectContaining({ - targetLocales: ['de'], - }), - ); - expect(result.translatedCount).toBe(1); - expect(result.entry.translations.de).toBe('Speichern'); + expect(result).toMatchObject({ translatedCount: 0, skippedLocales: [], mutations: [] }); + expect(result.entry.source).toBe('Save'); + expect(provider.calls).toEqual([]); + expect(readFileSync(join(dir(), 'common', TRACKER_META_FILENAME), 'utf8')).toBe(before); }); - it('should return skipped locales for ICU content', async () => { - const newLocaleMeta = { - plural: { - en: { checksum: 'base_hash' }, - 'fr-ca': { checksum: 'fr_hash', baseChecksum: 'base_hash', status: 'new' }, - es: { checksum: 'es_hash', baseChecksum: 'base_hash', status: 'new' }, - }, - }; - const icuValue = 'You have {count, plural, one {# item} other {# items}}'; - const resourceEntries = { plural: { source: icuValue, 'fr-ca': icuValue, es: icuValue } }; - mockFileSystem(resourceEntries, newLocaleMeta); - - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [], - skippedLocales: ['fr-ca', 'es'], - }); + it('reports complex ICU locales as skipped and leaves them untouched', async () => { + const target = collection(); + const plural = '{count, plural, one {# file} other {# files}}'; + seedSave(target, plural); + const provider = new InMemoryTranslationProvider(); - const result = await translateExistingResource(collection(['en', 'fr-ca', 'es']), 'messages.plural'); + const result = await translateExistingResource(target, 'common.save', { provider }); expect(result.translatedCount).toBe(0); - expect(result.skippedLocales).toEqual(['fr-ca', 'es']); - expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(result.skippedLocales).toEqual(['es', 'de']); + expect(result.mutations).toEqual([]); + expect(read(RESOURCE_ENTRIES_FILENAME, 'common').save.es).toBe(plural); }); - it('should write updated files when translations are applied', async () => { - const newLocaleMeta = { - save: { - en: { checksum: 'base_hash' }, - 'fr-ca': { checksum: 'save_hash', baseChecksum: 'base_hash', status: 'new' }, - }, - }; - mockFileSystem({ save: { source: 'Save', 'fr-ca': 'Save' } }, newLocaleMeta); - - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [{ locale: 'fr-ca', value: 'Sauvegarder', status: 'translated' }], - skippedLocales: [], - }); - - await translateExistingResource(collection(['en', 'fr-ca']), 'buttons.save'); - - const writeCalls = vi.mocked(fs.writeFileSync).mock.calls; - expect(writeCalls).toHaveLength(2); + it('skips a locale whose translation dropped a protected term', async () => { + const target = collection({ locales: ['en', 'fr', 'es'] }); + seedResources(target, { 'common.buy': { source: 'Buy an iPhone' } }); + const provider = new InMemoryTranslationProvider(({ targetLocale }) => + targetLocale === 'fr' ? 'Acheter un téléphone' : 'Comprar un iPhone', + ); - const updatedResources = JSON.parse(writeCalls[0][1] as string); - expect(updatedResources.save['fr-ca']).toBe('Sauvegarder'); + const result = await translateExistingResource(target, 'common.buy', { provider, protectedTerms: ['iPhone'] }); - const updatedMeta = JSON.parse(writeCalls[1][1] as string); - expect(updatedMeta.save['fr-ca'].status).toBe('translated'); - expect(updatedMeta.save['fr-ca'].baseChecksum).toBe('base_hash'); + expect(result.skippedLocales).toEqual(['fr']); + expect(read(RESOURCE_ENTRIES_FILENAME, 'common').buy).toEqual({ source: 'Buy an iPhone', es: 'Comprar un iPhone' }); }); - it('should pass the base value and base locale to autoTranslateResource', async () => { - const newLocaleMeta = { - greeting: { - en: { checksum: 'base_hash' }, - de: { checksum: 'de_hash', baseChecksum: 'base_hash', status: 'new' }, - }, - }; - mockFileSystem({ greeting: { source: 'Hello World', de: 'Hello World' } }, newLocaleMeta); - - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [{ locale: 'de', value: 'Hallo Welt', status: 'translated' }], - skippedLocales: [], - }); - - await translateExistingResource(collection(['en', 'de']), 'messages.greeting'); + it('stores values normalised to ICU, even when the stored base value is Transloco syntax', async () => { + const target = collection({ locales: ['en', 'fr'] }); + seedResources(target, { 'common.greet': { source: 'Hello {{ name }}' } }); + const provider = new InMemoryTranslationProvider(({ text }) => text.replace('Hello', 'Bonjour')); - expect(autoTranslateResource).toHaveBeenCalledWith({ - baseValue: 'Hello World', - baseLocale: 'en', - targetLocales: ['de'], - translationConfig: enabledTranslationConfig, - }); - }); + await translateExistingResource(target, 'common.greet', { provider }); - it('should throw a typed error when auto-translation is disabled', async () => { - await expect( - translateExistingResource(collection(['en', 'fr-ca'], { ...enabledTranslationConfig, enabled: false }), 'x.y'), - ).rejects.toThrow(AutoTranslationDisabledError); + expect(read(RESOURCE_ENTRIES_FILENAME, 'common').greet.fr).toBe('Bonjour {name}'); }); }); diff --git a/libs/core/src/lib/translation/translate-existing-resource.ts b/libs/core/src/lib/translation/translate-existing-resource.ts index e7b240ad..ede57663 100644 --- a/libs/core/src/lib/translation/translate-existing-resource.ts +++ b/libs/core/src/lib/translation/translate-existing-resource.ts @@ -5,10 +5,11 @@ import { AutoTranslationDisabledError, ResourceNotFoundError } from '../errors/l import { validateAndResolvePaths } from '../resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../resource/resource-folder'; import { type ResourceMutation, upsertMutation } from '../resource/resource-mutation'; -import { autoTranslateResource } from './auto-translate-resources'; +import { type OpenTranslatorOptions, openTranslator } from './translator'; export interface TranslateExistingResourceResult { readonly translatedCount: number; + /** Locales the Translator skipped (complex ICU, a lost placeholder, or a dropped protected term). */ readonly skippedLocales: string[]; readonly entry: ResourceTreeEntry; /** What changed on disk (empty when nothing was translated). */ @@ -16,23 +17,29 @@ export interface TranslateExistingResourceResult { } /** - * Auto-translates an existing resource entry of a collection, for every target locale - * that needs translation (no metadata, or status `new` or `stale`). + * Auto-translates an existing resource entry of a collection through the Translator, for every + * target locale that needs translation by the Staleness rule (no metadata, or status `new` or + * `stale`). Translated values are stored ICU-normalised with status `translated`; skipped locales + * are left as they are. * - * Returns early with `translatedCount: 0` when no locales require translation. + * Returns early with `translatedCount: 0` when no locales require translation, without opening the + * Translator (so without needing an API key). * * @param key - The entry's full key. + * @param options - `provider` / `protectedTerms`: used instead of the collection's (see {@link openTranslator}). * @throws {AutoTranslationDisabledError} The collection has no enabled translation config. * @throws {InvalidResourceKeyError} The key is malformed. * @throws {ResourceNotFoundError} No entry exists at the key. - * @throws {TranslationError} The translation provider failed. + * @throws {TranslationError} Some locale needs work and the API key is not set, or the provider failed. + * @throws {ProtectedTermsFileError} Some locale needs work and a protected-terms file is malformed. */ export async function translateExistingResource( collection: Collection, key: string, + options: OpenTranslatorOptions = {}, ): Promise { - const { translationConfig, baseLocale, translationsFolder } = collection; - if (!translationConfig?.enabled) { + const { baseLocale, translationsFolder } = collection; + if (!collection.translationConfig?.enabled) { throw new AutoTranslationDisabledError(collection.name); } @@ -57,29 +64,26 @@ export async function translateExistingResource( }; } - const { translations: translatedEntries, skippedLocales } = await autoTranslateResource({ - baseValue: entry.source, - baseLocale, + const { values, skipped } = await openTranslator(collection, options).translate( + [{ key: paths.resolvedKey, source: entry.source }], targetLocales, - translationConfig, - }); + ); - for (const { locale, value } of translatedEntries) { + for (const { locale, value } of values) { folder.setTranslation(paths.entryKey, locale, value, 'translated'); } - if (translatedEntries.length > 0) { + if (values.length > 0) { folder.save(); } const updatedEntry = requireTreeEntry(folder, paths.entryKey, paths.resolvedKey); return { - translatedCount: translatedEntries.length, - skippedLocales, + translatedCount: values.length, + skippedLocales: skipped.map(({ locale }) => locale), entry: updatedEntry, - mutations: - translatedEntries.length > 0 ? [upsertMutation(translationsFolder, paths.resolvedKey, updatedEntry)] : [], + mutations: values.length > 0 ? [upsertMutation(translationsFolder, paths.resolvedKey, updatedEntry)] : [], }; } diff --git a/libs/core/src/lib/translation/translate-locale.spec.ts b/libs/core/src/lib/translation/translate-locale.spec.ts index 298fb6fc..6514bd99 100644 --- a/libs/core/src/lib/translation/translate-locale.spec.ts +++ b/libs/core/src/lib/translation/translate-locale.spec.ts @@ -1,156 +1,40 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { TranslationError } from './translation-provider'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; import type { TranslationConfig } from '../../config/translation-config'; -import type { TranslateTextResult } from './translation-orchestrator'; -import type { ResourceTreeNode } from '../resource/load-resource-tree'; - -// --------------------------------------------------------------------------- -// Module mocks — must be declared before any imports that reference them -// --------------------------------------------------------------------------- - -vi.mock('../resource/load-resource-tree'); -vi.mock('../resource/extract-subtree'); -vi.mock('../file-io/json-file-operations'); -vi.mock('./translation-provider-factory'); -vi.mock('./translation-orchestrator'); -// ResourceFolder only reads files that exist. Only the resource file pair "exists"; the mocked readers above -// supply its contents. -vi.mock('node:fs', async (importOriginal) => ({ - ...(await importOriginal()), - existsSync: vi.fn((filePath: unknown) => /(^|[\\/])(resource_entries|tracker_meta)\.json$/.test(String(filePath))), -})); - -import { loadResourceTree } from '../resource/load-resource-tree'; -import { extractResourcesRecursively } from '../resource/extract-subtree'; -import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../file-io/json-file-operations'; -import { createTranslationProvider } from './translation-provider-factory'; -import { TranslationOrchestrator } from './translation-orchestrator'; -import { translateLocale } from './translate-locale'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const EMPTY_TREE: ResourceTreeNode = { - folderPathSegments: [], - resources: [], - children: [], -}; +import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants'; +import { seedResources, testCollection, useTempDir, writeFolderFiles } from '../../testing/temp-dir.spec-helpers'; +import type { Collection } from '../config/open-collection'; +import { openResourceFolder } from '../resource/resource-folder'; +import { InMemoryTranslationProvider } from './in-memory-translation-provider'; +import { type TranslateLocaleProgress, translateLocale } from './translate-locale'; +import { TranslationError } from './translation-provider'; -const BASE_CONFIG: TranslationConfig = { +const AUTO: TranslationConfig = { enabled: true, provider: 'google-translate', - apiKeyEnv: 'GOOGLE_TRANSLATE_API_KEY', - batchSize: 5, - delayMs: 0, // no delay in tests + apiKeyEnv: 'TRANSLATE_LOCALE_SPEC_KEY', + delayMs: 0, }; -function translated(value: string): TranslateTextResult { - return { kind: 'translated', value }; -} - -function skipped(value: string): TranslateTextResult { - return { kind: 'skipped', value }; -} - -function makeResource( - key: string, - source: string, - targetLocaleStatus?: 'new' | 'stale' | 'translated' | 'verified', - targetLocale = 'fr', -) { - return { - key, - source, - translations: {}, - metadata: targetLocaleStatus - ? { - [targetLocale]: { checksum: 'abc', status: targetLocaleStatus }, - en: { checksum: '123' }, - } - : { en: { checksum: '123' } }, - }; -} - -// --------------------------------------------------------------------------- -// Setup -// --------------------------------------------------------------------------- - -const mockTranslateBatchForLocale = vi.fn(); - -beforeEach(() => { - vi.clearAllMocks(); - - process.env.GOOGLE_TRANSLATE_API_KEY = 'test-api-key'; - - vi.mocked(loadResourceTree).mockReturnValue(EMPTY_TREE); - vi.mocked(extractResourcesRecursively).mockReturnValue([]); - - vi.mocked(createTranslationProvider).mockReturnValue({ - translate: vi.fn(), - getCapabilities: vi.fn(), - }); - - // Vitest 4 requires a constructable implementation for `new TranslationOrchestrator(...)`. - // biome-ignore lint/complexity/useArrowFunction: this mock must remain constructable - vi.mocked(TranslationOrchestrator).mockImplementation(function () { - return { - translateBatchForLocale: mockTranslateBatchForLocale, - } as unknown as TranslationOrchestrator; - }); +describe('translateLocale', () => { + const dir = useTempDir('translate-locale-'); - // Default mocks return generic entries so any entryKey resolves correctly. - vi.mocked(readResourceEntries).mockReturnValue({ - ok: { source: 'OK' }, - cancel: { source: 'Cancel' }, - a: { source: 'A' }, - b: { source: 'B' }, - c: { source: 'C' }, - key0: { source: 'Text 0' }, - key1: { source: 'Text 1' }, - key2: { source: 'Text 2' }, - key3: { source: 'Text 3' }, - key4: { source: 'Text 4' }, - key5: { source: 'Text 5' }, - } as ReturnType); - vi.mocked(readTrackerMetadata).mockReturnValue({ - ok: { en: { checksum: '123' } }, - cancel: { en: { checksum: '456' } }, - a: { en: { checksum: '111' } }, - b: { en: { checksum: '222' } }, - c: { en: { checksum: '333' } }, - }); - vi.mocked(writeJsonFile).mockImplementation(() => undefined); -}); + function collection(overrides: Partial = {}): Collection { + return testCollection(dir(), { locales: ['en', 'fr'], translationConfig: AUTO, ...overrides }); + } -afterEach(() => { - delete process.env.GOOGLE_TRANSLATE_API_KEY; -}); + function withBatchSize(batchSize: number): Collection { + return collection({ translationConfig: { ...AUTO, batchSize } }); + } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- + function read(file: string, ...segments: string[]) { + return JSON.parse(readFileSync(join(dir(), ...segments, file), 'utf8')); + } -describe('translateLocale', () => { - const defaultParams = { - translationsFolder: 'src/i18n', - translationConfig: BASE_CONFIG, - targetLocale: 'fr', - baseLocale: 'en', - allLocales: ['en', 'fr'], - cwd: '/project', - }; - - // ------------------------------------------------------------------------- - // Early exit — nothing to translate - // ------------------------------------------------------------------------- - - describe('early exit when nothing needs translating', () => { - it('returns zeroed result when there are no resources at all', async () => { - vi.mocked(extractResourcesRecursively).mockReturnValue([]); - const onProgress = vi.fn(); - - const result = await translateLocale({ ...defaultParams, onProgress }); + describe('when nothing needs translating', () => { + it('returns zeros for an empty collection, without an API key', async () => { + const result = await translateLocale(collection(), { targetLocale: 'fr' }); expect(result).toEqual({ totalResources: 0, @@ -159,271 +43,175 @@ describe('translateLocale', () => { skippedCount: 0, failures: [], skippedKeys: [], + warnings: [], }); - expect(mockTranslateBatchForLocale).not.toHaveBeenCalled(); - expect(onProgress).not.toHaveBeenCalled(); }); - it('returns zeroed result when all resources are already translated', async () => { - vi.mocked(extractResourcesRecursively).mockReturnValue([ - makeResource('apps.ok', 'OK', 'translated'), - makeResource('apps.cancel', 'Cancel', 'verified'), - ]); - const onProgress = vi.fn(); - - const result = await translateLocale({ ...defaultParams, onProgress }); - - expect(result.totalResources).toBe(0); - expect(mockTranslateBatchForLocale).not.toHaveBeenCalled(); - expect(onProgress).not.toHaveBeenCalled(); - }); - - it('skips resources with verified status', async () => { - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('apps.ok', 'OK', 'verified')]); - const onProgress = vi.fn(); + it('returns zeros when every resource is translated or verified', async () => { + seedResources(collection(), { + ok: { source: 'OK', translations: { fr: 'OK fr' } }, + cancel: { source: 'Cancel', translations: { fr: { value: 'Annuler', status: 'verified' } } }, + }); - const result = await translateLocale({ ...defaultParams, onProgress }); + const result = await translateLocale(collection(), { targetLocale: 'fr' }); expect(result.totalResources).toBe(0); - expect(mockTranslateBatchForLocale).not.toHaveBeenCalled(); - expect(onProgress).not.toHaveBeenCalled(); }); }); - // ------------------------------------------------------------------------- - // Status filtering - // ------------------------------------------------------------------------- - - describe('status filtering', () => { - it('translates resources with new status', async () => { - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'new')]); - mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]); - - const result = await translateLocale(defaultParams); - - expect(result.totalResources).toBe(1); - expect(result.translatedCount).toBe(1); - }); - - it('translates resources with stale status', async () => { - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'stale')]); - mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]); - - const result = await translateLocale(defaultParams); - - expect(result.totalResources).toBe(1); - expect(result.translatedCount).toBe(1); - }); - - it('translates resources with no metadata for the target locale', async () => { - // makeResource without a status means no 'fr' metadata entry. - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK')]); - mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]); - - const result = await translateLocale(defaultParams); + it('translates new, stale and metadata-less resources, and leaves translated and verified ones', async () => { + const target = collection(); + seedResources(target, { + fresh: { source: 'Fresh', translations: { fr: { value: 'Fresh', status: 'new' } } }, + done: { source: 'Done', translations: { fr: 'Fait' } }, + checked: { source: 'Checked', translations: { fr: { value: 'Vérifié', status: 'verified' } } }, + missing: { source: 'Missing' }, + old: { source: 'Old', translations: { fr: 'Vieux' } }, + }); + const folder = openResourceFolder(dir(), { baseLocale: 'en' }); + folder.setBase('old', 'Older'); + folder.save(); + const provider = new InMemoryTranslationProvider(); + + const result = await translateLocale(target, { targetLocale: 'fr', provider }); + + expect(result).toMatchObject({ totalResources: 3, translatedCount: 3, failedCount: 0, skippedCount: 0 }); + const entries = read(RESOURCE_ENTRIES_FILENAME); + expect(entries.fresh.fr).toBe('[fr] Fresh'); + expect(entries.missing.fr).toBe('[fr] Missing'); + expect(entries.old.fr).toBe('[fr] Older'); + expect(entries.done.fr).toBe('Fait'); + expect(entries.checked.fr).toBe('Vérifié'); + const meta = read(TRACKER_META_FILENAME); + expect(meta.fresh.fr.status).toBe('translated'); + expect(meta.old.fr.status).toBe('translated'); + expect(meta.checked.fr.status).toBe('verified'); + }); - expect(result.totalResources).toBe(1); - expect(result.translatedCount).toBe(1); + it('writes each folder it translates into, with values normalised to ICU', async () => { + const target = collection(); + seedResources(target, { + 'dialogs.greet': { source: 'Hello {{ name }}' }, + 'buttons.ok': { source: 'OK' }, }); + const provider = new InMemoryTranslationProvider(({ text }) => text.replace('Hello', 'Bonjour')); - it('does not translate resources with translated status', async () => { - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'translated')]); - - const result = await translateLocale(defaultParams); + const result = await translateLocale(target, { targetLocale: 'fr', provider }); - expect(result.totalResources).toBe(0); - expect(mockTranslateBatchForLocale).not.toHaveBeenCalled(); - }); + expect(result.translatedCount).toBe(2); + expect(read(RESOURCE_ENTRIES_FILENAME, 'dialogs').greet.fr).toBe('Bonjour {name}'); + expect(read(RESOURCE_ENTRIES_FILENAME, 'buttons').ok.fr).toBe('OK'); }); - // ------------------------------------------------------------------------- - // Batching - // ------------------------------------------------------------------------- - - describe('batch processing', () => { - it('calls onProgress for each batch', async () => { - const resources = [makeResource('a', 'A', 'new'), makeResource('b', 'B', 'new'), makeResource('c', 'C', 'new')]; - vi.mocked(extractResourcesRecursively).mockReturnValue(resources); + describe('batches', () => { + it('sends one provider call per batch of batchSize, and reports progress after each', async () => { + seedResources(collection(), { a: { source: 'A' }, b: { source: 'B' }, c: { source: 'C' } }); + const provider = new InMemoryTranslationProvider(); + const progress: TranslateLocaleProgress[] = []; - mockTranslateBatchForLocale - .mockResolvedValueOnce([translated('A-fr'), translated('B-fr')]) - .mockResolvedValueOnce([translated('C-fr')]); - - const progressEvents: number[] = []; - await translateLocale({ - ...defaultParams, - translationConfig: { ...BASE_CONFIG, batchSize: 2, delayMs: 0 }, - onProgress: (p) => progressEvents.push(p.currentBatch), + await translateLocale(withBatchSize(2), { + targetLocale: 'fr', + provider, + onProgress: (event) => progress.push(event), }); - expect(progressEvents).toEqual([1, 2]); + expect(provider.calls.map((call) => call.map(({ text }) => text))).toEqual([['A', 'B'], ['C']]); + expect(progress).toEqual([ + { totalResources: 3, translatedCount: 2, failedCount: 0, skippedCount: 0, currentBatch: 1, totalBatches: 2 }, + { totalResources: 3, translatedCount: 3, failedCount: 0, skippedCount: 0, currentBatch: 2, totalBatches: 2 }, + ]); }); - it('respects batchSize when calling the orchestrator', async () => { - const resources = Array.from({ length: 6 }, (_, i) => makeResource(`key${i}`, `Text ${i}`, 'new')); - vi.mocked(extractResourcesRecursively).mockReturnValue(resources); - - mockTranslateBatchForLocale.mockResolvedValue([translated('t1'), translated('t2'), translated('t3')]); - - await translateLocale({ - ...defaultParams, - translationConfig: { ...BASE_CONFIG, batchSize: 3, delayMs: 0 }, + it('marks every resource of a failed batch as failed and continues with the next batch', async () => { + seedResources(collection(), { a: { source: 'A' }, b: { source: 'B' }, c: { source: 'C' } }); + let calls = 0; + const provider = new InMemoryTranslationProvider(({ text }) => { + if (calls++ === 0) { + throw new TranslationError('quota exceeded', 'RATE_LIMIT', true); + } + return `${text}-fr`; }); - expect(mockTranslateBatchForLocale).toHaveBeenCalledTimes(2); - }); + const result = await translateLocale(withBatchSize(2), { targetLocale: 'fr', provider }); - it('reports accurate progress counts in onProgress', async () => { - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'new')]); - mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]); - - let capturedProgress: Parameters>[0] | undefined; - - await translateLocale({ - ...defaultParams, - onProgress: (p) => { - capturedProgress = p; - }, - }); - - expect(capturedProgress).toMatchObject({ - totalResources: 1, - translatedCount: 1, - failedCount: 0, - skippedCount: 0, - currentBatch: 1, - totalBatches: 1, - }); + expect(result.failedCount).toBe(2); + expect(result.failures).toEqual([ + { key: 'a', error: 'quota exceeded' }, + { key: 'b', error: 'quota exceeded' }, + ]); + expect(result.translatedCount).toBe(1); + expect(read(RESOURCE_ENTRIES_FILENAME).c.fr).toBe('C-fr'); + expect(read(RESOURCE_ENTRIES_FILENAME).a.fr).toBeUndefined(); }); }); - // ------------------------------------------------------------------------- - // ICU skipping - // ------------------------------------------------------------------------- + describe('skips', () => { + it('reports complex ICU resources in skippedKeys and leaves them untouched', async () => { + const plural = '{count, plural, one {# item} other {# items}}'; + seedResources(collection(), { items: { source: plural }, ok: { source: 'OK' } }); + const provider = new InMemoryTranslationProvider(); - describe('ICU skipping', () => { - it('adds skipped-ICU resource keys to skippedKeys', async () => { - const icuSource = '{count, plural, one {# item} other {# items}}'; - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', icuSource, 'new')]); - mockTranslateBatchForLocale.mockResolvedValueOnce([skipped(icuSource)]); + const result = await translateLocale(collection(), { targetLocale: 'fr', provider }); - const result = await translateLocale(defaultParams); - - expect(result.skippedKeys).toContain('ok'); - expect(result.skippedCount).toBe(1); - expect(result.translatedCount).toBe(0); + expect(result).toMatchObject({ translatedCount: 1, skippedCount: 1, skippedKeys: ['items'] }); + expect(read(RESOURCE_ENTRIES_FILENAME).items.fr).toBeUndefined(); }); - it('does not call writeJsonFile for skipped resources', async () => { - const icuSource = '{count, plural, one {# item} other {# items}}'; - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', icuSource, 'new')]); - mockTranslateBatchForLocale.mockResolvedValueOnce([skipped(icuSource)]); + it('reports a translation that dropped a protected term in skippedKeys', async () => { + const target = collection(); + seedResources(target, { buy: { source: 'Buy an iPhone' }, ok: { source: 'OK' } }); + const provider = new InMemoryTranslationProvider(({ text }) => text.replace('iPhone', 'téléphone')); - await translateLocale(defaultParams); + const result = await translateLocale(target, { targetLocale: 'fr', provider, protectedTerms: ['iPhone'] }); - expect(writeJsonFile).not.toHaveBeenCalled(); + expect(result).toMatchObject({ translatedCount: 1, skippedCount: 1, skippedKeys: ['buy'] }); + expect(read(RESOURCE_ENTRIES_FILENAME).buy.fr).toBeUndefined(); }); - it('skips writing and increments skippedCount when entryKey is missing from disk entries', async () => { - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'new')]); - mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]); - - // Return an empty object — the 'ok' entry is absent from disk - vi.mocked(readResourceEntries).mockReturnValue({} as ReturnType); + it('skips a resource whose entry was removed from disk while it was being translated', async () => { + seedResources(collection(), { ok: { source: 'OK' } }); + const provider = new InMemoryTranslationProvider(({ text }) => { + const folder = openResourceFolder(dir(), { baseLocale: 'en' }); + folder.remove('ok'); + folder.save(); + return text; + }); - const result = await translateLocale(defaultParams); + const result = await translateLocale(collection(), { targetLocale: 'fr', provider }); - expect(result.skippedCount).toBe(1); - expect(result.translatedCount).toBe(0); - expect(writeJsonFile).not.toHaveBeenCalled(); + expect(result).toMatchObject({ translatedCount: 0, skippedCount: 1, skippedKeys: ['ok'] }); }); }); - // ------------------------------------------------------------------------- - // File I/O - // ------------------------------------------------------------------------- - - describe('file I/O', () => { - it('writes entries and meta files once per unique folder', async () => { - // Both resources share the same root folder — grouped write produces 2 files. - vi.mocked(extractResourcesRecursively).mockReturnValue([ - makeResource('ok', 'OK', 'new'), - makeResource('cancel', 'Cancel', 'new'), - ]); - mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr'), translated('Annuler')]); - - await translateLocale(defaultParams); + it('does not translate a folder the Collection Reader cannot read, and reports it in warnings', async () => { + writeFolderFiles(dir(), 'broken', { entries: '{ not json' }); + seedResources(collection(), { ok: { source: 'OK' } }); - // Two resources in the same folder → one read-modify-write cycle → 2 file writes. - expect(writeJsonFile).toHaveBeenCalledTimes(2); + const result = await translateLocale(collection(), { + targetLocale: 'fr', + provider: new InMemoryTranslationProvider(), }); - it('writes entries and meta files once per unique folder across multiple folders', async () => { - // Resources in two different folders each get their own write cycle. - vi.mocked(extractResourcesRecursively).mockReturnValue([ - makeResource('folderA.ok', 'OK', 'new'), - makeResource('folderB.cancel', 'Cancel', 'new'), - ]); - mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr'), translated('Annuler')]); - - await translateLocale(defaultParams); - - // Two distinct folders → 2 read-modify-write cycles → 4 file writes. - expect(writeJsonFile).toHaveBeenCalledTimes(4); - }); + expect(result).toMatchObject({ totalResources: 1, translatedCount: 1 }); + expect(result.warnings).toEqual([expect.stringContaining("Folder 'broken' was not translated:")]); + expect(result.warnings[0]).toContain(RESOURCE_ENTRIES_FILENAME); }); - // ------------------------------------------------------------------------- - // Error handling - // ------------------------------------------------------------------------- - - describe('error handling', () => { - it('throws TranslationError with MISSING_API_KEY when the env var is absent', async () => { - delete process.env.GOOGLE_TRANSLATE_API_KEY; - vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'new')]); - - await expect(translateLocale(defaultParams)).rejects.toMatchObject({ - code: 'MISSING_API_KEY', - retryable: false, - }); - }); - - it('records all resources in a failed batch as failures and continues', async () => { - const resources = [makeResource('a', 'A', 'new'), makeResource('b', 'B', 'new'), makeResource('c', 'C', 'new')]; - vi.mocked(extractResourcesRecursively).mockReturnValue(resources); - - const providerError = new TranslationError('quota exceeded', 'RATE_LIMIT', true); + it('reports unreadable folders in warnings even when nothing needs translating', async () => { + writeFolderFiles(dir(), 'broken', { entries: '{ not json' }); - mockTranslateBatchForLocale - .mockRejectedValueOnce(providerError) // batch 1 fails - .mockResolvedValueOnce([translated('C-fr')]); // batch 2 succeeds + const result = await translateLocale(collection(), { targetLocale: 'fr' }); - const result = await translateLocale({ - ...defaultParams, - translationConfig: { ...BASE_CONFIG, batchSize: 2, delayMs: 0 }, - }); - - expect(result.failedCount).toBe(2); - expect(result.translatedCount).toBe(1); - expect(result.failures).toHaveLength(2); - expect(result.failures[0].key).toBe('a'); - expect(result.failures[1].key).toBe('b'); - }); - - it('continues processing subsequent batches after a batch failure', async () => { - const resources = [makeResource('a', 'A', 'new'), makeResource('b', 'B', 'new')]; - vi.mocked(extractResourcesRecursively).mockReturnValue(resources); + expect(result.totalResources).toBe(0); + expect(result.warnings).toHaveLength(1); + }); - const providerError = new TranslationError('fail', 'SERVER_ERROR', true); - mockTranslateBatchForLocale.mockRejectedValueOnce(providerError).mockResolvedValueOnce([translated('B-fr')]); + it('throws MISSING_API_KEY when there is work and no provider is injected', async () => { + seedResources(collection(), { ok: { source: 'OK' } }); - const result = await translateLocale({ - ...defaultParams, - translationConfig: { ...BASE_CONFIG, batchSize: 1, delayMs: 0 }, - }); - - expect(result.failedCount).toBe(1); - expect(result.translatedCount).toBe(1); + await expect(translateLocale(collection(), { targetLocale: 'fr' })).rejects.toMatchObject({ + code: 'MISSING_API_KEY', + retryable: false, }); }); }); diff --git a/libs/core/src/lib/translation/translate-locale.ts b/libs/core/src/lib/translation/translate-locale.ts index 4977c615..6c744167 100644 --- a/libs/core/src/lib/translation/translate-locale.ts +++ b/libs/core/src/lib/translation/translate-locale.ts @@ -1,38 +1,31 @@ /** * Bulk locale translation. * - * Translates all `new` and `stale` resources in a translations folder for a - * single target locale. Resources are processed in batches so that the number - * of API calls is bounded regardless of how many resources exist. + * Translates every resource of a collection that needs work for one target locale (the Staleness + * rule: status `new` or `stale`, or no metadata for the locale) through the Translator. Resources + * are sent in batches so that the number of provider calls is bounded regardless of how many + * resources exist. * - * Complex ICU resources (plural, select, etc.) are automatically skipped by - * the underlying {@link TranslationOrchestrator} and reported in `skippedKeys`. + * Resources the Translator skips (complex ICU, a lost placeholder, a dropped protected term) are + * reported in `skippedKeys` and left as they are. * * @module translate-locale */ -import * as path from 'node:path'; -import { loadResourceTree } from '../resource/load-resource-tree'; -import { extractResourcesRecursively } from '../resource/extract-subtree'; +import { needsTranslation } from '@simoncodes-ca/domain'; +import type { Collection } from '../config/open-collection'; +import { readCollection } from '../resource/read-collection'; import { resolveResourcePaths } from '../resource/resource-file-paths'; import { openResourceFolder } from '../resource/resource-folder'; -import { needsTranslation } from '@simoncodes-ca/domain'; -import { createTranslationProvider } from './translation-provider-factory'; -import { TranslationOrchestrator } from './translation-orchestrator'; -import { TranslationError } from './translation-provider'; -import type { TranslationConfig } from '../../config/translation-config'; +import { type OpenTranslatorOptions, openTranslator, type TranslatedValue } from './translator'; // --------------------------------------------------------------------------- // Public interfaces // --------------------------------------------------------------------------- -export interface TranslateLocaleParams { - readonly translationsFolder: string; - readonly translationConfig: TranslationConfig; +export interface TranslateLocaleParams extends OpenTranslatorOptions { + /** One of the collection's target locales. */ readonly targetLocale: string; - readonly baseLocale: string; - readonly allLocales: readonly string[]; - readonly cwd?: string; readonly onProgress?: (progress: TranslateLocaleProgress) => void; } @@ -62,6 +55,8 @@ export interface TranslateLocaleResult { readonly skippedCount: number; readonly failures: ReadonlyArray<{ key: string; error: string }>; readonly skippedKeys: string[]; + /** One line per folder the Collection Reader could not read (its resources were not translated). */ + readonly warnings: string[]; } // --------------------------------------------------------------------------- @@ -72,49 +67,26 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -// --------------------------------------------------------------------------- -// Internal types -// --------------------------------------------------------------------------- - -interface FolderWriteEntry { - readonly entryKey: string; - readonly result: { kind: string; value: string }; - readonly resourceKey: string; -} - -// --------------------------------------------------------------------------- -// Internal helpers (continued) -// --------------------------------------------------------------------------- - /** - * Reads the entries and metadata files for a folder once, applies all - * translated resources for that folder, and writes both files back once. - * Resources whose entry key is missing from disk are silently skipped. - * - * @returns How many entries were actually written (missing entries are 0). + * Opens a folder once, stores every translated value for it, and saves once. + * Values whose entry is no longer on disk are not written. */ -function writeTranslatedResources( +function writeTranslatedValues( folderPath: string, - entries: readonly FolderWriteEntry[], - targetLocale: string, + values: readonly { readonly entryKey: string; readonly value: TranslatedValue }[], baseLocale: string, -): { - writtenKeys: string[]; - skippedKeys: string[]; -} { +): { writtenKeys: string[]; skippedKeys: string[] } { const folder = openResourceFolder(folderPath, { baseLocale }); - const writtenKeys: string[] = []; const skippedKeys: string[] = []; - for (const entry of entries) { - if (!folder.has(entry.entryKey)) { - skippedKeys.push(entry.resourceKey); + for (const { entryKey, value } of values) { + if (!folder.has(entryKey)) { + skippedKeys.push(value.key); continue; } - - folder.setTranslation(entry.entryKey, targetLocale, entry.result.value, 'translated'); - writtenKeys.push(entry.resourceKey); + folder.setTranslation(entryKey, value.locale, value.value, 'translated'); + writtenKeys.push(value.key); } if (writtenKeys.length > 0) { @@ -129,32 +101,40 @@ function writeTranslatedResources( // --------------------------------------------------------------------------- /** - * Translates all `new` and `stale` resources in `translationsFolder` for the - * given `targetLocale`, writing the results back to disk. + * Translates every resource of `collection` that needs work for `targetLocale`, writing the + * results back to disk with status `translated` (values ICU-normalised by the Translator). * - * Resources are processed in batches of `translationConfig.batchSize` (default 5). - * A configurable delay (`translationConfig.delayMs`, default 1000 ms) is inserted - * between batches to avoid hitting provider rate limits. + * Resources are read with the Collection Reader (a folder it cannot read is not translated and is + * reported in `warnings`) and + * processed in batches of `translationConfig.batchSize` (default 5). A configurable delay + * (`translationConfig.delayMs`, default 1000 ms) is inserted between batches to avoid hitting + * provider rate limits. * - * Complex ICU messages (plural, select, etc.) are silently skipped and their keys - * are included in `TranslateLocaleResult.skippedKeys`. Provider-level errors mark - * all resources in the failing batch as failed but do not abort the run. + * When nothing needs translation, returns zeros without opening the Translator (so without + * needing an API key). Skipped resources are listed in `skippedKeys`. A provider error marks + * every resource in the failing batch as failed but does not abort the run. * - * @param params - Translation parameters. + * @param collection - The opened collection. + * @param params - The target locale, an optional progress callback, and optional `provider` / + * `protectedTerms` to use instead of the collection's (see {@link openTranslator}). * @returns A summary of how many resources were translated, skipped, or failed. - * @throws {TranslationError} with code `MISSING_API_KEY` when the env var is absent. + * @throws {AutoTranslationDisabledError} There is work and the collection has no enabled translation config. + * @throws {TranslationError} There is work, no provider was injected, and the API key env var is unset + * (`MISSING_API_KEY`). + * @throws {ProtectedTermsFileError} There is work and a protected-terms file is malformed. */ -export async function translateLocale(params: TranslateLocaleParams): Promise { - const { translationConfig, targetLocale, baseLocale, cwd = process.cwd(), onProgress } = params; - - const absoluteFolder = path.resolve(cwd, params.translationsFolder); - - // Load the entire resource tree. - const tree = loadResourceTree({ translationsFolder: absoluteFolder, baseLocale, depth: 999, cwd }); - const allResources = extractResourcesRecursively(tree); - - // Filter to only those that need translating for the target locale. - const resourcesToTranslate = allResources.filter((resource) => needsTranslation(resource.metadata[targetLocale])); +export async function translateLocale( + collection: Collection, + params: TranslateLocaleParams, +): Promise { + const { targetLocale, onProgress } = params; + const { baseLocale, translationsFolder } = collection; + + const { resources, problems } = readCollection(collection); + const warnings = problems.map( + ({ folderPath, message }) => `Folder '${folderPath || '(root)'}' was not translated: ${message}`, + ); + const resourcesToTranslate = resources.filter((resource) => needsTranslation(resource.entry.metadata[targetLocale])); if (resourcesToTranslate.length === 0) { return { @@ -164,24 +144,14 @@ export async function translateLocale(params: TranslateLocaleParams): Promise resource.source); try { - const batchResults = await orchestrator.translateBatchForLocale(sourceTexts, baseLocale, targetLocale); - - // Group translated results by folder so each folder's files are read and - // written only once, even when multiple resources share the same folder. - const byFolder = new Map(); - - for (let i = 0; i < batch.length; i++) { - const resource = batch[i]; - const result = batchResults[i]; - - if (result.kind === 'skipped') { - skippedKeys.push(resource.key); - skippedCount++; - continue; - } + const { values, skipped } = await translator.translate( + batch.map((resource) => ({ key: resource.fullKey, source: resource.entry.source })), + [targetLocale], + ); + + for (const { key } of skipped) { + skippedKeys.push(key); + skippedCount++; + } - const { folderPath, entryKey } = resolveResourcePaths({ - key: resource.key, - translationsFolder: absoluteFolder, - }); - const folderEntries = byFolder.get(folderPath) ?? []; - folderEntries.push({ entryKey, result, resourceKey: resource.key }); - byFolder.set(folderPath, folderEntries); + // Group by folder so each folder's files are read and written only once per batch. + const byFolder = new Map(); + for (const value of values) { + const { folderPath, entryKey } = resolveResourcePaths({ key: value.key, translationsFolder }); + const folderValues = byFolder.get(folderPath) ?? []; + folderValues.push({ entryKey, value }); + byFolder.set(folderPath, folderValues); } - for (const [folderPath, folderEntries] of byFolder) { - const { writtenKeys, skippedKeys: folderSkippedKeys } = writeTranslatedResources( - folderPath, - folderEntries, - targetLocale, - baseLocale, - ); - translatedCount += writtenKeys.length; - skippedCount += folderSkippedKeys.length; - skippedKeys.push(...folderSkippedKeys); + for (const [folderPath, folderValues] of byFolder) { + const written = writeTranslatedValues(folderPath, folderValues, baseLocale); + translatedCount += written.writtenKeys.length; + skippedCount += written.skippedKeys.length; + skippedKeys.push(...written.skippedKeys); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); for (const resource of batch) { - failures.push({ key: resource.key, error: errorMessage }); + failures.push({ key: resource.fullKey, error: errorMessage }); failedCount++; } } @@ -257,5 +215,5 @@ export async function translateLocale(params: TranslateLocaleParams): Promise } { - return { - translate: vi.fn(), - getCapabilities: vi.fn().mockReturnValue({ - supportsBatch: true, - maxBatchSize: 128, - supportsFormality: false, - }), - }; -} - -/** - * Builds a minimal TranslateResult for a translated string. - */ -function makeResult(translatedText: string): TranslateResult { - return { translatedText, provider: 'mock' }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('TranslationOrchestrator', () => { - let provider: ReturnType; - let orchestrator: TranslationOrchestrator; - - beforeEach(() => { - provider = makeProvider(); - orchestrator = new TranslationOrchestrator(provider); - }); - - // ------------------------------------------------------------------------- - // translateText — plain text (no ICU) - // ------------------------------------------------------------------------- - - describe('translateText — plain text (no ICU placeholders)', () => { - it('returns kind: translated with the provider result', async () => { - provider.translate.mockResolvedValue([makeResult('Hallo Welt')]); - - const result = await orchestrator.translateText('Hello World', 'en', 'de'); - - expect(result).toEqual({ kind: 'translated', value: 'Hallo Welt' }); - expect(provider.translate).toHaveBeenCalledOnce(); - expect(provider.translate).toHaveBeenCalledWith([ - { text: 'Hello World', sourceLocale: 'en', targetLocale: 'de' }, - ]); - }); - - it('forwards an empty string to the provider', async () => { - provider.translate.mockResolvedValue([makeResult('')]); - - const result = await orchestrator.translateText('', 'en', 'de'); - - expect(result).toEqual({ kind: 'translated', value: '' }); - expect(provider.translate).toHaveBeenCalledWith([{ text: '', sourceLocale: 'en', targetLocale: 'de' }]); - }); - }); - - // ------------------------------------------------------------------------- - // translateText — complex ICU (skipped, returned as-is) - // ------------------------------------------------------------------------- - - describe('translateText — complex ICU placeholders', () => { - it('returns kind: skipped without calling the provider for a plural block', async () => { - const input = 'You have {count, plural, one {# item} other {# items}} in cart'; - - const result = await orchestrator.translateText(input, 'en', 'de'); - - expect(result).toEqual({ kind: 'skipped', value: input }); - expect(provider.translate).not.toHaveBeenCalled(); - }); - - it('returns kind: skipped without calling the provider for a standalone plural block', async () => { - const input = '{count, plural, one {# item} other {# items}}'; - - const result = await orchestrator.translateText(input, 'en', 'de'); - - expect(result).toEqual({ kind: 'skipped', value: input }); - expect(provider.translate).not.toHaveBeenCalled(); - }); - - it('returns kind: skipped without calling the provider for a select block', async () => { - const input = '{gender, select, male {he} female {she} other {they}}'; - - const result = await orchestrator.translateText(input, 'en', 'de'); - - expect(result).toEqual({ kind: 'skipped', value: input }); - expect(provider.translate).not.toHaveBeenCalled(); - }); - - it('returns kind: skipped for mixed simple + complex ICU without calling the provider', async () => { - const input = 'Hello {name}, {count, plural, one {# item} other {# items}}'; - - const result = await orchestrator.translateText(input, 'en', 'de'); - - expect(result).toEqual({ kind: 'skipped', value: input }); - expect(provider.translate).not.toHaveBeenCalled(); - }); - }); - - // ------------------------------------------------------------------------- - // translateText — simple placeholders (marker-based translation) - // ------------------------------------------------------------------------- - - describe('translateText — simple ICU placeholders', () => { - it('returns kind: translated-with-placeholders for a single {name} placeholder', async () => { - // The provider receives the marker-protected text and returns a translated - // version with the marker still present. - provider.translate.mockImplementation(async (requests: TranslateRequest[]) => { - // Simulate provider translating surrounding text but leaving span intact. - const translated = requests[0].text.replace('Hello', 'Hallo'); - return [makeResult(translated)]; - }); - - const result = await orchestrator.translateText('Hello {name}', 'en', 'de'); - - expect(result.kind).toBe('translated-with-placeholders'); - expect(result.value).toBe('Hallo {name}'); - expect(provider.translate).toHaveBeenCalledOnce(); - }); - - it('returns kind: translated-with-placeholders for a Transloco {{ name }} placeholder', async () => { - provider.translate.mockImplementation(async (requests: TranslateRequest[]) => { - const translated = requests[0].text.replace('Hello', 'Hallo'); - return [makeResult(translated)]; - }); - - const result = await orchestrator.translateText('Hello {{ name }}', 'en', 'de'); - - expect(result.kind).toBe('translated-with-placeholders'); - expect(result.value).toBe('Hallo {{ name }}'); - }); - - it('restores multiple placeholders after translation', async () => { - provider.translate.mockImplementation(async (requests: TranslateRequest[]) => { - // Translate the surrounding text while preserving spans. - const translated = requests[0].text.replace('File', 'Datei').replace('is newer than', 'ist neuer als'); - return [makeResult(translated)]; - }); - - const result = await orchestrator.translateText('File {fileA} is newer than {fileB}', 'en', 'de'); - - expect(result.kind).toBe('translated-with-placeholders'); - expect(result.value).toBe('Datei {fileA} ist neuer als {fileB}'); - }); - - it('sends the marker-protected text (with span wrappers) to the provider', async () => { - provider.translate.mockImplementation(async (requests: TranslateRequest[]) => { - return [makeResult(requests[0].text)]; // echo back unchanged - }); - - await orchestrator.translateText('Hello {name}', 'en', 'de'); - - const sentText: string = provider.translate.mock.calls[0][0][0].text; - expect(sentText).toContain('__PH0__'); - expect(sentText).not.toContain('{name}'); - }); - - it('returns kind: skipped when the provider drops a placeholder marker', async () => { - // Provider strips the span / marker entirely. - provider.translate.mockResolvedValue([makeResult('Hallo')]); - - const result = await orchestrator.translateText('Hello {name}', 'en', 'de'); - - expect(result).toEqual({ kind: 'skipped', value: 'Hello {name}' }); - }); - - it('returns the original source text (not the provider result) when a marker is dropped', async () => { - provider.translate.mockResolvedValue([makeResult('some corrupted output')]); - - const input = 'Hello {name}'; - const result = await orchestrator.translateText(input, 'en', 'de'); - - // Must fall back to the original, not the corrupted provider output. - expect(result.value).toBe(input); - }); - }); - - // ------------------------------------------------------------------------- - // translateText — provider error propagation - // ------------------------------------------------------------------------- - - describe('translateText — provider error propagation', () => { - it('re-throws TranslationError from the provider unchanged', async () => { - const providerError = new TranslationError('quota exceeded', 'RATE_LIMIT', true); - provider.translate.mockRejectedValue(providerError); - - await expect(orchestrator.translateText('Hello', 'en', 'de')).rejects.toBe(providerError); - }); - - it('re-throws unexpected errors from the provider unchanged', async () => { - const networkError = new Error('ECONNRESET'); - provider.translate.mockRejectedValue(networkError); - - await expect(orchestrator.translateText('Hello', 'en', 'de')).rejects.toBe(networkError); - }); - - it('re-throws provider errors for simple-placeholder strings', async () => { - const providerError = new TranslationError('quota exceeded', 'RATE_LIMIT', true); - provider.translate.mockRejectedValue(providerError); - - await expect(orchestrator.translateText('Hello {name}', 'en', 'de')).rejects.toBe(providerError); - }); - }); - - // ------------------------------------------------------------------------- - // translateBatch - // ------------------------------------------------------------------------- - - describe('translateBatch', () => { - it('returns translations in the same order as the input items', async () => { - provider.translate.mockResolvedValueOnce([makeResult('Hallo')]).mockResolvedValueOnce([makeResult('Tschüss')]); - - const results = await orchestrator.translateBatch([ - { text: 'Hello', sourceLocale: 'en', targetLocale: 'de' }, - { text: 'Goodbye', sourceLocale: 'en', targetLocale: 'de' }, - ]); - - expect(results).toEqual([ - { kind: 'translated', value: 'Hallo' }, - { kind: 'translated', value: 'Tschüss' }, - ]); - }); - - it('returns kind: skipped for complex ICU items without calling the provider', async () => { - const results = await orchestrator.translateBatch([ - { text: '{count, plural, one {# item} other {# items}}', sourceLocale: 'en', targetLocale: 'de' }, - { text: '{gender, select, male {he} female {she} other {they}}', sourceLocale: 'en', targetLocale: 'de' }, - ]); - - expect(results[0].kind).toBe('skipped'); - expect(results[1].kind).toBe('skipped'); - expect(provider.translate).not.toHaveBeenCalled(); - }); - - it('returns kind: translated-with-placeholders for simple-placeholder items', async () => { - // Provider echoes text back unchanged (markers survive). - provider.translate.mockImplementation(async (requests: TranslateRequest[]) => [makeResult(requests[0].text)]); - - const results = await orchestrator.translateBatch([ - { text: 'Hello {name}', sourceLocale: 'en', targetLocale: 'de' }, - ]); - - expect(results[0].kind).toBe('translated-with-placeholders'); - }); - - it('returns an empty array for an empty input', async () => { - const results = await orchestrator.translateBatch([]); - - expect(results).toEqual([]); - expect(provider.translate).not.toHaveBeenCalled(); - }); - - it('processes items sequentially and stops on first failure', async () => { - const providerError = new TranslationError('fail', 'SERVER_ERROR', true); - provider.translate.mockResolvedValueOnce([makeResult('Hallo')]).mockRejectedValueOnce(providerError); - - await expect( - orchestrator.translateBatch([ - { text: 'Hello', sourceLocale: 'en', targetLocale: 'de' }, - { text: 'World', sourceLocale: 'en', targetLocale: 'de' }, - ]), - ).rejects.toBe(providerError); - - expect(provider.translate).toHaveBeenCalledTimes(2); - }); - - it('translates plain-text items and returns complex ICU items as skipped in a mixed batch (plain first)', async () => { - provider.translate.mockResolvedValueOnce([makeResult('Hallo Welt')]); - - const results = await orchestrator.translateBatch([ - { text: 'Hello World', sourceLocale: 'en', targetLocale: 'de' }, - { text: '{count, plural, one {# item} other {# items}}', sourceLocale: 'en', targetLocale: 'de' }, - ]); - - expect(results[0]).toEqual({ kind: 'translated', value: 'Hallo Welt' }); - expect(results[1].kind).toBe('skipped'); - // Provider is called once for the plain-text item only. - expect(provider.translate).toHaveBeenCalledOnce(); - }); - - it('returns complex ICU items as skipped and translates plain-text items in a mixed batch (ICU first)', async () => { - provider.translate.mockResolvedValueOnce([makeResult('Hallo Welt')]); - - const results = await orchestrator.translateBatch([ - { text: '{count, plural, one {# item} other {# items}}', sourceLocale: 'en', targetLocale: 'de' }, - { text: 'Hello World', sourceLocale: 'en', targetLocale: 'de' }, - ]); - - expect(results[0].kind).toBe('skipped'); - expect(results[1]).toEqual({ kind: 'translated', value: 'Hallo Welt' }); - expect(provider.translate).toHaveBeenCalledOnce(); - }); - }); - - // ------------------------------------------------------------------------- - // translateBatchForLocale - // ------------------------------------------------------------------------- - - describe('translateBatchForLocale', () => { - it('returns an empty array for an empty input without calling the provider', async () => { - const results = await orchestrator.translateBatchForLocale([], 'en', 'de'); - - expect(results).toEqual([]); - expect(provider.translate).not.toHaveBeenCalled(); - }); - - it('returns results in the same order as the input texts', async () => { - provider.translate.mockResolvedValueOnce([makeResult('Hallo'), makeResult('Tschüss')]); - - const results = await orchestrator.translateBatchForLocale(['Hello', 'Goodbye'], 'en', 'de'); - - expect(results[0]).toEqual({ kind: 'translated', value: 'Hallo' }); - expect(results[1]).toEqual({ kind: 'translated', value: 'Tschüss' }); - }); - - it('skips complex ICU texts immediately without including them in the provider call', async () => { - const icuText = '{count, plural, one {# item} other {# items}}'; - - const results = await orchestrator.translateBatchForLocale([icuText], 'en', 'de'); - - expect(results).toEqual([{ kind: 'skipped', value: icuText }]); - expect(provider.translate).not.toHaveBeenCalled(); - }); - - it('sends all plain texts to the provider in a single call', async () => { - provider.translate.mockResolvedValueOnce([makeResult('Hallo'), makeResult('Welt')]); - - await orchestrator.translateBatchForLocale(['Hello', 'World'], 'en', 'de'); - - expect(provider.translate).toHaveBeenCalledOnce(); - expect(provider.translate).toHaveBeenCalledWith([ - { text: 'Hello', sourceLocale: 'en', targetLocale: 'de' }, - { text: 'World', sourceLocale: 'en', targetLocale: 'de' }, - ]); - }); - - it('protects and restores placeholders for simple-placeholder texts', async () => { - provider.translate.mockImplementation(async (requests: TranslateRequest[]) => { - // Simulate translation of surrounding text while leaving span markers intact. - return requests.map((req) => makeResult(req.text.replace('Hello', 'Hallo'))); - }); - - const results = await orchestrator.translateBatchForLocale(['Hello {name}'], 'en', 'de'); - - expect(results[0].kind).toBe('translated-with-placeholders'); - expect(results[0].value).toBe('Hallo {name}'); - }); - - it('falls back to skipped when the provider drops a placeholder marker', async () => { - provider.translate.mockResolvedValueOnce([makeResult('Hallo')]); - - const results = await orchestrator.translateBatchForLocale(['Hello {name}'], 'en', 'de'); - - expect(results[0]).toEqual({ kind: 'skipped', value: 'Hello {name}' }); - }); - - it('calls the provider exactly once for a mixed batch of plain, simple-placeholder, and complex ICU texts', async () => { - const complexIcu = '{count, plural, one {# item} other {# items}}'; - - // Provider receives: plain text + protected simple-placeholder text (complex ICU is excluded). - provider.translate.mockImplementation(async (requests: TranslateRequest[]) => { - return requests.map((req) => makeResult(req.text.replace('Hello', 'Hallo'))); - }); - - const results = await orchestrator.translateBatchForLocale(['Hello', 'Hello {name}', complexIcu], 'en', 'de'); - - expect(provider.translate).toHaveBeenCalledOnce(); - expect(results[0]).toEqual({ kind: 'translated', value: 'Hallo' }); - expect(results[1].kind).toBe('translated-with-placeholders'); - expect(results[1].value).toBe('Hallo {name}'); - expect(results[2]).toEqual({ kind: 'skipped', value: complexIcu }); - }); - - it('returns kind: skipped for all items and never calls the provider when every item is complex ICU', async () => { - const icuPlural = '{count, plural, one {# item} other {# items}}'; - const icuSelect = '{gender, select, male {he} female {she} other {they}}'; - const icuMixed = 'Hello {name}, {count, plural, one {# item} other {# items}}'; - - const results = await orchestrator.translateBatchForLocale([icuPlural, icuSelect, icuMixed], 'en', 'de'); - - expect(provider.translate).not.toHaveBeenCalled(); - expect(results).toHaveLength(3); - expect(results[0]).toEqual({ kind: 'skipped', value: icuPlural }); - expect(results[1]).toEqual({ kind: 'skipped', value: icuSelect }); - expect(results[2]).toEqual({ kind: 'skipped', value: icuMixed }); - }); - - it('preserves order when complex ICU items are interspersed with translatable items', async () => { - const complexIcu = '{gender, select, male {he} female {she} other {they}}'; - - provider.translate.mockResolvedValueOnce([makeResult('Hallo'), makeResult('Tschüss')]); - - const results = await orchestrator.translateBatchForLocale(['Hello', complexIcu, 'Goodbye'], 'en', 'de'); - - expect(results[0]).toEqual({ kind: 'translated', value: 'Hallo' }); - expect(results[1]).toEqual({ kind: 'skipped', value: complexIcu }); - expect(results[2]).toEqual({ kind: 'translated', value: 'Tschüss' }); - // Only two texts were sent to the provider (Hello and Goodbye, not the ICU). - expect(provider.translate).toHaveBeenCalledOnce(); - expect(provider.translate.mock.calls[0][0]).toHaveLength(2); - }); - }); -}); diff --git a/libs/core/src/lib/translation/translation-orchestrator.ts b/libs/core/src/lib/translation/translation-orchestrator.ts deleted file mode 100644 index b8593c2d..00000000 --- a/libs/core/src/lib/translation/translation-orchestrator.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Translation orchestrator. - * - * Sits between callers and a {@link TranslationProvider}, routing strings to - * the provider using one of three strategies based on ICU content: - * - * 1. **Plain text** (no ICU syntax) — forwarded to the provider unchanged. - * 2. **Simple placeholders** (`{name}`, `{{ name }}`) — placeholders are - * replaced with HTML notranslate markers before sending, then restored from - * the translated result. Returns `kind: 'translated-with-placeholders'`. - * 3. **Complex ICU** (plural, select, number, date, time, or mixed) — returned - * unchanged without contacting the provider. Attempting to partially - * translate ICU branch content produces incorrect results; human translation - * is required. - * - * Marker-based placeholder protection also detects when the provider drops or - * corrupts a marker (count mismatch) and falls back to `kind: 'skipped'` so - * callers always receive a safe value. - * - * @module translation-orchestrator - */ - -import { classifyICUContent } from './icu-classifier'; -import { protectPlaceholders, restorePlaceholders } from './placeholder-protector'; -import type { TranslateRequest, TranslationProvider } from './translation-provider'; - -/** - * The result of a single translation attempt. - * - * - `kind: 'translated'` — plain text was translated by the provider. - * - `kind: 'translated-with-placeholders'` — simple-placeholder text was translated - * with marker protection; `value` contains - * the restored original placeholder syntax. - * - `kind: 'skipped'` — complex ICU syntax or a marker count - * mismatch; `value` is the unchanged source. - */ -export interface TranslateTextResult { - readonly kind: 'translated' | 'translated-with-placeholders' | 'skipped'; - readonly value: string; -} - -/** - * Orchestrates translation requests, forwarding strings to the configured - * {@link TranslationProvider} with the appropriate ICU-aware strategy. - * - * Construct with any {@link TranslationProvider} implementation. The - * orchestrator itself is provider-agnostic — swap providers without - * changing any caller code. - * - * @example - * ```typescript - * const provider = createTranslationProvider('google-translate', apiKey); - * const orchestrator = new TranslationOrchestrator(provider); - * - * // Plain text — sent to the provider directly. - * const plain = await orchestrator.translateText('Hello World', 'en', 'de'); - * // plain.kind === 'translated', plain.value === 'Hallo Welt' - * - * // Simple placeholder — translated with marker protection. - * const simple = await orchestrator.translateText('Hello {name}', 'en', 'de'); - * // simple.kind === 'translated-with-placeholders', simple.value === 'Hallo {name}' - * - * // Complex ICU — returned unchanged; requires human translation. - * const icu = await orchestrator.translateText( - * 'You have {count, plural, one {# item} other {# items}}', - * 'en', - * 'de', - * ); - * // icu.kind === 'skipped', icu.value === original text - * ``` - */ -export class TranslationOrchestrator { - readonly #provider: TranslationProvider; - - constructor(provider: TranslationProvider) { - this.#provider = provider; - } - - /** - * Translates a single text string using the ICU-aware strategy. - * - * @param text - Source text, may contain ICU placeholders. - * @param sourceLocale - BCP 47 locale code of the source text (e.g. `"en"`). - * @param targetLocale - BCP 47 locale code to translate into (e.g. `"de"`). - * @returns A {@link TranslateTextResult} describing the outcome. - * @throws {TranslationError} for any provider-level failure. - */ - async translateText(text: string, sourceLocale: string, targetLocale: string): Promise { - const classification = classifyICUContent(text); - - if (classification === 'complex-icu') { - return { kind: 'skipped', value: text }; - } - - if (classification === 'plain') { - const results = await this.#provider.translate([{ text, sourceLocale, targetLocale }]); - return { kind: 'translated', value: results[0].translatedText }; - } - - // classification === 'simple-placeholders' - return this.#translateWithPlaceholderProtection(text, sourceLocale, targetLocale); - } - - /** - * Translates an array of texts in sequence. - * - * Each item is classified independently. Complex ICU items return - * `kind: 'skipped'`; plain items return `kind: 'translated'`; simple - * placeholder items return `kind: 'translated-with-placeholders'`. - * - * @param items - Array of translation targets. - * @returns Results in the same order as the input. - * @throws {TranslationError} on the first item that fails. - */ - async translateBatch(items: ReadonlyArray): Promise { - const results: TranslateTextResult[] = []; - - for (const item of items) { - const result = await this.translateText(item.text, item.sourceLocale, item.targetLocale); - results.push(result); - } - - return results; - } - - /** - * Translates multiple texts for a single target locale in one provider call. - * - * This is the key optimization for bulk locale translation: rather than making - * one provider call per resource, all translatable texts are collected and sent - * in a single call (the provider handles its own internal chunking at 128 items). - * - * Complex ICU texts are classified and skipped immediately without any API call. - * Simple-placeholder texts have their placeholders protected before sending and - * restored from the translated result; a marker mismatch falls back to `kind: 'skipped'`. - * - * Results are returned in the same order as the input `texts` array. - * - * @param texts - Array of source strings to translate. - * @param sourceLocale - BCP 47 locale code of the source texts. - * @param targetLocale - BCP 47 locale code to translate into. - * @returns Results in the same order as the input. - * @throws {TranslationError} for any provider-level failure. - */ - async translateBatchForLocale( - texts: string[], - sourceLocale: string, - targetLocale: string, - ): Promise { - if (texts.length === 0) { - return []; - } - - type TextKind = 'plain' | 'simple-placeholders' | 'complex-icu'; - const classifications: TextKind[] = texts.map((text) => classifyICUContent(text)); - - // Collect translatable texts (plain + protected simple-placeholder) in one flat array, - // preserving an index so we can map results back to the original input positions. - const providerInputs: Array<{ text: string; originalIndex: number }> = []; - - // For simple-placeholder items we need the protect result to restore later. - const placeholderMaps = new Map['placeholders']>(); - - for (let i = 0; i < texts.length; i++) { - const kind = classifications[i]; - if (kind === 'complex-icu') continue; - - if (kind === 'plain') { - providerInputs.push({ text: texts[i], originalIndex: i }); - } else { - // simple-placeholders - const { protectedText, placeholders } = protectPlaceholders(texts[i]); - placeholderMaps.set(i, placeholders); - providerInputs.push({ text: protectedText, originalIndex: i }); - } - } - - let providerResults: Array<{ translatedText: string }> = []; - if (providerInputs.length > 0) { - providerResults = await this.#provider.translate( - providerInputs.map(({ text }) => ({ text, sourceLocale, targetLocale })), - ); - } - - const results: TranslateTextResult[] = new Array(texts.length); - - // Fill complex-ICU skips first. - for (let i = 0; i < texts.length; i++) { - if (classifications[i] === 'complex-icu') { - results[i] = { kind: 'skipped', value: texts[i] }; - } - } - - // Fill translated results. - for (let providerIdx = 0; providerIdx < providerInputs.length; providerIdx++) { - const { originalIndex } = providerInputs[providerIdx]; - const translatedText = providerResults[providerIdx].translatedText; - const kind = classifications[originalIndex]; - - if (kind === 'plain') { - results[originalIndex] = { kind: 'translated', value: translatedText }; - } else { - // simple-placeholders — restore markers - const placeholders = placeholderMaps.get(originalIndex); - if (!placeholders) { - results[originalIndex] = { kind: 'skipped', value: texts[originalIndex] }; - continue; - } - const restoreResult = restorePlaceholders(translatedText, placeholders); - if (!restoreResult.success) { - results[originalIndex] = { kind: 'skipped', value: texts[originalIndex] }; - } else { - results[originalIndex] = { kind: 'translated-with-placeholders', value: restoreResult.value }; - } - } - } - - return results; - } - - /** - * Protects simple placeholders with markers, translates the protected text, - * then restores the original placeholder syntax. - * - * Falls back to `kind: 'skipped'` when the provider drops or corrupts a - * marker, preserving a valid value for the caller. - * - * @param text - Source text containing only simple ICU placeholders. - * @param sourceLocale - BCP 47 source locale. - * @param targetLocale - BCP 47 target locale. - * @returns Translated result or a skip signal on marker mismatch. - */ - async #translateWithPlaceholderProtection( - text: string, - sourceLocale: string, - targetLocale: string, - ): Promise { - const { protectedText, placeholders } = protectPlaceholders(text); - - const results = await this.#provider.translate([{ text: protectedText, sourceLocale, targetLocale }]); - const translatedText = results[0].translatedText; - - const restoreResult = restorePlaceholders(translatedText, placeholders); - - if (!restoreResult.success) { - return { kind: 'skipped', value: text }; - } - - return { kind: 'translated-with-placeholders', value: restoreResult.value }; - } -} diff --git a/libs/core/src/lib/translation/translator.spec.ts b/libs/core/src/lib/translation/translator.spec.ts new file mode 100644 index 00000000..1999d99d --- /dev/null +++ b/libs/core/src/lib/translation/translator.spec.ts @@ -0,0 +1,270 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { TranslationConfig } from '../../config/translation-config'; +import { testCollection, useTempDir } from '../../testing/temp-dir.spec-helpers'; +import type { Collection } from '../config/open-collection'; +import { clearProtectedTermsFileCache, DEFAULT_PROTECTED_TERMS_FILENAME } from '../config/protected-terms-file'; +import { AutoTranslationDisabledError, ProtectedTermsFileError } from '../errors/lingo-tracker-error'; +import { InMemoryTranslationProvider } from './in-memory-translation-provider'; +import { TranslationError } from './translation-provider'; +import { openTranslator } from './translator'; + +const AUTO: TranslationConfig = { enabled: true, provider: 'google-translate', apiKeyEnv: 'TRANSLATOR_SPEC_KEY' }; + +const dir = useTempDir('translator-'); + +beforeEach(() => { + clearProtectedTermsFileCache(); +}); + +function collection(overrides: Partial = {}): Collection { + return testCollection(dir(), { translationConfig: AUTO, locales: ['en', 'fr', 'de'], ...overrides }); +} + +/** Translates by swapping English words for French ones, leaving everything else (markers included) intact. */ +const toFrench = new InMemoryTranslationProvider(({ text }) => + text.replace('Hello', 'Bonjour').replace('Save', 'Enregistrer'), +); + +describe('openTranslator', () => { + const previousKey = process.env[AUTO.apiKeyEnv]; + + beforeEach(() => { + delete process.env[AUTO.apiKeyEnv]; + }); + + afterEach(() => { + if (previousKey === undefined) { + delete process.env[AUTO.apiKeyEnv]; + } else { + process.env[AUTO.apiKeyEnv] = previousKey; + } + }); + + it('throws AutoTranslationDisabledError when the collection has no translation config', () => { + expect(() => openTranslator(collection({ translationConfig: undefined }))).toThrow(AutoTranslationDisabledError); + }); + + it('throws AutoTranslationDisabledError when the translation config is disabled, even with a provider', () => { + const disabled = collection({ translationConfig: { ...AUTO, enabled: false } }); + + expect(() => openTranslator(disabled, { provider: new InMemoryTranslationProvider() })).toThrow( + AutoTranslationDisabledError, + ); + }); + + it('fails fast with MISSING_API_KEY when the API key env var is unset', () => { + expect(() => openTranslator(collection())).toThrow( + expect.objectContaining({ code: 'MISSING_API_KEY', message: expect.stringContaining('TRANSLATOR_SPEC_KEY') }), + ); + expect(() => openTranslator(collection())).toThrow(TranslationError); + }); + + it('builds the configured provider from the API key when none is injected', async () => { + process.env[AUTO.apiKeyEnv] = 'secret'; + + // Nothing to send: the Google provider is built but never called. + await expect(openTranslator(collection()).translate([], ['fr'])).resolves.toEqual({ values: [], skipped: [] }); + expect(() => openTranslator(collection({ translationConfig: { ...AUTO, provider: 'nope' } }))).toThrow( + expect.objectContaining({ code: 'UNKNOWN_PROVIDER' }), + ); + }); + + it('does not need an API key when a provider is injected', async () => { + const provider = new InMemoryTranslationProvider(); + + const outcome = await openTranslator(collection(), { provider }).translate([{ key: 'ok', source: 'OK' }], ['fr']); + + expect(outcome.values).toEqual([{ key: 'ok', locale: 'fr', value: '[fr] OK' }]); + }); +}); + +describe('Translator.translate', () => { + it('makes one provider call per locale, with every sendable entry, and ignores the base locale', async () => { + const provider = new InMemoryTranslationProvider(); + const translator = openTranslator(collection(), { provider }); + + const outcome = await translator.translate( + [ + { key: 'a', source: 'Save' }, + { key: 'b', source: 'Cancel' }, + ], + ['en', 'fr', 'de'], + ); + + expect(provider.calls).toEqual([ + [ + { text: 'Save', sourceLocale: 'en', targetLocale: 'fr' }, + { text: 'Cancel', sourceLocale: 'en', targetLocale: 'fr' }, + ], + [ + { text: 'Save', sourceLocale: 'en', targetLocale: 'de' }, + { text: 'Cancel', sourceLocale: 'en', targetLocale: 'de' }, + ], + ]); + expect(outcome).toEqual({ + values: [ + { key: 'a', locale: 'fr', value: '[fr] Save' }, + { key: 'b', locale: 'fr', value: '[fr] Cancel' }, + { key: 'a', locale: 'de', value: '[de] Save' }, + { key: 'b', locale: 'de', value: '[de] Cancel' }, + ], + skipped: [], + }); + }); + + it('skips complex ICU without sending it, and does not call the provider when nothing is sendable', async () => { + const provider = new InMemoryTranslationProvider(); + const plural = '{count, plural, one {# item} other {# items}}'; + + const outcome = await openTranslator(collection(), { provider }).translate( + [ + { key: 'items', source: plural }, + { key: 'kind', source: '{gender, select, male {He} other {They}}' }, + ], + ['fr'], + ); + + expect(provider.calls).toEqual([]); + expect(outcome).toEqual({ + values: [], + skipped: [ + { key: 'items', locale: 'fr', reason: 'complex-icu' }, + { key: 'kind', locale: 'fr', reason: 'complex-icu' }, + ], + }); + }); + + it('keeps results in entry order when placeholder and complex ICU entries are interspersed', async () => { + const outcome = await openTranslator(collection(), { provider: new InMemoryTranslationProvider() }).translate( + [ + { key: 'a', source: 'One' }, + { key: 'b', source: '{n, plural, other {#}}' }, + { key: 'c', source: 'Hi {name}' }, + { key: 'd', source: '{g, select, other {x}}' }, + { key: 'e', source: 'Five' }, + ], + ['fr'], + ); + + expect(outcome.values.map(({ key, value }) => [key, value])).toEqual([ + ['a', '[fr] One'], + ['c', '[fr] Hi {name}'], + ['e', '[fr] Five'], + ]); + expect(outcome.skipped.map(({ key }) => key)).toEqual(['b', 'd']); + }); + + it('sends simple placeholders as notranslate markers and restores them', async () => { + const provider = new InMemoryTranslationProvider(({ text }) => + text.replace('File', 'Fichier').replace('is newer than', 'est plus récent que'), + ); + + const outcome = await openTranslator(collection(), { provider }).translate( + [{ key: 'f', source: 'File {fileA} is newer than {fileB}' }], + ['fr'], + ); + + const sent = provider.calls[0]?.[0]?.text ?? ''; + expect(sent).toContain('__PH0__'); + expect(sent).not.toContain('{fileA}'); + expect(outcome.values).toEqual([{ key: 'f', locale: 'fr', value: 'Fichier {fileA} est plus récent que {fileB}' }]); + }); + + it('skips a translation that lost a placeholder marker', async () => { + const provider = new InMemoryTranslationProvider(() => 'Bonjour'); + + const outcome = await openTranslator(collection(), { provider }).translate( + [{ key: 'greet', source: 'Hello {name}' }], + ['fr'], + ); + + expect(outcome).toEqual({ values: [], skipped: [{ key: 'greet', locale: 'fr', reason: 'placeholder-mismatch' }] }); + }); + + it('skips a translation that duplicated a placeholder marker', async () => { + const provider = new InMemoryTranslationProvider(({ text }) => `${text} ${text}`); + + const outcome = await openTranslator(collection(), { provider }).translate( + [{ key: 'greet', source: 'Hello {name}' }], + ['fr'], + ); + + expect(outcome.skipped).toEqual([{ key: 'greet', locale: 'fr', reason: 'placeholder-mismatch' }]); + }); + + it('normalises every value to ICU', async () => { + const outcome = await openTranslator(collection(), { provider: toFrench }).translate( + [{ key: 'greet', source: 'Hello {{ name }}' }], + ['fr'], + ); + + expect(outcome.values).toEqual([{ key: 'greet', locale: 'fr', value: 'Bonjour {name}' }]); + }); + + it('skips a translation that drops a protected term present in the source', async () => { + const provider = new InMemoryTranslationProvider(({ text }) => text.replace('iPhone', 'téléphone')); + const outcome = await openTranslator(collection(), { provider, protectedTerms: ['iPhone', 'Acme'] }).translate( + [ + { key: 'buy', source: 'Buy an iPhone' }, + { key: 'brand', source: 'Made by Acme' }, + ], + ['fr'], + ); + + expect(outcome.skipped).toEqual([{ key: 'buy', locale: 'fr', reason: 'protected-term', terms: ['iPhone'] }]); + expect(outcome.values).toEqual([{ key: 'brand', locale: 'fr', value: 'Made by Acme' }]); + }); + + it('skips a translation that changes the case of a protected term', async () => { + const provider = new InMemoryTranslationProvider(({ text }) => text.replace('iPhone', 'IPHONE')); + + const outcome = await openTranslator(collection(), { provider, protectedTerms: ['iPhone'] }).translate( + [{ key: 'buy', source: 'Buy an iPhone' }], + ['fr'], + ); + + expect(outcome.skipped).toEqual([{ key: 'buy', locale: 'fr', reason: 'protected-term', terms: ['iPhone'] }]); + }); + + it("reads the collection's protected-terms files when no terms are passed", async () => { + writeFileSync(join(dir(), DEFAULT_PROTECTED_TERMS_FILENAME), JSON.stringify(['iPhone']), 'utf8'); + const provider = new InMemoryTranslationProvider(({ text }) => text.replace('iPhone', 'téléphone')); + + const outcome = await openTranslator(collection(), { provider }).translate( + [{ key: 'buy', source: 'Buy an iPhone' }], + ['fr'], + ); + + expect(outcome.skipped).toEqual([{ key: 'buy', locale: 'fr', reason: 'protected-term', terms: ['iPhone'] }]); + }); + + it('throws ProtectedTermsFileError on open when a protected-terms file is malformed', () => { + writeFileSync(join(dir(), DEFAULT_PROTECTED_TERMS_FILENAME), '["iPhone",', 'utf8'); + + expect(() => openTranslator(collection(), { provider: new InMemoryTranslationProvider() })).toThrow( + ProtectedTermsFileError, + ); + }); + + it('propagates a provider failure', async () => { + const failure = new TranslationError('quota exceeded', 'RATE_LIMIT', true); + const provider = new InMemoryTranslationProvider(() => { + throw failure; + }); + + await expect( + openTranslator(collection(), { provider }).translate([{ key: 'ok', source: 'OK' }], ['fr']), + ).rejects.toBe(failure); + }); + + it('rejects a provider response with the wrong number of results', async () => { + const provider = new InMemoryTranslationProvider(); + provider.translate = async () => []; + + await expect( + openTranslator(collection(), { provider }).translate([{ key: 'ok', source: 'OK' }], ['fr']), + ).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }); + }); +}); diff --git a/libs/core/src/lib/translation/translator.ts b/libs/core/src/lib/translation/translator.ts new file mode 100644 index 00000000..06b09ea2 --- /dev/null +++ b/libs/core/src/lib/translation/translator.ts @@ -0,0 +1,196 @@ +/** + * Translator — the one way core machine-translates text for a collection. + * + * Opened from a resolved Collection, it owns everything the callers used to repeat: + * + * - **Setup**: the collection's translation config must be enabled, and the provider is built + * from it (`createTranslationProvider`, API key read from `process.env[apiKeyEnv]`) unless one + * is injected. + * - **ICU skip**: complex ICU (plural, select, number, date, time) is never sent to the provider. + * - **Placeholder guard**: simple placeholders (`{name}`, `{{ name }}`) are sent as notranslate + * markers and restored afterwards; a translation that loses or duplicates a marker is skipped. + * - **Protected-term guard**: a translation that drops a protected term present in the source + * (the collection's terms in force, read once when the Translator is opened) is skipped, as + * import would reject it. + * - **Normalisation**: every returned value is ICU (`translocoToICU`). + * + * Callers decide which entries and locales need work (the Staleness rule) and what to store. + * + * @module translator + */ + +import { classifyICUContent, findProtectedTermViolations, translocoToICU } from '@simoncodes-ca/domain'; +import type { Collection } from '../config/open-collection'; +import { readProtectedTermsInForce } from '../config/protected-terms-file'; +import { AutoTranslationDisabledError } from '../errors/lingo-tracker-error'; +import { type ExtractedPlaceholder, protectPlaceholders, restorePlaceholders } from './placeholder-protector'; +import { TranslationError, type TranslationProvider } from './translation-provider'; +import { createTranslationProvider } from './translation-provider-factory'; + +/** One text to translate. `key` is the caller's identifier (for example the full resource key). */ +export interface TranslatorEntry { + readonly key: string; + /** The base value (ICU or Transloco syntax). */ + readonly source: string; +} + +/** A translation that passed every guard, normalised to ICU. */ +export interface TranslatedValue { + readonly key: string; + readonly locale: string; + readonly value: string; +} + +/** + * Why an entry was not translated for a locale: + * - `complex-icu` — plural, select, or another complex ICU construct; never sent to the provider. + * - `placeholder-mismatch` — the provider lost or duplicated a placeholder marker. + * - `protected-term` — the translation dropped a protected term present in the source. + */ +export type TranslationSkipReason = 'complex-icu' | 'placeholder-mismatch' | 'protected-term'; + +export interface SkippedTranslation { + readonly key: string; + readonly locale: string; + readonly reason: TranslationSkipReason; + /** The protected terms the translation dropped (reason `protected-term` only). */ + readonly terms?: readonly string[]; +} + +export interface TranslationOutcome { + readonly values: TranslatedValue[]; + readonly skipped: SkippedTranslation[]; +} + +export interface Translator { + /** + * Translates every entry into every locale: one provider call per locale (the provider chunks + * internally), locales in parallel. The base locale is ignored. Results are ordered by locale, + * then by entry. + * + * @throws {TranslationError} The provider failed. + */ + translate(entries: readonly TranslatorEntry[], locales: readonly string[]): Promise; +} + +export interface OpenTranslatorOptions { + /** The provider to use instead of the one the collection's translation config names. */ + readonly provider?: TranslationProvider; + /** The protected terms to guard instead of the collection's terms files. */ + readonly protectedTerms?: readonly string[]; +} + +/** + * Opens the Translator for a collection. + * + * @throws {AutoTranslationDisabledError} The collection has no enabled translation config. + * @throws {TranslationError} No provider was injected and the API key env var is unset + * (`MISSING_API_KEY`) or the provider name is unknown (`UNKNOWN_PROVIDER`). + * @throws {ProtectedTermsFileError} No terms were passed and a terms file is not a JSON array of strings. + */ +export function openTranslator(collection: Collection, options: OpenTranslatorOptions = {}): Translator { + const config = collection.translationConfig; + if (!config?.enabled) { + throw new AutoTranslationDisabledError(collection.name); + } + + const provider = options.provider ?? createTranslationProvider(config.provider, readApiKey(config.apiKeyEnv)); + const protectedTerms = options.protectedTerms ?? readProtectedTermsInForce(collection); + const { baseLocale } = collection; + + return { + async translate(entries, locales) { + const prepared = entries.map(prepare); + const targets = [...new Set(locales)].filter((locale) => locale !== baseLocale); + const perLocale = await Promise.all( + targets.map((locale) => translateForLocale(provider, prepared, baseLocale, locale, protectedTerms)), + ); + return { + values: perLocale.flatMap(({ values }) => values), + skipped: perLocale.flatMap(({ skipped }) => skipped), + }; + }, + }; +} + +function readApiKey(apiKeyEnv: string): string { + const apiKey = process.env[apiKeyEnv]; + if (!apiKey) { + throw new TranslationError( + `Translation API key not found. Set the ${apiKeyEnv} environment variable.`, + 'MISSING_API_KEY', + false, + ); + } + return apiKey; +} + +/** An entry classified once for all locales: skipped, or the text to send and the markers to restore. */ +type PreparedEntry = + | { readonly entry: TranslatorEntry; readonly kind: 'complex-icu' } + | { + readonly entry: TranslatorEntry; + readonly kind: 'sendable'; + readonly text: string; + readonly placeholders: ReadonlyArray; + }; + +function prepare(entry: TranslatorEntry): PreparedEntry { + const classification = classifyICUContent(entry.source); + if (classification === 'complex-icu') { + return { entry, kind: 'complex-icu' }; + } + if (classification === 'plain') { + return { entry, kind: 'sendable', text: entry.source, placeholders: [] }; + } + const { protectedText, placeholders } = protectPlaceholders(entry.source); + return { entry, kind: 'sendable', text: protectedText, placeholders }; +} + +async function translateForLocale( + provider: TranslationProvider, + prepared: readonly PreparedEntry[], + sourceLocale: string, + targetLocale: string, + protectedTerms: readonly string[], +): Promise { + const sendable = prepared.filter((item) => item.kind === 'sendable'); + const results = + sendable.length > 0 + ? await provider.translate(sendable.map(({ text }) => ({ text, sourceLocale, targetLocale }))) + : []; + if (results.length !== sendable.length) { + throw new TranslationError( + `Translation provider returned ${results.length} results for ${sendable.length} texts.`, + 'INVALID_RESPONSE', + false, + ); + } + + const values: TranslatedValue[] = []; + const skipped: SkippedTranslation[] = []; + let next = 0; + for (const item of prepared) { + const { key, source } = item.entry; + if (item.kind === 'complex-icu') { + skipped.push({ key, locale: targetLocale, reason: 'complex-icu' }); + continue; + } + + const restored = restorePlaceholders(results[next++].translatedText, item.placeholders); + if (!restored.success) { + skipped.push({ key, locale: targetLocale, reason: 'placeholder-mismatch' }); + continue; + } + + const value = translocoToICU(restored.value); + const terms = findProtectedTermViolations(source, value, protectedTerms); + if (terms.length > 0) { + skipped.push({ key, locale: targetLocale, reason: 'protected-term', terms }); + continue; + } + + values.push({ key, locale: targetLocale, value }); + } + return { values, skipped }; +} diff --git a/libs/core/src/resource/add-resource.spec.ts b/libs/core/src/resource/add-resource.spec.ts index 78e24649..7637cbc3 100644 --- a/libs/core/src/resource/add-resource.spec.ts +++ b/libs/core/src/resource/add-resource.spec.ts @@ -1,18 +1,17 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { LingoTrackerConfig } from '../config/lingo-tracker-config'; import type { TranslationConfig } from '../config/translation-config'; import { type Collection, openCollection } from '../lib/config/open-collection'; +import { DEFAULT_PROTECTED_TERMS_FILENAME } from '../lib/config/protected-terms-file'; import { InvalidResourceKeyError, LocaleNotFoundError } from '../lib/errors/lingo-tracker-error'; -import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; +import { InMemoryTranslationProvider } from '../lib/translation/in-memory-translation-provider'; import { TranslationError } from '../lib/translation/translation-provider'; import { addResource } from './add-resource'; import { calculateChecksum as md5 } from './checksum'; -vi.mock('../lib/translation/auto-translate-resources'); - const AUTO: TranslationConfig = { enabled: true, provider: 'google-translate', apiKeyEnv: 'KEY' }; describe('addResource (real fs)', () => { @@ -27,7 +26,7 @@ describe('addResource (real fs)', () => { collections: { main: { translationsFolder: join(root, 'translations') } }, ...(options.translation && { translation: options.translation }), }; - return openCollection(config, 'main'); + return openCollection(config, 'main', { cwd: root }); } function read(file: 'resource_entries.json' | 'tracker_meta.json', ...segments: string[]) { @@ -36,7 +35,6 @@ describe('addResource (real fs)', () => { beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'add-resource-')); - vi.mocked(autoTranslateResource).mockReset(); }); afterEach(() => { @@ -45,7 +43,9 @@ describe('addResource (real fs)', () => { describe('locale seeding', () => { it('copies the base value as `new` into every target locale when auto-translation is off', async () => { - const result = await addResource(collection(), { key: 'common.ok', baseValue: 'OK' }); + const provider = new InMemoryTranslationProvider(); + + const result = await addResource(collection(), { key: 'common.ok', baseValue: 'OK' }, { provider }); expect(read('resource_entries.json', 'common')).toEqual({ ok: { source: 'OK', fr: 'OK', de: 'OK' } }); expect(read('tracker_meta.json', 'common').ok).toEqual({ @@ -58,7 +58,7 @@ describe('addResource (real fs)', () => { ['de', 'new'], ]); expect(result.skippedLocales).toBeUndefined(); - expect(autoTranslateResource).not.toHaveBeenCalled(); + expect(provider.calls).toEqual([]); }); it('keeps supplied translations and seeds only the missing locales', async () => { @@ -75,23 +75,19 @@ describe('addResource (real fs)', () => { }); it('auto-translates the missing locales when the collection enables it', async () => { - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [{ locale: 'de', value: 'Speichern', status: 'translated' }], - skippedLocales: [], - }); - - const result = await addResource(collection({ translation: AUTO }), { - key: 'common.save', - baseValue: 'Save', - translations: [{ locale: 'fr', value: 'Enregistrer', status: 'verified' }], - }); - - expect(autoTranslateResource).toHaveBeenCalledWith({ - baseValue: 'Save', - baseLocale: 'en', - targetLocales: ['de'], - translationConfig: AUTO, - }); + const provider = new InMemoryTranslationProvider(() => 'Speichern'); + + const result = await addResource( + collection({ translation: AUTO }), + { + key: 'common.save', + baseValue: 'Save', + translations: [{ locale: 'fr', value: 'Enregistrer', status: 'verified' }], + }, + { provider }, + ); + + expect(provider.calls).toEqual([[{ text: 'Save', sourceLocale: 'en', targetLocale: 'de' }]]); expect(read('resource_entries.json', 'common').save).toEqual({ source: 'Save', fr: 'Enregistrer', @@ -103,52 +99,115 @@ describe('addResource (real fs)', () => { expect(result.skippedLocales).toEqual([]); }); - it('copies the base value as `new` into a locale the provider skipped, and reports it', async () => { - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [{ locale: 'fr', value: '{count, plural, other {# éléments}}', status: 'translated' }], - skippedLocales: ['de'], - }); + it('stores auto-translations normalised to ICU', async () => { + const provider = new InMemoryTranslationProvider(({ text }) => text.replace('Hello', 'Hallo')); + + await addResource( + collection({ translation: AUTO }), + { key: 'greet', baseValue: 'Hello {{ name }}' }, + { provider }, + ); - const result = await addResource(collection({ translation: AUTO }), { - key: 'items', - baseValue: '{count, plural, other {# items}}', + expect(read('resource_entries.json').greet).toEqual({ + source: 'Hello {name}', + fr: 'Hallo {name}', + de: 'Hallo {name}', }); + }); + + it('copies the base value as `new` into every locale when the base value is complex ICU, and reports them', async () => { + const provider = new InMemoryTranslationProvider(); + + const result = await addResource( + collection({ translation: AUTO }), + { key: 'items', baseValue: '{count, plural, other {# items}}' }, + { provider }, + ); const entry = read('resource_entries.json').items; + expect(entry.fr).toBe('{count, plural, other {# items}}'); expect(entry.de).toBe('{count, plural, other {# items}}'); expect(read('tracker_meta.json').items.de.status).toBe('new'); - expect(result.skippedLocales).toEqual(['de']); + expect(result.skippedLocales).toEqual(['fr', 'de']); + expect(provider.calls).toEqual([]); }); - it('does not call the provider when every target locale was supplied', async () => { - await addResource(collection({ translation: AUTO }), { - key: 'ok', - baseValue: 'OK', - translations: [ - { locale: 'fr', value: "D'accord", status: 'translated' }, - { locale: 'de', value: 'Okay', status: 'translated' }, - ], + it('copies the base value as `new` into a locale whose translation dropped a protected term', async () => { + // The global terms file beside the config (root is the config directory). + writeFileSync(join(root, DEFAULT_PROTECTED_TERMS_FILENAME), JSON.stringify(['iPhone']), 'utf8'); + const provider = new InMemoryTranslationProvider(({ targetLocale }) => + targetLocale === 'fr' ? 'Acheter un téléphone' : 'iPhone kaufen', + ); + + const result = await addResource( + collection({ translation: AUTO }), + { key: 'buy', baseValue: 'Buy an iPhone' }, + { provider }, + ); + + expect(read('resource_entries.json').buy).toEqual({ + source: 'Buy an iPhone', + fr: 'Buy an iPhone', + de: 'iPhone kaufen', }); + expect(read('tracker_meta.json').buy.fr.status).toBe('new'); + expect(read('tracker_meta.json').buy.de.status).toBe('translated'); + expect(result.skippedLocales).toEqual(['fr']); + }); - expect(autoTranslateResource).not.toHaveBeenCalled(); + it('does not call the provider when every target locale was supplied', async () => { + const provider = new InMemoryTranslationProvider(); + + await addResource( + collection({ translation: AUTO }), + { + key: 'ok', + baseValue: 'OK', + translations: [ + { locale: 'fr', value: "D'accord", status: 'translated' }, + { locale: 'de', value: 'Okay', status: 'translated' }, + ], + }, + { provider }, + ); + + expect(provider.calls).toEqual([]); }); it('does not auto-translate when the collection translation config is disabled', async () => { - await addResource(collection({ translation: { ...AUTO, enabled: false } }), { key: 'ok', baseValue: 'OK' }); + const provider = new InMemoryTranslationProvider(); - expect(autoTranslateResource).not.toHaveBeenCalled(); + await addResource( + collection({ translation: { ...AUTO, enabled: false } }), + { key: 'ok', baseValue: 'OK' }, + { provider }, + ); + + expect(provider.calls).toEqual([]); expect(read('tracker_meta.json').ok.fr.status).toBe('new'); }); it('writes nothing when the provider fails', async () => { - vi.mocked(autoTranslateResource).mockRejectedValue(new TranslationError('quota', 'RATE_LIMIT', true)); + const provider = new InMemoryTranslationProvider(() => { + throw new TranslationError('quota', 'RATE_LIMIT', true); + }); await expect( - addResource(collection({ translation: AUTO }), { key: 'common.ok', baseValue: 'OK' }), + addResource(collection({ translation: AUTO }), { key: 'common.ok', baseValue: 'OK' }, { provider }), ).rejects.toThrow(TranslationError); expect(existsSync(join(root, 'translations', 'common', 'resource_entries.json'))).toBe(false); }); + it('writes nothing when the API key is not set', async () => { + await expect( + addResource(collection({ translation: { ...AUTO, apiKeyEnv: 'ADD_RESOURCE_SPEC_UNSET_KEY' } }), { + key: 'common.ok', + baseValue: 'OK', + }), + ).rejects.toMatchObject({ code: 'MISSING_API_KEY' }); + expect(existsSync(join(root, 'translations', 'common', 'resource_entries.json'))).toBe(false); + }); + it('stores an untranslated copy of the base value as `new`, whatever status was requested', async () => { await addResource(collection(), { key: 'ok', diff --git a/libs/core/src/resource/add-resource.ts b/libs/core/src/resource/add-resource.ts index 8a762fd2..1b8afe8e 100644 --- a/libs/core/src/resource/add-resource.ts +++ b/libs/core/src/resource/add-resource.ts @@ -1,6 +1,7 @@ import { isUntranslatedCopy, normalizeTags, translocoToICU } from '@simoncodes-ca/domain'; import type { Collection } from '../lib/config/open-collection'; import { ensureDirectoryExists } from '../lib/file-io/directory-operations'; +import type { OpenTranslatorOptions } from '../lib/translation/translator'; import { validateAndResolvePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder } from '../lib/resource/resource-folder'; import { type ResourceMutation, upsertMutation } from '../lib/resource/resource-mutation'; @@ -32,7 +33,7 @@ export interface AddResourceResult { readonly created: boolean; /** Every translation written, supplied and seeded. */ readonly translations: ResourceTranslation[]; - /** Locales the provider did not translate (ICU messages). Present only when auto-translation ran. */ + /** Locales the Translator skipped (see {@link seedLocales}). Present only when auto-translation ran. */ readonly skippedLocales?: string[]; /** The `upsert` for the stored entry. */ readonly mutations: ResourceMutation[]; @@ -50,11 +51,17 @@ export interface AddResourceResult { * Values are normalized to ICU before they are stored. Nothing is written when the * translation provider fails. * + * @param options - `provider` / `protectedTerms`: used instead of the collection's (see `openTranslator`). * @throws {InvalidResourceKeyError} The key or `targetFolder` is malformed. * @throws {LocaleNotFoundError} A supplied translation names a locale the collection does not have. * @throws {TranslationError} The translation provider failed. + * @throws {ProtectedTermsFileError} Auto-translation runs and a protected-terms file is malformed. */ -export async function addResource(collection: Collection, params: AddResourceParams): Promise { +export async function addResource( + collection: Collection, + params: AddResourceParams, + options: OpenTranslatorOptions = {}, +): Promise { const { baseLocale, translationsFolder } = collection; const paths = validateAndResolvePaths({ key: params.key, translationsFolder, targetFolder: params.targetFolder }); @@ -66,7 +73,7 @@ export async function addResource(collection: Collection, params: AddResourcePar const baseValue = translocoToICU(params.baseValue); // Resolve every value before touching the disk, so a provider failure writes nothing. - const seeding = await seedLocales(collection, { baseValue, supplied: supplied.map(({ locale }) => locale) }); + const seeding = await seedLocales(collection, { baseValue, supplied: supplied.map(({ locale }) => locale) }, options); const translations: ResourceTranslation[] = [ ...supplied.map(({ locale, value, status }) => { const normalized = translocoToICU(value); diff --git a/libs/core/src/resource/delete-resource.spec.ts b/libs/core/src/resource/delete-resource.spec.ts index 706f61e3..fb348a8d 100644 --- a/libs/core/src/resource/delete-resource.spec.ts +++ b/libs/core/src/resource/delete-resource.spec.ts @@ -11,6 +11,7 @@ const collection: Collection = { targetLocales: [], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder: 'translations' }, }; diff --git a/libs/core/src/resource/edit-resource.spec.ts b/libs/core/src/resource/edit-resource.spec.ts index 8528287a..b151954b 100644 --- a/libs/core/src/resource/edit-resource.spec.ts +++ b/libs/core/src/resource/edit-resource.spec.ts @@ -1,7 +1,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { LingoTrackerConfig } from '../config/lingo-tracker-config'; import type { TranslationConfig } from '../config/translation-config'; import { type Collection, openCollection } from '../lib/config/open-collection'; @@ -12,13 +12,11 @@ import { ResourceNotFoundError, } from '../lib/errors/lingo-tracker-error'; import { openResourceFolder } from '../lib/resource/resource-folder'; -import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; +import { InMemoryTranslationProvider } from '../lib/translation/in-memory-translation-provider'; import { TranslationError } from '../lib/translation/translation-provider'; import { calculateChecksum as md5 } from './checksum'; import { editResource } from './edit-resource'; -vi.mock('../lib/translation/auto-translate-resources'); - const AUTO: TranslationConfig = { enabled: true, provider: 'google-translate', apiKeyEnv: 'KEY' }; describe('editResource (real fs)', () => { @@ -33,7 +31,7 @@ describe('editResource (real fs)', () => { collections: { main: { translationsFolder: join(root, 'translations') } }, ...(translation && { translation }), }; - return openCollection(config, 'main'); + return openCollection(config, 'main', { cwd: root }); } /** @@ -54,7 +52,6 @@ describe('editResource (real fs)', () => { beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'edit-resource-')); - vi.mocked(autoTranslateResource).mockReset(); seedEntry(); }); @@ -80,7 +77,9 @@ describe('editResource (real fs)', () => { describe('base value change', () => { it('keeps real translations as `stale` and re-seeds copies and missing locales as `new`', async () => { - const result = await editResource(collection(), 'common.save', { baseValue: 'Save all' }); + const provider = new InMemoryTranslationProvider(); + + const result = await editResource(collection(), 'common.save', { baseValue: 'Save all' }, { provider }); expect(read('resource_entries.json', 'common').save).toEqual({ source: 'Save all', @@ -95,37 +94,37 @@ describe('editResource (real fs)', () => { expect(meta.es.status).toBe('new'); expect(result.updated).toBe(true); expect(result.skippedLocales).toBeUndefined(); - expect(autoTranslateResource).not.toHaveBeenCalled(); + expect(provider.calls).toEqual([]); }); it('auto-translates every locale that needs work, except those supplied in the same edit', async () => { - vi.mocked(autoTranslateResource).mockResolvedValue({ - translations: [{ locale: 'de', value: 'Alles speichern', status: 'translated' }], - skippedLocales: ['es'], - }); + // es loses the placeholder marker, so the Translator skips it. + const provider = new InMemoryTranslationProvider(({ text, targetLocale }) => + targetLocale === 'de' ? text.replace('Save all', 'Alles speichern') : 'Guardar todo', + ); - const result = await editResource(collection(AUTO), 'common.save', { - baseValue: 'Save all', - translations: { fr: { value: 'Tout enregistrer', status: 'translated' } }, - }); + const result = await editResource( + collection(AUTO), + 'common.save', + { + baseValue: 'Save all {count}', + translations: { fr: { value: 'Tout enregistrer {count}', status: 'translated' } }, + }, + { provider }, + ); - expect(autoTranslateResource).toHaveBeenCalledWith({ - baseValue: 'Save all', - baseLocale: 'en', - targetLocales: ['de', 'es'], - translationConfig: AUTO, - }); + expect(provider.calls.map((call) => call.map(({ targetLocale }) => targetLocale))).toEqual([['de'], ['es']]); expect(read('resource_entries.json', 'common').save).toEqual({ - source: 'Save all', - fr: 'Tout enregistrer', - de: 'Alles speichern', - es: 'Save all', + source: 'Save all {count}', + fr: 'Tout enregistrer {count}', + de: 'Alles speichern {count}', + es: 'Save all {count}', }); const meta = read('tracker_meta.json', 'common').save; expect(meta.fr.status).toBe('translated'); expect(meta.de).toEqual({ - checksum: md5('Alles speichern'), - baseChecksum: md5('Save all'), + checksum: md5('Alles speichern {count}'), + baseChecksum: md5('Save all {count}'), status: 'translated', }); expect(meta.es.status).toBe('new'); @@ -133,20 +132,24 @@ describe('editResource (real fs)', () => { }); it('keeps the saved edit when the provider fails', async () => { - vi.mocked(autoTranslateResource).mockRejectedValue(new TranslationError('down', 'SERVICE_ERROR', true)); + const provider = new InMemoryTranslationProvider(() => { + throw new TranslationError('down', 'SERVICE_ERROR', true); + }); - await expect(editResource(collection(AUTO), 'common.save', { baseValue: 'Save all' })).rejects.toThrow( - TranslationError, - ); + await expect( + editResource(collection(AUTO), 'common.save', { baseValue: 'Save all' }, { provider }), + ).rejects.toThrow(TranslationError); expect(read('resource_entries.json', 'common').save.source).toBe('Save all'); expect(read('tracker_meta.json', 'common').save.fr.status).toBe('stale'); }); it('does not seed anything when only the comment changes', async () => { - await editResource(collection(AUTO), 'common.save', { comment: 'Toolbar button' }); + const provider = new InMemoryTranslationProvider(); - expect(autoTranslateResource).not.toHaveBeenCalled(); + await editResource(collection(AUTO), 'common.save', { comment: 'Toolbar button' }, { provider }); + + expect(provider.calls).toEqual([]); expect(read('resource_entries.json', 'common').save.es).toBeUndefined(); }); @@ -256,20 +259,24 @@ describe('editResource (real fs)', () => { }); describe('while auto-translation is awaited', () => { - /** Makes the provider wait until `release()`; `called` resolves once the edit is awaiting it. */ - function holdProvider(): { called: Promise; release: () => void } { + /** A provider that waits until `release()`; `called` resolves once the edit is awaiting it. */ + function holdProvider(): { provider: InMemoryTranslationProvider; called: Promise; release: () => void } { let release = (): void => undefined; let markCalled = (): void => undefined; const called = new Promise((resolve) => { markCalled = resolve; }); - vi.mocked(autoTranslateResource).mockImplementation(() => { - markCalled(); - return new Promise((resolve) => { - release = () => resolve({ translations: [], skippedLocales: [] }); - }); + const held = new Promise((resolve) => { + release = resolve; }); - return { called, release: () => release() }; + const provider = new InMemoryTranslationProvider(); + const translate = provider.translate.bind(provider); + provider.translate = async (requests) => { + markCalled(); + await held; + return translate(requests); + }; + return { provider, called, release: () => release() }; } function writeDestinationEntry(key: string, value: string): void { @@ -281,7 +288,12 @@ describe('editResource (real fs)', () => { it('keeps an entry written to the destination folder meanwhile, and still moves the edited entry', async () => { const provider = holdProvider(); - const editing = editResource(collection(AUTO), 'common.save', { baseValue: 'Save all', moveTo: 'dialogs' }); + const editing = editResource( + collection(AUTO), + 'common.save', + { baseValue: 'Save all', moveTo: 'dialogs' }, + { provider: provider.provider }, + ); await provider.called; writeDestinationEntry('cancel', 'Cancel'); provider.release(); @@ -296,7 +308,12 @@ describe('editResource (real fs)', () => { it('throws ResourceAlreadyExistsError when the entry key was taken meanwhile, keeping both entries', async () => { const provider = holdProvider(); - const editing = editResource(collection(AUTO), 'common.save', { baseValue: 'Save all', moveTo: 'dialogs' }); + const editing = editResource( + collection(AUTO), + 'common.save', + { baseValue: 'Save all', moveTo: 'dialogs' }, + { provider: provider.provider }, + ); await provider.called; writeDestinationEntry('save', 'Other'); provider.release(); diff --git a/libs/core/src/resource/edit-resource.ts b/libs/core/src/resource/edit-resource.ts index af574f83..683fa0f8 100644 --- a/libs/core/src/resource/edit-resource.ts +++ b/libs/core/src/resource/edit-resource.ts @@ -11,6 +11,7 @@ import type { ResourceTreeEntry } from '../lib/resource/load-resource-tree'; import { validateAndResolvePaths } from '../lib/resource/resource-file-paths'; import { openResourceFolder, type ResourceFolder } from '../lib/resource/resource-folder'; import { removeMutation, type ResourceMutation, upsertMutation } from '../lib/resource/resource-mutation'; +import type { OpenTranslatorOptions } from '../lib/translation/translator'; import { assertCollectionLocales, seedLocales } from './locale-seeding'; /** What to change on an entry. `undefined` leaves a field alone. */ @@ -35,7 +36,7 @@ export interface EditResourceResult { readonly updated: boolean; readonly message?: string; readonly entry?: ResourceTreeEntry; - /** Locales the provider did not translate (ICU messages). Present only when auto-translation ran. */ + /** Locales the Translator skipped (see {@link seedLocales}). Present only when auto-translation ran. */ readonly skippedLocales?: string[]; /** What changed on disk (empty when nothing was updated). */ readonly mutations: ResourceMutation[]; @@ -58,17 +59,20 @@ export interface EditResourceResult { * throws `ResourceAlreadyExistsError` with the edit already saved in the source folder. * * @param key - The entry's full, existing key. + * @param options - `provider` / `protectedTerms`: used instead of the collection's (see `openTranslator`). * @throws {InvalidResourceKeyError} `key` or `moveTo` is malformed. * @throws {ResourceNotFoundError} No entry exists at `key`. * @throws {ResourceAlreadyExistsError} The destination folder already has an entry with this entry key * (checked before the edit, and again, on fresh disk state, just before the move). * @throws {LocaleNotFoundError} A translation names a locale the collection does not have. * @throws {TranslationError} The translation provider failed (the edit itself is saved). + * @throws {ProtectedTermsFileError} Auto-translation runs and a protected-terms file is malformed. */ export async function editResource( collection: Collection, key: string, changes: EditResourceChanges, + options: OpenTranslatorOptions = {}, ): Promise { const { baseLocale, translationsFolder } = collection; const paths = validateAndResolvePaths({ key, translationsFolder }); @@ -132,16 +136,20 @@ export async function editResource( let skippedLocales: string[] | undefined; if (baseChanged) { - const seeding = await seedLocales(collection, { - baseValue, - supplied: translations.map(([locale]) => locale), - needsWork: (locale) => needsTranslation(folder.get(entryKey)?.meta?.[locale]), - // A real translation is kept; no value, or an untranslated copy of the old base, is not. - keepsValue: (locale) => { - const value = entry[locale]; - return typeof value === 'string' && !isUntranslatedCopy(value, previousBase); + const seeding = await seedLocales( + collection, + { + baseValue, + supplied: translations.map(([locale]) => locale), + needsWork: (locale) => needsTranslation(folder.get(entryKey)?.meta?.[locale]), + // A real translation is kept; no value, or an untranslated copy of the old base, is not. + keepsValue: (locale) => { + const value = entry[locale]; + return typeof value === 'string' && !isUntranslatedCopy(value, previousBase); + }, }, - }); + options, + ); for (const translation of seeding.translations) { folder.setTranslation(entryKey, translation.locale, translation.value, translation.status); } diff --git a/libs/core/src/resource/locale-seeding.ts b/libs/core/src/resource/locale-seeding.ts index 47ac4c9f..107905a1 100644 --- a/libs/core/src/resource/locale-seeding.ts +++ b/libs/core/src/resource/locale-seeding.ts @@ -1,7 +1,7 @@ -import { type TranslationStatus, translocoToICU } from '@simoncodes-ca/domain'; +import type { TranslationStatus } from '@simoncodes-ca/domain'; import type { Collection } from '../lib/config/open-collection'; import { LocaleNotFoundError } from '../lib/errors/lingo-tracker-error'; -import { autoTranslateResource } from '../lib/translation/auto-translate-resources'; +import { type OpenTranslatorOptions, openTranslator } from '../lib/translation/translator'; /** A value for one locale and the status it is stored with. */ export interface ResourceTranslation { @@ -27,7 +27,10 @@ export interface LocaleSeedingRequest { export interface LocaleSeeding { /** Values to write, auto-translated ones first. */ readonly translations: ResourceTranslation[]; - /** Locales the provider did not translate (ICU messages). Present only when auto-translation ran. */ + /** + * Locales the Translator skipped (complex ICU, a lost placeholder, or a dropped protected term). + * Present only when auto-translation ran. + */ readonly skippedLocales?: string[]; } @@ -36,36 +39,39 @@ export interface LocaleSeeding { * For each of `collection.targetLocales` that needs work: * * 1. the caller supplied a translation → the caller writes it (the locale is skipped here); - * 2. the collection has auto-translation enabled → the provider's translation, as `translated`; - * 3. otherwise (or the provider skipped the locale) → a copy of the base value, as `new`, + * 2. the collection has auto-translation enabled → the Translator's value, as `translated`; + * 3. otherwise (or the Translator skipped the locale) → a copy of the base value, as `new`, * unless the locale holds a translation worth keeping (`keepsValue`), which the * Staleness rule has already marked. * * Returns the values; the caller writes them to its Resource Folder. * - * @throws {TranslationError} The provider failed. + * @param options - `provider` / `protectedTerms`: used instead of the collection's (see {@link openTranslator}). + * @throws {TranslationError} The provider failed, or its API key is not set. + * @throws {ProtectedTermsFileError} Auto-translation runs and a protected-terms file is malformed. */ -export async function seedLocales(collection: Collection, request: LocaleSeedingRequest): Promise { +export async function seedLocales( + collection: Collection, + request: LocaleSeedingRequest, + options: OpenTranslatorOptions = {}, +): Promise { const supplied = new Set(request.supplied); const open = collection.targetLocales.filter( (locale) => !supplied.has(locale) && (request.needsWork?.(locale) ?? true), ); - const { translationConfig, baseLocale } = collection; const translations: ResourceTranslation[] = []; let skippedLocales: string[] | undefined; - if (translationConfig?.enabled && open.length > 0) { - const result = await autoTranslateResource({ - baseValue: request.baseValue, - baseLocale, - targetLocales: open, - translationConfig, - }); - for (const { locale, value } of result.translations) { - translations.push({ locale, value: translocoToICU(value), status: 'translated' }); + if (collection.translationConfig?.enabled && open.length > 0) { + const { values, skipped } = await openTranslator(collection, options).translate( + [{ key: 'base', source: request.baseValue }], + open, + ); + for (const { locale, value } of values) { + translations.push({ locale, value, status: 'translated' }); } - skippedLocales = result.skippedLocales; + skippedLocales = skipped.map(({ locale }) => locale); } const translated = new Set(translations.map(({ locale }) => locale)); diff --git a/libs/core/src/resource/move-resource.real-fs.spec.ts b/libs/core/src/resource/move-resource.real-fs.spec.ts index a80aa28f..ebfdd1a1 100644 --- a/libs/core/src/resource/move-resource.real-fs.spec.ts +++ b/libs/core/src/resource/move-resource.real-fs.spec.ts @@ -24,6 +24,7 @@ describe('moving resources keeps metadata (real fs)', () => { targetLocales: ['fr', 'es'], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder }, }); diff --git a/libs/core/src/resource/move-resource.spec.ts b/libs/core/src/resource/move-resource.spec.ts index f15c4a48..2ba219e0 100644 --- a/libs/core/src/resource/move-resource.spec.ts +++ b/libs/core/src/resource/move-resource.spec.ts @@ -14,6 +14,7 @@ function collection(translationsFolder: string, name = 'main'): Collection { targetLocales: [], translationConfig: undefined, tags: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, readOnly: false, config: { translationsFolder }, }; diff --git a/libs/core/src/testing/temp-dir.spec-helpers.ts b/libs/core/src/testing/temp-dir.spec-helpers.ts index 8053a4ad..634329e2 100644 --- a/libs/core/src/testing/temp-dir.spec-helpers.ts +++ b/libs/core/src/testing/temp-dir.spec-helpers.ts @@ -11,6 +11,7 @@ import type { TranslationStatus } from '@simoncodes-ca/domain'; import { afterEach, beforeEach } from 'vitest'; import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../constants'; import type { Collection } from '../lib/config/open-collection'; +import { DEFAULT_PROTECTED_TERMS_FILENAME } from '../lib/config/protected-terms-file'; import { openResourceFolder } from '../lib/resource/resource-folder'; /** @@ -33,7 +34,10 @@ export function useTempDir(prefix = 'lingo-core-'): () => string { return () => dir; } -/** A resolved collection for tests. `locales` defaults to the base locale plus `fr` and `es`. */ +/** + * A resolved collection for tests. `locales` defaults to the base locale plus `fr` and `es`; the global + * protected-terms file defaults to `.lingo-tracker-protected-terms.json` inside `translationsFolder`. + */ export function testCollection(translationsFolder: string, overrides: Partial = {}): Collection { const baseLocale = overrides.baseLocale ?? 'en'; const locales = overrides.locales ?? [baseLocale, 'fr', 'es']; @@ -42,6 +46,7 @@ export function testCollection(translationsFolder: string, overrides: Partial(); const result: string[] = []; for (const term of terms) { @@ -69,7 +69,11 @@ export function findProtectedTerms(value: string, terms: string[]): string[] { * appears verbatim (case-sensitively) in `translatedValue`. Returns the terms * that are present in the source but absent verbatim from the translation. */ -export function findProtectedTermViolations(sourceValue: string, translatedValue: string, terms: string[]): string[] { +export function findProtectedTermViolations( + sourceValue: string, + translatedValue: string, + terms: readonly string[], +): string[] { const violations: string[] = []; for (const term of terms) { const presentInSource = buildProtectedTermRegex(term, { caseInsensitive: true }).test(sourceValue); From 845b1591cf22eb8f7b0df313340b16a1f3d1653d Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 21:45:05 -0700 Subject: [PATCH 13/20] refactor(core): one Bundle Selection over opened Collections resolveBundleCollections(definition, config, { cwd }) opens each collection of a bundle once per run; selectBundleEntries(collections, locale, { transformICUToTransloco, cache }) applies the 'All' expansion, entry selection rules, bundledKeyPrefix and mergeStrategy in one place and returns entries (value + winning origin), conflicts and warnings. generateBundle, planBundle and the type file all select through it. - generateBundleTypes({ bundleKey, definition, keys, tokenCasing, tokenConstantName?, cwd? }) is synchronous and no longer reads config or collections; its copy of the selection loop is deleted - deleted collectBundleData, processCollection, filterResources, matchesAnyRule, BundleKeyTrace, BundleKeyOrigin - GenerateBundleParams.cwd; translations folders, dist and typeDistFile resolve against it (CLI passes its INIT_CWD-aware directory, API passes process.cwd()); planBundle passes its cwd to openCollection too - both hierarchy builders use null-prototype nodes and own-property checks (a key such as __proto__.x or constructor.ok no longer walks or pollutes Object.prototype) - generate-bundle, plan-bundle and generate-types specs run on the real filesystem; new bundle-selection spec Behaviour changes: - "Collection 'x' not found in config" is reported once per run, not once per locale - the type generator no longer prints its own console warnings and no longer reads the disk a second time - a resource key such as `constructor` is no longer dropped as a false conflict under merge - the plan's exampleKey is the first key the selection produced (collection then folder order) rather than object-enumeration order - CLI bundle paths resolve against the INIT_CWD-aware directory Co-Authored-By: Claude Fable 5.1 --- .../app/bundles/bundle-job.service.spec.ts | 3 +- .../api/src/app/bundles/bundle-job.service.ts | 1 + apps/cli/src/commands/bundle.test.ts | 8 + apps/cli/src/commands/bundle.ts | 3 +- architecture-docs/api.md | 2 +- architecture-docs/bundle-generation.md | 50 +- architecture-docs/cli.md | 2 +- architecture-docs/core-library.md | 38 +- architecture-docs/glossary.md | 10 +- architecture-docs/user-flows.md | 9 +- .../src/lib/bundle/bundle-selection.spec.ts | 262 +++ libs/core/src/lib/bundle/bundle-selection.ts | 160 ++ .../src/lib/bundle/generate-bundle.spec.ts | 1960 +++++------------ libs/core/src/lib/bundle/generate-bundle.ts | 379 +--- .../src/lib/bundle/hierarchy-builder.spec.ts | 25 + libs/core/src/lib/bundle/hierarchy-builder.ts | 16 +- libs/core/src/lib/bundle/plan-bundle.spec.ts | 335 +-- libs/core/src/lib/bundle/plan-bundle.ts | 77 +- .../type-generation/generate-types.spec.ts | 562 ++--- .../bundle/type-generation/generate-types.ts | 104 +- .../type-generation/hierarchy-builder.spec.ts | 16 + .../type-generation/hierarchy-builder.ts | 22 +- 22 files changed, 1680 insertions(+), 2364 deletions(-) create mode 100644 libs/core/src/lib/bundle/bundle-selection.spec.ts create mode 100644 libs/core/src/lib/bundle/bundle-selection.ts diff --git a/apps/api/src/app/bundles/bundle-job.service.spec.ts b/apps/api/src/app/bundles/bundle-job.service.spec.ts index 79fd0634..b8fcc8b3 100644 --- a/apps/api/src/app/bundles/bundle-job.service.spec.ts +++ b/apps/api/src/app/bundles/bundle-job.service.spec.ts @@ -75,7 +75,7 @@ describe('BundleJobService', () => { expect(service.getJob('nope')).toBeUndefined(); }); - it('passes the bundle params, locales and an onProgress callback to generateBundle', async () => { + it('passes the bundle params, locales, the project directory and an onProgress callback to generateBundle', async () => { mockGenerateBundle.mockResolvedValue(makeResult()); service.startJob({ ...makeParams(), locales: ['fr'] }); @@ -87,6 +87,7 @@ describe('BundleJobService', () => { expect(params.bundleDefinition).toBe(bundleDefinition); expect(params.config).toBe(config); expect(params.locales).toEqual(['fr']); + expect(params.cwd).toBe(process.cwd()); expect(typeof params.onProgress).toBe('function'); }); diff --git a/apps/api/src/app/bundles/bundle-job.service.ts b/apps/api/src/app/bundles/bundle-job.service.ts index 8bfb75c1..b2ef0654 100644 --- a/apps/api/src/app/bundles/bundle-job.service.ts +++ b/apps/api/src/app/bundles/bundle-job.service.ts @@ -93,6 +93,7 @@ export class BundleJobService { config: params.config, ...(params.locales && { locales: params.locales }), onProgress, + cwd: process.cwd(), }); const completed = this.#jobs.get(jobId); diff --git a/apps/cli/src/commands/bundle.test.ts b/apps/cli/src/commands/bundle.test.ts index 62003b00..df0e0d76 100644 --- a/apps/cli/src/commands/bundle.test.ts +++ b/apps/cli/src/commands/bundle.test.ts @@ -199,6 +199,14 @@ describe('bundleCommand', () => { }); }); + describe('project directory', () => { + it('passes the directory the config was loaded from to generateBundle', async () => { + await bundleCommand({ name: 'core' }); + + expect(mockGenerateBundle).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/test' })); + }); + }); + describe('locale filtering', () => { it('should pass single locale filter to generateBundle', async () => { await bundleCommand({ name: 'core', locale: 'en' }); diff --git a/apps/cli/src/commands/bundle.ts b/apps/cli/src/commands/bundle.ts index e8ea633f..7dda5749 100644 --- a/apps/cli/src/commands/bundle.ts +++ b/apps/cli/src/commands/bundle.ts @@ -41,7 +41,7 @@ const DEFAULT_DEBUG_KEYS_LOCALE = '99'; export async function bundleCommand(options: BundleOptions): Promise { const loaded = loadConfiguration({ exitOnError: false }); if (!loaded) return; - const { config } = loaded; + const { config, cwd } = loaded; // Check if bundles are configured if (!config.bundles || Object.keys(config.bundles).length === 0) { @@ -122,6 +122,7 @@ export async function bundleCommand(options: BundleOptions): Promise { tokenConstantName: options.tokenConstantName, transformICUToTransloco: options.transformICUToTransloco, debugKeysLocale, + cwd, }); bundleResults.push({ diff --git a/architecture-docs/api.md b/architecture-docs/api.md index 6d4d1157..c6aa0393 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -87,7 +87,7 @@ Bundle definitions live under `bundles` in `.lingo-tracker.json` and are exposed | `POST` | `/bundles` | Create a [bundle](glossary.md#bundle) definition. Validation failures return 400 `{ message, errors[] }`; a duplicate name returns 409. | `CreateBundleDto` | `{ message: string }` | | `PUT` | `/bundles/:name` | Replace a bundle definition, optionally renaming it via `name` in the body. 404 when missing, 400 when invalid, 409 when the new name is taken. | `UpdateBundleDto` | `{ message: string }` | | `DELETE` | `/bundles/:name` | Remove a bundle definition (404 when missing) | — | `{ message: string }` | -| `POST` | `/bundles/dry-run` | Plan a bundle from the request body without writing anything. The definition does not have to be saved, so the UI can preview unsaved edits. | `BundleDryRunRequestDto` | `BundleDryRunResultDto` | +| `POST` | `/bundles/dry-run` | Plan a bundle from the request body without writing anything (`planBundle` with `cwd: process.cwd()`; generation jobs pass the same `cwd` to `generateBundle`). The definition does not have to be saved, so the UI can preview unsaved edits. | `BundleDryRunRequestDto` | `BundleDryRunResultDto` | | `POST` | `/bundles/:name/generate` | Fire-and-forget: start a generation job for a saved bundle. Optional `locales` must be a subset of the project locales (400 otherwise). | `GenerateBundleRequestDto` | `BundleGenerateJobDto` (202 Accepted) | | `GET` | `/bundles/jobs/:jobId` | Poll a bundle generation job by ID | — | `BundleGenerateJobDto` | diff --git a/architecture-docs/bundle-generation.md b/architecture-docs/bundle-generation.md index 2fb42552..bee0de10 100644 --- a/architecture-docs/bundle-generation.md +++ b/architecture-docs/bundle-generation.md @@ -42,12 +42,14 @@ Bundle generation is a sub-module of `@simoncodes-ca/core`. The entry point is ` ``` libs/core/src/lib/bundle/ ├── generate-bundle.ts # generateBundle(): main entry point, GenerateBundleParams, GenerateBundleResult +├── plan-bundle.ts # planBundle(): the dry-run plan, writes nothing +├── bundle-selection.ts # resolveBundleCollections() + selectBundleEntries(): the Bundle Selection ├── resource-loader.ts # loadCollectionResources(): one collection's FlatResource list per locale, via readCollection() ├── hierarchy-builder.ts # buildHierarchy(): dot-keys → nested JSON object ├── pattern-matcher.ts # matchesPattern(): glob-style key filtering ├── tag-filter.ts # matchesTags(): AND/OR tag filter logic └── type-generation/ - ├── generate-types.ts # generateBundleTypes(): orchestrates type file generation + ├── generate-types.ts # generateBundleTypes(): writes the type file from the selected keys ├── hierarchy-builder.ts # buildTypeHierarchy(), serializeHierarchy() ├── key-transformer.ts # segmentToPropertyName(), bundleKeyToConstantName(), constantNameToTypeName() └── file-header.ts # generateFileHeader(): auto-generated file comment block @@ -179,7 +181,7 @@ A more complex example demonstrating `bundledKeyPrefix`, filtered rules, and tag ## Entry Filtering Pipeline -For each locale, `generateBundle()` iterates over each `CollectionBundleDefinition`, loads resources, applies the filtering pipeline, and merges results into a single flat key-value map. The pipeline for a single collection runs as follows. +The pipeline is the [Bundle Selection](glossary.md#bundle-selection) (`bundle-selection.ts`). `resolveBundleCollections()` opens the collections once per run. Then, for each locale, `selectBundleEntries()` iterates over each `CollectionBundleDefinition`, loads resources, applies the filtering pipeline, and merges results into a single flat key-value map with the winning origin of each key. `generateBundle()`, `planBundle()` (the dry run) and the type file (keys only) all consume the same selection, so the rules below are applied in one place. The pipeline for a single collection runs as follows. ### Pipeline Flowchart @@ -187,16 +189,20 @@ For each locale, `generateBundle()` iterates over each `CollectionBundleDefiniti ```mermaid flowchart TD - START([generateBundle called\nfor one locale]) --> RESOLVE_COLLECTIONS + START([resolveBundleCollections\nonce per run]) --> RESOLVE_COLLECTIONS RESOLVE_COLLECTIONS{"collections === 'All'?"} RESOLVE_COLLECTIONS -- Yes --> EXPAND["Expand: create a CollectionBundleDefinition\nfor each entry in config.collections\nwith entriesSelectionRules: 'All'"] - RESOLVE_COLLECTIONS -- No --> USE_DEFINED["Use the explicit\nCollectionBundleDefinition array"] + RESOLVE_COLLECTIONS -- No --> USE_DEFINED["Use the explicit\nCollectionBundleDefinition array\nName not in config → left out,\none warning per run"] - EXPAND --> FOR_EACH_COLLECTION - USE_DEFINED --> FOR_EACH_COLLECTION + EXPAND --> OPEN + USE_DEFINED --> OPEN - FOR_EACH_COLLECTION["For each CollectionBundleDefinition\n(sequential loop)"] + OPEN["openCollection(config, name, { cwd })\nfor each → BundleCollection[]"] + + OPEN --> FOR_EACH_COLLECTION + + FOR_EACH_COLLECTION["selectBundleEntries(collections, locale)\nFor each BundleCollection\n(sequential loop)"] FOR_EACH_COLLECTION --> LOAD_RESOURCES @@ -207,7 +213,7 @@ flowchart TD RULES_ALL -- Yes --> ALL_PASS["All resources pass\n(no filtering)"] RULES_ALL -- No --> APPLY_RULES - APPLY_RULES["For each FlatResource:\nApply matchesAnyRule(resource, rules)"] + APPLY_RULES["For each FlatResource:\nkeep it if any rule matches"] APPLY_RULES --> FOR_EACH_RULE["For each EntrySelectionRule\n(short-circuit on first match)"] @@ -236,11 +242,11 @@ flowchart TD ICU_CONVERT --> MERGE_CHECK - MERGE_CHECK{"Key already in\nbundleData?"} + MERGE_CHECK{"Key already in\nentries?"} - MERGE_CHECK -- No --> ADD_KEY["Add key to bundleData\nbundleData[finalKey] = finalValue"] - MERGE_CHECK -- Yes, strategy='override' --> OVERWRITE["Overwrite existing value\nbundleData[finalKey] = finalValue"] - MERGE_CHECK -- Yes, strategy='merge' --> SKIP_KEY["Skip\n(first collection wins)"] + MERGE_CHECK -- No --> ADD_KEY["Add entry\n{ value, origin: { collectionName, sourceKey } }"] + MERGE_CHECK -- Yes, strategy='override' --> OVERWRITE["Record a conflict\nReplace value and origin\n(key keeps its position)"] + MERGE_CHECK -- Yes, strategy='merge' --> SKIP_KEY["Record a conflict\nSkip (first collection wins)"] ADD_KEY --> NEXT_COLLECTION OVERWRITE --> NEXT_COLLECTION @@ -250,16 +256,16 @@ flowchart TD NEXT_COLLECTION --> BUILD_HIERARCHY - BUILD_HIERARCHY["buildHierarchy(bundleData)\nFlat { 'a.b.c': 'val' }\n→ Nested { a: { b: { c: 'val' } } }"] + BUILD_HIERARCHY["generateBundle: buildHierarchy(values)\nFlat { 'a.b.c': 'val' }\n→ Nested { a: { b: { c: 'val' } } }"] BUILD_HIERARCHY --> WRITE_JSON - WRITE_JSON["writeBundleFile()\nWrite /.json\nCreate output directory if absent"] + WRITE_JSON["writeBundleFile()\nWrite /.json\n(resolved against cwd)\nCreate output directory if absent"] WRITE_JSON --> TYPE_GEN_CHECK{"typeDistFile\nconfigured?"} TYPE_GEN_CHECK -- No --> DONE(["Bundle complete for this locale"]) - TYPE_GEN_CHECK -- Yes --> GENERATE_TYPES["generateBundleTypes()\n(runs once per bundle, not per locale)\nSee Type Generation section"] + TYPE_GEN_CHECK -- Yes --> GENERATE_TYPES["generateBundleTypes()\n(runs once per bundle, not per locale)\nkeys = selection with\nCOLLECTION_BASE_LOCALE, no ICU\nSee Type Generation section"] GENERATE_TYPES --> DONE @@ -294,20 +300,22 @@ Any other pattern form (e.g. `'*.suffix'`, `'apps.*.buttons'`) is not supported ### Merge Strategy -When two `CollectionBundleDefinition` entries (or one collection iterated by `'All'`) produce the same final key, the `mergeStrategy` on the **second** definition controls the outcome: +When two `CollectionBundleDefinition` entries (or one collection iterated by `'All'`) produce the same final key, the key is recorded in the selection's `conflicts` (the dry-run plan reports them), and the `mergeStrategy` on the **second** definition controls the outcome: - `'merge'` (default) — the first value written wins. Subsequent collections that produce the same key are silently skipped. - `'override'` — the later collection's value unconditionally replaces the previously written value. This allows a layered composition pattern: a base design-system collection uses `'merge'`, and an app-specific collection uses `'override'` to patch specific keys. +**Key order.** The selection keeps its keys in the order it first selects them: collection order, then folder order. An `'override'` replaces the value but keeps the key's position. The dry-run plan's example key is the first key in that order. This changed with the Bundle Selection: the example key used to be the first key of a plain object, which lists a numeric-like key such as `404` before every other key, whatever collection it came from. + --- ## ICU-to-Transloco Conversion at Bundle Time LingoTracker stores translation values in [ICU format](glossary.md#icu-format) internally. At bundle time, `icuToTransloco()` from `@simoncodes-ca/domain` converts them to the syntax Angular's Transloco library expects. For the full explanation of why ICU is the internal storage format, see [domain-and-data-model.md — ICU vs Transloco Format](domain-and-data-model.md#icu-vs-transloco-format). -**Conversion happens per value, after filtering, before merging into `bundleData`.** +**Conversion happens per value, inside `selectBundleEntries()`, after filtering and before merging.** It is part of the selection so that the bundle and the dry-run plan report the same warnings. `generateBundle()`'s base selection, which gives the debug-keys bundle and the type file their keys, turns the conversion off, because it uses keys only. The conversion rules applied by `icuToTransloco()`: @@ -344,7 +352,7 @@ On both shapes, the interpolation pass strands a branch with no body. The ICU co ### Per-Locale JSON Files -For each locale in `config.locales` (or the `--locale` CLI override), one JSON file is written to `/.json`. The `{locale}` placeholder in `bundleName` is replaced with the locale code before the path is resolved. +For each locale in `config.locales` (or the `--locale` CLI override), one JSON file is written to `/.json`. The `{locale}` placeholder in `bundleName` is replaced with the locale code, and a relative path is resolved against the project directory (`cwd` on `generateBundle`: the CLI's `INIT_CWD`-aware directory, the API's `process.cwd()`). Collection `translationsFolder` values resolve against the same directory. Example for the `"main"` bundle with `bundleName: "{locale}"` and `dist: "./dist/i18n"`: @@ -390,7 +398,7 @@ Output nested JSON (`en.json`): } ``` -**Locale fallback**: if a [resource entry](glossary.md#resource-entry) has no translation for the target locale, `loadCollectionResources()` omits that key from `FlatResource[]` — it does not silently fall back to the base locale value. For the collection's own base locale (each collection is opened with `openCollection()`, so a collection can override the global `baseLocale`) the value is `entry.source`; all other locale values are the stored translations. An entry without a value for the requested locale simply does not appear in the bundle. The debug-keys bundle and the dry-run plan's key set (conflicts, example key, types count) read each collection's own base values instead (`COLLECTION_BASE_LOCALE`), so a collection with its own base locale is never left out of them. +**Locale fallback**: if a [resource entry](glossary.md#resource-entry) has no translation for the target locale, `loadCollectionResources()` omits that key from `FlatResource[]` — it does not silently fall back to the base locale value. For the collection's own base locale (each collection is opened with `openCollection()`, so a collection can override the global `baseLocale`) the value is `entry.source`; all other locale values are the stored translations. An entry without a value for the requested locale simply does not appear in the bundle. The debug-keys bundle, the type file's keys, and the dry-run plan's key set (conflicts, example key, types count) read each collection's own base values instead (`COLLECTION_BASE_LOCALE`), so a collection with its own base locale is never left out of them. **Reading**: the entries come from the [Collection Reader](glossary.md#collection-reader) (`readCollection()`), read once per collection per run. Its rules apply: entries without metadata are bundled, hidden folders are skipped, and a folder that cannot be read is left out and reported in the bundle result's `warnings`. Selection rules match against the reader's effective tags (collection tags united with the entry's own). @@ -423,7 +431,7 @@ When `typeDistFile` is set on a `BundleDefinition`, `generateBundleTypes()` emit 2. An `export const` declaration: the key tree as an `as const` object. 3. An `export type` declaration: the type alias derived from the `const`. -The type file is written to the path specified by `typeDistFile`, resolved with `path.resolve()`. The output directory is created if it does not already exist. +The type file is written to the path specified by `typeDistFile`, resolved against the project directory (`cwd`). The output directory is created if it does not already exist. `generateBundleTypes({ bundleKey, definition, keys, tokenCasing, tokenConstantName, cwd })` does not select keys: `generateBundle()` passes the [Bundle Selection](glossary.md#bundle-selection)'s base keys, the same key set as the debug-keys bundle. --- @@ -595,5 +603,5 @@ CLI flag → BundleDefinition field → global config field → hard defau - [domain-and-data-model.md](domain-and-data-model.md) — ICU format, resource entry structure (`ResourceEntry`, `TrackerMetadata`), and the full explanation of internal vs. bundle-time format. - [frontend.md](frontend.md) — how the Tracker UI imports and uses the generated type constants via Transloco. - [cli.md](cli.md) — the `bundle` CLI command that invokes `generateBundle()`, including interactive bundle selection and locale filtering. -- [api.md](api.md) — the REST API does not expose a bundle endpoint; bundle generation is CLI-only. +- [api.md](api.md) — the REST API's bundle endpoints: definition CRUD, `POST /bundles/dry-run` (`planBundle`), `POST /bundles/:name/generate` (a `generateBundle` job) and `GET /bundles/jobs/:jobId`. - [glossary.md](glossary.md) — definitions for [bundle](glossary.md#bundle), [resource key](glossary.md#resource-key), [ICU format](glossary.md#icu-format), [Transloco](glossary.md#transloco), [collection](glossary.md#collection), [base locale](glossary.md#base-locale). diff --git a/architecture-docs/cli.md b/architecture-docs/cli.md index bde050ca..64f4b75d 100644 --- a/architecture-docs/cli.md +++ b/architecture-docs/cli.md @@ -46,7 +46,7 @@ All commands are registered in `apps/cli/src/main.ts`. Each row below lists the | `move` | `--collection`, `--source`, `--dest`, `--override`, `--verbose` | `moveResource()` | | `normalize` | `--collection`, `--all`, `--dry-run`, `--json` | `normalize()` | | `translate-locale` | `--collection`, `--locale`, `--verbose` | `translateLocale(collection, { targetLocale, onProgress })` (through the [Translator](glossary.md#translator)); the summary prints `Skipped (needs human translation)` for complex ICU, lost placeholders and dropped protected terms | -| `bundle` | `--name`, `--locale`, `--verbose`, `--token-casing`, `--token-constant-name`, `--no-transform-icu-to-transloco`, `--debug-keys` | `generateBundle()` | +| `bundle` | `--name`, `--locale`, `--verbose`, `--token-casing`, `--token-constant-name`, `--no-transform-icu-to-transloco`, `--debug-keys` | `generateBundle()` (with the project `cwd`) | | `export` | `-f/--format`, `-c/--collection`, `-l/--locale`, `-s/--status`, `-t/--tags`, `-o/--output`, `--structure`, `--rich`, `--include-base`, `--include-status`, `--include-comment`, `--include-tags`, `--base-property-name`, `--filename`, `--no-protect-notes`, `--dry-run`, `--verbose` | `runExport()` | | `import` | `-f/--format`, `-s/--source`, `-l/--locale`, `-c/--collection`, `--strategy`, `--update-comments`, `--update-tags`, `--preserve-status`, `--create-missing`, `--validate-base`, `--dry-run`, `--verbose` | `parseJsonImport()` / `parseXliffImport()` → `importResources()` | | `validate` | `--allow-translated`, `--skip-locales`, `--skip-icu`, `--skip-placeholders`, `--require-portable-plurals` | `openCollection()` for each collection → `validateResources()`, `generateValidationSummary()` | diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index d7bae6ce..da417c47 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -65,11 +65,13 @@ libs/core/src/ └── lib/ # Deeper sub-modules ├── bundle/ # Bundle generation pipeline │ ├── generate-bundle.ts # generateBundle(): main entry point + │ ├── plan-bundle.ts # planBundle(): the dry-run plan (files, key counts, conflicts), writes nothing + │ ├── bundle-selection.ts # Bundle Selection: resolveBundleCollections() + selectBundleEntries() │ ├── resource-loader.ts # loadCollectionResources(): one collection's values for one locale, via readCollection() │ ├── hierarchy-builder.ts # buildHierarchy(): dot-keys → nested JSON object │ ├── pattern-matcher.ts # matchesPattern(): glob-style key filtering │ ├── tag-filter.ts # matchesTags(): AND/OR tag filter logic - │ └── type-generation/ # TypeScript type file generation from bundle keys + │ └── type-generation/ # generateBundleTypes(): the TypeScript type file from the selected keys │ ├── config/ # Config file I/O and collection resolution │ ├── load-config.ts # loadConfig(): the only reader of .lingo-tracker.json @@ -258,7 +260,7 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that | Errors | `LingoTrackerError` and every typed subclass, `TranslationError`, `PreferredTerminologyValidationError`. See [Error Model](#error-model). | | Types | Parameter and result types for the operations above (`AddResourceParams`, `GenerateBundleResult`, `ImportResult`, ...). | -Each sub-module with a barrel (`resource/`, `collections-manager/`, and `lib/bundle`, `config`, `errors`, `folder`, `import`, `normalize`, `resource`, `translation`, `validate`) lists its own public names the same way, and the root barrel re-exports from it. `lib/export/` has no barrel, so the root barrel imports its files directly. `lib/file-io/` is internal and has no barrel. Everything else is internal: `ErrorMessages`, `calculateChecksum`, the Translator, the provider classes and `createTranslationProvider`, the bundle helpers, the normalize walker, `SafeAny`, and the like. Core's specs import these by relative path. Test helpers live in `*.spec-helpers.ts` files, which `tsconfig.lib.json` excludes from the build: `setupMockFs` (`collections-manager/locale.spec-helpers.ts`) and the real-filesystem fixtures `useTempDir`, `testCollection`, `seedResources`, `writeFolderFiles` (`testing/temp-dir.spec-helpers.ts`). New reader specs use real temp directories rather than a mocked `fs`. +Each sub-module with a barrel (`resource/`, `collections-manager/`, and `lib/bundle`, `config`, `errors`, `folder`, `import`, `normalize`, `resource`, `translation`, `validate`) lists its own public names the same way, and the root barrel re-exports from it. `lib/export/` has no barrel, so the root barrel imports its files directly. `lib/file-io/` is internal and has no barrel. Everything else is internal: `ErrorMessages`, `calculateChecksum`, the Translator, the provider classes and `createTranslationProvider`, the [Bundle Selection](#bundle-selection) and the other bundle helpers, the normalize walker, `SafeAny`, and the like. Core's specs import these by relative path. Test helpers live in `*.spec-helpers.ts` files, which `tsconfig.lib.json` excludes from the build: `setupMockFs` (`collections-manager/locale.spec-helpers.ts`) and the real-filesystem fixtures `useTempDir`, `testCollection`, `seedResources`, `writeFolderFiles` (`testing/temp-dir.spec-helpers.ts`). New reader specs use real temp directories rather than a mocked `fs`. --- @@ -431,7 +433,7 @@ The caller decides what a problem means: |---|---| | `validateResources` | Lists it in `unreadableFolders`, and validation fails. | | `runExport` | Lists it under `malformedFiles` in the result and the summary. The other resources are exported. | -| Bundle and type generation (`loadCollectionResources`) | Adds a warning to the bundle result, once for each collection. Type generation logs it. | +| Bundle generation, the dry-run plan and type generation (the [Bundle Selection](#bundle-selection), through `loadCollectionResources`) | Adds a warning to the bundle result or the plan, once for each collection per run. | | `glossary` (CLI) | Writes a warning to stderr. | | `loadResourceTree`, `searchTranslations` | Log it. The tree keeps the folder, with no resources. | | `translateLocale` | Does not translate the folder's resources and adds one line to `warnings` in the result (`Folder '' was not translated: `). The CLI prints the warnings after the summary; the API translation job logs them with `Logger.warn`. | @@ -629,15 +631,29 @@ The pointer-setting functions call `assertWritableProtectedTermsPath()` *before* Key steps: -1. **Resolve configuration** — token casing, ICU-to-Transloco transformation flag, and target locales are resolved via a three-level priority chain: CLI override → bundle config → global config → default. -2. **Load resources** — each collection is opened with `openCollection(config, name)`. `loadCollectionResources(collection, locale, cache, warnings)` reads it through the [Collection Reader](#collection-reader) once per run (the cache holds each collection's read). It returns one `{ key, value, tags }` for each entry that has a value for the locale, with the reader's effective tags. The value is `source` when the locale is the collection's own base locale, and the stored translation otherwise. An entry with no value for the locale is left out. A folder that cannot be read becomes a warning. The base data of a run — the debug-keys bundle, and the plan's key set, conflicts and types count — passes `COLLECTION_BASE_LOCALE` instead of a locale, so each collection gives its own base values even when it overrides the global base locale. -3. **Filter entries** — `EntrySelectionRule` objects in the `BundleDefinition` combine pattern matching (`matchesPattern()`) and tag filtering (`matchesTags()`) to include only the relevant subset of resources. Collections set to `'All'` skip filtering. -4. **ICU conversion** — when `transformICUToTransloco` is `true` (the default), `icuToTransloco()` from `@simoncodes-ca/domain` is called on each value. Values with malformed ICU syntax are passed through with a warning. -5. **Build hierarchy** — `buildHierarchy()` converts the flat `{dotKey: value}` map into a nested object matching the Angular Transloco expected structure. -6. **Write output** — `writeBundleFile()` creates the output directory if needed and writes the JSON file at the path defined by `bundleDefinition.dist` + `bundleDefinition.bundleName.replace('{locale}', locale)`. -7. **Type generation** — if `bundleDefinition.typeDist` is configured, `generateBundleTypes()` emits a TypeScript constant file with the translation key tree for use in Angular templates. +1. **Resolve configuration** — token casing, ICU-to-Transloco transformation flag, and target locales are resolved via a three-level priority chain: CLI override → bundle config → global config → default. `cwd` (default `process.cwd()`; the CLI passes its `INIT_CWD`-aware project directory, the API `process.cwd()`) is the directory that translations folders, `dist` and `typeDistFile` resolve against. +2. **Resolve the collections** — `resolveBundleCollections(definition, config, { cwd })` opens each collection the definition reads once per run, with `openCollection(config, name, { cwd })`. See [Bundle Selection](#bundle-selection). +3. **Select, per locale** — `selectBundleEntries(collections, locale, { transformICUToTransloco, cache })` returns the locale's final keys with their values and origins. It reads, filters, prefixes, converts ICU and merges. +4. **Build hierarchy** — `buildHierarchy()` converts the flat `{dotKey: value}` map into a nested object matching the Angular Transloco expected structure. +5. **Write output** — `writeBundleFile()` creates the output directory if needed and writes the JSON file at `getBundleOutputPath(definition, locale)` (`dist` + `bundleName.replace('{locale}', locale)`), resolved against `cwd`. A locale with no entries is skipped with a warning. +6. **Base keys** — the debug-keys bundle and the type file use one more selection with `COLLECTION_BASE_LOCALE` and no ICU conversion: every collection's own base keys, computed once. +7. **Type generation** — if `typeDistFile` (or the deprecated `typeDist`) is configured, `generateBundleTypes({ bundleKey, definition, keys, tokenCasing, tokenConstantName, cwd })` writes the TypeScript constant file from those keys. It does not read collections itself. -For a deep-dive into `BundleDefinition` configuration and the type generation sub-pipeline, see [bundle-generation.md](bundle-generation.md) *(phase 5, coming soon)*. +`planBundle(params)` in `lib/bundle/plan-bundle.ts` runs steps 1 to 3 and writes nothing. It also makes one selection with `COLLECTION_BASE_LOCALE`, with the resolved ICU flag (its warnings are kept only when there are no target locales). It reports the files it would write, the keys per locale, the conflicts (from that selection's `conflicts`), the hierarchical conflicts and an example key: the first key the selection produced, in collection then folder order, with its origin. + +### Bundle Selection + +**Entry points:** `resolveBundleCollections(definition, config, { cwd })` and `selectBundleEntries(collections, locale, options)` in `lib/bundle/bundle-selection.ts` + +The [Bundle Selection](glossary.md#bundle-selection) is the one place that decides what a bundle holds. `generateBundle`, `planBundle` and the type file all consume it. + +- `resolveBundleCollections` expands `'All'` to every collection in the config, with `entriesSelectionRules: 'All'` and no prefix. It opens each named collection once and pairs it with its `CollectionBundleDefinition` (a `BundleCollection`). A name the config does not have is left out and reported once per run: `Collection '' not found in config`. +- `selectBundleEntries` reads each collection for the locale with `loadCollectionResources` (the `source` for the collection's own base locale or `COLLECTION_BASE_LOCALE`, otherwise the stored translation). It keeps the entries that match any rule (`matchesPattern()` and `matchesTags()` on the reader's effective tags), prepends `bundledKeyPrefix`, and converts ICU to Transloco when asked. Then it merges in definition order: the first value of a final key wins, unless a later collection's `mergeStrategy` is `'override'`. +- The result is `{ entries, conflicts, warnings }`. `entries` maps each final key to `{ value, origin: { collectionName, sourceKey } }` in first-selected order. `conflicts` holds the final keys that more than one resource defines. `warnings` holds the unreadable folders (on the first read of a run, through the shared `cache`) and the ICU warnings: a malformed value, and a branch body that cannot be carried to Transloco. + +The ICU conversion is inside the selection because the bundle and the plan report the same warnings for the same values. `generateBundle`'s base selection (for the debug-keys bundle and the type file) turns it off, because it uses keys only. The type file selects nothing itself: `generateBundleTypes` receives those keys. + +For a deep-dive into `BundleDefinition` configuration and the type generation sub-pipeline, see [bundle-generation.md](bundle-generation.md). --- diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 346a57c4..5d213449 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -28,6 +28,14 @@ Explained in context: [`bundle-generation.md`](bundle-generation.md), [`core-lib --- +### Bundle Selection + +What a [bundle](#bundle) holds for one locale: each final key, its value, and the resource the value came from. In code, `libs/core/src/lib/bundle/bundle-selection.ts` has two functions. `resolveBundleCollections(definition, config, { cwd })` opens each [collection](#collection) the definition reads once per run (`'All'` means every collection, with every entry and no prefix), and reports a name the config does not have once: `Collection '' not found in config`. `selectBundleEntries(collections, locale, { transformICUToTransloco, cache })` reads each collection for the locale through the [Collection Reader](#collection-reader), keeps the entries that match a rule (key pattern and [tags](#tags)), prepends `bundledKeyPrefix`, converts [ICU](#icu-format) to [Transloco](#transloco) when asked, and merges: the first value of a key wins, unless a later collection's `mergeStrategy` is `'override'`. It returns `{ entries, conflicts, warnings }`; each entry has a `value` and an `origin` (`collectionName`, `sourceKey`). Each collection's base value comes from its own [base locale](#base-locale); `COLLECTION_BASE_LOCALE` asks for every collection's base value. `generateBundle` writes the JSON files from it, `planBundle` counts keys and reports conflicts from it, and the type file takes its keys from it. None of them selects entries itself. + +Explained in context: [`core-library.md`](core-library.md#bundle-selection), [`bundle-generation.md`](bundle-generation.md#entry-filtering-pipeline) + +--- + ## C ### Checksum @@ -67,7 +75,7 @@ Explained in context: [`api.md`](api.md#collection-index) ### Collection Reader -The read side of the [Resource Folder](#resource-folder): the one walk over a [collection's](#collection) `translationsFolder`. In code, `readCollection(collection)` in `libs/core/src/lib/resource/read-collection.ts` opens every folder with the collection's [base locale](#base-locale) and returns `{ resources, problems }`. Each `StoredResource` has an address (`fullKey`, `folderPath`, `entryKey`), the `entry` as `ResourceFolder.treeEntry()` reads it, and `effectiveTags` ([Tags](#tags)). The rules are the same for every caller. Hidden folders are skipped. An entry without metadata is read with `metadata: {}`, so it counts as `new`. A folder whose file is not valid JSON, or that cannot be listed, is left out and returned as a problem, and the caller reports it. Export, validate, bundle, type generation, the resource tree, disk search and the CLI `glossary` all read through it. +The read side of the [Resource Folder](#resource-folder): the one walk over a [collection's](#collection) `translationsFolder`. In code, `readCollection(collection)` in `libs/core/src/lib/resource/read-collection.ts` opens every folder with the collection's [base locale](#base-locale) and returns `{ resources, problems }`. Each `StoredResource` has an address (`fullKey`, `folderPath`, `entryKey`), the `entry` as `ResourceFolder.treeEntry()` reads it, and `effectiveTags` ([Tags](#tags)). The rules are the same for every caller. Hidden folders are skipped. An entry without metadata is read with `metadata: {}`, so it counts as `new`. A folder whose file is not valid JSON, or that cannot be listed, is left out and returned as a problem, and the caller reports it. Export, validate, the [Bundle Selection](#bundle-selection) (bundle, dry-run plan and type file), the resource tree, disk search and the CLI `glossary` all read through it. Explained in context: [`core-library.md`](core-library.md#collection-reader) diff --git a/architecture-docs/user-flows.md b/architecture-docs/user-flows.md index b21d4323..4ca722c5 100644 --- a/architecture-docs/user-flows.md +++ b/architecture-docs/user-flows.md @@ -83,12 +83,13 @@ sequenceDiagram Note over Dev,FS: 5. Bundle Dev->>CLI: bundle - CLI->>Core: generateBundle(params) - Core->>FS: loadCollectionResources() — readCollection(): entries + metadata per folder, once per collection - Core->>Domain: icuToTransloco(value) — per entry + CLI->>Core: generateBundle({ ..., cwd }) + Core->>Core: resolveBundleCollections() — open each collection once + Core->>FS: selectBundleEntries() per locale — readCollection(), once per collection + Core->>Domain: icuToTransloco(value) — per selected entry Core->>Core: buildHierarchy() — dot-keys → nested object Core->>FS: writeBundleFile(dist/i18n/en.json, dist/i18n/fr.json, ...) - Core->>Core: generateBundleTypes() [if typeDist configured] + Core->>Core: generateBundleTypes(base keys) [if typeDistFile configured] Core->>FS: write TRACKER_TOKENS type file Core-->>CLI: BundleResult CLI-->>Dev: "Bundle written" diff --git a/libs/core/src/lib/bundle/bundle-selection.spec.ts b/libs/core/src/lib/bundle/bundle-selection.spec.ts new file mode 100644 index 00000000..a7defbe8 --- /dev/null +++ b/libs/core/src/lib/bundle/bundle-selection.spec.ts @@ -0,0 +1,262 @@ +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { BundleDefinition, CollectionBundleDefinition } from '../../config/bundle-definition'; +import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import type { Collection } from '../config/open-collection'; +import { + type SeedResource, + seedResources, + testCollection, + useTempDir, + writeFolderFiles, +} from '../../testing/temp-dir.spec-helpers'; +import { + type BundleCollection, + resolveBundleCollections, + selectBundleEntries, + selectionValues, +} from './bundle-selection'; +import { COLLECTION_BASE_LOCALE, type CollectionReadCache } from './resource-loader'; + +describe('Bundle Selection (real fs)', () => { + const root = useTempDir('bundle-selection-'); + + function seeded( + name: string, + resources: Record, + overrides: Partial = {}, + ): Collection { + const collection = testCollection(join(root(), name), { name, ...overrides }); + seedResources(collection, resources); + return collection; + } + + function bundled(collection: Collection, definition: Partial = {}): BundleCollection { + return { collection, definition: { name: collection.name, entriesSelectionRules: 'All', ...definition } }; + } + + const noTransform = { transformICUToTransloco: false } as const; + + describe('resolveBundleCollections', () => { + const config: LingoTrackerConfig = { + exportFolder: 'dist/export', + importFolder: 'dist/import', + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { + common: { translationsFolder: 'translations/common' }, + french: { translationsFolder: 'translations/french', baseLocale: 'fr' }, + }, + }; + + function definition(collections: BundleDefinition['collections']): BundleDefinition { + return { bundleName: '{locale}', dist: 'dist', collections }; + } + + it("expands 'All' to every configured collection with every entry and no prefix, opened against cwd", () => { + const { collections, warnings } = resolveBundleCollections(definition('All'), config, { cwd: root() }); + + expect(warnings).toEqual([]); + expect(collections.map(({ definition: d }) => d)).toEqual([ + { name: 'common', entriesSelectionRules: 'All' }, + { name: 'french', entriesSelectionRules: 'All' }, + ]); + expect(collections[0]?.collection.translationsFolder).toBe(join(root(), 'translations/common')); + expect(collections[1]?.collection.baseLocale).toBe('fr'); + }); + + it('keeps an explicit list in order with its settings, and reports a missing collection once', () => { + const french: CollectionBundleDefinition = { + name: 'french', + entriesSelectionRules: [{ matchingPattern: 'a.*' }], + bundledKeyPrefix: 'fr', + mergeStrategy: 'override', + }; + + const { collections, warnings } = resolveBundleCollections( + definition([french, { name: 'nonexistent', entriesSelectionRules: 'All' }]), + config, + { cwd: root() }, + ); + + expect(collections.map(({ definition: d }) => d)).toEqual([french]); + expect(warnings).toEqual(["Collection 'nonexistent' not found in config"]); + }); + }); + + describe('selectBundleEntries', () => { + it("reads each collection's value for the locale and leaves out entries without one", () => { + const common = seeded('common', { + 'buttons.ok': { source: 'OK', translations: { fr: "D'accord" } }, + 'buttons.cancel': { source: 'Cancel' }, + }); + + expect(selectionValues(selectBundleEntries([bundled(common)], 'en', noTransform))).toEqual({ + 'buttons.ok': 'OK', + 'buttons.cancel': 'Cancel', + }); + expect(selectionValues(selectBundleEntries([bundled(common)], 'fr', noTransform))).toEqual({ + 'buttons.ok': "D'accord", + }); + }); + + it("reads every collection's own base value for COLLECTION_BASE_LOCALE", () => { + const common = seeded('common', { title: { source: 'Title', translations: { fr: 'Titre' } } }); + const french = seeded('french', { bonjour: { source: 'Bonjour' } }, { baseLocale: 'fr', locales: ['fr', 'en'] }); + + const selection = selectBundleEntries([bundled(common), bundled(french)], COLLECTION_BASE_LOCALE, noTransform); + + expect(selectionValues(selection)).toEqual({ title: 'Title', bonjour: 'Bonjour' }); + }); + + it('keeps the entries matching any rule: key pattern and tags together', () => { + const common = seeded( + 'common', + { + 'apps.welcome': { source: 'Welcome', tags: ['ui'] }, + 'apps.untagged': { source: 'Untagged' }, + 'apps.both': { source: 'Both', tags: ['ui', 'critical'] }, + 'other.title': { source: 'Title', tags: ['ui'] }, + 'labels.title': { source: 'Label' }, + }, + { tags: [] }, + ); + const rules = (entriesSelectionRules: CollectionBundleDefinition['entriesSelectionRules']): string[] => + Array.from( + selectBundleEntries([bundled(common, { entriesSelectionRules })], 'en', noTransform).entries.keys(), + ).sort(); + + expect(rules([{ matchingPattern: 'apps.*' }])).toEqual(['apps.both', 'apps.untagged', 'apps.welcome']); + expect(rules([{ matchingPattern: 'apps.*', matchingTags: ['ui'] }])).toEqual(['apps.both', 'apps.welcome']); + expect(rules([{ matchingPattern: '*', matchingTags: ['ui', 'critical'], matchingTagOperator: 'All' }])).toEqual([ + 'apps.both', + ]); + expect(rules([{ matchingPattern: 'apps.*', matchingTags: ['*'] }])).toEqual(['apps.both', 'apps.welcome']); + expect(rules([{ matchingPattern: 'labels.title' }, { matchingPattern: 'other.*' }])).toEqual([ + 'labels.title', + 'other.title', + ]); + }); + + it("matches tags against the collection's tags united with the entry's own", () => { + const common = seeded('common', { ok: { source: 'OK' } }, { tags: ['shared'] }); + + const selection = selectBundleEntries( + [bundled(common, { entriesSelectionRules: [{ matchingPattern: '*', matchingTags: ['shared'] }] })], + 'en', + noTransform, + ); + + expect(Array.from(selection.entries.keys())).toEqual(['ok']); + }); + + it('prefixes the final key and keeps the source key in the origin', () => { + const common = seeded('common', { 'buttons.ok': { source: 'OK' } }); + + const selection = selectBundleEntries([bundled(common, { bundledKeyPrefix: 'ds' })], 'en', noTransform); + + expect(selection.entries.get('ds.buttons.ok')).toEqual({ + value: 'OK', + origin: { collectionName: 'common', sourceKey: 'buttons.ok' }, + }); + }); + + describe('merging', () => { + function twoCollections(): { first: Collection; second: Collection } { + return { + // Folder 'shared' is read before folder 'zzz', so the shared key comes first. + first: seeded('first', { 'shared.title': { source: 'First' }, 'zzz.only': { source: 'Only' } }), + second: seeded('second', { 'shared.title': { source: 'Second' } }), + }; + } + + it("keeps the first value under 'merge' (the default) and reports the conflict", () => { + const { first, second } = twoCollections(); + + const selection = selectBundleEntries([bundled(first), bundled(second)], 'en', noTransform); + + expect(selection.entries.get('shared.title')).toEqual({ + value: 'First', + origin: { collectionName: 'first', sourceKey: 'shared.title' }, + }); + expect(Array.from(selection.conflicts)).toEqual(['shared.title']); + }); + + it("takes the later value under 'override', keeping the key's first position", () => { + const { first, second } = twoCollections(); + + const selection = selectBundleEntries( + [bundled(first), bundled(second, { mergeStrategy: 'override' })], + 'en', + noTransform, + ); + + expect(Array.from(selection.entries.keys())).toEqual(['shared.title', 'zzz.only']); + expect(selection.entries.get('shared.title')).toEqual({ + value: 'Second', + origin: { collectionName: 'second', sourceKey: 'shared.title' }, + }); + expect(Array.from(selection.conflicts)).toEqual(['shared.title']); + }); + + it('reports no conflict when prefixes separate the collections', () => { + const { first, second } = twoCollections(); + + const selection = selectBundleEntries( + [bundled(first), bundled(second, { bundledKeyPrefix: 'second' })], + 'en', + noTransform, + ); + + expect(selection.conflicts.size).toBe(0); + expect(selection.entries.has('second.shared.title')).toBe(true); + }); + }); + + it('treats keys named like Object.prototype members as ordinary keys, not conflicts', () => { + const common = seeded('common', { constructor: { source: 'Builder' }, toString: { source: 'Text' } }); + + const selection = selectBundleEntries([bundled(common)], 'en', noTransform); + + expect(selection.conflicts.size).toBe(0); + expect(selectionValues(selection)).toEqual({ constructor: 'Builder', toString: 'Text' }); + }); + + describe('ICU to Transloco', () => { + it('converts values when asked, and leaves them as stored otherwise', () => { + const common = seeded('common', { greeting: { source: 'Hello {name}' } }); + + expect( + selectBundleEntries([bundled(common)], 'en', { transformICUToTransloco: true }).entries.get('greeting') + ?.value, + ).toBe('Hello {{ name }}'); + expect(selectBundleEntries([bundled(common)], 'en', noTransform).entries.get('greeting')?.value).toBe( + 'Hello {name}', + ); + }); + + it('warns about a malformed value and includes it as-is, only when converting', () => { + const common = seeded('common', { broken: { source: 'Hello {name' } }); + + const converted = selectBundleEntries([bundled(common)], 'en', { transformICUToTransloco: true }); + + expect(converted.warnings).toEqual(["Key 'broken': value has malformed ICU syntax and was included as-is"]); + expect(converted.entries.has('broken')).toBe(true); + expect(selectBundleEntries([bundled(common)], 'en', noTransform).warnings).toEqual([]); + }); + }); + + it('reports an unreadable folder in the first selection of a run only', () => { + const common = seeded('common', { ok: { source: 'OK', translations: { fr: 'Bien' } } }); + writeFolderFiles(common.translationsFolder, 'bad', { entries: '{ invalid json }' }); + const cache: CollectionReadCache = new Map(); + + const en = selectBundleEntries([bundled(common)], 'en', { ...noTransform, cache }); + const fr = selectBundleEntries([bundled(common)], 'fr', { ...noTransform, cache }); + + expect(en.warnings).toHaveLength(1); + expect(en.warnings[0]).toContain("Collection 'common': skipped unreadable folder"); + expect(fr.warnings).toEqual([]); + }); + }); +}); diff --git a/libs/core/src/lib/bundle/bundle-selection.ts b/libs/core/src/lib/bundle/bundle-selection.ts new file mode 100644 index 00000000..7fec31cb --- /dev/null +++ b/libs/core/src/lib/bundle/bundle-selection.ts @@ -0,0 +1,160 @@ +/** + * Bundle Selection: which value each final key of a bundle gets for one locale, and where it came + * from. The JSON bundle, the dry-run plan and the type file all select through here, so the + * `'All'` expansion, the entry selection rules, `bundledKeyPrefix` and `mergeStrategy` live in one + * place. + */ + +import { hasUnbundlableBranchBody, icuToTransloco, validateICUSyntax } from '@simoncodes-ca/domain'; +import type { BundleDefinition, CollectionBundleDefinition, EntrySelectionRule } from '../../config/bundle-definition'; +import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { type Collection, openCollection } from '../config/open-collection'; +import { matchesPattern } from './pattern-matcher'; +import { + type BundleLocale, + type CollectionReadCache, + type FlatResource, + loadCollectionResources, +} from './resource-loader'; +import { matchesTags } from './tag-filter'; + +/** One collection a bundle reads: its selection settings paired with the opened collection. */ +export interface BundleCollection { + readonly definition: CollectionBundleDefinition; + readonly collection: Collection; +} + +export interface ResolvedBundleCollections { + /** The collections to read, in definition order. */ + readonly collections: readonly BundleCollection[]; + /** One warning per collection the definition names but the config does not have. */ + readonly warnings: readonly string[]; +} + +/** Where a bundled value came from. */ +export interface BundleEntryOrigin { + readonly collectionName: string; + /** The key in its collection, before `bundledKeyPrefix`. */ + readonly sourceKey: string; +} + +export interface BundleEntry { + readonly value: string; + /** The resource whose value won (the first one, or the last `'override'` one). */ + readonly origin: BundleEntryOrigin; +} + +export interface BundleSelection { + /** Final (prefixed) key → value and winning origin, in the order keys were first selected. */ + readonly entries: ReadonlyMap; + /** Final keys that more than one selected resource defines. */ + readonly conflicts: ReadonlySet; + /** Unreadable folders (first read of a run only) and ICU transformation warnings. */ + readonly warnings: readonly string[]; +} + +export interface SelectBundleEntriesOptions { + /** Convert each value from ICU to Transloco syntax, warning on values that do not carry. */ + readonly transformICUToTransloco: boolean; + /** The run's read cache, so every locale of a run reads each collection once. */ + readonly cache?: CollectionReadCache; +} + +/** + * Opens the collections a bundle definition reads, once per run. `'All'` means every collection in + * the config with every entry and no prefix; a named collection the config lacks is left out and + * reported in `warnings`. + * + * @param options.cwd Directory relative translations folders resolve against (default: `process.cwd()`). + */ +export function resolveBundleCollections( + definition: BundleDefinition, + config: LingoTrackerConfig, + options: { readonly cwd?: string } = {}, +): ResolvedBundleCollections { + const configured = Object.keys(config.collections ?? {}); + const definitions: readonly CollectionBundleDefinition[] = + definition.collections === 'All' + ? configured.map((name) => ({ name, entriesSelectionRules: 'All' })) + : definition.collections; + + const collections: BundleCollection[] = []; + const warnings: string[] = []; + for (const collectionDefinition of definitions) { + if (!configured.includes(collectionDefinition.name)) { + warnings.push(`Collection '${collectionDefinition.name}' not found in config`); + continue; + } + const collection = openCollection(config, collectionDefinition.name, { cwd: options.cwd }); + collections.push({ definition: collectionDefinition, collection }); + } + + return { collections, warnings }; +} + +/** + * Selects a bundle's entries for one locale. Each collection's entries are read for `locale` (its + * own base locale reads `source`), filtered by its selection rules, prefixed, and merged in order: + * the first value of a key wins unless a later collection's `mergeStrategy` is `'override'`. + */ +export function selectBundleEntries( + collections: readonly BundleCollection[], + locale: BundleLocale, + options: SelectBundleEntriesOptions, +): BundleSelection { + const entries = new Map(); + const conflicts = new Set(); + const warnings: string[] = []; + + for (const { definition, collection } of collections) { + const override = definition.mergeStrategy === 'override'; + + for (const resource of loadCollectionResources(collection, locale, options.cache, warnings)) { + if (!isSelected(resource, definition.entriesSelectionRules)) continue; + + const finalKey = definition.bundledKeyPrefix ? `${definition.bundledKeyPrefix}.${resource.key}` : resource.key; + const value = options.transformICUToTransloco ? toTransloco(resource, warnings) : resource.value; + + if (entries.has(finalKey)) { + conflicts.add(finalKey); + if (!override) continue; + } + entries.set(finalKey, { value, origin: { collectionName: collection.name, sourceKey: resource.key } }); + } + } + + return { entries, conflicts, warnings }; +} + +/** The selection as the flat key → value record the JSON bundle is built from. */ +export function selectionValues(selection: BundleSelection): Record { + return Object.fromEntries(Array.from(selection.entries, ([key, entry]) => [key, entry.value])); +} + +function isSelected(resource: FlatResource, rules: CollectionBundleDefinition['entriesSelectionRules']): boolean { + return rules === 'All' || rules.some((rule) => matchesRule(resource, rule)); +} + +function matchesRule(resource: FlatResource, rule: EntrySelectionRule): boolean { + const tags = resource.tags && resource.tags.length > 0 ? resource.tags : undefined; + return ( + matchesPattern(resource.key, rule.matchingPattern) && matchesTags(tags, rule.matchingTags, rule.matchingTagOperator) + ); +} + +function toTransloco(resource: FlatResource, warnings: string[]): string { + if (resource.value.includes('{') && !validateICUSyntax(resource.value)) { + warnings.push(`Key '${resource.key}': value has malformed ICU syntax and was included as-is`); + } + if (hasUnbundlableBranchBody(resource.value)) { + warnings.push( + `Key '${resource.key}': a branch body cannot be carried to a Transloco runtime, so the bundled ` + + 'value does not render as written. A branch body survives only as a plain parameter name — ' + + 'not an argument carrying a format, and not a run that is no parameter name. Give the branch ' + + 'body text beside the argument, or move the format out of the branch:\n' + + ' {count, plural, =1 {{n, number} item} other {# items}}\n' + + ` value: ${resource.value}`, + ); + } + return icuToTransloco(resource.value); +} diff --git a/libs/core/src/lib/bundle/generate-bundle.spec.ts b/libs/core/src/lib/bundle/generate-bundle.spec.ts index 06c330d7..cea800b4 100644 --- a/libs/core/src/lib/bundle/generate-bundle.spec.ts +++ b/libs/core/src/lib/bundle/generate-bundle.spec.ts @@ -1,1551 +1,771 @@ -import * as path from 'node:path'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import * as fs from 'fs'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { BundleDefinition } from '../../config/bundle-definition'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; -import { RESOURCE_ENTRIES_FILENAME } from '../../constants'; -import { type GenerateBundleParams, generateBundle } from './generate-bundle'; -import type { FlatResource } from './resource-loader'; -import * as resourceLoader from './resource-loader'; - -// Mock fs and resourceLoader modules -vi.mock('fs'); -vi.mock('./resource-loader'); -vi.mock('./type-generation/generate-types'); -vi.mock('@simoncodes-ca/domain', async (importOriginal) => { - const original = await importOriginal(); - return { ...original, icuToTransloco: vi.fn((value: string) => value) }; -}); - -import * as icuToTranslocoModule from '@simoncodes-ca/domain'; -import { generateBundleTypes } from './type-generation/generate-types'; - -describe('generate-bundle', () => { - let mockConfig: LingoTrackerConfig; - - beforeEach(() => { - vi.clearAllMocks(); - - mockConfig = { +import type { Collection } from '../config/open-collection'; +import { type SeedResource, seedResources, testCollection, useTempDir } from '../../testing/temp-dir.spec-helpers'; +import { type BundleProgressEvent, generateBundle } from './generate-bundle'; + +describe('generateBundle (real fs)', () => { + const root = useTempDir('bundle-generate-'); + + afterEach(() => vi.restoreAllMocks()); + + function seed(name: string, resources: Record, overrides: Partial = {}): string { + const folder = join(root(), name); + seedResources(testCollection(folder, { name, ...overrides }), resources); + return folder; + } + + function config( + collections: Record, + overrides: Partial = {}, + ): LingoTrackerConfig { + return { exportFolder: 'dist/export', importFolder: 'dist/import', baseLocale: 'en', locales: ['en', 'fr', 'es'], - collections: { - default: { - translationsFolder: '/translations/default', - }, - admin: { - translationsFolder: '/translations/admin', - }, - }, + collections: Object.fromEntries( + Object.entries(collections).map(([name, translationsFolder]) => [name, { translationsFolder }]), + ), + ...overrides, }; + } - // Mock fs.existsSync to return true for directories - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - - // Default icuToTransloco to a pass-through so existing tests are unaffected. - // Individual describe blocks that test the transformation behaviour override this. - vi.spyOn(icuToTranslocoModule, 'icuToTransloco').mockImplementation((value) => value); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('generateBundle', () => { - /** - * The surrounding suite stubs icuToTransloco with an identity function, which would - * make an assertion on bundled output read back its own input. A block that asserts on - * what the emitter produces installs the real implementation over that stub instead. - */ - async function useRealEmitter(): Promise { - const domain = await vi.importActual('@simoncodes-ca/domain'); - - vi.spyOn(icuToTranslocoModule, 'icuToTransloco').mockImplementation(domain.icuToTransloco); - } - - function branchBodyWarnings(warnings: readonly string[]): string[] { - return warnings.filter((warning) => warning.includes('cannot be carried to a Transloco runtime')); - } - - it('should generate bundle for all locales by default', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: 'main.{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'welcome', value: 'Welcome', tags: undefined }, - ]); - - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - }; + function definition(overrides: Partial = {}): BundleDefinition { + return { bundleName: '{locale}', dist: 'dist/bundles', collections: 'All', ...overrides }; + } - const result = await generateBundle(params); + function readJson(path: string): Record { + return JSON.parse(readFileSync(path, 'utf8')) as Record; + } - expect(result.filesGenerated).toBe(3); // en, fr, es - expect(result.localesProcessed).toEqual(['en', 'fr', 'es']); - expect(result.warnings).toHaveLength(0); + it('generates every configured locale by default', async () => { + const common = seed('common', { + welcome: { source: 'Welcome', translations: { fr: 'Bienvenue', es: 'Bienvenido' } }, }); - it('should generate bundle for specific locales when provided', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'welcome', value: 'Welcome' }]); - - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en', 'fr'], - }; - - const result = await generateBundle(params); - - expect(result.filesGenerated).toBe(2); - expect(result.localesProcessed).toEqual(['en', 'fr']); + const result = await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition(), + config: config({ common }), + cwd: root(), }); - it('should warn about empty bundles', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: 'main.{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([]); - - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - }; - - const result = await generateBundle(params); + expect(result.filesGenerated).toBe(3); + expect(result.localesProcessed).toEqual(['en', 'fr', 'es']); + expect(result.warnings).toEqual([]); + }); - expect(result.filesGenerated).toBe(0); - expect(result.warnings).toContain("Bundle 'main' for locale 'en' is empty"); - expect(result.warnings).toContain("Bundle 'main' for locale 'fr' is empty"); - expect(result.warnings).toContain("Bundle 'main' for locale 'es' is empty"); + it('generates only the requested locale subset', async () => { + const common = seed('common', { + welcome: { source: 'Welcome', translations: { fr: 'Bienvenue', es: 'Bienvenido' } }, + }); + const result = await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition(), + config: config({ common }), + locales: ['en', 'fr'], + cwd: root(), }); - it('should process all collections when collections is "All"', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - }; + expect(result.filesGenerated).toBe(2); + expect(result.localesProcessed).toEqual(['en', 'fr']); + expect(existsSync(join(root(), 'dist/bundles/es.json'))).toBe(false); + }); - const loadSpy = vi - .spyOn(resourceLoader, 'loadCollectionResources') - .mockReturnValue([{ key: 'test', value: 'Test' }]); + it('warns for every empty locale and generates no files', async () => { + const result = await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition(), + config: config({}), + cwd: root(), + }); - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - }; + expect(result.filesGenerated).toBe(0); + expect(result.warnings).toContain("Bundle 'main' for locale 'en' is empty"); + expect(result.warnings).toContain("Bundle 'main' for locale 'fr' is empty"); + expect(result.warnings).toContain("Bundle 'main' for locale 'es' is empty"); + }); - await generateBundle(params); - - expect(loadSpy).toHaveBeenCalledWith( - expect.objectContaining({ name: 'default', translationsFolder: '/translations/default', baseLocale: 'en' }), - 'en', - expect.any(Map), - expect.any(Array), - ); - expect(loadSpy).toHaveBeenCalledWith( - expect.objectContaining({ name: 'admin', translationsFolder: '/translations/admin', baseLocale: 'en' }), - 'en', - expect.any(Map), - expect.any(Array), - ); + it('warns for a missing collection once per run rather than once per locale', async () => { + const common = seed('common', { + welcome: { source: 'Welcome', translations: { fr: 'Bienvenue' } }, }); - - it('should process specific collections with selection rules', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', + const result = await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition({ collections: [ - { - name: 'default', - entriesSelectionRules: [{ matchingPattern: 'apps.*' }], - }, + { name: 'nonexistent', entriesSelectionRules: 'All' }, + { name: 'common', entriesSelectionRules: 'All' }, ], - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'apps.welcome', value: 'Welcome' }, - { key: 'other.test', value: 'Test' }, - ]); - - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - }; - - await generateBundle(params); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData).toHaveProperty('apps'); - expect(writtenData).not.toHaveProperty('other'); + }), + config: config({ common }, { locales: ['en', 'fr'] }), + cwd: root(), }); - it('should warn about non-existent collections', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: [ - { - name: 'nonexistent', - entriesSelectionRules: 'All', - }, - ], - }; + expect( + result.warnings.filter((warning) => warning === "Collection 'nonexistent' not found in config"), + ).toHaveLength(1); + }); - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - }; + it('uses {locale} in a filename', async () => { + const common = seed('common', { welcome: { source: 'Welcome' } }); + await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition({ bundleName: 'main.{locale}' }), + config: config({ common }), + locales: ['en'], + cwd: root(), + }); - const result = await generateBundle(params); + expect(existsSync(join(root(), 'dist/bundles/main.en.json'))).toBe(true); + }); - expect(result.warnings).toContain("Collection 'nonexistent' not found in config"); + it('uses {locale} in a subdirectory and creates missing output directories', async () => { + const common = seed('common', { welcome: { source: 'Welcome' } }); + await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition({ bundleName: '{locale}/main' }), + config: config({ common }), + locales: ['en'], + cwd: root(), }); - it('should apply bundledKeyPrefix', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: [ - { - name: 'default', - bundledKeyPrefix: 'common', - entriesSelectionRules: 'All', - }, - ], - }; + expect(existsSync(join(root(), 'dist/bundles/en/main.json'))).toBe(true); + }); - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); + it('writes two-space-formatted JSON with nested dot-key hierarchy', async () => { + const common = seed('common', { + 'buttons.ok': { source: 'OK' }, + 'buttons.cancel': { source: 'Cancel' }, + }); + await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition(), + config: config({ common }), + locales: ['en'], + cwd: root(), + }); - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - }; + const expected = { buttons: { ok: 'OK', cancel: 'Cancel' } }; + expect(readFileSync(join(root(), 'dist/bundles/en.json'), 'utf8')).toBe(JSON.stringify(expected, null, 2)); + }); - await generateBundle(params); + it('resolves relative dist and typeDistFile paths against cwd', async () => { + const common = seed('common', { welcome: { source: 'Welcome' } }); + const result = await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition({ typeDistFile: 'types/tokens.ts' }), + config: config({ common }), + locales: ['en'], + cwd: root(), + }); - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); + expect(existsSync(join(root(), 'dist/bundles/en.json'))).toBe(true); + expect(existsSync(join(root(), 'types/tokens.ts'))).toBe(true); + expect(result.typeGenerationResult?.typeDistFile).toBe(join(root(), 'types/tokens.ts')); + }); - expect(writtenData).toHaveProperty('common'); - expect(writtenData.common).toHaveProperty('buttons'); - expect(writtenData.common.buttons.ok).toBe('OK'); + it('uses cwd to resolve a relative collection translationsFolder', async () => { + seed('translations/common', { welcome: { source: 'Welcome' } }); + const result = await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition(), + config: config({ common: 'translations/common' }), + locales: ['en'], + cwd: root(), }); - it('should apply merge strategy "merge" (first wins)', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: [ - { - name: 'default', - entriesSelectionRules: 'All', - mergeStrategy: 'merge', - }, - { - name: 'admin', - entriesSelectionRules: 'All', - mergeStrategy: 'merge', - }, - ], - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation(({ translationsFolder: folder }) => { - if (folder === '/translations/default') { - return [{ key: 'shared.title', value: 'Default Title' }]; - } - if (folder === '/translations/admin') { - return [{ key: 'shared.title', value: 'Admin Title' }]; - } - return []; - }); + expect(result.filesGenerated).toBe(1); + expect(readJson(join(root(), 'dist/bundles/en.json'))).toEqual({ welcome: 'Welcome' }); + }); - const params: GenerateBundleParams = { + describe('type generation', () => { + it('writes types and reports the generated file and key count when configured', async () => { + const common = seed('common', { 'buttons.ok': { source: 'OK' }, 'buttons.cancel': { source: 'Cancel' } }); + const result = await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, + bundleDefinition: definition({ typeDistFile: 'types/main.ts' }), + config: config({ common }), locales: ['en'], - }; - - await generateBundle(params); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); + cwd: root(), + }); - expect(writtenData.shared.title).toBe('Default Title'); + expect(result.typeGenerationResult).toBeDefined(); + expect(result.typeGenerationResult).toMatchObject({ fileGenerated: true, keysCount: 2 }); + expect(result.typeGenerationResult?.typeDistFile).toBe(join(root(), 'types/main.ts')); + expect(existsSync(join(root(), 'types/main.ts'))).toBe(true); }); - it('should apply merge strategy "override"', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: [ - { - name: 'default', - entriesSelectionRules: 'All', - }, - { - name: 'admin', - entriesSelectionRules: 'All', - mergeStrategy: 'override', - }, - ], - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation(({ translationsFolder: folder }) => { - if (folder === '/translations/default') { - return [{ key: 'shared.title', value: 'Default Title' }]; - } - if (folder === '/translations/admin') { - return [{ key: 'shared.title', value: 'Admin Title' }]; - } - return []; - }); - - const params: GenerateBundleParams = { + it('does not run type generation when no type output is configured', async () => { + const common = seed('common', { welcome: { source: 'Welcome' } }); + const result = await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, + bundleDefinition: definition(), + config: config({ common }), locales: ['en'], - }; - - await generateBundle(params); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); + cwd: root(), + }); - expect(writtenData.shared.title).toBe('Admin Title'); + expect(result.typeGenerationResult).toBeUndefined(); }); - it('should filter by tags with "Any" operator', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: [ - { - name: 'default', - entriesSelectionRules: [ - { - matchingPattern: '*', - matchingTags: ['ui', 'critical'], - matchingTagOperator: 'Any', - }, - ], - }, - ], - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'button.ok', value: 'OK', tags: ['ui'] }, - { key: 'error.critical', value: 'Error', tags: ['critical'] }, - { key: 'internal.log', value: 'Log', tags: ['debug'] }, - ]); - - const params: GenerateBundleParams = { + it('supports deprecated typeDist and emits its deprecation warning', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const common = seed('common', { welcome: { source: 'Welcome' } }); + const legacy = { ...definition(), typeDist: 'types/legacy.ts' } as unknown as BundleDefinition; + const result = await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, + bundleDefinition: legacy, + config: config({ common }), locales: ['en'], - }; - - await generateBundle(params); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); + cwd: root(), + }); - expect(writtenData.button.ok).toBe('OK'); - expect(writtenData.error.critical).toBe('Error'); - expect(writtenData.internal).toBeUndefined(); + expect(result.typeGenerationResult?.fileGenerated).toBe(true); + expect(existsSync(join(root(), 'types/legacy.ts'))).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("'typeDist' is deprecated")); }); - it('should filter by tags with "All" operator', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: [ - { - name: 'default', - entriesSelectionRules: [ - { - matchingPattern: '*', - matchingTags: ['ui', 'critical'], - matchingTagOperator: 'All', - }, - ], - }, - ], - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'button.ok', value: 'OK', tags: ['ui'] }, - { key: 'error.critical', value: 'Error', tags: ['ui', 'critical'] }, - { key: 'internal.log', value: 'Log', tags: ['debug'] }, - ]); - - const params: GenerateBundleParams = { + it('uses typeDistFile without warning when current and deprecated keys are both present', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const common = seed('common', { welcome: { source: 'Welcome' } }); + const withBoth = { + ...definition({ typeDistFile: 'types/current.ts' }), + typeDist: 'types/legacy.ts', + } as unknown as BundleDefinition; + await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, + bundleDefinition: withBoth, + config: config({ common }), locales: ['en'], - }; - - await generateBundle(params); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); + cwd: root(), + }); - expect(writtenData.button).toBeUndefined(); - expect(writtenData.error.critical).toBe('Error'); - expect(writtenData.internal).toBeUndefined(); + expect(existsSync(join(root(), 'types/current.ts'))).toBe(true); + expect(existsSync(join(root(), 'types/legacy.ts'))).toBe(false); + expect(warn).not.toHaveBeenCalled(); }); - it('should handle bundle naming with {locale} placeholder in filename', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: 'main.{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - - const params: GenerateBundleParams = { + it('passes tokenConstantName to the generated file', async () => { + const common = seed('common', { welcome: { source: 'Welcome' } }); + await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, + bundleDefinition: definition({ typeDistFile: 'types/main.ts' }), + config: config({ common }), locales: ['en'], - }; - - await generateBundle(params); + tokenConstantName: 'CUSTOM_TOKENS', + cwd: root(), + }); - expect(fs.writeFileSync).toHaveBeenCalledWith( - path.join('/dist/bundles', 'main.en.json'), - expect.any(String), - 'utf8', - ); + expect(readFileSync(join(root(), 'types/main.ts'), 'utf8')).toContain('export const CUSTOM_TOKENS'); }); - it('should handle bundle naming with {locale} placeholder in subdirectory', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}/main', - dist: '/dist/bundles', - collections: 'All', - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - - const params: GenerateBundleParams = { + it('captures thrown type generation errors as warnings', async () => { + const common = seed('common', { welcome: { source: 'Welcome' } }); + writeFileSync(join(root(), 'blocked'), 'not a directory'); + const result = await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['fr'], - }; - - await generateBundle(params); + bundleDefinition: definition({ typeDistFile: 'blocked/tokens.ts' }), + config: config({ common }), + locales: ['en'], + cwd: root(), + }); - expect(fs.writeFileSync).toHaveBeenCalledWith( - path.join('/dist/bundles', 'fr', 'main.json'), - expect.any(String), - 'utf8', - ); + expect(result.typeGenerationResult).toBeUndefined(); + expect(result.warnings.some((warning) => warning.startsWith("Type generation failed for 'main':"))).toBe(true); }); - it('should create output directory if it does not exist', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - - const params: GenerateBundleParams = { + it('reports an empty type key set as a bundle warning', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const empty = join(root(), 'empty'); + const result = await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - }; - - await generateBundle(params); - - expect(fs.mkdirSync).toHaveBeenCalledWith(path.join('/dist', 'bundles'), { - recursive: true, + bundleDefinition: definition({ typeDistFile: 'types/main.ts' }), + config: config({ empty }, { locales: [] }), + cwd: root(), }); - }); - - it('should write properly formatted JSON', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'apps.welcome', value: 'Welcome' }]); + expect(result.typeGenerationResult?.skippedReason).toBe('empty-bundle'); + expect(result.warnings).toContain("Type generation skipped for 'main': Bundle is empty"); + expect(existsSync(join(root(), 'types/main.ts'))).toBe(false); + }); - const params: GenerateBundleParams = { + it.each([ + { + name: 'CLI parameter over bundle and global config', + parameter: 'camelCase' as const, + bundle: 'upperCase' as const, + global: 'upperCase' as const, + expected: 'buttons: {', + }, + { + name: 'bundle config over global config', + parameter: undefined, + bundle: 'camelCase' as const, + global: 'upperCase' as const, + expected: 'buttons: {', + }, + { + name: 'global config when no higher override exists', + parameter: undefined, + bundle: undefined, + global: 'camelCase' as const, + expected: 'buttons: {', + }, + { + name: 'upperCase by default', + parameter: undefined, + bundle: undefined, + global: undefined, + expected: 'BUTTONS: {', + }, + ])('uses $name for token casing', async ({ parameter, bundle, global, expected }) => { + const common = seed('common', { 'buttons.ok': { source: 'OK' } }); + await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, + bundleDefinition: definition({ typeDistFile: 'types/main.ts', tokenCasing: bundle }), + config: config({ common }, { tokenCasing: global }), locales: ['en'], - }; - - await generateBundle(params); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenJson = writeCall[1] as string; + tokenCasing: parameter, + cwd: root(), + }); - // Should be formatted with 2-space indentation - expect(writtenJson).toContain('{\n "apps": {\n "welcome": "Welcome"'); - // Should be valid JSON - expect(() => JSON.parse(writtenJson)).not.toThrow(); + expect(readFileSync(join(root(), 'types/main.ts'), 'utf8')).toContain(expected); }); + }); - it('should invoke type generation when typeDistFile is configured', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - typeDistFile: 'src/generated/types.ts', - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - vi.mocked(generateBundleTypes).mockResolvedValue({ - bundleKey: 'main', - typeDistFile: 'src/generated/types.ts', - keysCount: 1, - fileGenerated: true, + describe('progress and key counts', () => { + it('emits ordered 1-based progress events with configured output paths', async () => { + const common = seed('common', { + welcome: { source: 'Welcome', translations: { fr: 'Bienvenue' } }, }); - - const params: GenerateBundleParams = { + const events: BundleProgressEvent[] = []; + const bundleDefinition = definition({ bundleName: 'main.{locale}' }); + await generateBundle({ bundleKey: 'main', bundleDefinition, - config: mockConfig, - locales: ['en'], - }; - - await generateBundle(params); + config: config({ common }), + locales: ['en', 'fr'], + onProgress: (event) => events.push(event), + cwd: root(), + }); - expect(generateBundleTypes).toHaveBeenCalledWith('main', mockConfig, 'upperCase', undefined, bundleDefinition); + expect(events).toEqual([ + { locale: 'en', index: 1, total: 2, file: 'dist/bundles/main.en.json' }, + { locale: 'fr', index: 2, total: 2, file: 'dist/bundles/main.fr.json' }, + ]); }); - it('should not invoke type generation when typeDistFile is missing', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - vi.mocked(generateBundleTypes).mockClear(); - - const params: GenerateBundleParams = { + it('counts the debug locale in progress and emits it last', async () => { + const common = seed('common', { welcome: { source: 'Welcome' } }); + const events: BundleProgressEvent[] = []; + const bundleDefinition = definition(); + await generateBundle({ bundleKey: 'main', bundleDefinition, - config: mockConfig, + config: config({ common }), locales: ['en'], - }; - - await generateBundle(params); + debugKeysLocale: '99', + onProgress: (event) => events.push(event), + cwd: root(), + }); - expect(generateBundleTypes).not.toHaveBeenCalled(); + expect(events).toEqual([ + { locale: 'en', index: 1, total: 2, file: 'dist/bundles/en.json' }, + { locale: '99', index: 2, total: 2, file: 'dist/bundles/99.json' }, + ]); }); - it('should invoke type generation when the deprecated typeDist key is present', async () => { - const bundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All' as const, - // Simulating a user config that still uses the old key name - ...({ typeDist: 'src/generated/types.ts' } as unknown as object), - } as BundleDefinition; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - vi.mocked(generateBundleTypes).mockResolvedValue({ - bundleKey: 'main', - typeDistFile: 'src/generated/types.ts', - keysCount: 1, - fileGenerated: true, - }); - - const params: GenerateBundleParams = { + it('emits progress for a locale that turns out empty', async () => { + const events: BundleProgressEvent[] = []; + await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, + bundleDefinition: definition(), + config: config({}), locales: ['en'], - }; - - await generateBundle(params); + onProgress: (event) => events.push(event), + cwd: root(), + }); - expect(generateBundleTypes).toHaveBeenCalledWith('main', mockConfig, 'upperCase', undefined, bundleDefinition); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ locale: 'en', index: 1, total: 1 }); }); - it('should use typeDistFile and not emit a deprecation warning when both typeDist and typeDistFile are present', async () => { - const bundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All' as const, - typeDistFile: 'src/generated/types.ts', - // Simulating a partially-migrated config that still has the old key alongside the new one - ...({ typeDist: 'src/generated/old-types.ts' } as unknown as object), - } as BundleDefinition; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - vi.mocked(generateBundleTypes).mockResolvedValue({ - bundleKey: 'main', - typeDistFile: 'src/generated/types.ts', - keysCount: 1, - fileGenerated: true, + it('reports key counts for each processed locale', async () => { + const common = seed('common', { + one: { source: 'One', translations: { fr: 'Un' } }, + two: { source: 'Two', translations: { fr: 'Deux' } }, }); - - const params: GenerateBundleParams = { + const result = await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - }; - - await generateBundle(params); + bundleDefinition: definition(), + config: config({ common }), + locales: ['en', 'fr'], + cwd: root(), + }); - // generateBundleTypes is mocked here so no real deprecation logic runs. - // The no-warn behaviour for the both-keys-present scenario is verified in generate-types.spec.ts. - expect(generateBundleTypes).toHaveBeenCalledWith('main', mockConfig, 'upperCase', undefined, bundleDefinition); + expect(result.keysPerLocale).toEqual({ en: 2, fr: 2 }); }); - it('should pass tokenConstantName through to generateBundleTypes', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - typeDistFile: 'src/generated/types.ts', - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - vi.mocked(generateBundleTypes).mockResolvedValue({ + it('omits empty locales from key counts and includes the debug locale', async () => { + const common = seed('common', { one: { source: 'One' } }); + const result = await generateBundle({ bundleKey: 'main', - typeDistFile: 'src/generated/types.ts', - keysCount: 1, - fileGenerated: true, + bundleDefinition: definition(), + config: config({ common }), + locales: ['en', 'fr'], + debugKeysLocale: '99', + cwd: root(), }); - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - tokenConstantName: 'CUSTOM_TOKENS', - }; - - await generateBundle(params); - - expect(vi.mocked(generateBundleTypes)).toHaveBeenCalledWith( - 'main', - mockConfig, - 'upperCase', - 'CUSTOM_TOKENS', - bundleDefinition, - ); + expect(result.keysPerLocale).toEqual({ en: 1, '99': 1 }); }); + }); - it('should capture type generation errors in warnings', async () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - typeDistFile: 'src/generated/types.ts', - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - vi.mocked(generateBundleTypes).mockRejectedValue(new Error('Type gen failed')); - - const params: GenerateBundleParams = { + describe('debugKeysLocale', () => { + it('emits one extra file whose values equal their keys', async () => { + const common = seed('common', { + 'buttons.ok': { source: 'OK' }, + 'buttons.cancel': { source: 'Cancel' }, + }); + const result = await generateBundle({ bundleKey: 'main', - bundleDefinition, - config: mockConfig, + bundleDefinition: definition(), + config: config({ common }), locales: ['en'], - }; - - const result = await generateBundle(params); - - expect(result.warnings).toContain("Type generation failed for 'main': Type gen failed"); - }); - - describe('tokenCasing precedence', () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - typeDistFile: 'src/generated/types.ts', - }; - - beforeEach(() => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'test', value: 'Test' }]); - vi.mocked(generateBundleTypes).mockResolvedValue({ - bundleKey: 'main', - typeDistFile: 'src/generated/types.ts', - keysCount: 1, - fileGenerated: true, - }); - }); - - it('should use CLI tokenCasing override when provided', async () => { - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - tokenCasing: 'camelCase', - }; - - await generateBundle(params); - - expect(vi.mocked(generateBundleTypes)).toHaveBeenCalledWith( - 'main', - mockConfig, - 'camelCase', - undefined, - bundleDefinition, - ); + debugKeysLocale: '99', + cwd: root(), }); - it('should use bundle-level tokenCasing when no CLI override is given', async () => { - const bundleDefWithCasing: BundleDefinition = { - ...bundleDefinition, - tokenCasing: 'camelCase', - }; - - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition: bundleDefWithCasing, - config: mockConfig, - locales: ['en'], - }; - - await generateBundle(params); - - expect(vi.mocked(generateBundleTypes)).toHaveBeenCalledWith( - 'main', - mockConfig, - 'camelCase', - undefined, - bundleDefWithCasing, - ); - }); - - it('should use global config tokenCasing when no CLI or bundle-level override is given', async () => { - const configWithCasing: LingoTrackerConfig = { - ...mockConfig, - tokenCasing: 'camelCase', - }; - - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: configWithCasing, - locales: ['en'], - }; - - await generateBundle(params); - - expect(vi.mocked(generateBundleTypes)).toHaveBeenCalledWith( - 'main', - configWithCasing, - 'camelCase', - undefined, - bundleDefinition, - ); + expect(result.filesGenerated).toBe(2); + expect(readJson(join(root(), 'dist/bundles/99.json'))).toEqual({ + buttons: { ok: 'buttons.ok', cancel: 'buttons.cancel' }, }); }); - describe('onProgress', () => { - const bundleDefinition: BundleDefinition = { - bundleName: 'main.{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - it('emits one event per locale, in order, with a 1-based index and the output file', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - const onProgress = vi.fn(); - - await generateBundle({ bundleKey: 'main', bundleDefinition, config: mockConfig, onProgress }); - - expect(onProgress.mock.calls.map((call) => call[0])).toEqual([ - { locale: 'en', index: 1, total: 3, file: path.join('/dist/bundles', 'main.en.json') }, - { locale: 'fr', index: 2, total: 3, file: path.join('/dist/bundles', 'main.fr.json') }, - { locale: 'es', index: 3, total: 3, file: path.join('/dist/bundles', 'main.es.json') }, - ]); - }); - - it('counts the debug-keys locale in total and emits it last', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - const onProgress = vi.fn(); - - await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en', 'fr'], - debugKeysLocale: '99', - onProgress, - }); - - expect(onProgress).toHaveBeenCalledTimes(3); - expect(onProgress.mock.calls.map((call) => call[0].total)).toEqual([3, 3, 3]); - expect(onProgress).toHaveBeenLastCalledWith({ - locale: '99', - index: 3, - total: 3, - file: path.join('/dist/bundles', 'main.99.json'), - }); + it('uses prefixed bundled keys as debug values', async () => { + const common = seed('common', { 'buttons.ok': { source: 'OK' } }); + await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition({ + collections: [{ name: 'common', entriesSelectionRules: 'All', bundledKeyPrefix: 'shared' }], + }), + config: config({ common }), + locales: [], + debugKeysLocale: 'debug', + cwd: root(), }); - it('still emits for a locale whose bundle turns out empty', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([]); - const onProgress = vi.fn(); - - await generateBundle({ bundleKey: 'main', bundleDefinition, config: mockConfig, locales: ['en'], onProgress }); - - expect(onProgress).toHaveBeenCalledTimes(1); + expect(readJson(join(root(), 'dist/bundles/debug.json'))).toEqual({ + shared: { buttons: { ok: 'shared.buttons.ok' } }, }); }); - describe('keysPerLocale', () => { - const bundleDefinition: BundleDefinition = { - bundleName: 'main.{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - it('reports the number of keys written for each processed locale', async () => { - // Two collections ('default' and 'admin') each return the same two keys, - // so the merged bundle holds two keys per locale. - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'buttons.ok', value: 'OK' }, - { key: 'buttons.cancel', value: 'Cancel' }, - ]); - - const result = await generateBundle({ bundleKey: 'main', bundleDefinition, config: mockConfig }); - - expect(result.keysPerLocale).toEqual({ en: 2, fr: 2, es: 2 }); + it('uses a custom locale code in the filename', async () => { + const common = seed('common', { welcome: { source: 'Welcome' } }); + await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition({ bundleName: 'main.{locale}' }), + config: config({ common }), + locales: [], + debugKeysLocale: 'keys', + cwd: root(), }); - it('omits empty locales and includes the debug-keys locale', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation((_folder, locale) => - locale === 'fr' ? [] : [{ key: 'buttons.ok', value: 'OK' }], - ); - - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en', 'fr'], - debugKeysLocale: '99', - }); - - expect(result.keysPerLocale).toEqual({ en: 1, '99': 1 }); - expect(result.keysPerLocale).not.toHaveProperty('fr'); - }); + expect(existsSync(join(root(), 'dist/bundles/main.keys.json'))).toBe(true); }); - describe('debugKeysLocale', () => { - const bundleDefinition: BundleDefinition = { - bundleName: 'main.{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - it('emits one extra file in addition to normal locale files', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - debugKeysLocale: '99', - }); - - // 1 real locale + 1 debug - expect(result.filesGenerated).toBe(2); - expect(result.localesProcessed).toEqual(['en', '99']); - }); - - it('writes debug file where every value equals its key', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'buttons.ok', value: 'OK' }, - { key: 'header.title', value: 'Title' }, - ]); - - await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - debugKeysLocale: '99', - }); - - const debugWriteCall = vi - .mocked(fs.writeFileSync) - .mock.calls.find((call) => (call[0] as string).includes('99')); - expect(debugWriteCall).toBeDefined(); - const writtenData = JSON.parse(debugWriteCall?.[1] as string); - expect(writtenData.buttons.ok).toBe('buttons.ok'); - expect(writtenData.header.title).toBe('header.title'); - }); - - it('uses the bundledKeyPrefix in the value (post-prefix key)', async () => { - const bundleDefWithPrefix: BundleDefinition = { - ...bundleDefinition, - collections: [{ name: 'default', bundledKeyPrefix: 'app', entriesSelectionRules: 'All' }], - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - - await generateBundle({ - bundleKey: 'main', - bundleDefinition: bundleDefWithPrefix, - config: mockConfig, - locales: ['en'], - debugKeysLocale: '99', - }); - - const debugWriteCall = vi - .mocked(fs.writeFileSync) - .mock.calls.find((call) => (call[0] as string).includes('99')); - const writtenData = JSON.parse(debugWriteCall?.[1] as string); - // Value should reflect the prefixed key - expect(writtenData.app.buttons.ok).toBe('app.buttons.ok'); - }); - - it('uses a custom locale code in the output filename', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'msg', value: 'Hello' }]); - - await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - debugKeysLocale: 'keys', - }); - - expect(fs.writeFileSync).toHaveBeenCalledWith( - path.join('/dist/bundles', 'main.keys.json'), - expect.any(String), - 'utf8', - ); + it('warns and writes no debug file for an empty bundle', async () => { + const result = await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition(), + config: config({}), + locales: [], + debugKeysLocale: '99', + cwd: root(), }); - it('emits a warning and no debug file when the bundle is empty', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([]); - - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - debugKeysLocale: '99', - }); + expect(result.warnings).toContain("Bundle 'main' debug bundle is empty"); + expect(result.filesGenerated).toBe(0); + expect(existsSync(join(root(), 'dist/bundles/99.json'))).toBe(false); + }); - expect(result.warnings).toContain("Bundle 'main' debug bundle is empty"); - expect(vi.mocked(fs.writeFileSync).mock.calls.some((c) => (c[0] as string).includes('99'))).toBe(false); + it('does not produce ICU warnings during the debug-only pass', async () => { + const common = seed('common', { greeting: { source: 'Hello {name' } }); + const result = await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition(), + config: config({ common }), + locales: [], + debugKeysLocale: '99', + transformICUToTransloco: true, + cwd: root(), }); - it('does not call icuToTransloco for the debug pass', async () => { - const icuSpy = vi.spyOn(icuToTranslocoModule, 'icuToTransloco'); - - const singleCollectionConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - default: { translationsFolder: '/translations/default' }, - }, - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'greeting', value: 'Hello {name}' }, - ]); - - await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: singleCollectionConfig, - locales: ['en'], - transformICUToTransloco: true, - debugKeysLocale: '99', - }); - - // 1 call for the real 'en' locale pass (1 collection × 1 resource); 0 for the debug pass - expect(icuSpy).toHaveBeenCalledTimes(1); - }); + expect(readJson(join(root(), 'dist/bundles/99.json'))).toEqual({ greeting: 'greeting' }); + expect(result.warnings.some((warning) => warning.includes("Key 'greeting'"))).toBe(false); }); + }); - describe('transformICUToTransloco', () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - beforeEach(() => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'greeting', value: 'Hello {name}' }, - ]); - vi.spyOn(icuToTranslocoModule, 'icuToTransloco').mockImplementation((value) => - value.replace(/\{(\w+)\}/g, '{{ $1 }}'), - ); - }); - - it('should transform ICU values to Transloco format when transformICUToTransloco is true', async () => { - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - transformICUToTransloco: true, - }; - - await generateBundle(params); - - expect(icuToTranslocoModule.icuToTransloco).toHaveBeenCalledWith('Hello {name}'); - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - expect(writtenData.greeting).toBe('Hello {{ name }}'); + describe('transformICUToTransloco precedence', () => { + it.each([ + { name: 'true parameter', parameter: true, bundle: undefined, global: undefined, expected: 'Hello {{ name }}' }, + { name: 'false parameter', parameter: false, bundle: undefined, global: undefined, expected: 'Hello {name}' }, + { name: 'default', parameter: undefined, bundle: undefined, global: undefined, expected: 'Hello {{ name }}' }, + { name: 'bundle setting', parameter: undefined, bundle: false, global: undefined, expected: 'Hello {name}' }, + { name: 'global setting', parameter: undefined, bundle: undefined, global: false, expected: 'Hello {name}' }, + { + name: 'parameter over bundle and global', + parameter: true, + bundle: false, + global: false, + expected: 'Hello {{ name }}', + }, + ])('uses the $name', async ({ parameter, bundle, global, expected }) => { + const common = seed('common', { greeting: { source: 'Hello {name}' } }); + await generateBundle({ + bundleKey: 'main', + bundleDefinition: definition({ transformICUToTransloco: bundle }), + config: config({ common }, { transformICUToTransloco: global }), + locales: ['en'], + transformICUToTransloco: parameter, + cwd: root(), }); - it('should preserve ICU values when transformICUToTransloco is false', async () => { - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - transformICUToTransloco: false, - }; - - await generateBundle(params); - - expect(icuToTranslocoModule.icuToTransloco).not.toHaveBeenCalled(); - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - expect(writtenData.greeting).toBe('Hello {name}'); - }); + expect(readJson(join(root(), 'dist/bundles/en.json'))).toEqual({ greeting: expected }); + }); + }); - it('should default transformICUToTransloco to true when not specified', async () => { - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: mockConfig, - locales: ['en'], - // transformICUToTransloco not set — should default to true - }; + describe('branch bodies the bundler cannot rewrite', () => { + const UNBUNDLABLE_VALUE = '{count, plural, =1 {{n, number}} other {# items}}'; + const UNRESOLVABLE_NAME_VALUE = '{a, plural, one {{some text}} other {z}}'; + const EXPANDED_VALUE = + 'This will delete {nameExists, select, hasName {{name}} other {this item}} and cannot be undone.'; + const EXPANDED_OUTPUT = + 'This will delete {nameExists, select, hasName {{{name}}} other {this item}} and cannot be undone.'; + const SELECTORDINAL_CASES: readonly { description: string; stored: string; emitted: string }[] = [ + { + description: 'a selectordinal group with its branches intact', + stored: '{rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}', + emitted: '{rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}', + }, + { + description: 'a bare-argument selectordinal branch body as the triple', + stored: '{rank, selectordinal, one {{itemName}} other {#th}}', + emitted: '{rank, selectordinal, one {{{itemName}}} other {#th}}', + }, + ]; + const SAFE_VALUES: readonly string[] = [ + 'x {{name} extra}', + 'x {pre {name}}', + 'x {{b, plural, one {p} other {q}}}', + '{a, plural, one {{b, plural, one {p} other {q}}} other {z}}', + ]; - await generateBundle(params); + function branchBodyWarnings(warnings: readonly string[]): string[] { + return warnings.filter((warning) => warning.includes('cannot be carried to a Transloco runtime')); + } - expect(icuToTranslocoModule.icuToTransloco).toHaveBeenCalledWith('Hello {name}'); + async function bundleValue( + key: string, + source: string, + options: { translations?: Record; locales?: string[]; transform?: boolean } = {}, + ): Promise>> { + const common = seed('common', { + [key]: { source, ...(options.translations && { translations: options.translations }) }, }); - - it('should use bundle-level transformICUToTransloco when no CLI override is given', async () => { - const bundleDefWithFlag: BundleDefinition = { - ...bundleDefinition, - transformICUToTransloco: false, - }; - - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition: bundleDefWithFlag, - config: mockConfig, - locales: ['en'], - }; - - await generateBundle(params); - - expect(icuToTranslocoModule.icuToTransloco).not.toHaveBeenCalled(); + return generateBundle({ + bundleKey: 'main', + bundleDefinition: definition(), + config: config({ common }, { locales: options.locales ?? ['en'] }), + locales: options.locales ?? ['en'], + transformICUToTransloco: options.transform ?? true, + cwd: root(), }); + } - it('should use global config transformICUToTransloco when no CLI or bundle-level override is given', async () => { - const configWithFlag: LingoTrackerConfig = { - ...mockConfig, - transformICUToTransloco: false, - }; - - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition, - config: configWithFlag, - locales: ['en'], - }; + it('warns once for a format-carrying branch body', async () => { + const result = await bundleValue('itemCount', UNBUNDLABLE_VALUE); + const reported = branchBodyWarnings(result.warnings); - await generateBundle(params); + expect(reported).toHaveLength(1); + expect(reported[0]).toContain("Key 'itemCount':"); + expect(reported[0]).toContain('does not render as written'); + expect(reported[0]).toContain('an argument carrying a format'); + expect(reported[0]).toContain(`value: ${UNBUNDLABLE_VALUE}`); + expect(reported[0]).not.toContain('malformed'); + }); - expect(icuToTranslocoModule.icuToTransloco).not.toHaveBeenCalled(); - }); + it('warns for a double-brace branch run that is not a parameter name', async () => { + const result = await bundleValue('choice', UNRESOLVABLE_NAME_VALUE); + const reported = branchBodyWarnings(result.warnings); - it('should let CLI override take precedence over bundle and global config', async () => { - const bundleDefWithFlag: BundleDefinition = { - ...bundleDefinition, - transformICUToTransloco: false, - }; - const configWithFlag: LingoTrackerConfig = { - ...mockConfig, - transformICUToTransloco: false, - }; - - const params: GenerateBundleParams = { - bundleKey: 'main', - bundleDefinition: bundleDefWithFlag, - config: configWithFlag, - locales: ['en'], - transformICUToTransloco: true, // CLI override wins - }; - - await generateBundle(params); - - expect(icuToTranslocoModule.icuToTransloco).toHaveBeenCalledWith('Hello {name}'); - }); + expect(reported).toHaveLength(1); + expect(reported[0]).toContain("Key 'choice':"); + expect(reported[0]).toContain('a run that is no parameter name'); + expect(reported[0]).toContain(`value: ${UNRESOLVABLE_NAME_VALUE}`); }); - describe('branch bodies the bundler cannot rewrite', () => { - const bundleDefinition: BundleDefinition = { - bundleName: '{locale}', - dist: '/dist/bundles', - collections: 'All', - }; - - const singleCollectionConfig: LingoTrackerConfig = { - exportFolder: 'dist/export', - importFolder: 'dist/import', - baseLocale: 'en', + it('warns once per key per locale and once across each locale', async () => { + const result = await bundleValue('itemCount', UNBUNDLABLE_VALUE, { + translations: { fr: UNBUNDLABLE_VALUE }, locales: ['en', 'fr'], - collections: { - default: { translationsFolder: '/translations/default' }, - }, - }; - - /** A branch body that is an argument carrying a format. */ - const UNBUNDLABLE_VALUE = '{count, plural, =1 {{n, number}} other {# items}}'; - - /** A branch body whose double-brace run is not a parameter name. */ - const UNRESOLVABLE_NAME_VALUE = '{a, plural, one {{some text}} other {z}}'; - - /** A branch body that is a bare argument, which the emitter carries as the triple. */ - const EXPANDED_VALUE = - 'This will delete {nameExists, select, hasName {{name}} other {this item}} and cannot be undone.'; - const EXPANDED_OUTPUT = - 'This will delete {nameExists, select, hasName {{{name}}} other {this item}} and cannot be undone.'; - - /** - * `selectordinal` groups the emitter must carry the same way it carries `plural` and - * `select`: the structure stands, and a bare-argument branch body gains the triple. - */ - const SELECTORDINAL_CASES: readonly { description: string; stored: string; emitted: string }[] = [ - { - description: 'a selectordinal group with its branches intact', - stored: '{rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}', - emitted: '{rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}', - }, - { - description: 'a bare-argument selectordinal branch body as the triple', - stored: '{rank, selectordinal, one {{itemName}} other {#th}}', - emitted: '{rank, selectordinal, one {{{itemName}}} other {#th}}', - }, - ]; - - /** The safe shapes that must never be reported. */ - const SAFE_VALUES: readonly string[] = [ - 'x {{name} extra}', - 'x {pre {name}}', - 'x {{b, plural, one {p} other {q}}}', - '{a, plural, one {{b, plural, one {p} other {q}}} other {z}}', - ]; - - beforeEach(async () => { - await useRealEmitter(); - }); - - it('runs the real emitter rather than the suite-level pass-through', () => { - // Every assertion about bundled output in this suite holds only while this hook does. - // Under the pass-through they would read back their own input and stay green. - expect(icuToTranslocoModule.icuToTransloco(EXPANDED_VALUE)).toBe(EXPANDED_OUTPUT); - }); - - it('warns once for a key whose branch body is an argument carrying a format', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'itemCount', value: UNBUNDLABLE_VALUE }, - ]); - - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: singleCollectionConfig, - locales: ['en'], - transformICUToTransloco: true, - }); - - const reported = branchBodyWarnings(result.warnings); - - expect(reported).toHaveLength(1); - expect(reported[0]).toContain("Key 'itemCount':"); - expect(reported[0]).toContain('does not render as written'); - expect(reported[0]).toContain('an argument carrying a format'); - expect(reported[0]).toContain(`value: ${UNBUNDLABLE_VALUE}`); - expect(reported[0]).not.toContain('malformed'); - }); - - it('warns for a branch body whose double-brace run is not a parameter name', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'choice', value: UNRESOLVABLE_NAME_VALUE }, - ]); - - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: singleCollectionConfig, - locales: ['en'], - transformICUToTransloco: true, - }); - - const reported = branchBodyWarnings(result.warnings); - - expect(reported).toHaveLength(1); - expect(reported[0]).toContain("Key 'choice':"); - expect(reported[0]).toContain('a run that is no parameter name'); - expect(reported[0]).toContain(`value: ${UNRESOLVABLE_NAME_VALUE}`); - }); - - it('warns once per locale', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'itemCount', value: UNBUNDLABLE_VALUE }, - ]); - - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: singleCollectionConfig, - locales: ['en', 'fr'], - transformICUToTransloco: true, - }); - - expect(branchBodyWarnings(result.warnings)).toHaveLength(2); }); - it('bundles the value unchanged and generates the file', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'itemCount', value: UNBUNDLABLE_VALUE }, - ]); + expect(branchBodyWarnings(result.warnings)).toHaveLength(2); + }); - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: singleCollectionConfig, - locales: ['en'], - transformICUToTransloco: true, - }); + it('bundles the emitted value and still generates the file', async () => { + const result = await bundleValue('itemCount', UNBUNDLABLE_VALUE); - expect(result.filesGenerated).toBe(1); + expect(result.filesGenerated).toBe(1); + expect(readJson(join(root(), 'dist/bundles/en.json'))).toEqual({ itemCount: UNBUNDLABLE_VALUE }); + }); - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); + it('does not warn when ICU transformation is disabled', async () => { + const result = await bundleValue('itemCount', UNBUNDLABLE_VALUE, { transform: false }); + expect(branchBodyWarnings(result.warnings)).toHaveLength(0); + }); - expect(writtenData.itemCount).toBe(UNBUNDLABLE_VALUE); - }); + it('bundles a bare-argument branch body as the triple', async () => { + const result = await bundleValue('deleteConfirm', EXPANDED_VALUE); - it('does not warn when the ICU transformation is off', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'itemCount', value: UNBUNDLABLE_VALUE }, - ]); + expect(branchBodyWarnings(result.warnings)).toHaveLength(0); + expect(readJson(join(root(), 'dist/bundles/en.json'))).toEqual({ deleteConfirm: EXPANDED_OUTPUT }); + }); - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: singleCollectionConfig, - locales: ['en'], - transformICUToTransloco: false, - }); + for (const { description, stored, emitted } of SELECTORDINAL_CASES) { + it(`bundles ${description}`, async () => { + const result = await bundleValue('rank', stored); expect(branchBodyWarnings(result.warnings)).toHaveLength(0); + expect(readJson(join(root(), 'dist/bundles/en.json'))).toEqual({ rank: emitted }); }); + } - it('bundles a bare-argument branch body as the triple', async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([ - { key: 'deleteConfirm', value: EXPANDED_VALUE }, - ]); - - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: singleCollectionConfig, - locales: ['en'], - transformICUToTransloco: true, - }); - + for (const value of SAFE_VALUES) { + it(`does not warn for ${value}`, async () => { + const result = await bundleValue('safe', value); expect(branchBodyWarnings(result.warnings)).toHaveLength(0); - expect(result.filesGenerated).toBe(1); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.deleteConfirm).toBe(EXPANDED_OUTPUT); }); + } + }); - for (const { description, stored, emitted } of SELECTORDINAL_CASES) { - it(`bundles ${description}`, async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'rank', value: stored }]); - - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: singleCollectionConfig, - locales: ['en'], - transformICUToTransloco: true, - }); - - expect(branchBodyWarnings(result.warnings)).toHaveLength(0); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.rank).toBe(emitted); - }); - } - - for (const value of SAFE_VALUES) { - it(`does not warn for ${value}`, async () => { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockReturnValue([{ key: 'safe', value }]); - - const result = await generateBundle({ - bundleKey: 'main', - bundleDefinition, - config: singleCollectionConfig, - locales: ['en'], - transformICUToTransloco: true, - }); - - expect(branchBodyWarnings(result.warnings)).toHaveLength(0); - }); - } - }); - - describe('the icu-edge-cases fixture collection', () => { - /** Repository root, five directories above this spec. */ - const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..'); - - const FIXTURE_COLLECTION = 'icuEdgeCases'; - - /** The locales the collection is configured for. Narrowing this set narrows the coverage. */ - const EXPECTED_FIXTURE_LOCALES: readonly string[] = ['en', 'fr-ca', 'ja']; - - /** The one fixture key whose branch body is an argument carrying a format. */ - const FORMAT_CARRYING_KEY = 'status.syncedRecordCount'; - - interface FixtureConfigFile { - readonly baseLocale: string; - readonly locales?: string[]; - readonly collections: Record; - } - - let realFs: typeof import('node:fs'); - let fixtureConfig: LingoTrackerConfig; - let fixtureLocales: string[]; - - /** - * Reads the stored fixture entries the way `loadCollectionResources` does. The - * surrounding suite mocks `fs` and `./resource-loader`, so the real loader cannot - * reach disk here. This walk stands in for it and reads through the unmocked module. - */ - function loadFixtureResources(translationsFolder: string, locale: string, baseLocale: string): FlatResource[] { - const resources: FlatResource[] = []; - - const walk = (directory: string, keyPrefix: string): void => { - for (const dirent of realFs.readdirSync(directory, { withFileTypes: true })) { - const childPath = path.join(directory, dirent.name); - - if (dirent.isDirectory()) { - walk(childPath, keyPrefix ? `${keyPrefix}.${dirent.name}` : dirent.name); - continue; - } - - if (dirent.name !== RESOURCE_ENTRIES_FILENAME) continue; - - const entries = JSON.parse(realFs.readFileSync(childPath, 'utf8')) as Record< - string, - Record - >; - - for (const [entryKey, entry] of Object.entries(entries)) { - const value = locale === baseLocale ? entry.source : entry[locale]; - - if (typeof value === 'string') { - resources.push({ key: keyPrefix ? `${keyPrefix}.${entryKey}` : entryKey, value }); - } - } - } - }; - - walk(path.resolve(REPO_ROOT, translationsFolder), ''); - - return resources; - } - - /** Bundle output nests by key segment. The harness works on whole keys. */ - function flattenBundle(data: Record, prefix = ''): Record { - const flat: Record = {}; - - for (const [key, value] of Object.entries(data)) { - const fullKey = prefix ? `${prefix}.${key}` : key; - - if (typeof value === 'string') { - flat[fullKey] = value; - } else if (value && typeof value === 'object') { - Object.assign(flat, flattenBundle(value as Record, fullKey)); - } - } - - return flat; - } - - /** Bundles one locale and hands back every warning the run produced, unfiltered. */ - async function bundleFixtureLocale( - locale: string, - ): Promise<{ emitted: Record; warnings: string[] }> { - vi.mocked(fs.writeFileSync).mockClear(); - - const result = await generateBundle({ - bundleKey: 'icu-edge-cases', - bundleDefinition: { bundleName: '{locale}', dist: 'dist/fixture-bundles', collections: 'All' }, - config: fixtureConfig, - locales: [locale], - transformICUToTransloco: true, - }); - - expect(result.filesGenerated).toBe(1); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - expect(writeCall).toBeDefined(); - - return { - emitted: flattenBundle(JSON.parse(String(writeCall?.[1])) as Record), - warnings: result.warnings, - }; - } - - beforeEach(async () => { - await useRealEmitter(); - - realFs = await vi.importActual('node:fs'); - - const rawConfig = JSON.parse( - realFs.readFileSync(path.join(REPO_ROOT, '.lingo-tracker.json'), 'utf8'), - ) as FixtureConfigFile; + describe('the icu-edge-cases fixture collection', () => { + const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..'); + const FIXTURE_COLLECTION = 'icuEdgeCases'; + const EXPECTED_FIXTURE_LOCALES: readonly string[] = ['en', 'fr-ca', 'ja']; + const FORMAT_CARRYING_KEY = 'status.syncedRecordCount'; - const collection = rawConfig.collections[FIXTURE_COLLECTION]; - expect(collection).toBeDefined(); + interface FixtureConfigFile { + readonly baseLocale: string; + readonly locales?: string[]; + readonly collections: Record; + } - fixtureLocales = collection?.locales ?? rawConfig.locales ?? []; - fixtureConfig = { + function fixture(): { config: LingoTrackerConfig; locales: string[] } { + const raw = JSON.parse(readFileSync(join(REPO_ROOT, '.lingo-tracker.json'), 'utf8')) as FixtureConfigFile; + const collection = raw.collections[FIXTURE_COLLECTION]; + expect(collection).toBeDefined(); + const locales = collection?.locales ?? raw.locales ?? []; + return { + locales, + config: { exportFolder: 'dist/export', importFolder: 'dist/import', - baseLocale: rawConfig.baseLocale, - locales: fixtureLocales, + baseLocale: raw.baseLocale, + locales, collections: collection ? { [FIXTURE_COLLECTION]: { ...collection, - translationsFolder: path.resolve(REPO_ROOT, collection.translationsFolder), + translationsFolder: resolve(REPO_ROOT, collection.translationsFolder), }, } : {}, - }; - - vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation( - ({ translationsFolder, baseLocale }, locale) => loadFixtureResources(translationsFolder, locale, baseLocale), - ); - }); + }, + }; + } - it('runs the real emitter rather than the suite-level pass-through', () => { - // Under the pass-through every assertion in this suite would run against stored - // ICU, so nothing would exercise the triple and the suite would stay green. - expect(icuToTranslocoModule.icuToTransloco('Cannot delete {n, plural, =1 {{itemName}} other {items}}')).toBe( - 'Cannot delete {n, plural, =1 {{{itemName}}} other {items}}', - ); - }); + function flattenBundle(data: Record, prefix = ''): Record { + const flat: Record = {}; + for (const [key, value] of Object.entries(data)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'string') flat[fullKey] = value; + else if (value && typeof value === 'object') { + Object.assign(flat, flattenBundle(value as Record, fullKey)); + } + } + return flat; + } - it('produces one file per configured locale, including ja', async () => { - expect(fixtureLocales).toEqual(EXPECTED_FIXTURE_LOCALES); + async function bundleFixtureLocale( + locale: string, + ): Promise<{ emitted: Record; warnings: string[] }> { + const fixtureData = fixture(); + const result = await generateBundle({ + bundleKey: 'icu-edge-cases', + bundleDefinition: { bundleName: '{locale}', dist: join(root(), 'fixture-bundles'), collections: 'All' }, + config: fixtureData.config, + locales: [locale], + transformICUToTransloco: true, + cwd: root(), + }); + expect(result.filesGenerated).toBe(1); + return { + emitted: flattenBundle(readJson(join(root(), 'fixture-bundles', `${locale}.json`))), + warnings: result.warnings, + }; + } - const result = await generateBundle({ - bundleKey: 'icu-edge-cases', - bundleDefinition: { bundleName: '{locale}', dist: 'dist/fixture-bundles', collections: 'All' }, - config: fixtureConfig, - locales: fixtureLocales, - transformICUToTransloco: true, - }); + it('produces one file per configured locale, including ja', async () => { + const fixtureData = fixture(); + expect(fixtureData.locales).toEqual(EXPECTED_FIXTURE_LOCALES); - expect(result.filesGenerated).toBe(fixtureLocales.length); - expect(result.localesProcessed).toEqual(fixtureLocales); + const result = await generateBundle({ + bundleKey: 'icu-edge-cases', + bundleDefinition: { bundleName: '{locale}', dist: join(root(), 'fixture-bundles'), collections: 'All' }, + config: fixtureData.config, + locales: fixtureData.locales, + transformICUToTransloco: true, + cwd: root(), }); - it('carries both branch-body shapes under one key, keyed on the position and not the key', async () => { - const base = await bundleFixtureLocale('en'); - const japanese = await bundleFixtureLocale('ja'); - - // The stored source is a placeholder followed by branch text, so it stays as written. - expect(base.emitted['errors.restrictedChildren']).toContain('=1 {{itemName} contains}'); - // The ja value puts a bare placeholder in the same branch, so it gains the brace pair. - expect(japanese.emitted['errors.restrictedChildren']).toContain('=1 {{{itemName}}}'); - }); + expect(result.filesGenerated).toBe(fixtureData.locales.length); + expect(result.localesProcessed).toEqual(fixtureData.locales); + for (const locale of fixtureData.locales) { + expect(existsSync(join(root(), 'fixture-bundles', `${locale}.json`))).toBe(true); + } + }); - it('bundles every value and warns once per locale, only for the format-carrying branch body', async () => { - const warned: string[] = []; + it('carries both branch-body shapes based on position rather than key', async () => { + const base = await bundleFixtureLocale('en'); + const japanese = await bundleFixtureLocale('ja'); - for (const locale of fixtureLocales) { - const { emitted, warnings } = await bundleFixtureLocale(locale); + expect(base.emitted['errors.restrictedChildren']).toContain('=1 {{itemName} contains}'); + expect(japanese.emitted['errors.restrictedChildren']).toContain('=1 {{{itemName}}}'); + }); - expect(Object.keys(emitted).length).toBeGreaterThan(0); + it('bundles every value and warns once per locale only for the format-carrying key', async () => { + const { locales } = fixture(); + const warned: string[] = []; - for (const warning of warnings) { - warned.push(`${locale}:${warning}`); - } - } + for (const locale of locales) { + const { emitted, warnings } = await bundleFixtureLocale(locale); + expect(Object.keys(emitted).length).toBeGreaterThan(0); + for (const warning of warnings) warned.push(`${locale}:${warning}`); + } - // The complete warning set, not the branch-body subset. A malformed stored value or - // an empty bundle warns too, and either one means the collection stopped bundling - // cleanly. - expect(warned).toHaveLength(fixtureLocales.length); - for (const warning of warned) { - expect(warning).toContain(`Key '${FORMAT_CARRYING_KEY}'`); - expect(warning).toContain('cannot be carried to a Transloco runtime'); - } - }); + expect(warned).toHaveLength(locales.length); + for (const warning of warned) { + expect(warning).toContain(`Key '${FORMAT_CARRYING_KEY}'`); + expect(warning).toContain('cannot be carried to a Transloco runtime'); + } }); }); }); diff --git a/libs/core/src/lib/bundle/generate-bundle.ts b/libs/core/src/lib/bundle/generate-bundle.ts index 0ae70ca1..3b6d3660 100644 --- a/libs/core/src/lib/bundle/generate-bundle.ts +++ b/libs/core/src/lib/bundle/generate-bundle.ts @@ -1,29 +1,26 @@ /** - * Core bundle generation logic + * Bundle generation: writes a bundle's JSON file per locale (and the debug-keys file and the type + * file when asked) from the Bundle Selection. */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { hasUnbundlableBranchBody, icuToTransloco, type TokenCasing, validateICUSyntax } from '@simoncodes-ca/domain'; -import { - type BundleDefinition, - type CollectionBundleDefinition, - type EntrySelectionRule, - hasTypeDistConfigured, -} from '../../config/bundle-definition'; +import type { TokenCasing } from '@simoncodes-ca/domain'; +import { type BundleDefinition, hasTypeDistConfigured } from '../../config/bundle-definition'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; -import { type Collection, openCollection } from '../config/open-collection'; +import { + type BundleSelection, + resolveBundleCollections, + selectBundleEntries, + selectionValues, +} from './bundle-selection'; import { buildHierarchy } from './hierarchy-builder'; -import { matchesPattern } from './pattern-matcher'; +import { type BundleLocale, COLLECTION_BASE_LOCALE, type CollectionReadCache } from './resource-loader'; import { - type BundleLocale, - COLLECTION_BASE_LOCALE, - type CollectionReadCache, - type FlatResource, - loadCollectionResources, -} from './resource-loader'; -import { matchesTags } from './tag-filter'; -import { type GenerateTypesResult, generateBundleTypes } from './type-generation/generate-types'; + type GenerateBundleTypesParams, + type GenerateTypesResult, + generateBundleTypes, +} from './type-generation/generate-types'; export interface GenerateBundleParams { readonly bundleKey: string; @@ -55,6 +52,11 @@ export interface GenerateBundleParams { * requested, is included in `total` and emitted last). */ readonly onProgress?: (event: BundleProgressEvent) => void; + /** + * The project directory (holding `.lingo-tracker.json`): translations folders, `dist` and + * `typeDistFile` resolve against it. Default: `process.cwd()`. + */ + readonly cwd?: string; } export interface BundleProgressEvent { @@ -79,156 +81,84 @@ export interface GenerateBundleResult { } /** - * Optional trace collected while merging collections into a bundle. - * Used by the dry-run planner to report key conflicts and winning origins. - */ -export interface BundleKeyTrace { - /** Final (prefixed) keys that were defined by more than one resource. */ - readonly conflicts: Set; - /** Winning origin per final key. */ - readonly origins: Map; -} - -export interface BundleKeyOrigin { - readonly collectionName: string; - readonly sourceKey: string; -} - -/** - * Generates translation bundle files for specified bundle configuration - * - * @param params - Bundle generation parameters - * @returns Result with count of files generated and any warnings + * Generates a bundle's files: one JSON file per locale (a locale with no entries is skipped with a + * warning), the debug-keys file when `debugKeysLocale` is set, and the type file when the + * definition configures one. Collections the config lacks, unreadable folders, ICU values that do + * not carry to Transloco, and type generation failures are reported in `warnings`. */ export async function generateBundle(params: GenerateBundleParams): Promise { - const { - bundleKey, - bundleDefinition, - config, - locales, - tokenCasing: tokenCasingOverride, - tokenConstantName, - transformICUToTransloco: transformICUToTranslocoOverride, - debugKeysLocale, - onProgress, - } = params; - const warnings: string[] = []; - const localesProcessed: string[] = []; - const keysPerLocale: Record = {}; - - // Resolve token casing: CLI override → bundle config → global config → default - const resolvedTokenCasing: TokenCasing = - tokenCasingOverride ?? bundleDefinition.tokenCasing ?? config.tokenCasing ?? 'upperCase'; - - // Resolve ICU transformation: CLI override → bundle config → global config → default (true) - const resolvedTransformICUToTransloco: boolean = - transformICUToTranslocoOverride ?? + const { bundleKey, bundleDefinition, config, debugKeysLocale, onProgress } = params; + const cwd = params.cwd ?? process.cwd(); + + // CLI override → bundle config → global config → default + const tokenCasing: TokenCasing = + params.tokenCasing ?? bundleDefinition.tokenCasing ?? config.tokenCasing ?? 'upperCase'; + const transformICUToTransloco: boolean = + params.transformICUToTransloco ?? bundleDefinition.transformICUToTransloco ?? config.transformICUToTransloco ?? true; - const targetLocales = locales ?? config.locales; - let filesGenerated = 0; - const resourceCache: CollectionReadCache = new Map(); - const totalFiles = targetLocales.length + (debugKeysLocale ? 1 : 0); - let progressIndex = 0; - - for (const locale of targetLocales) { - progressIndex++; - onProgress?.({ - locale, - index: progressIndex, - total: totalFiles, - file: getBundleOutputPath(bundleDefinition, locale), - }); - - const bundleData = collectBundleData( - bundleDefinition, - config, - locale, - warnings, - resolvedTransformICUToTransloco, - resourceCache, - ); - - if (Object.keys(bundleData).length === 0) { - warnings.push(`Bundle '${bundleKey}' for locale '${locale}' is empty`); - continue; - } - - const hierarchicalData = buildHierarchy(bundleData); + const { collections, warnings: missing } = resolveBundleCollections(bundleDefinition, config, { cwd }); + const warnings = [...missing]; + const cache: CollectionReadCache = new Map(); + const select = (locale: BundleLocale, transform: boolean): BundleSelection => { + const selection = selectBundleEntries(collections, locale, { transformICUToTransloco: transform, cache }); + warnings.push(...selection.warnings); + return selection; + }; + // Every collection's base keys, so a collection with its own base locale is not left out. + let baseKeys: string[] | undefined; + const selectBaseKeys = (): string[] => { + baseKeys ??= Array.from(select(COLLECTION_BASE_LOCALE, false).entries.keys()); + return baseKeys; + }; - const outputPath = getBundleOutputPath(bundleDefinition, locale); - writeBundleFile(outputPath, hierarchicalData); + const localesProcessed: string[] = []; + const keysPerLocale: Record = {}; - filesGenerated++; + const write = (locale: string, data: Record): void => { + writeBundleFile(path.resolve(cwd, getBundleOutputPath(bundleDefinition, locale)), buildHierarchy(data)); localesProcessed.push(locale); - keysPerLocale[locale] = Object.keys(bundleData).length; - } - - if (debugKeysLocale) { - progressIndex++; - onProgress?.({ - locale: debugKeysLocale, - index: progressIndex, - total: totalFiles, - file: getBundleOutputPath(bundleDefinition, debugKeysLocale), - }); + keysPerLocale[locale] = Object.keys(data).length; + }; - // Every collection's base values, so a collection with its own base locale is not left out. - const debugBaseData = collectBundleData( - bundleDefinition, - config, - COLLECTION_BASE_LOCALE, - warnings, - false, - resourceCache, - ); + const targetLocales = params.locales ?? config.locales; + const total = targetLocales.length + (debugKeysLocale ? 1 : 0); + const progress = (locale: string, index: number): void => + onProgress?.({ locale, index, total, file: getBundleOutputPath(bundleDefinition, locale) }); - const debugData: Record = {}; - for (const key of Object.keys(debugBaseData)) { - debugData[key] = key; + targetLocales.forEach((locale, index) => { + progress(locale, index + 1); + const selection = select(locale, transformICUToTransloco); + if (selection.entries.size === 0) { + warnings.push(`Bundle '${bundleKey}' for locale '${locale}' is empty`); + return; } + write(locale, selectionValues(selection)); + }); - if (Object.keys(debugData).length === 0) { + if (debugKeysLocale) { + progress(debugKeysLocale, total); + const keys = selectBaseKeys(); + if (keys.length === 0) { warnings.push(`Bundle '${bundleKey}' debug bundle is empty`); } else { - const hierarchicalData = buildHierarchy(debugData); - const outputPath = getBundleOutputPath(bundleDefinition, debugKeysLocale); - writeBundleFile(outputPath, hierarchicalData); - filesGenerated++; - localesProcessed.push(debugKeysLocale); - keysPerLocale[debugKeysLocale] = Object.keys(debugData).length; + write(debugKeysLocale, Object.fromEntries(keys.map((key) => [key, key]))); } } - // Generate types if configured - let typeGenerationResult: GenerateTypesResult | undefined; - if (hasTypeDistConfigured(bundleDefinition)) { - try { - typeGenerationResult = await generateBundleTypes( - bundleKey, - config, - resolvedTokenCasing, - tokenConstantName, - bundleDefinition, - ); - if (typeGenerationResult.fileGenerated) { - // We don't increment filesGenerated here as it tracks bundle JSON files - // But we could add a note to warnings or a new field if needed - } else if (typeGenerationResult.skippedReason === 'empty-bundle') { - warnings.push(`Type generation skipped for '${bundleKey}': Bundle is empty`); - } - } catch (error) { - warnings.push( - `Type generation failed for '${bundleKey}': ${error instanceof Error ? error.message : String(error)}`, - ); - } - } + const typeGenerationResult = hasTypeDistConfigured(bundleDefinition) + ? generateTypes( + { bundleKey, definition: bundleDefinition, tokenCasing, tokenConstantName: params.tokenConstantName, cwd }, + selectBaseKeys, + warnings, + ) + : undefined; return { bundleKey, - filesGenerated, + filesGenerated: localesProcessed.length, warnings, localesProcessed, keysPerLocale, @@ -237,150 +167,31 @@ export async function generateBundle(params: GenerateBundleParams): Promise, + selectKeys: () => readonly string[], warnings: string[], - transformICUToTransloco: boolean, - cache: CollectionReadCache, - trace?: BundleKeyTrace, -): Record { - const bundleData: Record = {}; - - if (bundleDefinition.collections === 'All') { - for (const collectionName of Object.keys(config.collections)) { - const collectionBundleDef: CollectionBundleDefinition = { - name: collectionName, - entriesSelectionRules: 'All', - }; - processCollection( - collectionBundleDef, - openCollection(config, collectionName), - locale, - bundleData, - transformICUToTransloco, - warnings, - cache, - trace, - ); - } - } else { - for (const collectionBundleDef of bundleDefinition.collections) { - if (!Object.keys(config.collections).includes(collectionBundleDef.name)) { - warnings.push(`Collection '${collectionBundleDef.name}' not found in config`); - continue; - } - - processCollection( - collectionBundleDef, - openCollection(config, collectionBundleDef.name), - locale, - bundleData, - transformICUToTransloco, - warnings, - cache, - trace, - ); - } - } - - return bundleData; -} - -/** - * Processes a single collection and adds its entries to bundle data. - * The collection's own base locale decides whether `locale` reads the base value or a translation. - */ -function processCollection( - collectionDef: CollectionBundleDefinition, - collection: Collection, - locale: BundleLocale, - bundleData: Record, - transformICUToTransloco: boolean, - warnings: string[], - cache: CollectionReadCache, - trace?: BundleKeyTrace, -): void { - const resources = loadCollectionResources(collection, locale, cache, warnings); - const filteredResources = filterResources(resources, collectionDef); - const mergeStrategy = collectionDef.mergeStrategy ?? 'merge'; - - for (const resource of filteredResources) { - const finalKey = collectionDef.bundledKeyPrefix - ? `${collectionDef.bundledKeyPrefix}.${resource.key}` - : resource.key; - - let finalValue = resource.value; - if (transformICUToTransloco) { - if (resource.value.includes('{') && !validateICUSyntax(resource.value)) { - warnings.push(`Key '${resource.key}': value has malformed ICU syntax and was included as-is`); - } - if (hasUnbundlableBranchBody(resource.value)) { - warnings.push( - `Key '${resource.key}': a branch body cannot be carried to a Transloco runtime, so the bundled ` + - 'value does not render as written. A branch body survives only as a plain parameter name — ' + - 'not an argument carrying a format, and not a run that is no parameter name. Give the branch ' + - 'body text beside the argument, or move the format out of the branch:\n' + - ' {count, plural, =1 {{n, number} item} other {# items}}\n' + - ` value: ${resource.value}`, - ); - } - finalValue = icuToTransloco(resource.value); +): GenerateTypesResult | undefined { + try { + const result = generateBundleTypes({ ...params, keys: selectKeys() }); + if (result.skippedReason === 'empty-bundle') { + warnings.push(`Type generation skipped for '${params.bundleKey}': Bundle is empty`); } - - if (finalKey in bundleData) { - trace?.conflicts.add(finalKey); - if (mergeStrategy === 'override') { - bundleData[finalKey] = finalValue; - trace?.origins.set(finalKey, { collectionName: collectionDef.name, sourceKey: resource.key }); - } - // merge (default) - keep existing (first wins) - // Skip to next resource since key already exists - } else { - // New key - add it - bundleData[finalKey] = finalValue; - trace?.origins.set(finalKey, { collectionName: collectionDef.name, sourceKey: resource.key }); - } - } -} - -/** - * Filters resources based on entry selection rules - */ -function filterResources(resources: FlatResource[], collectionDef: CollectionBundleDefinition): FlatResource[] { - if (collectionDef.entriesSelectionRules === 'All') { - return resources; - } - - // Apply selection rules (TypeScript knows it's EntrySelectionRule[] here) - const rules = collectionDef.entriesSelectionRules; - return resources.filter((resource) => matchesAnyRule(resource, rules)); -} - -/** - * Checks if resource matches any of the selection rules - */ -function matchesAnyRule(resource: FlatResource, rules: EntrySelectionRule[]): boolean { - const tags = resource.tags; - return rules.some((rule) => { - const patternMatch = matchesPattern(resource.key, rule.matchingPattern); - const tagMatch = matchesTags( - tags && tags.length > 0 ? tags : undefined, - rule.matchingTags, - rule.matchingTagOperator, + return result; + } catch (error) { + warnings.push( + `Type generation failed for '${params.bundleKey}': ${error instanceof Error ? error.message : String(error)}`, ); - return patternMatch && tagMatch; - }); + return undefined; + } } /** - * Determines output file path for bundle + * The bundle file for `locale`: `/.json`, as configured + * (relative paths stay relative; resolve against the project directory before touching the disk). */ export function getBundleOutputPath(bundleDefinition: BundleDefinition, locale: string): string { const fileName = bundleDefinition.bundleName.replace('{locale}', locale); diff --git a/libs/core/src/lib/bundle/hierarchy-builder.spec.ts b/libs/core/src/lib/bundle/hierarchy-builder.spec.ts index c9d0c0c6..6ac422c1 100644 --- a/libs/core/src/lib/bundle/hierarchy-builder.spec.ts +++ b/libs/core/src/lib/bundle/hierarchy-builder.spec.ts @@ -159,5 +159,30 @@ describe('hierarchy-builder', () => { }, }); }); + + it('treats __proto__ and constructor segments as ordinary keys without touching Object.prototype', () => { + const result = buildHierarchy({ + '__proto__.x': 'polluted?', + 'constructor.ok': 'OK', + 'toString.label': 'Label', + 'buttons.ok': 'Fine', + }); + + expect(({} as Record).x).toBeUndefined(); + expect(Object.prototype).not.toHaveProperty('x'); + expect(JSON.parse(JSON.stringify(result))).toEqual( + JSON.parse( + '{"__proto__":{"x":"polluted?"},"constructor":{"ok":"OK"},"toString":{"label":"Label"},"buttons":{"ok":"Fine"}}', + ), + ); + }); + + it('serializes normal keys exactly as a plain object would', () => { + const flat = { 'a.b': '1', 'a.c': '2', d: '3' }; + + expect(JSON.stringify(buildHierarchy(flat), null, 2)).toBe( + JSON.stringify({ a: { b: '1', c: '2' }, d: '3' }, null, 2), + ); + }); }); }); diff --git a/libs/core/src/lib/bundle/hierarchy-builder.ts b/libs/core/src/lib/bundle/hierarchy-builder.ts index 4d04e3af..d9fbf88a 100644 --- a/libs/core/src/lib/bundle/hierarchy-builder.ts +++ b/libs/core/src/lib/bundle/hierarchy-builder.ts @@ -13,7 +13,7 @@ * @returns Hierarchical object */ export function buildHierarchy(flatEntries: Record): Record { - const result: Record = {}; + const result = createNode(); for (const [key, value] of Object.entries(flatEntries)) { setNestedValue(result, key, value); @@ -22,6 +22,14 @@ export function buildHierarchy(flatEntries: Record): Record { + return Object.create(null) as Record; +} + /** * Sets a value at a nested path in an object * @@ -36,9 +44,9 @@ function setNestedValue(obj: Record, key: string, value: string for (let i = 0; i < segments.length - 1; i++) { const segment = segments[i]; - // Create intermediate object if it doesn't exist - if (!(segment in current)) { - current[segment] = {}; + // Create intermediate object if it doesn't exist (own properties only; lib es2020 has no Object.hasOwn) + if (Object.getOwnPropertyDescriptor(current, segment) === undefined) { + current[segment] = createNode(); } // Navigate deeper (cast as Record for type safety) diff --git a/libs/core/src/lib/bundle/plan-bundle.spec.ts b/libs/core/src/lib/bundle/plan-bundle.spec.ts index f4501d1e..f87e50a8 100644 --- a/libs/core/src/lib/bundle/plan-bundle.spec.ts +++ b/libs/core/src/lib/bundle/plan-bundle.spec.ts @@ -1,69 +1,63 @@ -import * as fs from 'fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { BundleDefinition } from '../../config/bundle-definition'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { type SeedResource, seedResources, testCollection, useTempDir } from '../../testing/temp-dir.spec-helpers'; import { planBundle } from './plan-bundle'; -import type { FlatResource } from './resource-loader'; -import * as resourceLoader from './resource-loader'; -import { generateBundleTypes } from './type-generation/generate-types'; -vi.mock('fs'); -vi.mock('./resource-loader'); -vi.mock('./type-generation/generate-types'); +describe('planBundle (real fs)', () => { + const root = useTempDir('bundle-plan-'); -describe('planBundle', () => { - const cwd = '/project'; - let config: LingoTrackerConfig; + const definition: BundleDefinition = { + bundleName: 'main.{locale}', + dist: './dist/i18n', + collections: 'All', + }; - beforeEach(() => { - vi.clearAllMocks(); + function seed(name: string, resources: Record): string { + const folder = path.join(root(), name); + seedResources(testCollection(folder, { name, locales: ['en', 'fr'] }), resources); + return folder; + } - config = { + function config( + collections: Record, + overrides: Partial = {}, + ): LingoTrackerConfig { + return { exportFolder: 'dist/export', importFolder: 'dist/import', baseLocale: 'en', locales: ['en', 'fr'], - collections: { - common: { translationsFolder: '/translations/common' }, - admin: { translationsFolder: '/translations/admin' }, - }, + collections: Object.fromEntries( + Object.entries(collections).map(([name, translationsFolder]) => [name, { translationsFolder }]), + ), + ...overrides, }; - - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - /** Returns resources keyed by translations folder so each collection has distinct content. */ - function mockResourcesByFolder(byFolder: Record): void { - vi.spyOn(resourceLoader, 'loadCollectionResources').mockImplementation( - (collection) => byFolder[collection.translationsFolder] ?? [], - ); } - const definition: BundleDefinition = { - bundleName: 'main.{locale}', - dist: './dist/i18n', - collections: 'All', - }; - - it('lists one bundle file per locale with resolved paths and exists flags', () => { - mockResourcesByFolder({ '/translations/common': [{ key: 'buttons.ok', value: 'OK' }] }); - vi.mocked(fs.existsSync).mockImplementation((p) => String(p).endsWith('main.en.json')); + it('lists one bundle file per locale with configured and resolved paths and exists flags', () => { + const common = seed('common', { + 'buttons.ok': { source: 'OK', translations: { fr: "D'accord" } }, + }); + const existing = path.join(root(), 'dist/i18n/main.en.json'); + mkdirSync(path.dirname(existing), { recursive: true }); + writeFileSync(existing, '{}'); - const plan = planBundle({ bundleKey: 'main', bundleDefinition: definition, config, cwd }); + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: definition, + config: config({ common }), + cwd: root(), + }); expect(plan.bundleKey).toBe('main'); expect(plan.locales).toEqual(['en', 'fr']); expect(plan.files).toEqual([ { path: path.join('./dist/i18n', 'main.en.json'), - absolutePath: path.resolve(cwd, 'dist/i18n/main.en.json'), + absolutePath: path.resolve(root(), 'dist/i18n/main.en.json'), kind: 'bundle', locale: 'en', exists: true, @@ -71,7 +65,7 @@ describe('planBundle', () => { }, { path: path.join('./dist/i18n', 'main.fr.json'), - absolutePath: path.resolve(cwd, 'dist/i18n/main.fr.json'), + absolutePath: path.resolve(root(), 'dist/i18n/main.fr.json'), kind: 'bundle', locale: 'fr', exists: false, @@ -81,61 +75,64 @@ describe('planBundle', () => { expect(plan.keysPerLocale).toEqual({ en: 1, fr: 1 }); }); - it('respects a locales subset', () => { - mockResourcesByFolder({ '/translations/common': [{ key: 'a', value: 'A' }] }); + it('respects a locales subset while still using base keys for the example', () => { + const common = seed('common', { a: { source: 'A', translations: { fr: 'Un' } } }); - const plan = planBundle({ bundleKey: 'main', bundleDefinition: definition, config, cwd, locales: ['fr'] }); + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: definition, + config: config({ common }), + cwd: root(), + locales: ['fr'], + }); expect(plan.locales).toEqual(['fr']); expect(plan.files.map((file) => file.locale)).toEqual(['fr']); expect(plan.keysPerLocale).toEqual({ fr: 1 }); - // The base locale is still consulted for the example key even when not planned. expect(plan.exampleKey).toEqual({ collectionName: 'common', sourceKey: 'a', bundledKey: 'a' }); }); - it('never writes to disk and never calls type generation', () => { - mockResourcesByFolder({ '/translations/common': [{ key: 'a', value: 'A' }] }); + it('never writes bundle directories or the configured types file', () => { + const common = seed('common', { a: { source: 'A', translations: { fr: 'Un' } } }); planBundle({ bundleKey: 'main', bundleDefinition: { ...definition, typeDistFile: './src/tokens.ts' }, - config, - cwd, + config: config({ common }), + cwd: root(), }); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - expect(fs.mkdirSync).not.toHaveBeenCalled(); - expect(generateBundleTypes).not.toHaveBeenCalled(); + expect(existsSync(path.join(root(), 'dist'))).toBe(false); + expect(existsSync(path.join(root(), 'src/tokens.ts'))).toBe(false); }); it('omits the types file when types are not configured', () => { - mockResourcesByFolder({ '/translations/common': [{ key: 'a', value: 'A' }] }); - - const plan = planBundle({ bundleKey: 'main', bundleDefinition: definition, config, cwd }); + const common = seed('common', { a: { source: 'A' } }); + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: definition, + config: config({ common }), + cwd: root(), + }); expect(plan.files.filter((file) => file.kind === 'types')).toEqual([]); expect(plan.exampleKey?.tokenPath).toBeUndefined(); }); - it('adds a types file with the base-locale key count when configured', () => { - mockResourcesByFolder({ - '/translations/common': [ - { key: 'a', value: 'A' }, - { key: 'b', value: 'B' }, - ], - }); - + it('adds a types file with the base-locale key count', () => { + const common = seed('common', { a: { source: 'A' }, b: { source: 'B' } }); const plan = planBundle({ bundleKey: 'main', bundleDefinition: { ...definition, typeDistFile: './src/tokens.ts' }, - config, - cwd, + config: config({ common }), + cwd: root(), }); const typesFile = plan.files.find((file) => file.kind === 'types'); + expect(typesFile).toBeDefined(); expect(typesFile).toEqual({ path: './src/tokens.ts', - absolutePath: path.resolve(cwd, 'src/tokens.ts'), + absolutePath: path.resolve(root(), 'src/tokens.ts'), kind: 'types', exists: false, keysCount: 2, @@ -144,9 +141,12 @@ describe('planBundle', () => { }); it('warns about empty locales and reports zero keys', () => { - mockResourcesByFolder({}); - - const plan = planBundle({ bundleKey: 'main', bundleDefinition: definition, config, cwd }); + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: definition, + config: config({}), + cwd: root(), + }); expect(plan.keysPerLocale).toEqual({ en: 0, fr: 0 }); expect(plan.warnings).toContain("Bundle 'main' for locale 'en' is empty"); @@ -154,27 +154,35 @@ describe('planBundle', () => { expect(plan.exampleKey).toBeUndefined(); }); + function conflictSetup(): { common: string; admin: string } { + return { + common: seed('common', { + 'shared.title': { source: 'Common title', translations: { fr: 'Titre commun' } }, + 'z.only': { source: 'Only in common', translations: { fr: 'Seulement commun' } }, + }), + admin: seed('admin', { + 'shared.title': { source: 'Admin title', translations: { fr: 'Titre admin' } }, + }), + }; + } + describe('conflicts', () => { - beforeEach(() => { - mockResourcesByFolder({ - '/translations/common': [ - { key: 'shared.title', value: 'Common title' }, - { key: 'common.only', value: 'Only in common' }, - ], - '/translations/admin': [{ key: 'shared.title', value: 'Admin title' }], + it('records conflicting keys once regardless of locale count', () => { + const folders = conflictSetup(); + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: definition, + config: config(folders), + cwd: root(), }); - }); - - it('records conflicting keys once, regardless of locale count', () => { - const plan = planBundle({ bundleKey: 'main', bundleDefinition: definition, config, cwd }); expect(plan.conflictsCount).toBe(1); expect(plan.conflictKeys).toEqual(['shared.title']); - // Conflicting keys are still counted once per locale in the output. expect(plan.keysPerLocale).toEqual({ en: 2, fr: 2 }); }); it('attributes the example key to the first collection under merge', () => { + const folders = conflictSetup(); const plan = planBundle({ bundleKey: 'main', bundleDefinition: { @@ -184,8 +192,8 @@ describe('planBundle', () => { { name: 'admin', entriesSelectionRules: 'All', mergeStrategy: 'merge' }, ], }, - config, - cwd, + config: config(folders), + cwd: root(), }); expect(plan.conflictKeys).toEqual(['shared.title']); @@ -197,6 +205,7 @@ describe('planBundle', () => { }); it('attributes the example key to the overriding collection under override', () => { + const folders = conflictSetup(); const plan = planBundle({ bundleKey: 'main', bundleDefinition: { @@ -206,11 +215,10 @@ describe('planBundle', () => { { name: 'admin', entriesSelectionRules: 'All', mergeStrategy: 'override' }, ], }, - config, - cwd, + config: config(folders), + cwd: root(), }); - expect(plan.conflictKeys).toEqual(['shared.title']); expect(plan.exampleKey).toEqual({ collectionName: 'admin', sourceKey: 'shared.title', @@ -218,7 +226,8 @@ describe('planBundle', () => { }); }); - it('reports no conflicts when prefixes separate the collections', () => { + it('reports no conflicts when prefixes separate collections', () => { + const folders = conflictSetup(); const plan = planBundle({ bundleKey: 'main', bundleDefinition: { @@ -228,8 +237,8 @@ describe('planBundle', () => { { name: 'admin', entriesSelectionRules: 'All', bundledKeyPrefix: 'admin' }, ], }, - config, - cwd, + config: config(folders), + cwd: root(), }); expect(plan.conflictsCount).toBe(0); @@ -239,18 +248,19 @@ describe('planBundle', () => { }); describe('hierarchical conflicts', () => { - it('reports a key that is both a leaf and a parent, and warns about it', () => { - mockResourcesByFolder({ - '/translations/common': [ - { key: 'buttons.ok', value: 'OK' }, - { key: 'buttons.ok.label', value: 'OK label' }, - ], + it('reports and warns about a key that is both a leaf and a parent', () => { + const common = seed('common', { + 'buttons.ok': { source: 'OK' }, + 'buttons.ok.label': { source: 'OK label' }, + }); + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: definition, + config: config({ common }), + cwd: root(), }); - - const plan = planBundle({ bundleKey: 'main', bundleDefinition: definition, config, cwd }); expect(plan.hierarchicalConflicts).toEqual(['buttons.ok']); - // It is not a cross-collection conflict, so the existing counter stays clear. expect(plan.conflictsCount).toBe(0); expect(plan.conflictKeys).toEqual([]); expect( @@ -258,12 +268,9 @@ describe('planBundle', () => { ).toBe(true); }); - it('reports a collision introduced by a bundled key prefix', () => { - mockResourcesByFolder({ - '/translations/common': [{ key: 'ok', value: 'OK' }], - '/translations/admin': [{ key: 'ok.label', value: 'OK label' }], - }); - + it('reports a hierarchical collision introduced by a prefix', () => { + const common = seed('common', { ok: { source: 'OK' } }); + const admin = seed('admin', { 'ok.label': { source: 'OK label' } }); const plan = planBundle({ bundleKey: 'main', bundleDefinition: { @@ -273,40 +280,65 @@ describe('planBundle', () => { { name: 'admin', entriesSelectionRules: 'All', bundledKeyPrefix: 'buttons' }, ], }, - config, - cwd, + config: config({ common, admin }), + cwd: root(), }); expect(plan.hierarchicalConflicts).toEqual(['buttons.ok']); }); - it('is empty for a well-formed key set', () => { - mockResourcesByFolder({ - '/translations/common': [ - { key: 'buttons.ok', value: 'OK' }, - { key: 'buttons.cancel', value: 'Cancel' }, - ], + it('reports no hierarchical conflicts for well-formed keys', () => { + const common = seed('common', { + 'buttons.ok': { source: 'OK', translations: { fr: "D'accord" } }, + 'buttons.cancel': { source: 'Cancel', translations: { fr: 'Annuler' } }, + }); + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: definition, + config: config({ common }), + cwd: root(), }); - - const plan = planBundle({ bundleKey: 'main', bundleDefinition: definition, config, cwd }); expect(plan.hierarchicalConflicts).toEqual([]); expect(plan.warnings).toEqual([]); }); }); + function exampleSetup(): { common: string; prefixed: BundleDefinition } { + const common = seed('common', { 'buttons.file-upload': { source: 'Upload' } }); + return { + common, + prefixed: { + ...definition, + collections: [{ name: 'common', entriesSelectionRules: 'All', bundledKeyPrefix: 'shared' }], + }, + }; + } + describe('exampleKey', () => { - beforeEach(() => { - mockResourcesByFolder({ '/translations/common': [{ key: 'buttons.file-upload', value: 'Upload' }] }); - }); + it('is the first key the selection produced, even when a later key is numeric-like', () => { + const first = seed('first', { welcome: { source: 'Welcome' } }); + const second = seed('second', { '404': { source: 'Not found' } }); - const prefixed: BundleDefinition = { - ...definition, - collections: [{ name: 'common', entriesSelectionRules: 'All', bundledKeyPrefix: 'shared' }], - }; + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: definition, + config: config({ first, second }), + cwd: root(), + }); + + // A plain object would list '404' first; the selection keeps collection then folder order. + expect(plan.exampleKey).toEqual({ collectionName: 'first', sourceKey: 'welcome', bundledKey: 'welcome' }); + }); it('includes the prefix in bundledKey but not sourceKey', () => { - const plan = planBundle({ bundleKey: 'main', bundleDefinition: prefixed, config, cwd }); + const { common, prefixed } = exampleSetup(); + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: prefixed, + config: config({ common }), + cwd: root(), + }); expect(plan.exampleKey).toEqual({ collectionName: 'common', @@ -315,71 +347,72 @@ describe('planBundle', () => { }); }); - it('builds an upperCase tokenPath from the derived constant name by default', () => { + it('builds an upperCase tokenPath from the derived constant name', () => { + const { common, prefixed } = exampleSetup(); const plan = planBundle({ bundleKey: 'core-ui', bundleDefinition: { ...prefixed, typeDistFile: './src/tokens.ts' }, - config, - cwd, + config: config({ common }), + cwd: root(), }); expect(plan.exampleKey?.tokenPath).toBe('CORE_UI_TOKENS.SHARED.BUTTONS.FILE_UPLOAD'); }); - it('honours the casing and constant-name precedence chain (param > definition > config)', () => { + it('honours casing and constant-name precedence from parameter through definition and config', () => { + const { common, prefixed } = exampleSetup(); const withTypes: BundleDefinition = { ...prefixed, typeDistFile: './src/tokens.ts', tokenCasing: 'upperCase', tokenConstantName: 'DEF_TOKENS', }; - const fromDefinition = planBundle({ bundleKey: 'main', bundleDefinition: withTypes, - config: { ...config, tokenCasing: 'camelCase' }, - cwd, + config: config({ common }, { tokenCasing: 'camelCase' }), + cwd: root(), }); - expect(fromDefinition.exampleKey?.tokenPath).toBe('DEF_TOKENS.SHARED.BUTTONS.FILE_UPLOAD'); - const fromParams = planBundle({ bundleKey: 'main', bundleDefinition: withTypes, - config, - cwd, + config: config({ common }), + cwd: root(), tokenCasing: 'camelCase', tokenConstantName: 'paramTokens', }); - expect(fromParams.exampleKey?.tokenPath).toBe('paramTokens.shared.buttons.fileUpload'); - const fromConfig = planBundle({ bundleKey: 'main', bundleDefinition: { ...prefixed, typeDistFile: './src/tokens.ts' }, - config: { ...config, tokenCasing: 'camelCase' }, - cwd, + config: config({ common }, { tokenCasing: 'camelCase' }), + cwd: root(), }); + + expect(fromDefinition.exampleKey?.tokenPath).toBe('DEF_TOKENS.SHARED.BUTTONS.FILE_UPLOAD'); + expect(fromParams.exampleKey?.tokenPath).toBe('paramTokens.shared.buttons.fileUpload'); expect(fromConfig.exampleKey?.tokenPath).toBe('MAIN_TOKENS.shared.buttons.fileUpload'); }); }); - it('warns about unknown collections in an explicit list', () => { - mockResourcesByFolder({}); - + it('warns about an unknown collection exactly once across locales', () => { const plan = planBundle({ bundleKey: 'main', bundleDefinition: { ...definition, collections: [{ name: 'ghost', entriesSelectionRules: 'All' }] }, - config, - cwd, - locales: ['en'], + config: config({}), + cwd: root(), }); - expect(plan.warnings).toContain("Collection 'ghost' not found in config"); + expect(plan.warnings.filter((warning) => warning === "Collection 'ghost' not found in config")).toHaveLength(1); }); it('defaults cwd to process.cwd() for exists checks', () => { - mockResourcesByFolder({ '/translations/common': [{ key: 'a', value: 'A' }] }); - - const plan = planBundle({ bundleKey: 'main', bundleDefinition: definition, config, locales: ['en'] }); + const common = seed('common', { a: { source: 'A' } }); + const plan = planBundle({ + bundleKey: 'main', + bundleDefinition: definition, + config: config({ common }), + locales: ['en'], + }); expect(plan.files[0]?.absolutePath).toBe(path.resolve(process.cwd(), 'dist/i18n/main.en.json')); }); diff --git a/libs/core/src/lib/bundle/plan-bundle.ts b/libs/core/src/lib/bundle/plan-bundle.ts index 445a6f39..5e195377 100644 --- a/libs/core/src/lib/bundle/plan-bundle.ts +++ b/libs/core/src/lib/bundle/plan-bundle.ts @@ -12,8 +12,9 @@ import * as path from 'node:path'; import { detectHierarchicalConflicts, type TokenCasing } from '@simoncodes-ca/domain'; import { type BundleDefinition, hasTypeDistConfigured } from '../../config/bundle-definition'; import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; -import { type BundleKeyTrace, collectBundleData, getBundleOutputPath } from './generate-bundle'; -import { COLLECTION_BASE_LOCALE, type CollectionReadCache } from './resource-loader'; +import { type BundleSelection, resolveBundleCollections, selectBundleEntries } from './bundle-selection'; +import { getBundleOutputPath } from './generate-bundle'; +import { type BundleLocale, COLLECTION_BASE_LOCALE, type CollectionReadCache } from './resource-loader'; import { bundleKeyToConstantName, segmentToPropertyName, @@ -32,7 +33,10 @@ export interface PlanBundleParams { readonly tokenConstantName?: string; /** Override for ICU → Transloco transformation; same precedence as `generateBundle`. */ readonly transformICUToTransloco?: boolean; - /** Base directory used to resolve relative paths for `exists` checks (default: `process.cwd()`). */ + /** + * The project directory (holding `.lingo-tracker.json`): translations folders and the planned + * files resolve against it. Default: `process.cwd()`. + */ readonly cwd?: string; } @@ -50,6 +54,7 @@ export interface BundlePlanFile { readonly keysCount: number; } +/** One bundled key, to show where a resource lands: the first key the selection produced. */ export interface BundlePlanExampleKey { readonly collectionName: string; readonly sourceKey: string; @@ -75,6 +80,10 @@ export interface BundlePlan { * fail. Each one is also echoed in `warnings`. */ readonly hierarchicalConflicts: string[]; + /** + * The first key the selection produced, in collection then folder order (from every collection's + * base values). Absent when the bundle has no keys. + */ readonly exampleKey?: BundlePlanExampleKey; readonly warnings: string[]; } @@ -105,22 +114,19 @@ export function planBundle(params: PlanBundleParams): BundlePlan { tokenConstantNameOverride ?? bundleDefinition.tokenConstantName ?? bundleKeyToConstantName(bundleKey); const targetLocales = locales ?? config.locales; - const warnings: string[] = []; + const { collections, warnings: missing } = resolveBundleCollections(bundleDefinition, config, { cwd }); + const warnings = [...missing]; const keysPerLocale: Record = {}; const files: BundlePlanFile[] = []; - const resourceCache: CollectionReadCache = new Map(); + const cache: CollectionReadCache = new Map(); + const select = (locale: BundleLocale): BundleSelection => + selectBundleEntries(collections, locale, { transformICUToTransloco: resolvedTransformICUToTransloco, cache }); for (const locale of targetLocales) { - const bundleData = collectBundleData( - bundleDefinition, - config, - locale, - warnings, - resolvedTransformICUToTransloco, - resourceCache, - ); + const selection = select(locale); + warnings.push(...selection.warnings); - const keysCount = Object.keys(bundleData).length; + const keysCount = selection.entries.size; keysPerLocale[locale] = keysCount; if (keysCount === 0) { @@ -132,32 +138,26 @@ export function planBundle(params: PlanBundleParams): BundlePlan { } // The key set drives conflict detection, the example key and the types count. It is read - // once, from every collection's own base values (a collection may override the base locale), - // and traced only here: conflicts are a property of the key set, not of a locale. Its - // warnings were already reported by the locale passes, so they are dropped unless there were none. - const trace: BundleKeyTrace = { conflicts: new Set(), origins: new Map() }; - const baseLocaleData = collectBundleData( - bundleDefinition, - config, - COLLECTION_BASE_LOCALE, - targetLocales.length > 0 ? [] : warnings, - resolvedTransformICUToTransloco, - resourceCache, - trace, - ); + // once, from every collection's own base values (a collection may override the base locale). + // Conflicts are a property of the key set, not of a locale. Its warnings were already reported + // by the locale passes, so they are dropped unless there were none. + const base = select(COLLECTION_BASE_LOCALE); + if (targetLocales.length === 0) { + warnings.push(...base.warnings); + } + const baseKeys = Array.from(base.entries.keys()); const typesConfigured = hasTypeDistConfigured(bundleDefinition); - const baseKeysCount = Object.keys(baseLocaleData).length; if (typesConfigured && bundleDefinition.typeDistFile) { - files.push(describeFile(bundleDefinition.typeDistFile, 'types', baseKeysCount, cwd)); + files.push(describeFile(bundleDefinition.typeDistFile, 'types', baseKeys.length, cwd)); } - const conflictKeys = Array.from(trace.conflicts).sort(); + const conflictKeys = Array.from(base.conflicts).sort(); // A key that is both a leaf and a parent makes `buildHierarchy` throw during // generation, so surface it in the plan rather than letting the run explode. - const hierarchicalConflicts = detectHierarchicalConflicts(Object.keys(baseLocaleData)).sort(); + const hierarchicalConflicts = detectHierarchicalConflicts(baseKeys).sort(); for (const key of hierarchicalConflicts) { warnings.push( `Hierarchical conflict: bundled key '${key}' has a value and child keys; generation would fail. ` + @@ -165,7 +165,7 @@ export function planBundle(params: PlanBundleParams): BundlePlan { ); } - const exampleKey = pickExampleKey(baseLocaleData, trace, typesConfigured, resolvedConstantName, resolvedTokenCasing); + const exampleKey = pickExampleKey(base, typesConfigured, resolvedConstantName, resolvedTokenCasing); return { bundleKey, @@ -199,22 +199,17 @@ function describeFile( } function pickExampleKey( - baseLocaleData: Record, - trace: BundleKeyTrace, + base: BundleSelection, typesConfigured: boolean, constantName: string, tokenCasing: TokenCasing, ): BundlePlanExampleKey | undefined { - const [firstKey] = Object.keys(baseLocaleData); - if (firstKey === undefined) { - return undefined; - } - - const origin = trace.origins.get(firstKey); - if (!origin) { + const [first] = base.entries; + if (first === undefined) { return undefined; } + const [firstKey, { origin }] = first; const example: BundlePlanExampleKey = { collectionName: origin.collectionName, sourceKey: origin.sourceKey, diff --git a/libs/core/src/lib/bundle/type-generation/generate-types.spec.ts b/libs/core/src/lib/bundle/type-generation/generate-types.spec.ts index 72192b82..4a278c06 100644 --- a/libs/core/src/lib/bundle/type-generation/generate-types.spec.ts +++ b/libs/core/src/lib/bundle/type-generation/generate-types.spec.ts @@ -1,443 +1,221 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { BundleDefinition } from '../../../config/bundle-definition'; +import { useTempDir } from '../../../testing/temp-dir.spec-helpers'; import { generateBundleTypes } from './generate-types'; -import type { LingoTrackerConfig } from '../../../config/lingo-tracker-config'; -import * as resourceLoader from '../resource-loader'; - -vi.mock('fs'); -vi.mock('path'); -vi.mock('../resource-loader'); - -describe('generateBundleTypes', () => { - const mockConfig: LingoTrackerConfig = { - exportFolder: 'export', - importFolder: 'import', - baseLocale: 'en', - locales: ['en', 'fr'], - collections: { - common: { - translationsFolder: 'libs/common/i18n', - }, - admin: { - translationsFolder: 'libs/admin/i18n', - }, - }, - bundles: { - main: { - bundleName: 'main', - dist: 'dist/i18n', - collections: 'All', - typeDistFile: 'src/generated/main-tokens.ts', - }, - legacy: { - bundleName: 'legacy', - dist: 'dist/i18n', - collections: 'All', - // No typeDistFile - }, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(path.resolve).mockImplementation((p) => `/abs/${p}`); - vi.mocked(path.dirname).mockReturnValue('/abs/src/generated'); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as ReturnType); - }); - it('should skip generation if typeDistFile is not configured', async () => { - const result = await generateBundleTypes('legacy', mockConfig); +describe('generateBundleTypes (real fs)', () => { + const root = useTempDir('bundle-types-'); - expect(result.fileGenerated).toBe(false); - expect(result.skippedReason).toBe('not-configured'); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - }); + afterEach(() => vi.restoreAllMocks()); - it('should generate types for configured bundle', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([ - { key: 'buttons.ok', value: 'OK' }, - { key: 'buttons.cancel', value: 'Cancel' }, - ]); + function definition(overrides: Partial = {}): BundleDefinition { + return { + bundleName: 'main', + dist: 'dist/i18n', + collections: 'All', + typeDistFile: 'src/generated/main-tokens.ts', + ...overrides, + }; + } + + function generate( + overrides: Partial[0]> = {}, + ): ReturnType { + return generateBundleTypes({ + bundleKey: 'main', + definition: definition(), + keys: ['buttons.ok'], + tokenCasing: 'upperCase', + cwd: root(), + ...overrides, + }); + } + + it('skips generation when typeDistFile is not configured', () => { + const result = generate({ definition: definition({ typeDistFile: undefined }) }); + + expect(result).toMatchObject({ + fileGenerated: false, + keysCount: 0, + skippedReason: 'not-configured', + typeDistFile: undefined, + }); + expect(existsSync(join(root(), 'src/generated/main-tokens.ts'))).toBe(false); + }); - const result = await generateBundleTypes('main', mockConfig); + it('generates a type file with its header, constant, sorted keys, and type alias', () => { + const result = generate({ keys: ['buttons.ok', 'buttons.cancel'] }); + const output = readFileSync(join(root(), 'src/generated/main-tokens.ts'), 'utf8'); - expect(result.fileGenerated).toBe(true); - expect(result.keysCount).toBe(2); - expect(fs.writeFileSync).toHaveBeenCalledWith( - '/abs/src/generated/main-tokens.ts', - expect.stringContaining('export const MAIN_TOKENS'), - 'utf-8', - ); + expect(result).toMatchObject({ fileGenerated: true, keysCount: 2 }); + expect(output).toContain('Auto-generated translation keys for bundle: main'); + expect(output).toContain('export const MAIN_TOKENS'); + expect(output).toContain("CANCEL: 'buttons.cancel'"); + expect(output).toContain("OK: 'buttons.ok'"); + expect(output).toContain('export type MainTokens = typeof MAIN_TOKENS'); }); - it('should handle empty bundles', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([]); + it('sorts keys supplied in an arbitrary order', () => { + generate({ keys: ['z.last', 'a.first', 'm.middle'] }); + const output = readFileSync(join(root(), 'src/generated/main-tokens.ts'), 'utf8'); - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(output.indexOf('A: {')).toBeLessThan(output.indexOf('M: {')); + expect(output.indexOf('M: {')).toBeLessThan(output.indexOf('Z: {')); + }); - const result = await generateBundleTypes('main', mockConfig); + it('skips an empty bundle without writing a file or logging', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const result = generate({ keys: [] }); - expect(result.fileGenerated).toBe(false); expect(result.skippedReason).toBe('empty-bundle'); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - - consoleWarnSpy.mockRestore(); + expect(result.fileGenerated).toBe(false); + expect(warn).not.toHaveBeenCalled(); + expect(existsSync(join(root(), 'src/generated/main-tokens.ts'))).toBe(false); }); - it('should create directory if it does not exist', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'test', value: 'test' }]); - vi.mocked(fs.existsSync).mockReturnValue(false); - - await generateBundleTypes('main', mockConfig); + it('creates the output directory when it does not exist', () => { + const result = generate({ definition: definition({ typeDistFile: 'new/deep/tokens.ts' }) }); - expect(fs.mkdirSync).toHaveBeenCalledWith('/abs/src/generated', { - recursive: true, - }); + expect(result.fileGenerated).toBe(true); + expect(existsSync(join(root(), 'new/deep/tokens.ts'))).toBe(true); }); - it('should generate camelCase property names when tokenCasing is camelCase', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'file-upload', value: 'Upload' }]); + it('generates camelCase property names', () => { + generate({ keys: ['file-upload'], tokenCasing: 'camelCase' }); + const output = readFileSync(join(root(), 'src/generated/main-tokens.ts'), 'utf8'); - await generateBundleTypes('main', mockConfig, 'camelCase'); - - expect(fs.writeFileSync).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('fileUpload'), 'utf-8'); - const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string; - expect(writtenContent).not.toContain('FILE_UPLOAD'); + expect(output).toContain("fileUpload: 'file-upload'"); + expect(output).not.toContain('FILE_UPLOAD'); }); - it('should preserve non-hyphenated mixed-case keys in camelCase mode', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'agGrid', value: 'AG Grid' }]); - - await generateBundleTypes('main', mockConfig, 'camelCase'); + it('preserves non-hyphenated mixed-case keys in camelCase mode', () => { + generate({ keys: ['agGrid'], tokenCasing: 'camelCase' }); + const output = readFileSync(join(root(), 'src/generated/main-tokens.ts'), 'utf8'); - const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string; - expect(writtenContent).toContain("agGrid: 'agGrid'"); - expect(writtenContent).not.toContain('aggrid'); - expect(writtenContent).not.toContain('AGGRID'); + expect(output).toContain("agGrid: 'agGrid'"); + expect(output).not.toContain('aggrid'); + expect(output).not.toContain('AGGRID'); }); - it('should apply key prefixes if configured', async () => { - const configWithPrefix: LingoTrackerConfig = { - ...mockConfig, - bundles: { - prefixed: { - bundleName: 'prefixed', - dist: 'dist', - typeDistFile: 'types.ts', - collections: [ - { - name: 'common', - entriesSelectionRules: 'All', - bundledKeyPrefix: 'shared', - }, - ], - }, - }, - }; - - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'ok', value: 'OK' }]); + it('returns an error when typeDistFile points to an existing directory', () => { + mkdirSync(join(root(), 'types.ts')); + const result = generate({ definition: definition({ typeDistFile: 'types.ts' }) }); - const result = await generateBundleTypes('prefixed', configWithPrefix); - - expect(result.fileGenerated).toBe(true); - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining("OK: 'shared.ok'"), - 'utf-8', - ); + expect(result.fileGenerated).toBe(false); + expect(result.errorReason).toMatch(/typeDistFile must be a file path/); + expect(result.errorReason).toContain(join(root(), 'types.ts')); }); - describe('validation', () => { - it('should return an error when typeDistFile points to an existing directory', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as ReturnType); - - const result = await generateBundleTypes('main', mockConfig); + it('returns an error containing the configured value when typeDistFile is not a .ts file', () => { + const result = generate({ definition: definition({ typeDistFile: 'src/generated/tokens.js' }) }); - expect(result.fileGenerated).toBe(false); - expect(result.errorReason).toMatch(/typeDistFile must be a file path/); - expect(result.errorReason).toMatch(/resolves to a directory at/); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - }); - - it('should return an error when typeDistFile does not end with .ts', async () => { - const configWithBadExtension: LingoTrackerConfig = { - ...mockConfig, - bundles: { - main: { - bundleName: 'main', - dist: 'dist/i18n', - collections: 'All', - typeDistFile: 'src/generated/main-tokens.js', - }, - }, - }; - - const result = await generateBundleTypes('main', configWithBadExtension); - - expect(result.fileGenerated).toBe(false); - expect(result.errorReason).toMatch(/typeDistFile must end with a \.ts extension/); - expect(result.errorReason).toContain('src/generated/main-tokens.js'); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - }); + expect(result.fileGenerated).toBe(false); + expect(result.errorReason).toMatch(/typeDistFile must end with a \.ts extension/); + expect(result.errorReason).toContain('src/generated/tokens.js'); + expect(existsSync(join(root(), 'src/generated/tokens.js'))).toBe(false); }); - describe('bundleDefinition parameter', () => { - it('should prefer the passed definition over the one in config', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'ok', value: 'OK' }]); - - const result = await generateBundleTypes('main', mockConfig, 'upperCase', undefined, { - bundleName: 'main', - dist: 'dist/i18n', - typeDistFile: 'src/generated/other-tokens.ts', - tokenConstantName: 'PASSED_TOKENS', - collections: [{ name: 'common', entriesSelectionRules: 'All', bundledKeyPrefix: 'passed' }], - }); - - expect(result.fileGenerated).toBe(true); - expect(result.typeDistFile).toBe('/abs/src/generated/other-tokens.ts'); - expect(fs.writeFileSync).toHaveBeenCalledWith( - '/abs/src/generated/other-tokens.ts', - expect.stringContaining("OK: 'passed.ok'"), - 'utf-8', - ); - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('export const PASSED_TOKENS'), - 'utf-8', - ); - // Only the passed definition's single collection is loaded, not both from 'All'. - expect(resourceLoader.loadCollectionResources).toHaveBeenCalledTimes(1); - }); - - it('should generate for a bundle key that is absent from config when a definition is passed', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'ok', value: 'OK' }]); - - const result = await generateBundleTypes('unsaved', mockConfig, 'upperCase', undefined, { - bundleName: 'unsaved', - dist: 'dist/i18n', - typeDistFile: 'src/generated/unsaved.ts', - collections: 'All', - }); - - expect(result.fileGenerated).toBe(true); - expect(result.keysCount).toBe(1); - }); + it('resolves relative typeDistFile against cwd and returns its absolute path', () => { + const result = generate({ definition: definition({ typeDistFile: 'types/tokens.ts' }) }); - it('should fall back to the config definition when none is passed', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'ok', value: 'OK' }]); - - const result = await generateBundleTypes('main', mockConfig, 'upperCase', undefined, undefined); - - expect(result.typeDistFile).toBe('/abs/src/generated/main-tokens.ts'); - }); + expect(result.typeDistFile).toBe(join(root(), 'types/tokens.ts')); + expect(existsSync(join(root(), 'types/tokens.ts'))).toBe(true); }); - describe('tokenConstantName', () => { - it('should use the provided tokenConstantName parameter as the constant name', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - - await generateBundleTypes('main', mockConfig, 'upperCase', 'MY_CUSTOM_TOKENS'); - - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('export const MY_CUSTOM_TOKENS'), - 'utf-8', - ); - }); - - it('should derive PascalCase type name from a custom SCREAMING_SNAKE constant name', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - - await generateBundleTypes('main', mockConfig, 'upperCase', 'MY_CUSTOM_TOKENS'); - - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('export type MyCustomTokens = typeof MY_CUSTOM_TOKENS'), - 'utf-8', - ); - }); - - it('should derive PascalCase type name from a camelCase constant name', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - - await generateBundleTypes('main', mockConfig, 'upperCase', 'myCustomTokens'); - - const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string; - expect(writtenContent).toContain('export const myCustomTokens'); - expect(writtenContent).toContain('export type MyCustomTokens = typeof myCustomTokens'); - }); - - it('should use tokenConstantName from bundle config when no param is provided', async () => { - const configWithConstantName = { - ...mockConfig, - bundles: { - main: { - bundleName: 'main', - dist: 'dist/i18n', - collections: 'All' as const, - typeDistFile: 'src/generated/main-tokens.ts', - tokenConstantName: 'APP_TOKENS', - }, - }, - }; - - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - - await generateBundleTypes('main', configWithConstantName); - - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('export const APP_TOKENS'), - 'utf-8', - ); - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('export type AppTokens = typeof APP_TOKENS'), - 'utf-8', - ); - }); + it('uses the tokenConstantName parameter', () => { + generate({ tokenConstantName: 'MY_CUSTOM_TOKENS' }); + expect(readFileSync(join(root(), 'src/generated/main-tokens.ts'), 'utf8')).toContain( + 'export const MY_CUSTOM_TOKENS', + ); + }); - it('should prefer the tokenConstantName param over the bundle config value', async () => { - const configWithConstantName = { - ...mockConfig, - bundles: { - main: { - bundleName: 'main', - dist: 'dist/i18n', - collections: 'All' as const, - typeDistFile: 'src/generated/main-tokens.ts', - tokenConstantName: 'CONFIG_TOKENS', - }, - }, - }; - - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - - await generateBundleTypes('main', configWithConstantName, 'upperCase', 'CLI_OVERRIDE_TOKENS'); - - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('export const CLI_OVERRIDE_TOKENS'), - 'utf-8', - ); - }); + it('derives a PascalCase type name from a SCREAMING_SNAKE constant name', () => { + generate({ tokenConstantName: 'MY_CUSTOM_TOKENS' }); + expect(readFileSync(join(root(), 'src/generated/main-tokens.ts'), 'utf8')).toContain( + 'export type MyCustomTokens = typeof MY_CUSTOM_TOKENS', + ); + }); - it('should return an error result when tokenConstantName is an invalid JavaScript identifier', async () => { - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); + it('derives a PascalCase type name from a camelCase constant name', () => { + generate({ tokenConstantName: 'myCustomTokens' }); + const output = readFileSync(join(root(), 'src/generated/main-tokens.ts'), 'utf8'); - const result = await generateBundleTypes('main', mockConfig, 'upperCase', 'my-bad-name'); + expect(output).toContain('export const myCustomTokens'); + expect(output).toContain('export type MyCustomTokens = typeof myCustomTokens'); + }); - expect(result.fileGenerated).toBe(false); - expect(result.errorReason).toMatch(/Invalid tokenConstantName/); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - }); + it('uses tokenConstantName from the definition when no parameter is provided', () => { + generate({ definition: definition({ tokenConstantName: 'APP_TOKENS' }) }); + const output = readFileSync(join(root(), 'src/generated/main-tokens.ts'), 'utf8'); - it('should return an error result when bundle config tokenConstantName is an invalid JavaScript identifier', async () => { - const configWithBadConstantName = { - ...mockConfig, - bundles: { - main: { - bundleName: 'main', - dist: 'dist/i18n', - collections: 'All' as const, - typeDistFile: 'src/generated/main-tokens.ts', - tokenConstantName: '1bad', - }, - }, - }; - - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - - const result = await generateBundleTypes('main', configWithBadConstantName); - - expect(result.fileGenerated).toBe(false); - expect(result.errorReason).toMatch(/Invalid tokenConstantName/); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - }); + expect(output).toContain('export const APP_TOKENS'); + expect(output).toContain('export type AppTokens = typeof APP_TOKENS'); }); - describe('backwards compatibility', () => { - it('should return not-configured and not throw when typeDist holds a non-string value', async () => { - const configWithNullTypeDist: LingoTrackerConfig = { - ...mockConfig, - bundles: { - main: { - bundleName: 'main', - dist: 'dist/i18n', - collections: 'All', - ...({ typeDist: null } as unknown as object), - }, - }, - }; - - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const result = await generateBundleTypes('main', configWithNullTypeDist); - - expect(result.fileGenerated).toBe(false); - expect(result.skippedReason).toBe('not-configured'); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - - consoleWarnSpy.mockRestore(); + it('prefers the tokenConstantName parameter over the definition', () => { + generate({ + definition: definition({ tokenConstantName: 'CONFIG_TOKENS' }), + tokenConstantName: 'CLI_OVERRIDE_TOKENS', }); + expect(readFileSync(join(root(), 'src/generated/main-tokens.ts'), 'utf8')).toContain( + 'export const CLI_OVERRIDE_TOKENS', + ); + }); - it('should use typeDistFile and not emit a deprecation warning when both typeDist and typeDistFile are present', async () => { - const configWithBothKeys: LingoTrackerConfig = { - ...mockConfig, - bundles: { - main: { - bundleName: 'main', - dist: 'dist/i18n', - collections: 'All', - typeDistFile: 'src/generated/main-tokens.ts', - ...({ typeDist: 'src/generated/old-tokens.ts' } as unknown as object), - }, - }, - }; - - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); - - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const result = await generateBundleTypes('main', configWithBothKeys); + it('returns an error for an invalid tokenConstantName parameter', () => { + const result = generate({ tokenConstantName: 'my-bad-name' }); - expect(result.fileGenerated).toBe(true); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - expect(fs.writeFileSync).toHaveBeenCalledWith('/abs/src/generated/main-tokens.ts', expect.any(String), 'utf-8'); + expect(result.fileGenerated).toBe(false); + expect(result.errorReason).toMatch(/Invalid tokenConstantName/); + expect(existsSync(join(root(), 'src/generated/main-tokens.ts'))).toBe(false); + }); - consoleWarnSpy.mockRestore(); - }); + it('returns an error for an invalid tokenConstantName in the definition', () => { + const result = generate({ definition: definition({ tokenConstantName: '1bad' }) }); - it('should support the deprecated typeDist property and emit a deprecation warning', async () => { - const configWithDeprecatedKey: LingoTrackerConfig = { - ...mockConfig, - bundles: { - main: { - bundleName: 'main', - dist: 'dist/i18n', - collections: 'All', - // Simulating a user config that still uses the old key name - ...({ typeDist: 'src/generated/main-tokens.ts' } as unknown as object), - }, - }, - }; + expect(result.fileGenerated).toBe(false); + expect(result.errorReason).toMatch(/Invalid tokenConstantName/); + expect(existsSync(join(root(), 'src/generated/main-tokens.ts'))).toBe(false); + }); - vi.mocked(resourceLoader.loadCollectionResources).mockReturnValue([{ key: 'buttons.ok', value: 'OK' }]); + it('treats a non-string legacy typeDist value as not configured', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const legacy = { ...definition({ typeDistFile: undefined }), typeDist: null } as unknown as BundleDefinition; + const result = generate({ definition: legacy }); - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(result.skippedReason).toBe('not-configured'); + expect(result.fileGenerated).toBe(false); + expect(warn).not.toHaveBeenCalled(); + }); - const result = await generateBundleTypes('main', configWithDeprecatedKey); + it('uses typeDistFile without warning when both current and legacy keys are present', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const withBoth = { + ...definition({ typeDistFile: 'types/current.ts' }), + typeDist: 'types/legacy.ts', + } as unknown as BundleDefinition; + const result = generate({ definition: withBoth }); + + expect(result.typeDistFile).toBe(join(root(), 'types/current.ts')); + expect(existsSync(join(root(), 'types/current.ts'))).toBe(true); + expect(existsSync(join(root(), 'types/legacy.ts'))).toBe(false); + expect(warn).not.toHaveBeenCalled(); + }); - expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Bundle 'main'")); - expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("'typeDist' is deprecated")); - expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("'typeDistFile'")); - expect(result.fileGenerated).toBe(true); + it('supports deprecated typeDist and emits a deprecation warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const legacy = { + ...definition({ typeDistFile: undefined }), + typeDist: 'types/legacy.ts', + } as unknown as BundleDefinition; + const result = generate({ definition: legacy }); - consoleWarnSpy.mockRestore(); - }); + expect(result.fileGenerated).toBe(true); + expect(existsSync(join(root(), 'types/legacy.ts'))).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Bundle 'main'")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("'typeDist' is deprecated")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("'typeDistFile'")); }); }); diff --git a/libs/core/src/lib/bundle/type-generation/generate-types.ts b/libs/core/src/lib/bundle/type-generation/generate-types.ts index b1073716..07c1f3f3 100644 --- a/libs/core/src/lib/bundle/type-generation/generate-types.ts +++ b/libs/core/src/lib/bundle/type-generation/generate-types.ts @@ -1,12 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { LingoTrackerConfig } from '../../../config/lingo-tracker-config'; -import { type BundleDefinition, hasTypeDistConfigured } from '../../../config/bundle-definition'; -import { openCollection } from '../../config/open-collection'; -import { loadCollectionResources } from '../resource-loader'; -import { matchesPattern } from '../pattern-matcher'; -import { matchesTags } from '../tag-filter'; import type { TokenCasing } from '@simoncodes-ca/domain'; +import { type BundleDefinition, hasTypeDistConfigured } from '../../../config/bundle-definition'; import { buildTypeHierarchy, serializeHierarchy } from './hierarchy-builder'; import { generateFileHeader } from './file-header'; import { bundleKeyToConstantName, validateJavaScriptIdentifier } from './key-transformer'; @@ -20,29 +15,41 @@ export interface GenerateTypesResult { errorReason?: string; } -export async function generateBundleTypes( - bundleKey: string, - config: LingoTrackerConfig, - tokenCasing: TokenCasing = 'upperCase', - tokenConstantName?: string, - bundleDefinition?: BundleDefinition, -): Promise { - // An explicitly passed definition wins over the one stored in config, so - // callers can generate types for an unsaved or renamed bundle. - const bundleDef = bundleDefinition ?? config.bundles?.[bundleKey]; +export interface GenerateBundleTypesParams { + readonly bundleKey: string; + readonly definition: BundleDefinition; + /** The bundle's keys, from the Bundle Selection (any order; the file lists them sorted). */ + readonly keys: readonly string[]; + /** Resolved casing: CLI override → bundle config → global config → default. */ + readonly tokenCasing: TokenCasing; + /** CLI override for the constant name; wins over `definition.tokenConstantName`. */ + readonly tokenConstantName?: string; + /** The project directory `typeDistFile` resolves against. Default: `process.cwd()`. */ + readonly cwd?: string; +} + +/** + * Writes a bundle's type file: the keys as an `as const` tree plus its type alias, at + * `typeDistFile` (or the deprecated `typeDist`, with a warning). Selecting the keys is the caller's + * job (see Bundle Selection). Returns `skippedReason` when no file is configured or there are no + * keys, and `errorReason` for a path that does not end in `.ts`, a path that is a directory, or an + * invalid constant name. + */ +export function generateBundleTypes(params: GenerateBundleTypesParams): GenerateTypesResult { + const { bundleKey, definition: bundleDef, tokenCasing, tokenConstantName } = params; // Support deprecated 'typeDist' property — read the legacy value without mutating the config object - const legacyTypeDist = (bundleDef as unknown as Record)?.['typeDist']; + const legacyTypeDist = (bundleDef as unknown as Record)['typeDist']; const resolvedTypeDistFile = - bundleDef?.typeDistFile ?? (typeof legacyTypeDist === 'string' ? legacyTypeDist : undefined); + bundleDef.typeDistFile ?? (typeof legacyTypeDist === 'string' ? legacyTypeDist : undefined); - if (bundleDef && typeof legacyTypeDist === 'string' && !bundleDef.typeDistFile) { + if (typeof legacyTypeDist === 'string' && !bundleDef.typeDistFile) { console.warn( `Warning: Bundle '${bundleKey}': 'typeDist' is deprecated and will be removed in the next major version. Please rename to 'typeDistFile' in your .lingo-tracker.json config.`, ); } - if (!bundleDef || !hasTypeDistConfigured(bundleDef) || !resolvedTypeDistFile) { + if (!hasTypeDistConfigured(bundleDef) || !resolvedTypeDistFile) { return { bundleKey, typeDistFile: undefined, @@ -65,7 +72,7 @@ export async function generateBundleTypes( } // resolvedTypeDistFile is narrowed to string by the guard above - const outputPath = path.resolve(resolvedTypeDistFile); + const outputPath = path.resolve(params.cwd ?? process.cwd(), resolvedTypeDistFile); // Validate: typeDistFile must not point to an existing directory if (fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) { @@ -78,62 +85,9 @@ export async function generateBundleTypes( }; } - // Collect all keys for the bundle (reusing logic from generate-bundle) - // We don't need to process values, just keys - const allKeys = new Set(); - const collections = - bundleDef.collections === 'All' - ? Object.keys(config.collections).map((name) => ({ - name, - entriesSelectionRules: 'All' as const, - bundledKeyPrefix: undefined, - })) - : bundleDef.collections; - - for (const collectionDef of collections) { - if (!Object.keys(config.collections).includes(collectionDef.name)) { - console.warn(`Collection '${collectionDef.name}' not found in configuration`); - continue; - } - - // Load resources (the collection's base values are the source of truth for keys) - const collection = openCollection(config, collectionDef.name); - const resources = loadCollectionResources(collection, collection.baseLocale); - - for (const resource of resources) { - // Apply filters - let isMatch = false; - - if (collectionDef.entriesSelectionRules === 'All') { - isMatch = true; - } else { - const tags = resource.tags; - for (const rule of collectionDef.entriesSelectionRules) { - if ( - matchesPattern(resource.key, rule.matchingPattern) && - matchesTags(tags && tags.length > 0 ? tags : undefined, rule.matchingTags, rule.matchingTagOperator) - ) { - isMatch = true; - break; - } - } - } - - if (isMatch) { - // Apply prefix if configured - const finalKey = collectionDef.bundledKeyPrefix - ? `${collectionDef.bundledKeyPrefix}.${resource.key}` - : resource.key; - - allKeys.add(finalKey); - } - } - } - - const sortedKeys = Array.from(allKeys).sort(); + const sortedKeys = [...params.keys].sort(); if (sortedKeys.length === 0) { - console.warn(`Warning: Bundle '${bundleKey}' is empty. Skipping type generation.`); return { bundleKey, typeDistFile: resolvedTypeDistFile, diff --git a/libs/core/src/lib/bundle/type-generation/hierarchy-builder.spec.ts b/libs/core/src/lib/bundle/type-generation/hierarchy-builder.spec.ts index 4305fa1d..d0be5a2b 100644 --- a/libs/core/src/lib/bundle/type-generation/hierarchy-builder.spec.ts +++ b/libs/core/src/lib/bundle/type-generation/hierarchy-builder.spec.ts @@ -97,6 +97,22 @@ describe('Hierarchy Builder', () => { expect(commonNode.value).toBe('common'); expect(commonNode.children['TITLE']).toBeDefined(); }); + + it('treats __proto__ and constructor segments as ordinary children without touching Object.prototype', () => { + const result = buildTypeHierarchy(['__proto__.x', 'constructor.ok', 'constructor'], 'camelCase'); + + expect(Object.prototype).not.toHaveProperty('value'); + expect(Object.prototype).not.toHaveProperty('x'); + expect(Object.keys(result.children)).toEqual(['__proto__', 'constructor']); + expect(Object.getOwnPropertyDescriptor(result.children, '__proto__')?.value).toEqual({ + children: { x: { children: {}, value: '__proto__.x' } }, + }); + expect(result.children.constructor).toEqual({ + children: { ok: { children: {}, value: 'constructor.ok' } }, + value: 'constructor', + }); + expect(serializeHierarchy(result, 'TOKENS')).toContain("ok: 'constructor.ok',"); + }); }); describe('serializeHierarchy', () => { diff --git a/libs/core/src/lib/bundle/type-generation/hierarchy-builder.ts b/libs/core/src/lib/bundle/type-generation/hierarchy-builder.ts index 9e0b4f02..cf2172be 100644 --- a/libs/core/src/lib/bundle/type-generation/hierarchy-builder.ts +++ b/libs/core/src/lib/bundle/type-generation/hierarchy-builder.ts @@ -28,7 +28,7 @@ export interface TypeHierarchyNode { * } */ export function buildTypeHierarchy(keys: string[], casing: TokenCasing = 'upperCase'): TypeHierarchyNode { - const root: TypeHierarchyNode = { children: {} }; + const root = createTypeNode(); for (const key of keys) { const segments = splitKeyIntoSegments(key); @@ -38,11 +38,13 @@ export function buildTypeHierarchy(keys: string[], casing: TokenCasing = 'upperC const segment = segments[i]; const propertyName = segmentToPropertyName(segment, casing); - if (!currentNode.children[propertyName]) { - currentNode.children[propertyName] = { children: {} }; - } - - currentNode = currentNode.children[propertyName]; + // Own properties only (lib es2020 has no Object.hasOwn) + const existing = Object.getOwnPropertyDescriptor(currentNode.children, propertyName)?.value as + | TypeHierarchyNode + | undefined; + const child = existing ?? createTypeNode(); + currentNode.children[propertyName] = child; + currentNode = child; // If this is the last segment, set the value if (i === segments.length - 1) { @@ -54,6 +56,14 @@ export function buildTypeHierarchy(keys: string[], casing: TokenCasing = 'upperC return root; } +/** + * A node whose `children` has no prototype, so a property named `__proto__` or `constructor` is an + * ordinary child rather than a member of `Object.prototype`. + */ +function createTypeNode(): TypeHierarchyNode { + return { children: Object.create(null) as Record }; +} + /** * Serializes a type hierarchy into a formatted TypeScript code string. * From ce033da202ec71bb2da7d59e1ca6880f697ca68b Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 22:39:27 -0700 Subject: [PATCH 14/20] refactor(cli): one Command Runner for every command defineCommand()({ name, collection: 'writable' | 'read' | 'none', collectionOption?, config?, prompts?, required?, run }) in apps/cli/src/runner. The runner owns the project root (INIT_CWD), the one interactive rule (stdin and stdout are both a terminal), loading .lingo-tracker.json, resolving and opening the collection, asking the questions or checking the required flags, cancellation, and turning a thrown error into "" and exit code 1. It sets process.exitCode and returns; the CLI never calls process.exit(). - all 21 commands run through it; the large ones (normalize, bundle, export, import, validate, glossary, install-skill) keep their flow inside run - deleted utils: config-loader, collection-prompts, collection-resolver, report-error; isInteractiveTerminal and executePromptsWithFallback left prompt-utils; unused ErrorMessages entries removed - required flags are checked in both modes ('' counts as missing) and typed as present in run - specs mock core loadConfig and runner/terminal and assert process.exitCode; new runner spec, new add-collection, move, translate-locale and main specs Behaviour changes (see architecture-docs/cli.md, "Changes Introduced by the Command Runner"): - exit 1 where it was 0: a core error in add-collection, delete-collection, add-resource, edit-resource, delete-resource, move, add-locale, remove-locale; an unknown collection anywhere (including export); no collections configured; a missing required flag when non-interactive (including import --source/--locale); an empty interactive answer to a required field; partial failures in delete-resource, move, normalize, bundle; add-collection duplicate name; import parse/detect failures - cancel prints one " cancelled." line and exits 0 - one interactive rule: commands that only checked stdout are non-interactive when stdin is piped - find-similar and translate-locale auto-select the only collection; find-similar prompts for --value when interactive - import --source and install-skill --dir resolve against the project root (INIT_CWD), like every other path - add-resource --translations is parsed inside the command (a malformed value reports "Invalid --translations ..." and exits 1) - validate --skip-placeholders is now passed through (was ignored) Co-Authored-By: Claude Fable 5.1 --- .../src/add-collection/add-collection.test.ts | 135 +++++ apps/cli/src/add-collection/add-collection.ts | 225 ++++---- .../cli/src/add-resource/add-resource.test.ts | 282 ++++------ apps/cli/src/add-resource/add-resource.ts | 328 +++++------ apps/cli/src/commands/add-locale.spec.ts | 174 +++--- apps/cli/src/commands/add-locale.ts | 53 +- apps/cli/src/commands/bundle.test.ts | 104 +--- apps/cli/src/commands/bundle.ts | 147 ++--- apps/cli/src/commands/delete-resource.test.ts | 249 ++++----- apps/cli/src/commands/delete-resource.ts | 102 +--- apps/cli/src/commands/edit-collection.test.ts | 80 +-- apps/cli/src/commands/edit-collection.ts | 93 ++-- apps/cli/src/commands/edit-resource.test.ts | 114 ++-- apps/cli/src/commands/edit-resource.ts | 120 ++-- apps/cli/src/commands/export-cmd.test.ts | 126 +++-- apps/cli/src/commands/export-cmd.ts | 268 ++++----- apps/cli/src/commands/find-similar.spec.ts | 168 +++--- apps/cli/src/commands/find-similar.ts | 45 +- apps/cli/src/commands/glossary.spec.ts | 118 ++-- apps/cli/src/commands/glossary.ts | 47 +- apps/cli/src/commands/import-cmd.spec.ts | 269 +++++---- apps/cli/src/commands/import-cmd.ts | 491 +++++++---------- apps/cli/src/commands/install-skill.spec.ts | 86 ++- apps/cli/src/commands/install-skill.ts | 159 +++--- apps/cli/src/commands/move.test.ts | 132 +++++ apps/cli/src/commands/move.ts | 115 ++-- apps/cli/src/commands/normalize.test.ts | 212 +++++--- apps/cli/src/commands/normalize.ts | 383 +++++-------- .../commands/preferred-terminology.spec.ts | 50 +- .../cli/src/commands/preferred-terminology.ts | 47 +- apps/cli/src/commands/protected-terms.spec.ts | 37 +- apps/cli/src/commands/protected-terms.ts | 160 +++--- apps/cli/src/commands/remove-locale.spec.ts | 191 ++++--- apps/cli/src/commands/remove-locale.ts | 62 +-- .../cli/src/commands/translate-locale.test.ts | 157 ++++++ apps/cli/src/commands/translate-locale.ts | 187 +++---- apps/cli/src/commands/validate.icu.test.ts | 28 +- apps/cli/src/commands/validate.test.ts | 180 +++--- apps/cli/src/commands/validate.ts | 26 +- .../delete-collection.test.ts | 126 +++-- .../delete-collection/delete-collection.ts | 25 +- apps/cli/src/init/init.test.ts | 56 +- apps/cli/src/init/init.ts | 86 ++- apps/cli/src/main.spec.ts | 48 ++ apps/cli/src/main.ts | 7 +- apps/cli/src/runner/command-runner.spec.ts | 513 ++++++++++++++++++ apps/cli/src/runner/command-runner.ts | 301 ++++++++++ apps/cli/src/runner/terminal.ts | 22 + apps/cli/src/utils/collection-prompts.spec.ts | 314 ----------- apps/cli/src/utils/collection-prompts.ts | 55 -- .../cli/src/utils/collection-resolver.spec.ts | 332 ------------ apps/cli/src/utils/collection-resolver.ts | 74 --- apps/cli/src/utils/config-loader.spec.ts | 221 -------- apps/cli/src/utils/config-loader.ts | 119 ---- apps/cli/src/utils/error-messages.spec.ts | 34 -- apps/cli/src/utils/error-messages.ts | 41 -- apps/cli/src/utils/index.ts | 4 - apps/cli/src/utils/prompt-utils.spec.ts | 334 +----------- apps/cli/src/utils/prompt-utils.ts | 97 +--- apps/cli/src/utils/report-error.spec.ts | 48 -- apps/cli/src/utils/report-error.ts | 25 - architecture-docs/README.md | 2 +- architecture-docs/cli.md | 343 +++++++----- architecture-docs/glossary.md | 8 + 64 files changed, 4086 insertions(+), 5099 deletions(-) create mode 100644 apps/cli/src/add-collection/add-collection.test.ts create mode 100644 apps/cli/src/commands/move.test.ts create mode 100644 apps/cli/src/commands/translate-locale.test.ts create mode 100644 apps/cli/src/main.spec.ts create mode 100644 apps/cli/src/runner/command-runner.spec.ts create mode 100644 apps/cli/src/runner/command-runner.ts create mode 100644 apps/cli/src/runner/terminal.ts delete mode 100644 apps/cli/src/utils/collection-prompts.spec.ts delete mode 100644 apps/cli/src/utils/collection-prompts.ts delete mode 100644 apps/cli/src/utils/collection-resolver.spec.ts delete mode 100644 apps/cli/src/utils/collection-resolver.ts delete mode 100644 apps/cli/src/utils/config-loader.spec.ts delete mode 100644 apps/cli/src/utils/config-loader.ts delete mode 100644 apps/cli/src/utils/report-error.spec.ts delete mode 100644 apps/cli/src/utils/report-error.ts diff --git a/apps/cli/src/add-collection/add-collection.test.ts b/apps/cli/src/add-collection/add-collection.test.ts new file mode 100644 index 00000000..7d418845 --- /dev/null +++ b/apps/cli/src/add-collection/add-collection.test.ts @@ -0,0 +1,135 @@ +import { addCollection, type LingoTrackerConfig, loadConfig } from '@simoncodes-ca/core'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isInteractiveTerminal } from '../runner/terminal'; +import { addCollectionCommand } from './add-collection'; + +vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadConfig: vi.fn(), addCollection: vi.fn() }; +}); + +const CONFIG: LingoTrackerConfig = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { existing: { translationsFolder: 'src/i18n' } }, +}; + +describe('addCollectionCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + vi.mocked(loadConfig).mockReturnValue(CONFIG); + vi.mocked(addCollection).mockReturnValue({ message: 'Collection "admin" added' }); + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('adds the collection with the given flags and defaults for the rest', async () => { + await addCollectionCommand({ collectionName: 'admin', translationsFolder: 'src/admin' }); + + expect(addCollection).toHaveBeenCalledWith( + 'admin', + { + translationsFolder: 'src/admin', + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'en', + locales: expect.any(Array), + }, + { cwd: '/project' }, + ); + expect(console.log).toHaveBeenCalledWith('✅ Collection "admin" added in .lingo-tracker.json'); + expect(process.exitCode).toBe(0); + }); + + it('marks a folder under node_modules read-only by default when non-interactive', async () => { + await addCollectionCommand({ collectionName: 'vendor', translationsFolder: 'node_modules/lib/i18n' }); + + expect(addCollection).toHaveBeenCalledWith('vendor', expect.objectContaining({ readOnly: true }), { + cwd: '/project', + }); + }); + + it('lets --no-read-only override the node_modules detection', async () => { + await addCollectionCommand({ + collectionName: 'vendor', + translationsFolder: 'node_modules/lib/i18n', + readOnly: false, + }); + + expect(addCollection).toHaveBeenCalledWith('vendor', expect.not.objectContaining({ readOnly: true }), { + cwd: '/project', + }); + }); + + it('exits 1 naming the missing flags in non-interactive mode', async () => { + await addCollectionCommand({ collectionName: 'admin' }); + + expect(console.log).toHaveBeenCalledWith( + '❌ Missing required options in non-interactive mode: --translations-folder', + ); + expect(addCollection).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 when the collection already exists', async () => { + await addCollectionCommand({ collectionName: 'existing', translationsFolder: 'src/x' }); + + expect(console.log).toHaveBeenCalledWith('❌ Collection "existing" already exists.'); + expect(addCollection).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 with the core message when core refuses', async () => { + vi.mocked(addCollection).mockImplementation(() => { + throw new Error('Invalid locale "xx_"'); + }); + + await addCollectionCommand({ collectionName: 'admin', translationsFolder: 'src/admin' }); + + expect(console.log).toHaveBeenCalledWith('❌ Invalid locale "xx_"'); + expect(process.exitCode).toBe(1); + }); + + describe('interactive', () => { + beforeEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + }); + + it('asks for missing values, then the read-only question', async () => { + vi.mocked(prompts) + .mockResolvedValueOnce({ collectionName: 'admin', translationsFolder: 'src/admin', locales: ['en', 'de'] }) + .mockResolvedValueOnce({ readOnly: true }); + + await addCollectionCommand({}); + + expect(addCollection).toHaveBeenCalledWith( + 'admin', + expect.objectContaining({ translationsFolder: 'src/admin', locales: ['en', 'de'], readOnly: true }), + { cwd: '/project' }, + ); + }); + + it('cancelling prints one cancel line and exits 0', async () => { + vi.mocked(prompts).mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'text', name: 'collectionName', message: 'Collection name' }, {}); + return {}; + }); + + await addCollectionCommand({}); + + expect(console.log).toHaveBeenCalledWith('❌ Add collection cancelled.'); + expect(addCollection).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); + }); +}); diff --git a/apps/cli/src/add-collection/add-collection.ts b/apps/cli/src/add-collection/add-collection.ts index 1cbae418..eae1eba8 100644 --- a/apps/cli/src/add-collection/add-collection.ts +++ b/apps/cli/src/add-collection/add-collection.ts @@ -2,62 +2,118 @@ import type prompts from 'prompts'; import { CONFIG_FILENAME, addCollection, DEFAULT_CONFIG } from '@simoncodes-ca/core'; import { isUnderNodeModules } from '@simoncodes-ca/domain'; import type { InitOptions } from '../types/init-options.js'; -import { loadConfiguration, ConsoleFormatter, ErrorMessages, executePromptsWithFallback } from '../utils'; - -export async function addCollectionCommand(options: InitOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config: existingConfig, cwd } = loaded; - - const answers = await promptForMissing(options); - const collectionName = answers.collectionName; - const translationsFolder = answers.translationsFolder; - const exportFolder = answers.exportFolder; - const importFolder = answers.importFolder; - const baseLocale = answers.baseLocale; - const locales = answers.locales; - - if (existingConfig.collections?.[collectionName]) { - ConsoleFormatter.error(ErrorMessages.COLLECTION_EXISTS(collectionName)); - return; - } - - const readOnly = await resolveReadOnly(options, isUnderNodeModules(translationsFolder)); - - const newCollection = { - translationsFolder, - exportFolder, - importFolder, - baseLocale, - locales, - // Only persist the flag when set, keeping writable collections clean in config. - ...(readOnly ? { readOnly: true } : {}), - }; +import { type Ask, defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; + +export const addCollectionCommand = defineCommand()({ + name: 'Add collection', + collection: 'none', + prompts: (options) => { + const questions: prompts.PromptObject[] = []; + + if (!options.collectionName) { + questions.push({ + type: 'text', + name: 'collectionName', + message: 'Collection name', + validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), + }); + } + + if (!options.translationsFolder) { + questions.push({ + type: 'text', + name: 'translationsFolder', + message: 'Path to translations folder', + validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), + }); + } + + if (!options.exportFolder) { + questions.push({ + type: 'text', + name: 'exportFolder', + message: 'Export folder', + initial: DEFAULT_CONFIG.exportFolder, + }); + } + + if (!options.importFolder) { + questions.push({ + type: 'text', + name: 'importFolder', + message: 'Import folder', + initial: DEFAULT_CONFIG.importFolder, + }); + } + + if (!options.baseLocale) { + questions.push({ + type: 'text', + name: 'baseLocale', + message: 'Base locale', + initial: DEFAULT_CONFIG.baseLocale, + validate: (val) => (val && val.trim().length > 0 ? true : 'Required'), + }); + } + + if (!options.locales) { + questions.push({ + type: 'list', + name: 'locales', + message: 'Supported locales (comma-separated)', + initial: 'en,fr-ca,es,de', + separator: ',', + }); + } + + return questions; + }, + required: ['collectionName', 'translationsFolder'], + run: async ({ config, cwd, answers, interactive, ask }) => { + const { collectionName, translationsFolder } = answers; + + if (config.collections?.[collectionName]) { + throw new Error(`Collection "${collectionName}" already exists.`); + } + + const readOnly = await resolveReadOnly(answers.readOnly, isUnderNodeModules(translationsFolder), interactive, ask); + + const newCollection = { + translationsFolder, + exportFolder: answers.exportFolder ?? DEFAULT_CONFIG.exportFolder, + importFolder: answers.importFolder ?? DEFAULT_CONFIG.importFolder, + baseLocale: answers.baseLocale ?? DEFAULT_CONFIG.baseLocale, + locales: answers.locales ?? DEFAULT_CONFIG.locales, + // Only persist the flag when set, keeping writable collections clean in config. + ...(readOnly ? { readOnly: true } : {}), + }; - try { const result = addCollection(collectionName, newCollection, { cwd }); ConsoleFormatter.success(`${result.message} in ${CONFIG_FILENAME}`); - } catch (e: unknown) { - ConsoleFormatter.error(e instanceof Error ? e.message : 'Failed to add collection'); - } -} + }, +}); /** * Resolves the collection's read-only flag. An explicit --read-only/--no-read-only flag - * always wins. Otherwise, in an interactive terminal the user is prompted (pre-filled from + * always wins. Otherwise, in an interactive terminal the user is asked (pre-filled from * node_modules detection); in non-interactive mode the node_modules detection is the default. */ -async function resolveReadOnly(options: InitOptions, nodeModulesDefault: boolean): Promise { - if (typeof options.readOnly === 'boolean') { - return options.readOnly; +async function resolveReadOnly( + flag: boolean | undefined, + nodeModulesDefault: boolean, + interactive: boolean, + ask: Ask, +): Promise { + if (typeof flag === 'boolean') { + return flag; } - if (!process.stdout.isTTY) { + if (!interactive) { return nodeModulesDefault; } - const prompt = (await import('prompts')).default; - const result = await prompt({ + const result = await ask({ type: 'confirm', name: 'readOnly', message: nodeModulesDefault @@ -68,86 +124,3 @@ async function resolveReadOnly(options: InitOptions, nodeModulesDefault: boolean return Boolean(result.readOnly); } - -async function promptForMissing(options: InitOptions): Promise<{ - collectionName: string; - translationsFolder: string; - exportFolder: string; - importFolder: string; - baseLocale: string; - locales: string[]; -}> { - const questions: prompts.PromptObject[] = []; - - if (!options.collectionName) { - questions.push({ - type: 'text', - name: 'collectionName', - message: 'Collection name', - validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), - }); - } - - if (!options.translationsFolder) { - questions.push({ - type: 'text', - name: 'translationsFolder', - message: 'Path to translations folder', - validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), - }); - } - - if (!options.exportFolder) { - questions.push({ - type: 'text', - name: 'exportFolder', - message: 'Export folder', - initial: DEFAULT_CONFIG.exportFolder, - }); - } - - if (!options.importFolder) { - questions.push({ - type: 'text', - name: 'importFolder', - message: 'Import folder', - initial: DEFAULT_CONFIG.importFolder, - }); - } - - if (!options.baseLocale) { - questions.push({ - type: 'text', - name: 'baseLocale', - message: 'Base locale', - initial: DEFAULT_CONFIG.baseLocale, - validate: (val) => (val && val.trim().length > 0 ? true : 'Required'), - }); - } - - if (!options.locales) { - questions.push({ - type: 'list', - name: 'locales', - message: 'Supported locales (comma-separated)', - initial: 'en,fr-ca,es,de', - separator: ',', - }); - } - - const result = await executePromptsWithFallback({ - questions, - currentValues: options, - requiredFields: ['collectionName', 'translationsFolder'], - operationName: 'Add collection', - }); - - return { - collectionName: result.collectionName as string, - translationsFolder: result.translationsFolder as string, - exportFolder: (result.exportFolder as string) ?? DEFAULT_CONFIG.exportFolder, - importFolder: (result.importFolder as string) ?? DEFAULT_CONFIG.importFolder, - baseLocale: (result.baseLocale as string) ?? DEFAULT_CONFIG.baseLocale, - locales: (result.locales as string[]) ?? DEFAULT_CONFIG.locales, - }; -} diff --git a/apps/cli/src/add-resource/add-resource.test.ts b/apps/cli/src/add-resource/add-resource.test.ts index 0ab8a514..0d69d574 100644 --- a/apps/cli/src/add-resource/add-resource.test.ts +++ b/apps/cli/src/add-resource/add-resource.test.ts @@ -2,13 +2,14 @@ import * as fs from 'node:fs'; import * as core from '@simoncodes-ca/core'; import prompts from 'prompts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as utils from '../utils'; +import { isInteractiveTerminal } from '../runner/terminal'; import { addResourceCommand } from './add-resource'; // Mock prompts to avoid interactive input vi.mock('prompts', () => ({ default: vi.fn(), })); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); const fsMocks = vi.hoisted(() => ({ existsSync: vi.fn(), @@ -16,6 +17,7 @@ const fsMocks = vi.hoisted(() => ({ writeFileSync: vi.fn(), })); +// fs is mocked so the existing-entry check (openResourceFolder) reads what each test says. vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; @@ -24,142 +26,107 @@ vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; }); -vi.mock('@simoncodes-ca/core', async () => { - const actual = await vi.importActual('@simoncodes-ca/core'); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - CONFIG_FILENAME: '.lingo-tracker.json', + loadConfig: vi.fn(), addResource: vi.fn().mockResolvedValue({ resolvedKey: 'test.key', created: true }), loadPreferredTerminology: vi.fn(() => ({ rules: [], filePath: '/test/.lingo-tracker-preferred-terminology.json' })), - resolveResourceKey: vi.fn((key: string, targetFolder?: string) => { - return targetFolder ? `${targetFolder}.${key}` : key; - }), - splitResolvedKey: vi.fn((key: string) => { - const parts = key.split('.'); - const entryKey = parts.pop() || key; - return { folderPath: parts, entryKey }; - }), - }; -}); - -vi.mock('../utils', async () => { - const actual = await vi.importActual('../utils'); - return { - ...actual, - loadConfiguration: vi.fn(), - promptForCollection: vi.fn(), - resolveWritableCollection: vi.fn(), - parseCommaSeparatedList: vi.fn((input: string | undefined) => { - if (!input) return undefined; - const result = input - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - return result.length > 0 ? result : undefined; - }), }; }); describe('addResourceCommand', () => { beforeEach(() => { process.env.INIT_CWD = '/test'; + process.exitCode = undefined; vi.clearAllMocks(); vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.readFileSync).mockImplementation(() => { throw new Error('File not found'); }); vi.mocked(prompts).mockResolvedValue({}); - - // Default mock implementations for utils - vi.mocked(utils.loadConfiguration).mockReturnValue(null); - vi.mocked(utils.promptForCollection).mockResolvedValue(null); - vi.mocked(utils.resolveWritableCollection).mockReturnValue(null); + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + vi.mocked(core.loadConfig).mockImplementation(() => { + throw new core.ConfigNotFoundError('/test/.lingo-tracker.json'); + }); }); afterEach(() => { delete process.env.INIT_CWD; + process.exitCode = undefined; }); - it('should show error when config file does not exist', async () => { - // loadConfiguration returns null when config not found - vi.mocked(utils.loadConfiguration).mockReturnValue(null); - + it('should show error and exit 1 when config file does not exist', async () => { await addResourceCommand({ collection: 'test-collection', key: 'buttons.ok', value: 'OK', }); - // Should call loadConfiguration with exitOnError: false - expect(utils.loadConfiguration).toHaveBeenCalledWith({ - exitOnError: false, - }); + expect(core.loadConfig).toHaveBeenCalledWith({ cwd: '/test' }); + expect(console.error).toHaveBeenCalledWith('❌ Configuration file .lingo-tracker.json not found.'); + expect(core.addResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('should validate key format - config check happens first', async () => { - // loadConfiguration returns null when config not found - vi.mocked(utils.loadConfiguration).mockReturnValue(null); + it('should print a core error (invalid key) and exit 1', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + collections: { TestCollection: { translationsFolder: 'translations' } }, + }); + vi.mocked(core.addResource).mockRejectedValueOnce( + new core.InvalidResourceKeyError('invalid key with spaces', 'Invalid resource key'), + ); - // Invalid key, but config doesn't exist so that error comes first await addResourceCommand({ - collection: 'test-collection', + collection: 'TestCollection', key: 'invalid key with spaces', value: 'Test', }); - // Config error is checked before key validation - expect(utils.loadConfiguration).toHaveBeenCalledWith({ - exitOnError: false, - }); + expect(console.log).toHaveBeenCalledWith('❌ Invalid resource key'); + expect(process.exitCode).toBe(1); }); - it('should handle command with all parameters', async () => { - // loadConfiguration returns null (config doesn't exist) - vi.mocked(utils.loadConfiguration).mockReturnValue(null); + it('should show error when collection does not exist', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + collections: { + ExistingCollection: { translationsFolder: 'translations' }, + }, + }); await addResourceCommand({ - collection: 'test-collection', + collection: 'NonExistentCollection', key: 'buttons.ok', value: 'OK', - comment: 'Ok button', - tags: 'ui,buttons', - targetFolder: 'common', }); - // Should stop early since config doesn't exist - expect(utils.loadConfiguration).toHaveBeenCalledWith({ - exitOnError: false, - }); + expect(console.log).toHaveBeenCalledWith('❌ Collection "NonExistentCollection" not found'); + expect(core.addResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('should show error when collection does not exist', async () => { - const config = { - collections: { - ExistingCollection: { translationsFolder: 'translations' }, - }, - }; - - // Mock successful config loading - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', + it('should refuse a read-only collection with exit 1', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + collections: { Vendor: { translationsFolder: 'node_modules/x', readOnly: true } }, }); - // Mock promptForCollection to return the collection name - vi.mocked(utils.promptForCollection).mockResolvedValue('NonExistentCollection'); + await addResourceCommand({ collection: 'Vendor', key: 'buttons.ok', value: 'OK' }); - // Mock resolveWritableCollection to return null (collection not found) - vi.mocked(utils.resolveWritableCollection).mockReturnValue(null); + expect(core.addResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); - await addResourceCommand({ - collection: 'NonExistentCollection', - key: 'buttons.ok', - value: 'OK', + it('should exit 1 naming --key and --value when both are missing in non-interactive mode', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + collections: { TestCollection: { translationsFolder: 'translations' } }, }); - // Should call resolveWritableCollection and get null back - expect(utils.resolveWritableCollection).toHaveBeenCalledWith('NonExistentCollection', config, '/test'); + await addResourceCommand({ collection: 'TestCollection' }); + + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --key, --value'); + expect(core.addResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should pass the opened collection and supplied fields through to core', async () => { @@ -174,23 +141,7 @@ describe('addResourceCommand', () => { baseLocale: 'en', locales: ['en', 'fr-ca', 'es'], }; - - // Mock successful config loading - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', - }); - - // Mock promptForCollection to return the collection name - vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); - - // Mock resolveWritableCollection to return collection data - vi.mocked(utils.resolveWritableCollection).mockReturnValue( - core.openCollection(config, 'TestCollection', { cwd: '/test' }), - ); - - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(core.loadConfig).mockReturnValue(config); await addResourceCommand({ collection: 'TestCollection', @@ -199,10 +150,10 @@ describe('addResourceCommand', () => { comment: 'Primary confirmation action', tags: 'ui, buttons', targetFolder: 'common', - translations: [ + translations: JSON.stringify([ { locale: 'fr-ca', value: "D'accord", status: 'translated' }, { locale: 'es', value: 'Aceptar', status: 'verified' }, - ], + ]), }); expect(core.addResource).toHaveBeenCalledWith( @@ -223,10 +174,42 @@ describe('addResourceCommand', () => { ], }, ); + expect(process.exitCode).toBe(0); + }); + + it('should exit 1 with a clear message on malformed --translations JSON', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + collections: { TestCollection: { translationsFolder: 'translations' } }, + }); + + await addResourceCommand({ collection: 'TestCollection', key: 'a.b', value: 'OK', translations: '[{"locale":' }); + + expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/^❌ Invalid --translations JSON: /)); + expect(core.addResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('should exit 1 when --translations is valid JSON of the wrong shape', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + collections: { TestCollection: { translationsFolder: 'translations' } }, + }); + + await addResourceCommand({ + collection: 'TestCollection', + key: 'a.b', + value: 'OK', + translations: '[{"locale":"fr","value":"Oui","status":"done"}]', + }); + + expect(console.log).toHaveBeenCalledWith( + '❌ Invalid --translations: expected a JSON array of { "locale", "value", "status" } with status one of new, translated, stale, verified', + ); + expect(core.addResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should prompt for overwrite confirmation when resource exists in interactive mode', async () => { - const config = { + vi.mocked(core.loadConfig).mockReturnValue({ collections: { TestCollection: { translationsFolder: 'translations', @@ -234,44 +217,21 @@ describe('addResourceCommand', () => { }, }, baseLocale: 'en', - }; - - // Mock successful config loading - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', }); - // Mock promptForCollection to return the collection name - vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); - - // Mock resolveWritableCollection to return collection data - vi.mocked(utils.resolveWritableCollection).mockReturnValue( - core.openCollection(config, 'TestCollection', { cwd: '/test' }), - ); - - vi.mocked(fs.readFileSync).mockImplementation((path: string) => { - if (path.includes('resource_entries.json')) { + vi.mocked(fs.readFileSync).mockImplementation((path: fs.PathOrFileDescriptor) => { + if (String(path).includes('resource_entries.json')) { return JSON.stringify({ ok: { source: 'OK' } }); } throw new Error('File not found'); }); // Mock resource file exists and contains the entry - vi.mocked(fs.existsSync).mockImplementation((path: string) => { - return path.includes('resource_entries.json'); - }); + vi.mocked(fs.existsSync).mockImplementation((path: fs.PathLike) => String(path).includes('resource_entries.json')); - // Mock TTY to simulate interactive mode - const originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - }); - - // Mock prompt to return false (user cancels) - vi.mocked(prompts).mockResolvedValueOnce({ value: false }); + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + // Optional fields are asked first; then the user declines the overwrite. + vi.mocked(prompts).mockResolvedValueOnce({}).mockResolvedValueOnce({ value: false }); await addResourceCommand({ collection: 'TestCollection', @@ -284,13 +244,11 @@ describe('addResourceCommand', () => { type: 'confirm', message: expect.stringContaining('already exists'), }), + expect.anything(), ); - - // Restore - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - }); + expect(core.addResource).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Add resource cancelled.'); + expect(process.exitCode).toBe(0); }); describe('preferred terminology', () => { @@ -300,26 +258,14 @@ describe('addResourceCommand', () => { baseLocale: 'en', locales: ['en', 'fr'], }; - let originalIsTTY: boolean | undefined; let logSpy: ReturnType; beforeEach(() => { - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', - }); - vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); - vi.mocked(utils.resolveWritableCollection).mockReturnValue( - core.openCollection(config, 'TestCollection', { cwd: '/test' }), - ); - originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true }); + vi.mocked(core.loadConfig).mockReturnValue(config); logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); }); afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); logSpy.mockRestore(); }); @@ -343,7 +289,7 @@ describe('addResourceCommand', () => { expect(lines).toContain(' Finance style guide'); expect(lines).toContain('⚠️ Preferred terminology: consider "email" instead of "e-mail"'); expect(lines.filter((line) => line.includes('Preferred terminology:'))).toHaveLength(2); - expect(process.exitCode ?? 0).toBe(0); + expect(process.exitCode).toBe(0); }); it('prints nothing when the value uses no discouraged term', async () => { @@ -387,28 +333,20 @@ describe('addResourceCommand', () => { await add('Expenditure'); expect(core.loadPreferredTerminology).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('❌ boom'); + expect(process.exitCode).toBe(1); }); }); - it('reports a cancelled prompt and returns without exiting or adding the resource', async () => { - const config = { + it('reports a cancelled prompt once and exits 0 without adding the resource', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ collections: { TestCollection: { translationsFolder: 'translations', baseLocale: 'en' } }, baseLocale: 'en', - }; - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', }); - vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); - vi.mocked(utils.resolveWritableCollection).mockReturnValue( - core.openCollection(config, 'TestCollection', { cwd: '/test' }), - ); - const originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + vi.mocked(isInteractiveTerminal).mockReturnValue(true); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - // The user presses Esc: prompts calls onCancel, which throws PromptCancelledError. + const exit = vi.spyOn(process, 'exit'); + // The user presses Esc: prompts calls onCancel. vi.mocked(prompts).mockImplementation(async (questions, options) => { const [question] = Array.isArray(questions) ? questions : [questions]; options?.onCancel?.(question, {}); @@ -418,13 +356,15 @@ describe('addResourceCommand', () => { try { await expect(addResourceCommand({})).resolves.toBeUndefined(); - expect(log).toHaveBeenCalledWith('❌ ❌ Add resource cancelled.'); + expect(log.mock.calls.filter(([line]) => String(line).includes('cancelled'))).toEqual([ + ['❌ Add resource cancelled.'], + ]); expect(core.addResource).not.toHaveBeenCalled(); expect(exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); } finally { log.mockRestore(); exit.mockRestore(); - Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); } }); }); diff --git a/apps/cli/src/add-resource/add-resource.ts b/apps/cli/src/add-resource/add-resource.ts index 9de0f20f..c7aa497a 100644 --- a/apps/cli/src/add-resource/add-resource.ts +++ b/apps/cli/src/add-resource/add-resource.ts @@ -1,17 +1,15 @@ import type { Collection } from '@simoncodes-ca/core'; import { addResource, openResourceFolder, resolveResourcePaths } from '@simoncodes-ca/core'; import { type TranslationStatus, translocoToICU } from '@simoncodes-ca/domain'; -import prompts from 'prompts'; -import { - ConsoleFormatter, - ErrorMessages, - loadConfiguration, - parseCommaSeparatedList, - promptForCollection, - resolveWritableCollection, - warnAboutPreferredTerminology, -} from '../utils'; -import { PromptCancelledError } from '../utils/report-error'; +import type prompts from 'prompts'; +import { type Ask, CommandCancelledError, defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter, parseCommaSeparatedList, warnAboutPreferredTerminology } from '../utils'; + +interface TranslationInput { + locale: string; + value: string; + status: TranslationStatus; +} export interface AddResourceOptions { collection?: string; @@ -20,71 +18,83 @@ export interface AddResourceOptions { comment?: string; tags?: string; targetFolder?: string; - translations?: Array<{ - locale: string; - value: string; - status: TranslationStatus; - }>; + /** Raw `--translations` JSON: an array of `{ locale, value, status }`. Parsed in `run`. */ + translations?: string; } -export async function addResourceCommand(options: AddResourceOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - const collectionName = await promptForCollection(config, options.collection); - if (!collectionName) return; - - const collection = resolveWritableCollection(collectionName, config, cwd); - if (!collection) return; - - let answers: Awaited>; - try { - answers = await promptForMissing(options, collection); - } catch (error) { - if (error instanceof PromptCancelledError) { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED(error.operation)); - return; +const nonEmpty = (val: string) => (val && val.trim().length > 0 ? true : 'Required'); + +export const addResourceCommand = defineCommand()({ + name: 'Add resource', + collection: 'writable', + prompts: (options) => { + const questions: prompts.PromptObject[] = []; + if (!options.key) { + questions.push({ + type: 'text', + name: 'key', + message: 'Resource key (dot-delimited, e.g., apps.common.buttons.ok)', + validate: nonEmpty, + }); } - throw error; - } + if (!options.value) { + questions.push({ type: 'text', name: 'value', message: 'Base value (source text)', validate: nonEmpty }); + } + if (!options.comment) { + questions.push({ type: 'text', name: 'comment', message: 'Comment (optional, press enter to skip)' }); + } + if (!options.tags) { + questions.push({ type: 'text', name: 'tags', message: 'Tags (optional, comma-separated)' }); + } + if (!options.targetFolder) { + questions.push({ + type: 'text', + name: 'targetFolder', + message: 'Target folder (optional, dot-delimited override)', + }); + } + return questions; + }, + required: ['key', 'value'], + run: async ({ collection, config, cwd, answers, interactive, ask }) => { + const { key, value } = answers; + const targetFolder = answers.targetFolder || undefined; + + const translations = answers.translations + ? parseTranslations(answers.translations) + : interactive + ? await promptForTranslations(collection, value, ask) + : undefined; - try { - // Check if resource already exists const { resolvedKey, folderPath, entryKey } = resolveResourcePaths({ - key: answers.key, + key, translationsFolder: collection.translationsFolder, - targetFolder: answers.targetFolder || undefined, + targetFolder, }); - const resourceExists = hasEntryKey(folderPath, entryKey); - - if (resourceExists) { - if (process.stdout.isTTY) { - // Interactive mode: prompt for confirmation - const confirm = await prompts({ - type: 'confirm', - name: 'value', - message: `Resource "${resolvedKey}" already exists. Override?`, - initial: false, - }); - - if (!confirm.value) { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Add resource')); - return; - } + + // Interactive: an existing entry is only overwritten after confirmation. + if (interactive && hasEntryKey(folderPath, entryKey)) { + const confirm = await ask({ + type: 'confirm', + name: 'value', + message: `Resource "${resolvedKey}" already exists. Override?`, + initial: false, + }); + if (confirm.value !== true) { + throw new CommandCancelledError(); } } - const tagsArray = parseCommaSeparatedList(answers.tags) || []; + const tagsArray = parseCommaSeparatedList(answers.tags) ?? []; // Locales without a supplied translation are seeded by core (auto-translated or copied as `new`). const result = await addResource(collection, { - key: answers.key, - baseValue: answers.value, + key, + baseValue: value, comment: answers.comment || undefined, tags: tagsArray.length > 0 ? tagsArray : undefined, - targetFolder: answers.targetFolder || undefined, - translations: answers.translations, + targetFolder, + translations, }); ConsoleFormatter.success(`Resource added: ${result.resolvedKey}`); @@ -94,151 +104,89 @@ export async function addResourceCommand(options: AddResourceOptions): Promise; -}> { - const responses: Partial<{ - key: string; - value: string; - comment: string; - tags: string; - targetFolder: string; - translations?: Array<{ - locale: string; - value: string; - status: TranslationStatus; - }>; - }> = {}; - - const questions: prompts.PromptObject[] = []; - - if (!options.key) { - questions.push({ - type: 'text', - name: 'key', - message: 'Resource key (dot-delimited, e.g., apps.common.buttons.ok)', - validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), - }); - } +function isTranslationList(value: unknown): value is TranslationInput[] { + return Array.isArray(value) && value.every(isTranslationInput); +} - if (!options.value) { - questions.push({ - type: 'text', - name: 'value', - message: 'Base value (source text)', - validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), - }); +function isTranslationInput(item: unknown): item is TranslationInput { + if (typeof item !== 'object' || item === null) { + return false; } + const { locale, value, status } = item as Record; + return typeof locale === 'string' && typeof value === 'string' && STATUSES.some((known) => known === status); +} - if (!options.comment) { - questions.push({ - type: 'text', - name: 'comment', - message: 'Comment (optional, press enter to skip)', - }); +/** Interactive only: offers a translation and a status for each target locale. */ +async function promptForTranslations( + collection: Collection, + baseValue: string, + ask: Ask, +): Promise { + if (collection.targetLocales.length === 0) { + return undefined; } - if (!options.tags) { - questions.push({ - type: 'text', - name: 'tags', - message: 'Tags (optional, comma-separated)', - }); + const shouldAddTranslations = await ask({ + type: 'confirm', + name: 'value', + message: 'Add translations for other locales?', + initial: false, + }); + if (shouldAddTranslations.value !== true) { + return undefined; } - if (!options.targetFolder) { - questions.push({ + const translations: TranslationInput[] = []; + for (const locale of collection.targetLocales) { + const translationPrompt = await ask({ type: 'text', - name: 'targetFolder', - message: 'Target folder (optional, dot-delimited override)', + name: 'value', + message: `Translation for ${locale} (press enter to use base value)`, }); - } - if (questions.length > 0 && process.stdout.isTTY) { - const result = await prompts(questions, { - onCancel: () => { - throw new PromptCancelledError('Add resource'); - }, + const statusPrompt = await ask({ + type: 'select', + name: 'value', + message: `Status for ${locale}`, + choices: [ + { title: 'new', value: 'new' }, + { title: 'translated', value: 'translated' }, + { title: 'verified', value: 'verified' }, + ], + initial: 1, // Default to 'translated' }); - Object.assign(responses, result); - } else if (questions.length > 0) { - if (!options.key) throw new Error(ErrorMessages.MISSING_OPTION('key')); - if (!options.value) throw new Error(ErrorMessages.MISSING_OPTION('value')); - } - - // Handle translations in interactive mode - let translations: Array<{ locale: string; value: string; status: TranslationStatus }> | undefined; - if (!options.translations && process.stdout.isTTY) { - const nonBaseLocales = collection.targetLocales; - - if (nonBaseLocales.length > 0) { - const shouldAddTranslations = await prompts({ - type: 'confirm', - name: 'value', - message: 'Add translations for other locales?', - initial: false, - }); - if (shouldAddTranslations.value) { - translations = []; - for (const locale of nonBaseLocales) { - const translationPrompt = await prompts({ - type: 'text', - name: 'value', - message: `Translation for ${locale} (press enter to use base value)`, - }); - - const baseValue = options.value ?? (responses.value as string); - const translationValue = translationPrompt.value || baseValue; - - const statusPrompt = await prompts({ - type: 'select', - name: 'value', - message: `Status for ${locale}`, - choices: [ - { title: 'new', value: 'new' }, - { title: 'translated', value: 'translated' }, - { title: 'verified', value: 'verified' }, - ], - initial: 1, // Default to 'translated' - }); - - translations.push({ - locale, - value: translationValue, - status: statusPrompt.value as TranslationStatus, - }); - } - } - } + translations.push({ + locale, + value: + typeof translationPrompt.value === 'string' && translationPrompt.value ? translationPrompt.value : baseValue, + status: statusPrompt.value as TranslationStatus, + }); } - - return { - key: options.key ?? (responses.key as string), - value: options.value ?? (responses.value as string), - comment: options.comment ?? (responses.comment as string) ?? '', - tags: options.tags ?? (responses.tags as string) ?? '', - targetFolder: options.targetFolder ?? (responses.targetFolder as string) ?? '', - translations: options.translations || translations, - }; + return translations; } /** diff --git a/apps/cli/src/commands/add-locale.spec.ts b/apps/cli/src/commands/add-locale.spec.ts index e52b3900..1c366b70 100644 --- a/apps/cli/src/commands/add-locale.spec.ts +++ b/apps/cli/src/commands/add-locale.spec.ts @@ -1,94 +1,71 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { resolve } from 'node:path'; +import { ConfigNotFoundError, type LingoTrackerConfig, loadConfig, addLocaleToCollection } from '@simoncodes-ca/core'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isInteractiveTerminal } from '../runner/terminal'; import { addLocaleCommand, type AddLocaleOptions } from './add-locale'; -vi.mock('@simoncodes-ca/core', () => ({ - addLocaleToCollection: vi.fn(), -})); - -vi.mock('../utils', () => ({ - loadConfiguration: vi.fn(), - promptForCollection: vi.fn(), - resolveWritableCollection: vi.fn(), - ConsoleFormatter: { - error: vi.fn(), - success: vi.fn(), - keyValue: vi.fn(), - }, -})); - -vi.mock('prompts', () => ({ - default: vi.fn(), -})); - -import { type Collection, addLocaleToCollection } from '@simoncodes-ca/core'; -import { loadConfiguration, promptForCollection, resolveWritableCollection, ConsoleFormatter } from '../utils'; +vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadConfig: vi.fn(), addLocaleToCollection: vi.fn() }; +}); -const BASE_CONFIG = { +const BASE_CONFIG: LingoTrackerConfig = { baseLocale: 'en', locales: ['en', 'fr'], collections: { main: { translationsFolder: 'src/i18n' }, + vendor: { translationsFolder: 'vendor/i18n', readOnly: true }, }, }; -const LOADED_CONFIG = { - config: BASE_CONFIG, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', -}; - -const RESOLVED_COLLECTION: Collection = { - name: 'main', - translationsFolder: '/project/src/i18n', - baseLocale: 'en', - locales: ['en', 'fr'], - targetLocales: ['fr'], - translationConfig: undefined, - tags: [], - protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, - readOnly: false, - config: { translationsFolder: 'src/i18n' }, -}; +const mockCore = vi.mocked(addLocaleToCollection); describe('addLocaleCommand', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); - vi.mocked(promptForCollection).mockResolvedValue('main'); - vi.mocked(resolveWritableCollection).mockReturnValue(RESOLVED_COLLECTION); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + vi.mocked(isInteractiveTerminal).mockReturnValue(false); }); - it('returns early when loadConfiguration returns null', async () => { - vi.mocked(loadConfiguration).mockReturnValue(null); - - await addLocaleCommand({ locale: 'de' }); - - expect(addLocaleToCollection).not.toHaveBeenCalled(); + afterEach(() => { + process.exitCode = undefined; }); - it('returns early when promptForCollection returns null', async () => { - vi.mocked(promptForCollection).mockResolvedValue(null); + it('exits 1 without calling core when the config is missing', async () => { + vi.mocked(loadConfig).mockImplementation(() => { + throw new ConfigNotFoundError(resolve('/project', '.lingo-tracker.json')); + }); - await addLocaleCommand({ locale: 'de' }); + await addLocaleCommand({ collection: 'main', locale: 'de' }); - expect(addLocaleToCollection).not.toHaveBeenCalled(); + expect(mockCore).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('returns early when resolveWritableCollection returns null', async () => { - vi.mocked(resolveWritableCollection).mockReturnValue(null); + it('exits 1 without calling core when the collection does not exist', async () => { + await addLocaleCommand({ collection: 'nope', locale: 'de' }); - await addLocaleCommand({ locale: 'de' }); - - expect(addLocaleToCollection).not.toHaveBeenCalled(); + expect(mockCore).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Collection "nope" not found'); + expect(process.exitCode).toBe(1); }); - describe('non-TTY mode', () => { - beforeEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); - }); + it('exits 1 without calling core when the collection is read-only', async () => { + await addLocaleCommand({ collection: 'vendor', locale: 'de' }); + + expect(mockCore).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Collection "vendor" is read-only. Its resources cannot be modified.'); + expect(process.exitCode).toBe(1); + }); + describe('non-interactive mode', () => { it('calls addLocaleToCollection and prints success when --locale is provided', async () => { - vi.mocked(addLocaleToCollection).mockResolvedValue({ + mockCore.mockResolvedValue({ message: 'Locale "de" added to collection "main" successfully', entriesBackfilled: 3, filesUpdated: 2, @@ -97,34 +74,73 @@ describe('addLocaleCommand', () => { const options: AddLocaleOptions = { collection: 'main', locale: 'de' }; await addLocaleCommand(options); - expect(addLocaleToCollection).toHaveBeenCalledWith('main', 'de', { cwd: '/project' }); - expect(ConsoleFormatter.success).toHaveBeenCalledWith('Locale "de" added to collection "main" successfully'); - expect(ConsoleFormatter.keyValue).toHaveBeenCalledWith('Entries backfilled', 3); - expect(ConsoleFormatter.keyValue).toHaveBeenCalledWith('Files updated', 2); + expect(mockCore).toHaveBeenCalledWith('main', 'de', { cwd: '/project' }); + expect(console.log).toHaveBeenCalledWith('✅ Locale "de" added to collection "main" successfully'); + expect(console.log).toHaveBeenCalledWith(' Entries backfilled: 3'); + expect(console.log).toHaveBeenCalledWith(' Files updated: 2'); + expect(process.exitCode).toBe(0); }); - it('prints error and returns without calling core when --locale is missing', async () => { - const options: AddLocaleOptions = { collection: 'main' }; - await addLocaleCommand(options); + it('exits 1 without calling core when --locale is missing', async () => { + await addLocaleCommand({ collection: 'main' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Missing required option: --locale'); - expect(addLocaleToCollection).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --locale'); + expect(mockCore).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('prints error via ConsoleFormatter.error when core function throws', async () => { - vi.mocked(addLocaleToCollection).mockRejectedValue(new Error('Locale "de" already exists in collection "main"')); + it('prints the core error and exits 1 when the core function throws', async () => { + mockCore.mockRejectedValue(new Error('Locale "de" already exists in collection "main"')); await addLocaleCommand({ collection: 'main', locale: 'de' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Locale "de" already exists in collection "main"'); + expect(console.log).toHaveBeenCalledWith('❌ Locale "de" already exists in collection "main"'); + expect(process.exitCode).toBe(1); }); - it('prints generic error message when core throws a non-Error value', async () => { - vi.mocked(addLocaleToCollection).mockRejectedValue('unexpected'); + it('prints a non-Error thrown value and exits 1', async () => { + mockCore.mockRejectedValue('unexpected'); await addLocaleCommand({ collection: 'main', locale: 'de' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Failed to add locale'); + expect(console.log).toHaveBeenCalledWith('❌ unexpected'); + expect(process.exitCode).toBe(1); + }); + }); + + describe('interactive mode', () => { + beforeEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + }); + + it('prompts for the locale when --locale is missing', async () => { + mockCore.mockResolvedValue({ + message: 'Locale "de" added to collection "main" successfully', + entriesBackfilled: 3, + filesUpdated: 2, + }); + vi.mocked(prompts).mockResolvedValueOnce({ locale: 'de' }); + + await addLocaleCommand({ collection: 'main' }); + + expect(prompts).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'locale', type: 'text' })], + expect.anything(), + ); + expect(mockCore).toHaveBeenCalledWith('main', 'de', { cwd: '/project' }); + }); + + it('cancelling the prompt prints one cancel line and exits 0', async () => { + vi.mocked(prompts).mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'text', name: 'locale', message: 'Locale' }, {}); + return {}; + }); + + await addLocaleCommand({ collection: 'main' }); + + expect(mockCore).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Add locale cancelled.'); + expect(process.exitCode).toBe(0); }); }); }); diff --git a/apps/cli/src/commands/add-locale.ts b/apps/cli/src/commands/add-locale.ts index 2328377c..6dbf2b96 100644 --- a/apps/cli/src/commands/add-locale.ts +++ b/apps/cli/src/commands/add-locale.ts @@ -1,51 +1,22 @@ -import prompts from 'prompts'; import { addLocaleToCollection } from '@simoncodes-ca/core'; -import { loadConfiguration, promptForCollection, resolveWritableCollection, ConsoleFormatter } from '../utils'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface AddLocaleOptions { collection?: string; locale?: string; } -export async function addLocaleCommand(options: AddLocaleOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - const collectionName = await promptForCollection(config, options.collection); - if (!collectionName) return; - - const collection = resolveWritableCollection(collectionName, config, cwd); - if (!collection) return; - - let locale = options.locale; - - if (!locale) { - if (!process.stdout.isTTY) { - ConsoleFormatter.error('Missing required option: --locale'); - return; - } - - const answer = await prompts( - { - type: 'text', - name: 'locale', - message: 'Enter locale to add (e.g. fr-ca, de, es)', - }, - { onCancel: () => process.exit(0) }, - ); - - locale = answer.locale as string; - } - - if (!locale) return; - - try { - const result = await addLocaleToCollection(collectionName, locale, { cwd }); +export const addLocaleCommand = defineCommand()({ + name: 'Add locale', + collection: 'writable', + prompts: (options) => + options.locale ? [] : [{ type: 'text', name: 'locale', message: 'Enter locale to add (e.g. fr-ca, de, es)' }], + required: ['locale'], + run: async ({ collection, cwd, answers }) => { + const result = await addLocaleToCollection(collection.name, answers.locale, { cwd }); ConsoleFormatter.success(result.message); ConsoleFormatter.keyValue('Entries backfilled', result.entriesBackfilled); ConsoleFormatter.keyValue('Files updated', result.filesUpdated); - } catch (e: unknown) { - ConsoleFormatter.error(e instanceof Error ? e.message : 'Failed to add locale'); - } -} + }, +}); diff --git a/apps/cli/src/commands/bundle.test.ts b/apps/cli/src/commands/bundle.test.ts index df0e0d76..8124d3fc 100644 --- a/apps/cli/src/commands/bundle.test.ts +++ b/apps/cli/src/commands/bundle.test.ts @@ -1,49 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { bundleCommand } from './bundle'; -import * as fs from 'node:fs'; import prompts from 'prompts'; -import * as utils from '../utils'; +import { isInteractiveTerminal } from '../runner/terminal'; -const fsMocks = vi.hoisted(() => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), - writeFileSync: vi.fn(), -})); - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); vi.mock('@simoncodes-ca/core', async () => { const actual = await vi.importActual('@simoncodes-ca/core'); return { ...actual, + loadConfig: vi.fn(), generateBundle: vi.fn(), }; }); -vi.mock('../utils', async () => { - const actual = await vi.importActual('../utils'); - return { - ...actual, - loadConfiguration: vi.fn(), - parseCommaSeparatedList: vi.fn((input: string | undefined) => { - if (!input) return undefined; - const result = input - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - return result.length > 0 ? result : undefined; - }), - }; -}); - import * as core from '@simoncodes-ca/core'; const mockGenerateBundle = core.generateBundle as ReturnType; @@ -72,22 +43,15 @@ describe('bundleCommand', () => { }, }; - const originalStdout = process.stdout.isTTY; const originalLog = console.log; beforeEach(() => { vi.clearAllMocks(); console.log = vi.fn(); - process.stdout.isTTY = false; - - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockConfig)); - - // Mock loadConfiguration to return the mock config - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config: mockConfig, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', - }); + process.env.INIT_CWD = '/test'; + process.exitCode = undefined; + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + vi.mocked(core.loadConfig).mockReturnValue(mockConfig); mockGenerateBundle.mockReturnValue({ bundleKey: 'core', @@ -98,46 +62,42 @@ describe('bundleCommand', () => { }); afterEach(() => { - process.stdout.isTTY = originalStdout; console.log = originalLog; + process.exitCode = undefined; }); describe('configuration validation', () => { it('should error when config file is missing', async () => { - vi.mocked(utils.loadConfiguration).mockReturnValue(null); + vi.mocked(core.loadConfig).mockImplementation(() => { + throw new core.ConfigNotFoundError('/test/.lingo-tracker.json'); + }); await bundleCommand({}); - expect(utils.loadConfiguration).toHaveBeenCalledWith({ - exitOnError: false, - }); + expect(core.loadConfig).toHaveBeenCalledWith({ cwd: '/test' }); + expect(mockGenerateBundle).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should error when no bundles are configured', async () => { const configWithoutBundles = { ...mockConfig, bundles: {} }; - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config: configWithoutBundles, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', - }); + vi.mocked(core.loadConfig).mockReturnValue(configWithoutBundles); await bundleCommand({}); expect(console.log).toHaveBeenCalledWith('❌ No bundles configured in .lingo-tracker.json'); + expect(process.exitCode).toBe(1); }); it('should error when bundles property is missing', async () => { const configWithoutBundles = { ...mockConfig }; delete (configWithoutBundles as { bundles?: unknown }).bundles; - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config: configWithoutBundles, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', - }); + vi.mocked(core.loadConfig).mockReturnValue(configWithoutBundles); await bundleCommand({}); expect(console.log).toHaveBeenCalledWith('❌ No bundles configured in .lingo-tracker.json'); + expect(process.exitCode).toBe(1); }); }); @@ -145,6 +105,7 @@ describe('bundleCommand', () => { it('should process all bundles by default in non-TTY mode', async () => { await bundleCommand({}); + expect(process.exitCode).toBe(0); expect(mockGenerateBundle).toHaveBeenCalledTimes(2); expect(mockGenerateBundle).toHaveBeenCalledWith( expect.objectContaining({ @@ -196,6 +157,7 @@ describe('bundleCommand', () => { expect(console.log).toHaveBeenCalledWith('❌ Bundle "nonexistent" not found.'); expect(mockGenerateBundle).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); @@ -493,6 +455,7 @@ describe('bundleCommand', () => { ); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Please target a single bundle')); expect(mockGenerateBundle).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should error when --token-constant-name is used in non-TTY mode (all bundles)', async () => { @@ -506,21 +469,6 @@ describe('bundleCommand', () => { expect(mockGenerateBundle).not.toHaveBeenCalled(); }); - it('should error when --token-constant-name is set but no bundles are selected', async () => { - // Provide a non-empty --name so the promptForMissing name-parsing branch is entered, - // but mock parseCommaSeparatedList to return an empty array so bundlesToProcess - // stays empty (length === 0) and the zero-bundle guard fires. - vi.mocked(utils.parseCommaSeparatedList).mockReturnValueOnce([]); - - await bundleCommand({ name: 'nonexistent', tokenConstantName: 'MY_CUSTOM_TOKENS' }); - - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('No bundles selected')); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('--token-constant-name requires a single bundle to be targeted'), - ); - expect(mockGenerateBundle).not.toHaveBeenCalled(); - }); - it('should not pass tokenConstantName when not provided', async () => { await bundleCommand({ name: 'core' }); @@ -582,6 +530,7 @@ describe('bundleCommand', () => { expect(console.log).toHaveBeenCalledWith(' ❌ Bundle generation failed'); expect(console.log).toHaveBeenCalledWith('🔄 Generating bundle: admin'); expect(mockGenerateBundle).toHaveBeenCalledTimes(2); + expect(process.exitCode).toBe(1); }); it('should show error count in summary', async () => { @@ -604,7 +553,7 @@ describe('bundleCommand', () => { describe('interactive mode (TTY)', () => { beforeEach(() => { - process.stdout.isTTY = true; + vi.mocked(isInteractiveTerminal).mockReturnValue(true); }); it('should prompt for bundle selection when no --name provided', async () => { @@ -664,8 +613,8 @@ describe('bundleCommand', () => { }); it('should report prompt cancellation and return without exiting or generating', async () => { - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - // The user presses Esc: prompts calls onCancel, which throws PromptCancelledError. + const exit = vi.spyOn(process, 'exit'); + // The user presses Esc: prompts calls onCancel. vi.mocked(prompts).mockImplementation(async (questions, options) => { const [question] = Array.isArray(questions) ? questions : [questions]; options?.onCancel?.(question, {}); @@ -674,9 +623,10 @@ describe('bundleCommand', () => { await expect(bundleCommand({})).resolves.toBeUndefined(); - expect(console.log).toHaveBeenCalledWith('❌ ❌ Bundle generation cancelled.'); + expect(console.log).toHaveBeenCalledWith('❌ Bundle generation cancelled.'); expect(mockGenerateBundle).not.toHaveBeenCalled(); expect(exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); exit.mockRestore(); }); }); diff --git a/apps/cli/src/commands/bundle.ts b/apps/cli/src/commands/bundle.ts index 7dda5749..75080120 100644 --- a/apps/cli/src/commands/bundle.ts +++ b/apps/cli/src/commands/bundle.ts @@ -1,9 +1,8 @@ -import prompts from 'prompts'; import type { LingoTrackerConfig } from '@simoncodes-ca/core'; import type { TokenCasing } from '@simoncodes-ca/domain'; import { generateBundle, hasTypeDistConfigured } from '@simoncodes-ca/core'; -import { loadConfiguration, parseCommaSeparatedList, ConsoleFormatter, ErrorMessages } from '../utils'; -import { PromptCancelledError } from '../utils/report-error'; +import { type Answers, type CommandResult, defineCommand } from '../runner/command-runner'; +import { ALL_ITEMS_SENTINEL, parseCommaSeparatedList, ConsoleFormatter } from '../utils'; export interface BundleOptions { name?: string; @@ -38,51 +37,55 @@ interface BundleGenerationResult { const DEFAULT_DEBUG_KEYS_LOCALE = '99'; -export async function bundleCommand(options: BundleOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; +export const bundleCommand = defineCommand()({ + name: 'Bundle generation', + collection: 'none', + // Interactive without --name: pick one bundle or all. Non-interactive without --name: all bundles. + prompts: (options, { config }) => { + const bundleKeys = Object.keys(config.bundles ?? {}); + if (options.name || bundleKeys.length === 0) { + return []; + } + return [ + { + type: 'select', + name: 'bundleOrAll', + message: 'Select bundle to generate', + choices: [ + ...bundleKeys.map((key) => ({ title: key, value: key })), + { title: 'All bundles', value: ALL_ITEMS_SENTINEL }, + ], + }, + ]; + }, + run: ({ config, cwd, answers }) => run(config, cwd, answers), +}); +async function run(config: LingoTrackerConfig, cwd: string, options: Answers): Promise { // Check if bundles are configured if (!config.bundles || Object.keys(config.bundles).length === 0) { ConsoleFormatter.error('No bundles configured in .lingo-tracker.json'); ConsoleFormatter.indent('Add a "bundles" section to your configuration file.'); - return; + return { exitCode: 1 }; } - let answers: Awaited>; - try { - answers = await promptForMissing(options, config); - } catch (error) { - if (error instanceof PromptCancelledError) { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED(error.operation)); - return; - } - throw error; - } - - // Determine which bundles to process - const bundlesToProcess: string[] = []; - - if (answers.all) { - bundlesToProcess.push(...Object.keys(config.bundles)); - } else if (answers.names && answers.names.length > 0) { - bundlesToProcess.push(...answers.names); - } + const picked = typeof options.bundleOrAll === 'string' ? options.bundleOrAll : undefined; + const names = options.name ? parseCommaSeparatedList(options.name) : undefined; + const bundlesToProcess = + names && names.length > 0 + ? names + : picked && picked !== ALL_ITEMS_SENTINEL + ? [picked] + : Object.keys(config.bundles); // --token-constant-name is only valid for a single bundle - if (options.tokenConstantName && bundlesToProcess.length === 0) { - ConsoleFormatter.error('No bundles selected. --token-constant-name requires a single bundle to be targeted.'); - return; - } - if (options.tokenConstantName && bundlesToProcess.length > 1) { - ConsoleFormatter.error('Cannot use --token-constant-name with multiple bundles. Please target a single bundle.'); - return; + throw new Error('Cannot use --token-constant-name with multiple bundles. Please target a single bundle.'); } // Parse locale filter if provided - const localeFilter = answers.locales && answers.locales.length > 0 ? answers.locales : undefined; + const locales = parseCommaSeparatedList(options.locale); + const localeFilter = locales && locales.length > 0 ? locales : undefined; const debugKeysLocale = options.debugKeys === true ? DEFAULT_DEBUG_KEYS_LOCALE : options.debugKeys || undefined; @@ -223,78 +226,6 @@ export async function bundleCommand(options: BundleOptions): Promise { ConsoleFormatter.warning(`${errors.length} bundle(s) failed to generate`); } } -} - -async function promptForMissing( - options: BundleOptions, - config: LingoTrackerConfig, -): Promise<{ - names?: string[]; - locales?: string[]; - all: boolean; -}> { - const responses: { - names?: string[]; - locales?: string[]; - all: boolean; - } = { - all: false, - }; - - const bundleKeys = Object.keys(config.bundles || {}); - const questions: prompts.PromptObject[] = []; - - // Parse comma-separated bundle names if provided - if (options.name) { - responses.names = parseCommaSeparatedList(options.name); - } - - // Parse comma-separated locales if provided - if (options.locale) { - responses.locales = parseCommaSeparatedList(options.locale); - } - - // If no bundle names provided, prompt for selection - if (!options.name && process.stdout.isTTY) { - if (bundleKeys.length === 0) { - ConsoleFormatter.error('No bundles configured. Add bundles to .lingo-tracker.json first.'); - throw new Error('No bundles available'); - } - - const choices = [ - ...bundleKeys.map((key) => ({ title: key, value: key })), - { title: 'All bundles', value: '__ALL__' }, - ]; - - questions.push({ - type: 'select', - name: 'bundleOrAll', - message: 'Select bundle to generate', - choices, - }); - } - - if (questions.length > 0) { - const result = await prompts(questions, { - onCancel: () => { - throw new PromptCancelledError('Bundle generation'); - }, - }); - - if (result.bundleOrAll === '__ALL__') { - responses.all = true; - } else if (result.bundleOrAll) { - responses.names = [result.bundleOrAll as string]; - } - } else if (!options.name) { - // Non-TTY mode or no prompts - default to all bundles - responses.all = true; - } - - // If still no bundles selected, default to all - if (!responses.names && !responses.all) { - responses.all = true; - } - return responses; + return bundleResults.some((r) => r.error) ? { exitCode: 1 } : undefined; } diff --git a/apps/cli/src/commands/delete-resource.test.ts b/apps/cli/src/commands/delete-resource.test.ts index 5d8b3e60..b96d4e77 100644 --- a/apps/cli/src/commands/delete-resource.test.ts +++ b/apps/cli/src/commands/delete-resource.test.ts @@ -1,188 +1,175 @@ -import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { deleteResource } from '@simoncodes-ca/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigNotFoundError, deleteResource, loadConfig } from '@simoncodes-ca/core'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isInteractiveTerminal } from '../runner/terminal'; import { deleteResourceCommand } from './delete-resource'; -const fsMocks = vi.hoisted(() => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), -})); - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); vi.mock('prompts'); -vi.mock('@simoncodes-ca/core', async () => { - const actual = await vi.importActual('@simoncodes-ca/core'); - return { - ...actual, - deleteResource: vi.fn(), - }; +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadConfig: vi.fn(), deleteResource: vi.fn() }; }); -const mockExistsSync = vi.mocked(existsSync); -const mockReadFileSync = vi.mocked(readFileSync); const mockDeleteResource = vi.mocked(deleteResource); +const mockPrompts = vi.mocked(prompts); + +const mockConfig = { + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { + default: { translationsFolder: 'src/i18n' }, + }, +}; + +const expectedCollection = expect.objectContaining({ + name: 'default', + translationsFolder: resolve('/test/project', 'src/i18n'), +}); describe('deleteResourceCommand', () => { beforeEach(() => { vi.clearAllMocks(); process.env.INIT_CWD = '/test/project'; + process.exitCode = undefined; + vi.mocked(loadConfig).mockReturnValue(mockConfig); + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + mockDeleteResource.mockReturnValue({ entriesDeleted: 1 }); }); - const mockConfig = { - exportFolder: 'dist/lingo-export', - importFolder: 'dist/lingo-import', - baseLocale: 'en', - locales: ['en', 'fr'], - collections: { - default: { - translationsFolder: 'src/i18n', - }, - }, - }; - - it('should delete a single resource successfully', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); - mockDeleteResource.mockReturnValue({ - entriesDeleted: 1, - }); + afterEach(() => { + process.exitCode = undefined; + }); + + it('deletes a single resource and exits 0', async () => { + await deleteResourceCommand({ collection: 'default', key: 'apps.common.buttons.ok', yes: true }); + + expect(mockDeleteResource).toHaveBeenCalledWith(expectedCollection, { keys: ['apps.common.buttons.ok'] }); + expect(process.exitCode).toBe(0); + }); - const options = { + it('trims comma-separated keys and drops empty ones', async () => { + await deleteResourceCommand({ collection: 'default', - key: 'apps.common.buttons.ok', + key: 'apps.common.buttons.ok, , apps.common.buttons.cancel, ', yes: true, - }; - - await deleteResourceCommand(options); + }); - expect(mockDeleteResource).toHaveBeenCalledWith( - expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), - { keys: ['apps.common.buttons.ok'] }, - ); + expect(mockDeleteResource).toHaveBeenCalledWith(expectedCollection, { + keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel'], + }); }); - it('should delete multiple resources from comma-separated keys', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + it('exits 1 when some keys could not be deleted', async () => { mockDeleteResource.mockReturnValue({ - entriesDeleted: 3, + entriesDeleted: 1, + errors: [{ key: 'apps.common.invalid', error: 'Resource not found' }], }); - const options = { - collection: 'default', - key: 'apps.common.buttons.ok, apps.common.buttons.cancel, apps.common.buttons.save', - yes: true, - }; - - await deleteResourceCommand(options); + await deleteResourceCommand({ collection: 'default', key: 'apps.common.ok,apps.common.invalid', yes: true }); - expect(mockDeleteResource).toHaveBeenCalledWith( - expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), - { keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel', 'apps.common.buttons.save'] }, - ); + expect(mockDeleteResource).toHaveBeenCalledWith(expectedCollection, { + keys: ['apps.common.ok', 'apps.common.invalid'], + }); + expect(console.log).toHaveBeenCalledWith('⚠️ Some operations failed:'); + expect(console.log).toHaveBeenCalledWith(' - apps.common.invalid: Resource not found'); + expect(process.exitCode).toBe(1); }); - it('should handle partial success with errors', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + it('warns on zero deletions and exits 1 for the key that failed', async () => { mockDeleteResource.mockReturnValue({ - entriesDeleted: 2, - errors: [{ key: 'apps.common.invalid', error: 'Resource not found' }], + entriesDeleted: 0, + errors: [{ key: 'apps.common.notfound', error: 'Resource not found' }], }); - const options = { + await deleteResourceCommand({ collection: 'default', key: 'apps.common.notfound', yes: true }); + + expect(mockDeleteResource).toHaveBeenCalledWith(expectedCollection, { keys: ['apps.common.notfound'] }); + expect(console.log).toHaveBeenCalledWith('⚠️ No resources were deleted.'); + expect(process.exitCode).toBe(1); + }); + + it('deletes several comma-separated keys in one call', async () => { + await deleteResourceCommand({ collection: 'default', - key: 'apps.common.buttons.ok, apps.common.buttons.cancel, apps.common.invalid', + key: 'apps.common.buttons.ok, apps.common.buttons.cancel, apps.common.buttons.save', yes: true, - }; - - await deleteResourceCommand(options); + }); - expect(mockDeleteResource).toHaveBeenCalledWith( - expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), - { keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel', 'apps.common.invalid'] }, - ); + expect(mockDeleteResource).toHaveBeenCalledWith(expectedCollection, { + keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel', 'apps.common.buttons.save'], + }); + expect(console.log).toHaveBeenCalledWith('✅ Deleted 1 resource(s)'); }); - it('should not delete if config does not exist', async () => { - mockReadFileSync.mockImplementation(() => { - throw new Error('ENOENT: no such file or directory'); + it('exits 1 when core throws', async () => { + mockDeleteResource.mockImplementation(() => { + throw new Error('disk full'); }); - const options = { - collection: 'default', - key: 'apps.common.buttons.ok', - yes: true, - }; + await deleteResourceCommand({ collection: 'default', key: 'a.b', yes: true }); + + expect(console.log).toHaveBeenCalledWith('❌ disk full'); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 without deleting when the config is missing', async () => { + vi.mocked(loadConfig).mockImplementation(() => { + throw new ConfigNotFoundError('/test/project/.lingo-tracker.json'); + }); - await deleteResourceCommand(options); + await deleteResourceCommand({ collection: 'default', key: 'a.b', yes: true }); expect(mockDeleteResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('should not delete if collection does not exist', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + it('exits 1 without deleting when the collection does not exist', async () => { + await deleteResourceCommand({ collection: 'nonexistent', key: 'a.b', yes: true }); - const options = { - collection: 'nonexistent', - key: 'apps.common.buttons.ok', - yes: true, - }; + expect(mockDeleteResource).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Collection "nonexistent" not found'); + expect(process.exitCode).toBe(1); + }); - await deleteResourceCommand(options); + it('exits 1 when --key is missing in non-interactive mode', async () => { + await deleteResourceCommand({ collection: 'default' }); expect(mockDeleteResource).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --key'); + expect(process.exitCode).toBe(1); }); - it('should trim and filter empty keys from comma-separated input', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); - mockDeleteResource.mockReturnValue({ - entriesDeleted: 2, + describe('interactive', () => { + beforeEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); }); - const options = { - collection: 'default', - key: 'apps.common.buttons.ok, , apps.common.buttons.cancel, ', - yes: true, - }; + it('asks for the key, then for confirmation', async () => { + mockPrompts.mockResolvedValueOnce({ key: 'a.b' }).mockResolvedValueOnce({ confirmed: true }); - await deleteResourceCommand(options); + await deleteResourceCommand({ collection: 'default' }); - expect(mockDeleteResource).toHaveBeenCalledWith( - expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), - { keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel'] }, - ); - }); - - it('should handle zero deletions', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); - mockDeleteResource.mockReturnValue({ - entriesDeleted: 0, - errors: [{ key: 'apps.common.notfound', error: 'Resource not found' }], + expect(mockDeleteResource).toHaveBeenCalledWith(expectedCollection, { keys: ['a.b'] }); + expect(process.exitCode).toBe(0); }); - const options = { - collection: 'default', - key: 'apps.common.notfound', - yes: true, - }; + it('declining the confirmation cancels with exit 0', async () => { + mockPrompts.mockResolvedValueOnce({ confirmed: false }); - await deleteResourceCommand(options); + await deleteResourceCommand({ collection: 'default', key: 'a.b' }); - expect(mockDeleteResource).toHaveBeenCalledWith( - expect.objectContaining({ name: 'default', translationsFolder: resolve('/test/project', 'src/i18n') }), - { keys: ['apps.common.notfound'] }, - ); + expect(mockDeleteResource).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Delete resource cancelled.'); + expect(process.exitCode).toBe(0); + }); + + it('--yes skips the confirmation', async () => { + await deleteResourceCommand({ collection: 'default', key: 'a.b', yes: true }); + + expect(mockPrompts).not.toHaveBeenCalled(); + expect(mockDeleteResource).toHaveBeenCalled(); + }); }); }); diff --git a/apps/cli/src/commands/delete-resource.ts b/apps/cli/src/commands/delete-resource.ts index 5ee78610..ec81bd98 100644 --- a/apps/cli/src/commands/delete-resource.ts +++ b/apps/cli/src/commands/delete-resource.ts @@ -1,15 +1,6 @@ -import prompts from 'prompts'; import { deleteResource } from '@simoncodes-ca/core'; -import { - loadConfiguration, - parseCommaSeparatedList, - promptForCollection, - resolveWritableCollection, - ConsoleFormatter, - ErrorMessages, - isInteractiveTerminal, - executePromptsWithFallback, -} from '../utils'; +import { type Ask, CommandCancelledError, defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter, parseCommaSeparatedList } from '../utils'; export interface DeleteResourceOptions { collection?: string; @@ -17,40 +8,32 @@ export interface DeleteResourceOptions { yes?: boolean; } -export async function deleteResourceCommand(options: DeleteResourceOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - // Prompt for collection first - const collectionName = await promptForCollection(config, options.collection); - if (!collectionName) return; - - // Validate collection exists - const collection = resolveWritableCollection(collectionName, config, cwd); - if (!collection) return; - - // Prompt for other fields - const answers = await promptForMissing(options); - - // Parse keys - const keys = parseCommaSeparatedList(answers.key) || []; - - if (keys.length === 0) { - ConsoleFormatter.error('No valid keys provided.'); - return; - } +export const deleteResourceCommand = defineCommand()({ + name: 'Delete resource', + collection: 'writable', + prompts: (options) => + options.key + ? [] + : [ + { + type: 'text', + name: 'key', + message: 'Resource key(s) (single key or comma-separated)', + validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), + }, + ], + required: ['key'], + run: async ({ collection, answers, interactive, ask }) => { + const keys = parseCommaSeparatedList(answers.key) ?? []; + if (keys.length === 0) { + throw new Error('No valid keys provided.'); + } - // Show confirmation unless --yes flag or non-TTY mode - if (!options.yes && isInteractiveTerminal()) { - const confirmed = await confirmDeletion(keys); - if (!confirmed) { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Delete resource')); - return; + // Confirm unless --yes, or non-interactive (nobody to ask). + if (!answers.yes && interactive && !(await confirmDeletion(keys, ask))) { + throw new CommandCancelledError(); } - } - try { const result = deleteResource(collection, { keys }); if (result.entriesDeleted === 0) { @@ -65,37 +48,12 @@ export async function deleteResourceCommand(options: DeleteResourceOptions): Pro for (const error of result.errors) { ConsoleFormatter.indent(`- ${error.key}: ${error.error}`); } + return { exitCode: 1 }; } - } catch (e: unknown) { - ConsoleFormatter.error(e instanceof Error ? e.message : 'Failed to delete resource'); - } -} - -async function promptForMissing(options: DeleteResourceOptions): Promise<{ key: string }> { - const questions: prompts.PromptObject[] = []; - - if (!options.key) { - questions.push({ - type: 'text', - name: 'key', - message: 'Resource key(s) (single key or comma-separated)', - validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), - }); - } - - const result = await executePromptsWithFallback({ - questions, - currentValues: options, - requiredFields: ['key'], - operationName: 'Delete resource', - }); - - return { - key: result.key as string, - }; -} + }, +}); -async function confirmDeletion(keys: string[]): Promise { +async function confirmDeletion(keys: string[], ask: Ask): Promise { console.log('\nYou are about to delete:'); if (keys.length === 1) { @@ -109,7 +67,7 @@ async function confirmDeletion(keys: string[]): Promise { console.log('\n⚠️ This will remove translations for all locales.'); - const response = await prompts({ + const response = await ask({ type: 'confirm', name: 'confirmed', message: 'Are you sure?', diff --git a/apps/cli/src/commands/edit-collection.test.ts b/apps/cli/src/commands/edit-collection.test.ts index adc0b02d..4aef1f85 100644 --- a/apps/cli/src/commands/edit-collection.test.ts +++ b/apps/cli/src/commands/edit-collection.test.ts @@ -1,32 +1,16 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { loadConfig, updateCollection } from '@simoncodes-ca/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { editCollectionCommand } from './edit-collection'; -import { updateCollection } from '@simoncodes-ca/core'; -const fsMocks = vi.hoisted(() => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), - writeFileSync: vi.fn(), -})); - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); -vi.mock('@simoncodes-ca/core', async () => { - const actual = await vi.importActual('@simoncodes-ca/core'); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, + loadConfig: vi.fn(), updateCollection: vi.fn().mockResolvedValue({ message: 'updated' }), }; }); -const mockExistsSync = vi.mocked(existsSync); -const mockReadFileSync = vi.mocked(readFileSync); const mockUpdateCollection = vi.mocked(updateCollection); describe('editCollectionCommand', () => { @@ -44,9 +28,25 @@ describe('editCollectionCommand', () => { beforeEach(() => { vi.clearAllMocks(); process.env.INIT_CWD = '/test/project'; - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); - vi.mocked(writeFileSync).mockImplementation(() => undefined); + process.exitCode = undefined; + vi.mocked(loadConfig).mockReturnValue(mockConfig); + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('passes the stored collection with the new tags and the project root', async () => { + await editCollectionCommand('myApp', { addTag: ['new-feature'] }); + + expect(mockUpdateCollection).toHaveBeenCalledWith( + 'myApp', + undefined, + { translationsFolder: './src/i18n', tags: ['existing-tag', 'new-feature'] }, + { cwd: '/test/project' }, + ); + expect(console.log).toHaveBeenCalledWith('✅ Collection "myApp" tags updated: existing-tag, new-feature'); + expect(process.exitCode).toBe(0); }); it('adds a new tag to the collection', async () => { @@ -95,31 +95,31 @@ describe('editCollectionCommand', () => { expect(collectionArg.tags).toEqual([]); }); - it('exits with error when --set-tags is combined with --add-tag', async () => { - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + it('exits 1 when --set-tags is combined with --add-tag', async () => { await editCollectionCommand('myApp', { setTags: 'foo', addTag: ['bar'] }); - expect(exitSpy).toHaveBeenCalledWith(1); - exitSpy.mockRestore(); + expect(mockUpdateCollection).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ --set-tags cannot be combined with --add-tag or --remove-tag'); + expect(process.exitCode).toBe(1); }); - it('exits with error when --set-tags is combined with --remove-tag', async () => { - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + it('exits 1 when --set-tags is combined with --remove-tag', async () => { await editCollectionCommand('myApp', { setTags: 'foo', removeTag: ['existing-tag'] }); - expect(exitSpy).toHaveBeenCalledWith(1); - exitSpy.mockRestore(); + expect(mockUpdateCollection).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ --set-tags cannot be combined with --add-tag or --remove-tag'); + expect(process.exitCode).toBe(1); }); - it('exits with error when no options provided', async () => { - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + it('exits 1 when no options provided', async () => { await editCollectionCommand('myApp', {}); - expect(exitSpy).toHaveBeenCalledWith(1); - exitSpy.mockRestore(); + expect(mockUpdateCollection).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Provide at least one of --add-tag, --remove-tag, or --set-tags'); + expect(process.exitCode).toBe(1); }); - it('exits with error when collection is not found', async () => { - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + it('exits 1 when collection is not found', async () => { await editCollectionCommand('nonexistent', { addTag: ['foo'] }); - expect(exitSpy).toHaveBeenCalledWith(1); - exitSpy.mockRestore(); + expect(mockUpdateCollection).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Collection "nonexistent" not found'); + expect(process.exitCode).toBe(1); }); }); diff --git a/apps/cli/src/commands/edit-collection.ts b/apps/cli/src/commands/edit-collection.ts index cd3a3282..6b84ce7c 100644 --- a/apps/cli/src/commands/edit-collection.ts +++ b/apps/cli/src/commands/edit-collection.ts @@ -1,6 +1,7 @@ import { updateCollection } from '@simoncodes-ca/core'; import { normalizeTags } from '@simoncodes-ca/domain'; -import { loadConfiguration, ConsoleFormatter } from '../utils'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface EditCollectionOptions { addTag?: string[]; @@ -8,63 +9,55 @@ export interface EditCollectionOptions { setTags?: string; } -export async function editCollectionCommand(collectionName: string, options: EditCollectionOptions): Promise { - const hasAdd = options.addTag && options.addTag.length > 0; - const hasRemove = options.removeTag && options.removeTag.length > 0; - const hasSet = options.setTags !== undefined; - - if (hasSet && (hasAdd || hasRemove)) { - ConsoleFormatter.error('--set-tags cannot be combined with --add-tag or --remove-tag'); - process.exit(1); - return; - } - - if (!hasAdd && !hasRemove && !hasSet) { - ConsoleFormatter.error('Provide at least one of --add-tag, --remove-tag, or --set-tags'); - process.exit(1); - return; - } - - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - const collection = config.collections?.[collectionName]; - if (!collection) { - ConsoleFormatter.error(`Collection "${collectionName}" not found`); - process.exit(1); - return; - } +const run = defineCommand()({ + name: 'Edit collection', + // Edits the collection's registration (tags), not its resources, so a read-only collection is allowed. + collection: 'read', + collectionOption: 'name', + run: async ({ collection, cwd, answers }) => { + const hasAdd = (answers.addTag ?? []).length > 0; + const hasRemove = (answers.removeTag ?? []).length > 0; + const hasSet = answers.setTags !== undefined; + + if (hasSet && (hasAdd || hasRemove)) { + throw new Error('--set-tags cannot be combined with --add-tag or --remove-tag'); + } - let currentTags = [...(collection.tags ?? [])]; + if (!hasAdd && !hasRemove && !hasSet) { + throw new Error('Provide at least one of --add-tag, --remove-tag, or --set-tags'); + } - if (hasSet) { - currentTags = normalizeTags( - (options.setTags ?? '') - .split(',') - .map((t) => t.trim()) - .filter(Boolean), - ); - } else { - if (hasAdd) { - const toAdd = normalizeTags(options.addTag ?? []); - for (const tag of toAdd) { + const stored = collection.config; + let currentTags = [...(stored.tags ?? [])]; + + if (hasSet) { + currentTags = normalizeTags( + (answers.setTags ?? '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean), + ); + } else { + for (const tag of normalizeTags(answers.addTag ?? [])) { if (!currentTags.includes(tag)) { currentTags.push(tag); } } - } - if (hasRemove) { - const toRemove = normalizeTags(options.removeTag ?? []); + const toRemove = normalizeTags(answers.removeTag ?? []); currentTags = currentTags.filter((t) => !toRemove.includes(t)); } - } - await updateCollection(collectionName, undefined, { ...collection, tags: currentTags }, { cwd }); + await updateCollection(collection.name, undefined, { ...stored, tags: currentTags }, { cwd }); + + if (currentTags.length === 0) { + ConsoleFormatter.success(`Collection "${collection.name}" tags cleared`); + } else { + ConsoleFormatter.success(`Collection "${collection.name}" tags updated: ${currentTags.join(', ')}`); + } + }, +}); - if (currentTags.length === 0) { - ConsoleFormatter.success(`Collection "${collectionName}" tags cleared`); - } else { - ConsoleFormatter.success(`Collection "${collectionName}" tags updated: ${currentTags.join(', ')}`); - } +/** `edit-collection `: the collection is the positional argument. */ +export function editCollectionCommand(collectionName: string, options: EditCollectionOptions): Promise { + return run({ ...options, name: collectionName }); } diff --git a/apps/cli/src/commands/edit-resource.test.ts b/apps/cli/src/commands/edit-resource.test.ts index 35eefd15..ef8bd4fa 100644 --- a/apps/cli/src/commands/edit-resource.test.ts +++ b/apps/cli/src/commands/edit-resource.test.ts @@ -1,41 +1,34 @@ -import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { editResource, loadPreferredTerminology } from '@simoncodes-ca/core'; +import { ConfigNotFoundError, editResource, loadConfig, loadPreferredTerminology } from '@simoncodes-ca/core'; import prompts from 'prompts'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isInteractiveTerminal } from '../runner/terminal'; import { editResourceCommand } from './edit-resource'; -const fsMocks = vi.hoisted(() => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), -})); - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); vi.mock('prompts'); -vi.mock('@simoncodes-ca/core', async () => { - const actual = await vi.importActual('@simoncodes-ca/core'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, + loadConfig: vi.fn(), editResource: vi.fn(), loadPreferredTerminology: vi.fn(() => ({ rules: [], filePath: '/test/project/terms.json' })), }; }); -const mockExistsSync = vi.mocked(existsSync); -const mockReadFileSync = vi.mocked(readFileSync); const mockEditResource = vi.mocked(editResource); describe('editResourceCommand', () => { beforeEach(() => { vi.clearAllMocks(); process.env.INIT_CWD = '/test/project'; + process.exitCode = undefined; + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + }); + + afterEach(() => { + process.exitCode = undefined; }); const mockConfig = { @@ -52,8 +45,7 @@ describe('editResourceCommand', () => { }; it('should update a resource successfully', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadConfig).mockReturnValue(mockConfig); mockEditResource.mockResolvedValue({ resolvedKey: 'apps.common.buttons.ok', updated: true, @@ -81,8 +73,7 @@ describe('editResourceCommand', () => { }); it('should handle no changes detected', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadConfig).mockReturnValue(mockConfig); mockEditResource.mockResolvedValue({ resolvedKey: 'apps.common.buttons.ok', updated: false, @@ -101,8 +92,7 @@ describe('editResourceCommand', () => { }); it('should update comment and tags', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadConfig).mockReturnValue(mockConfig); mockEditResource.mockResolvedValue({ resolvedKey: 'apps.common.buttons.ok', updated: true, @@ -128,8 +118,7 @@ describe('editResourceCommand', () => { }); it('should update locale value', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadConfig).mockReturnValue(mockConfig); mockEditResource.mockResolvedValue({ resolvedKey: 'apps.common.buttons.ok', updated: true, @@ -156,8 +145,7 @@ describe('editResourceCommand', () => { }); it('should warn if locale provided without value', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadConfig).mockReturnValue(mockConfig); const consoleSpy = vi.spyOn(console, 'log'); @@ -183,8 +171,8 @@ describe('editResourceCommand', () => { }); it('should not update if config does not exist', async () => { - mockReadFileSync.mockImplementation(() => { - throw new Error('ENOENT: no such file or directory'); + vi.mocked(loadConfig).mockImplementation(() => { + throw new ConfigNotFoundError('/test/project/.lingo-tracker.json'); }); const options = { @@ -195,11 +183,11 @@ describe('editResourceCommand', () => { await editResourceCommand(options); expect(mockEditResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should not update if collection does not exist', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadConfig).mockReturnValue(mockConfig); const options = { collection: 'nonexistent', @@ -209,11 +197,11 @@ describe('editResourceCommand', () => { await editResourceCommand(options); expect(mockEditResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should prompt for baseValue if not provided', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadConfig).mockReturnValue(mockConfig); mockEditResource.mockResolvedValue({ resolvedKey: 'apps.common.buttons.ok', updated: true, @@ -230,30 +218,8 @@ describe('editResourceCommand', () => { key: 'apps.common.buttons.ok', }; - // Mock isTTY to true to trigger prompts (both stdin and stdout) - const originalStdinIsTTY = process.stdin.isTTY; - const originalStdoutIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdin, 'isTTY', { - value: true, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - - try { - await editResourceCommand(options); - } finally { - Object.defineProperty(process.stdin, 'isTTY', { - value: originalStdinIsTTY, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: originalStdoutIsTTY, - configurable: true, - }); - } + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + await editResourceCommand(options); expect(promptsMock).toHaveBeenCalledWith( expect.arrayContaining([ @@ -275,8 +241,7 @@ describe('editResourceCommand', () => { }); it('maps --target-folder to moveTo', async () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadConfig).mockReturnValue(mockConfig); mockEditResource.mockResolvedValue({ resolvedKey: 'shared.ok', updated: true }); await editResourceCommand({ @@ -292,12 +257,31 @@ describe('editResourceCommand', () => { ); }); + it('prints the core error and exits 1 when core throws', async () => { + vi.mocked(loadConfig).mockReturnValue(mockConfig); + mockEditResource.mockRejectedValue(new Error('Resource not found: apps.missing')); + + await editResourceCommand({ collection: 'default', key: 'apps.missing', baseValue: 'x' }); + + expect(console.log).toHaveBeenCalledWith('❌ Resource not found: apps.missing'); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 when --key is missing in non-interactive mode', async () => { + vi.mocked(loadConfig).mockReturnValue(mockConfig); + + await editResourceCommand({ collection: 'default', baseValue: 'x' }); + + expect(mockEditResource).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --key'); + expect(process.exitCode).toBe(1); + }); + describe('preferred terminology', () => { const rules = [{ discouraged: 'Expenditure', preferred: 'Investment', reason: 'Finance style guide' }]; beforeEach(() => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadConfig).mockReturnValue(mockConfig); vi.mocked(loadPreferredTerminology).mockReturnValue({ rules, filePath: '/test/project/terms.json' }); }); @@ -311,7 +295,7 @@ describe('editResourceCommand', () => { expect(logged(logSpy)).toContain('⚠️ Preferred terminology: consider "Investment" instead of "Expenditure"'); expect(logged(logSpy)).toContain(' Finance style guide'); - expect(process.exitCode ?? 0).toBe(0); + expect(process.exitCode).toBe(0); logSpy.mockRestore(); }); diff --git a/apps/cli/src/commands/edit-resource.ts b/apps/cli/src/commands/edit-resource.ts index 487e5110..6182efc9 100644 --- a/apps/cli/src/commands/edit-resource.ts +++ b/apps/cli/src/commands/edit-resource.ts @@ -1,15 +1,7 @@ -import { type EditResourceChanges, editResource, type LingoTrackerConfig } from '@simoncodes-ca/core'; +import { type EditResourceChanges, editResource } from '@simoncodes-ca/core'; import { translocoToICU } from '@simoncodes-ca/domain'; -import type prompts from 'prompts'; -import { - ConsoleFormatter, - executePromptsWithFallback, - loadConfiguration, - parseCommaSeparatedList, - promptForCollection, - resolveWritableCollection, - warnAboutPreferredTerminology, -} from '../utils'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter, parseCommaSeparatedList, warnAboutPreferredTerminology } from '../utils'; export interface EditResourceOptions { collection?: string; @@ -22,35 +14,41 @@ export interface EditResourceOptions { localeValue?: string; } -export async function editResourceCommand(options: EditResourceOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - const collectionName = await promptForCollection(config, options.collection); - if (!collectionName) return; - - const collection = resolveWritableCollection(collectionName, config, cwd); - if (!collection) return; - - const answers = await promptForMissing(options, config, collectionName); - - const translations = - options.locale && options.localeValue ? { [options.locale]: { value: options.localeValue } } : undefined; - if (!translations && (options.locale || options.localeValue)) { - ConsoleFormatter.warning('Both --locale and --localeValue must be provided to update a translation.'); - } +export const editResourceCommand = defineCommand()({ + name: 'Edit resource', + collection: 'writable', + prompts: (options) => [ + ...(options.key + ? [] + : [ + { + type: 'text' as const, + name: 'key', + message: 'Resource key', + validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), + }, + ]), + ...(options.baseValue + ? [] + : [{ type: 'text' as const, name: 'baseValue', message: 'New base value (leave empty to keep current)' }]), + ], + required: ['key'], + run: async ({ collection, config, cwd, answers }) => { + const translations = + answers.locale && answers.localeValue ? { [answers.locale]: { value: answers.localeValue } } : undefined; + if (!translations && (answers.locale || answers.localeValue)) { + ConsoleFormatter.warning('Both --locale and --localeValue must be provided to update a translation.'); + } - const changes: EditResourceChanges = { - baseValue: answers.baseValue || undefined, - comment: options.comment || undefined, - tags: options.tags ? parseCommaSeparatedList(options.tags) : undefined, - translations, - // `--target-folder` names the folder the entry moves to ('' for the collection root). - moveTo: options.targetFolder, - }; + const changes: EditResourceChanges = { + baseValue: answers.baseValue || undefined, + comment: answers.comment || undefined, + tags: answers.tags ? parseCommaSeparatedList(answers.tags) : undefined, + translations, + // `--target-folder` names the folder the entry moves to ('' for the collection root). + moveTo: answers.targetFolder, + }; - try { const result = await editResource(collection, answers.key, changes); if (result.updated) { @@ -63,47 +61,5 @@ export async function editResourceCommand(options: EditResourceOptions): Promise } else { ConsoleFormatter.info(result.message || 'No changes detected'); } - } catch (e: unknown) { - ConsoleFormatter.error(e instanceof Error ? e.message : 'Failed to update resource'); - } -} - -async function promptForMissing( - options: EditResourceOptions, - _config: LingoTrackerConfig, - _collectionName: string, -): Promise<{ - key: string; - baseValue?: string; -}> { - const questions: prompts.PromptObject[] = []; - - if (!options.key) { - questions.push({ - type: 'text', - name: 'key', - message: 'Resource key', - validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), - }); - } - - if (!options.baseValue) { - questions.push({ - type: 'text', - name: 'baseValue', - message: 'New base value (leave empty to keep current)', - }); - } - - const result = await executePromptsWithFallback({ - questions, - currentValues: options, - requiredFields: ['key'], - operationName: 'Edit resource', - }); - - return { - key: result.key as string, - baseValue: result.baseValue as string | undefined, - }; -} + }, +}); diff --git a/apps/cli/src/commands/export-cmd.test.ts b/apps/cli/src/commands/export-cmd.test.ts index 8ac2d529..7845bdce 100644 --- a/apps/cli/src/commands/export-cmd.test.ts +++ b/apps/cli/src/commands/export-cmd.test.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import { join } from 'node:path'; import prompts from 'prompts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isInteractiveTerminal } from '../runner/terminal'; import { exportCommand } from './export-cmd'; const fsMocks = vi.hoisted(() => ({ @@ -27,12 +28,13 @@ vi.mock('fs', async (importOriginal) => { }; }); vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); vi.mock('@simoncodes-ca/core', async (importOriginal) => { const actual = await importOriginal(); return { - // Config loading and collection resolution run for real against the mocked config. - loadConfig: actual.loadConfig, + // Collection resolution runs for real against the mocked config. + loadConfig: vi.fn(), openCollection: actual.openCollection, exportTargetLocales: actual.exportTargetLocales, ConfigNotFoundError: actual.ConfigNotFoundError, @@ -94,30 +96,23 @@ describe('exportCommand', () => { }, }; - const originalStdout = process.stdout.isTTY; const originalLog = console.log; const originalError = console.error; const originalWarn = console.warn; - const originalExit = process.exit; - const originalExitCode = process.exitCode; beforeEach(() => { vi.clearAllMocks(); console.log = vi.fn(); console.error = vi.fn(); console.warn = vi.fn(); - process.exit = vi.fn() as unknown as (code?: number | string | null | undefined) => never; - process.exitCode = 0; + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; - // Set to non-TTY by default to avoid prompts - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - configurable: true, - }); + // Non-interactive by default to avoid prompts + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + vi.mocked(core.loadConfig).mockReturnValue(mockConfig); vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockConfig)); vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); mockValidateOutputDirectory.mockReturnValue(undefined); @@ -125,58 +120,50 @@ describe('exportCommand', () => { }); afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalStdout, - writable: true, - configurable: true, - }); console.log = originalLog; console.error = originalError; console.warn = originalWarn; - process.exit = originalExit; - process.exitCode = originalExitCode; + process.exitCode = undefined; }); describe('configuration validation', () => { it('should error when config file is missing', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - // Make process.exit actually throw to prevent further execution - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); + vi.mocked(core.loadConfig).mockImplementation(() => { + throw new core.ConfigNotFoundError('/project/.lingo-tracker.json'); }); - await expect(exportCommand({ format: 'json' })).rejects.toThrow('process.exit called with code 1'); + await exportCommand({ format: 'json' }); + expect(process.exitCode).toBe(1); expect(console.error).toHaveBeenCalledWith('❌ Configuration file .lingo-tracker.json not found.'); }); it('should error when config file is malformed', async () => { - vi.mocked(fs.readFileSync).mockReturnValue('invalid json'); - // Make process.exit actually throw to prevent further execution - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); + vi.mocked(core.loadConfig).mockImplementation(() => { + throw new core.ConfigParseError('/project/.lingo-tracker.json', 'Unexpected token i in JSON'); }); - await expect(exportCommand({ format: 'json' })).rejects.toThrow('process.exit called with code 1'); + await exportCommand({ format: 'json' }); + expect(process.exitCode).toBe(1); expect(console.error).toHaveBeenCalledWith(expect.stringContaining('❌ Failed to parse configuration file')); }); it('should error when format is missing in non-TTY mode', async () => { - // In non-TTY mode without format, promptForMissing throws an error directly - await expect(exportCommand({})).rejects.toThrow('❌ Missing required option: --format'); + await exportCommand({}); + + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --format'); + expect(mockRunExport).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should handle validateOutputDirectory errors', async () => { mockValidateOutputDirectory.mockImplementation(() => { throw new Error('Invalid output directory'); }); - // Make process.exit actually throw to prevent further execution - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(exportCommand({ format: 'json' })).rejects.toThrow('process.exit called with code 1'); + await exportCommand({ format: 'json' }); + expect(process.exitCode).toBe(1); expect(console.log).toHaveBeenCalledWith('❌ Invalid output directory'); }); @@ -303,14 +290,31 @@ describe('exportCommand', () => { expect(console.log).toHaveBeenCalledWith(' Skipping fr: No matching resources.'); }); - it('should warn when no collections found', async () => { + it('should exit 1 for an unknown collection', async () => { await exportCommand({ format: 'json', - collection: 'nonexistent', + collection: 'common,nonexistent', }); - expect(console.log).toHaveBeenCalledWith('⚠️ No matching collections found.'); + expect(console.log).toHaveBeenCalledWith('❌ Collection "nonexistent" not found'); expect(mockRunExport).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('should exit 1 when no collections are configured', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ ...mockConfig, collections: {} }); + + await exportCommand({ format: 'json' }); + + expect(console.log).toHaveBeenCalledWith('❌ No collections found. Run `lingo-tracker add-collection` first.'); + expect(mockRunExport).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('should export a collection named twice only once', async () => { + await exportCommand({ format: 'json', collection: 'common,common' }); + + expect(exportedCollections()).toEqual(['common']); }); it('should warn when no target locales selected', async () => { @@ -326,11 +330,7 @@ describe('exportCommand', () => { describe('interactive mode', () => { beforeEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, - }); + vi.mocked(isInteractiveTerminal).mockReturnValue(true); }); it('should prompt for format when not provided', async () => { @@ -361,7 +361,7 @@ describe('exportCommand', () => { }); it('should handle user cancellation gracefully', async () => { - // The user presses Esc: prompts calls onCancel, which throws PromptCancelledError. + // The user presses Esc: prompts calls onCancel. vi.mocked(prompts).mockImplementation(async (questions, options) => { const [question] = Array.isArray(questions) ? questions : [questions]; options?.onCancel?.(question, {}); @@ -370,8 +370,9 @@ describe('exportCommand', () => { await exportCommand({}); - expect(console.log).toHaveBeenCalledWith('❌ ❌ Export cancelled.'); - expect(process.exit).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Export cancelled.'); + expect(mockRunExport).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('should prompt for collections when not provided', async () => { @@ -772,11 +773,9 @@ describe('exportCommand', () => { it('should report a run that cannot start and exit with an error', async () => { mockRunExport.mockRejectedValue(new Error('Cannot export collections with different base locales together')); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(exportCommand({ format: 'json' })).rejects.toThrow('process.exit called with code 1'); + await exportCommand({ format: 'json' }); + expect(process.exitCode).toBe(1); expect(console.log).toHaveBeenCalledWith( expect.stringContaining('Cannot export collections with different base locales together'), @@ -799,21 +798,18 @@ describe('exportCommand', () => { }); it('should exit with error when --base-property-name validation fails', async () => { - mockValidateBasePropertyName.mockImplementation(() => { + mockValidateBasePropertyName.mockImplementationOnce(() => { throw new Error('basePropertyName "value" is a reserved key'); }); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect( - exportCommand({ - format: 'json', - locale: 'fr', - basePropertyName: 'value', - includeBase: true, - }), - ).rejects.toThrow('process.exit called with code 1'); + await exportCommand({ + format: 'json', + locale: 'fr', + basePropertyName: 'value', + includeBase: true, + }); + expect(process.exitCode).toBe(1); + expect(mockRunExport).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('basePropertyName "value" is a reserved key')); }); diff --git a/apps/cli/src/commands/export-cmd.ts b/apps/cli/src/commands/export-cmd.ts index 6f0a4177..a64efa00 100644 --- a/apps/cli/src/commands/export-cmd.ts +++ b/apps/cli/src/commands/export-cmd.ts @@ -14,17 +14,15 @@ import { import type { TranslationStatus } from '@simoncodes-ca/domain'; import * as fs from 'fs'; import * as path from 'path'; -import prompts from 'prompts'; +import type prompts from 'prompts'; +import { type Answers, defineCommand, NO_COLLECTIONS_MESSAGE } from '../runner/command-runner'; import { buildSummaryPath, ConsoleFormatter, - ErrorMessages, - loadConfiguration, multiselectResultToString, parseCommaSeparatedList, processMultiselectWithAll, } from '../utils'; -import { exitWithError, PromptCancelledError } from '../utils/report-error'; export interface ExportCommandOptions { format?: ExportFormat; @@ -47,95 +45,52 @@ export interface ExportCommandOptions { protectNotes?: boolean; } -export async function exportCommand(options: ExportCommandOptions): Promise { - const loaded = loadConfiguration(); - if (!loaded) return; - const { config, cwd } = loaded; - - const allCollections = Object.keys(config.collections || {}).map((name) => openCollection(config, name, { cwd })); - - let answers: Partial; - try { - answers = await promptForMissing(options, config, exportTargetLocales(allCollections)); - } catch (error) { - if (error instanceof PromptCancelledError) { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Export')); - return; +export const exportCommand = defineCommand()({ + name: 'Export', + // `--collection` takes a comma-separated list here (default: every collection), so the command opens them. + collection: 'none', + // The locale choices need the opened collections; only build them when they will be asked. + prompts: (options, { config, cwd, interactive }) => + interactive ? buildQuestions(options, config, exportTargetLocales(openCollections(config, cwd))) : [], + required: ['format'], + run: async ({ config, cwd, answers }) => { + const options = resolveAnswers(answers); + const { format } = options; + + // Warn if --base-property-name was set without --include-base + if (options.basePropertyName && !options.includeBase) { + ConsoleFormatter.warning('--base-property-name has no effect without --include-base'); } - throw error; - } - - if (!answers.format) { - ConsoleFormatter.error('Format is required. Use --format or run in interactive mode.'); - process.exit(1); - } - // Update options with answers - options.format = answers.format; - options.collection = answers.collection; - options.locale = answers.locale; - options.status = answers.status; - options.tags = answers.tags; - options.output = answers.output; - options.structure = answers.structure; - options.rich = answers.rich; - options.includeBase = answers.includeBase; - options.includeStatus = answers.includeStatus; - options.includeComment = answers.includeComment; - options.includeTags = answers.includeTags; - options.basePropertyName = answers.basePropertyName; - options.filename = answers.filename; - options.dryRun = answers.dryRun; - options.verbose = answers.verbose; - - // Warn if --base-property-name was set without --include-base - if (options.basePropertyName && !options.includeBase) { - ConsoleFormatter.warning('--base-property-name has no effect without --include-base'); - } - - // Validate --base-property-name if provided - if (options.basePropertyName) { - try { + // Validate --base-property-name if provided (throws a message the runner prints) + if (options.basePropertyName) { validateBasePropertyName(options.basePropertyName); - } catch (error) { - exitWithError(error); } - } - // Resolve output directory - const outputDir = options.output - ? path.resolve(cwd, options.output) - : path.resolve(cwd, config.exportFolder || 'dist/lingo-export'); + // Resolve output directory + const outputDir = options.output + ? path.resolve(cwd, options.output) + : path.resolve(cwd, config.exportFolder || 'dist/lingo-export'); - try { validateOutputDirectory(outputDir); - } catch (error) { - exitWithError(error); - } - const collectionNames = parseCommaSeparatedList(options.collection); - const collections = allCollections.filter((c) => !collectionNames || collectionNames.includes(c.name)); - if (collections.length === 0) { - ConsoleFormatter.warning('No matching collections found.'); - return; - } + // An unknown name throws CollectionNotFoundError; none configured fails like every other command. + const collections = openCollections(config, cwd, parseCommaSeparatedList(options.collection)); - const targetLocales = exportTargetLocales(collections, parseCommaSeparatedList(options.locale)); - if (targetLocales.length === 0) { - ConsoleFormatter.warning('No target locales selected.'); - return; - } + const targetLocales = exportTargetLocales(collections, parseCommaSeparatedList(options.locale)); + if (targetLocales.length === 0) { + ConsoleFormatter.warning('No target locales selected.'); + return; + } - ConsoleFormatter.progress(`Exporting to ${options.format.toUpperCase()}...`); - ConsoleFormatter.indent(`Collections: ${collections.map((c) => c.name).join(', ')}`); - ConsoleFormatter.indent(`Locales: ${targetLocales.join(', ')}`); - ConsoleFormatter.indent(`Output: ${outputDir}`); - if (options.dryRun) ConsoleFormatter.indent('[DRY RUN]'); + ConsoleFormatter.progress(`Exporting to ${format.toUpperCase()}...`); + ConsoleFormatter.indent(`Collections: ${collections.map((c) => c.name).join(', ')}`); + ConsoleFormatter.indent(`Locales: ${targetLocales.join(', ')}`); + ConsoleFormatter.indent(`Output: ${outputDir}`); + if (options.dryRun) ConsoleFormatter.indent('[DRY RUN]'); - let result: ExportRunResult; - try { - result = await runExport(collections, { - format: options.format, + const result = await runExport(collections, { + format, outputDirectory: outputDir, locales: targetLocales, status: parseCommaSeparatedList(options.status)?.map((s) => s as TranslationStatus), @@ -154,21 +109,29 @@ export async function exportCommand(options: ExportCommandOptions): Promise console.log(` ${msg}`) : undefined, }); - } catch (error) { - exitWithError(error); - } - displayResults(result); + displayResults(result); - const summaryPath = buildSummaryPath('export'); - if (!options.dryRun) { - fs.writeFileSync(summaryPath, result.summary); - console.log(`\n📄 Summary written to: ${summaryPath}`); - } else { - console.log('\n📄 Summary (Dry Run):'); - console.log(result.summary); + const summaryPath = buildSummaryPath('export'); + if (!options.dryRun) { + fs.writeFileSync(summaryPath, result.summary); + console.log(`\n📄 Summary written to: ${summaryPath}`); + } else { + console.log('\n📄 Summary (Dry Run):'); + console.log(result.summary); + } + const failed = result.errors.length + result.hierarchicalConflicts.length > 0 && !options.dryRun; + return failed ? { exitCode: 1 } : undefined; + }, +}); + +/** Opens the named collections (default: every one). Throws when none is configured or a name is unknown. */ +function openCollections(config: LingoTrackerConfig, cwd: string, names?: string[]): Collection[] { + const configured = Object.keys(config.collections ?? {}); + if (configured.length === 0) { + throw new Error(NO_COLLECTIONS_MESSAGE); } - if (result.errors.length + result.hierarchicalConflicts.length > 0 && !options.dryRun) process.exitCode = 1; + return [...new Set(names ?? configured)].map((name) => openCollection(config, name, { cwd })); } function readProtectedTerms(config: LingoTrackerConfig, collections: readonly Collection[], cwd: string) { @@ -209,47 +172,12 @@ function displayResults(result: ExportRunResult): void { } } -async function promptForMissing( +/** The questions for every option the flags left out. */ +function buildQuestions( options: ExportCommandOptions, config: LingoTrackerConfig, targetLocales: string[], -): Promise<{ - format?: ExportFormat; - collection?: string; - locale?: string; - status?: string; - tags?: string; - output?: string; - structure?: 'flat' | 'hierarchical'; - rich?: boolean; - includeBase?: boolean; - includeStatus?: boolean; - includeComment?: boolean; - includeTags?: boolean; - basePropertyName?: string; - filename?: string; - dryRun?: boolean; - verbose?: boolean; -}> { - const responses: Partial<{ - format: ExportFormat; - collection: string; - locale: string; - status: string; - tags: string; - output: string; - structure: 'flat' | 'hierarchical'; - rich: boolean; - includeBase: boolean; - includeStatus: boolean; - includeComment: boolean; - includeTags: boolean; - basePropertyName: string; - filename: string; - dryRun: boolean; - verbose: boolean; - }> = {}; - +): prompts.PromptObject[] { const collectionNames = Object.keys(config.collections || {}); const questions: prompts.PromptObject[] = []; @@ -487,52 +415,38 @@ async function promptForMissing( }); } - if (questions.length > 0 && process.stdout.isTTY) { - const result = await prompts(questions, { - onCancel: () => { - throw new PromptCancelledError('Export'); - }, - }); - - Object.assign(responses, result); - - // Handle multiselect "All" options - if (result.collections) { - const selected = processMultiselectWithAll(result.collections, collectionNames); - responses.collection = multiselectResultToString(selected); - } - - if (result.locales) { - const selected = processMultiselectWithAll(result.locales, targetLocales); - responses.locale = multiselectResultToString(selected); - } - - if (result.statusFilter) { - responses.status = result.statusFilter.join(','); - } - } else if (questions.length > 0 && !process.stdout.isTTY) { - // Non-TTY mode - require format to be provided - if (!options.format) { - throw new Error(ErrorMessages.MISSING_OPTION('format')); - } - } + return questions; +} +/** + * Flags win over prompt answers; the multiselect answers (`collections`, `locales`, + * `statusFilter`) become the comma-separated options; unset options get their defaults. + */ +function resolveAnswers(answers: Answers): ExportCommandOptions { + const collections = stringList(answers.collections); + const locales = stringList(answers.locales); + const statusFilter = stringList(answers.statusFilter); return { - format: options.format ?? responses.format, - collection: options.collection ?? responses.collection, - locale: options.locale ?? responses.locale, - status: options.status ?? responses.status, - tags: options.tags ?? (responses.tags || undefined), - output: options.output ?? (responses.output || undefined), - structure: options.structure ?? responses.structure ?? 'hierarchical', - rich: options.rich ?? responses.rich ?? false, - includeBase: options.includeBase ?? responses.includeBase ?? false, - includeStatus: options.includeStatus ?? responses.includeStatus ?? false, - includeComment: options.includeComment ?? responses.includeComment ?? false, - includeTags: options.includeTags ?? responses.includeTags ?? false, - basePropertyName: options.basePropertyName ?? (responses.basePropertyName || undefined), - filename: options.filename ?? (responses.filename || undefined), - dryRun: options.dryRun ?? responses.dryRun ?? false, - verbose: options.verbose ?? responses.verbose ?? false, + ...answers, + collection: + answers.collection ?? (collections && multiselectResultToString(processMultiselectWithAll(collections))), + locale: answers.locale ?? (locales && multiselectResultToString(processMultiselectWithAll(locales))), + status: answers.status ?? statusFilter?.join(','), + tags: answers.tags || undefined, + output: answers.output || undefined, + structure: answers.structure ?? 'hierarchical', + rich: answers.rich ?? false, + includeBase: answers.includeBase ?? false, + includeStatus: answers.includeStatus ?? false, + includeComment: answers.includeComment ?? false, + includeTags: answers.includeTags ?? false, + basePropertyName: answers.basePropertyName || undefined, + filename: answers.filename || undefined, + dryRun: answers.dryRun ?? false, + verbose: answers.verbose ?? false, }; } + +function stringList(value: unknown): string[] | undefined { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : undefined; +} diff --git a/apps/cli/src/commands/find-similar.spec.ts b/apps/cli/src/commands/find-similar.spec.ts index 392c0235..fb9777f7 100644 --- a/apps/cli/src/commands/find-similar.spec.ts +++ b/apps/cli/src/commands/find-similar.spec.ts @@ -1,23 +1,12 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { afterEach, describe, it, expect, beforeEach, vi } from 'vitest'; import { findSimilarCommand } from './find-similar'; vi.mock('@simoncodes-ca/core', async (importOriginal) => { const actual = await importOriginal(); - return { - // Config loading and collection resolution run for real against the mocked config. - loadConfig: actual.loadConfig, - openCollection: actual.openCollection, - ConfigNotFoundError: actual.ConfigNotFoundError, - ConfigParseError: actual.ConfigParseError, - CollectionNotFoundError: actual.CollectionNotFoundError, - ReadOnlyCollectionError: actual.ReadOnlyCollectionError, - searchTranslations: vi.fn(), - }; + // Collection resolution runs for real against the mocked config. + return { ...actual, loadConfig: vi.fn(), searchTranslations: vi.fn() }; }); - -vi.mock('../utils', () => ({ - loadConfiguration: vi.fn(), -})); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); vi.mock('path', async (importOriginal) => { const actual = await importOriginal(); @@ -31,9 +20,8 @@ vi.mock('path', async (importOriginal) => { }; }); -import { searchTranslations } from '@simoncodes-ca/core'; -import type { MatchType, SearchResult } from '@simoncodes-ca/core'; -import { loadConfiguration } from '../utils'; +import { ConfigNotFoundError, loadConfig, searchTranslations } from '@simoncodes-ca/core'; +import type { LingoTrackerConfig, MatchType, SearchResult } from '@simoncodes-ca/core'; /** * Builds a fully typed SearchResult so the mocked searchTranslations return @@ -48,7 +36,7 @@ function searchResult(key: string, matchType: MatchType, baseValue: string): Sea }; } -const BASE_CONFIG = { +const BASE_CONFIG: LingoTrackerConfig = { baseLocale: 'en', locales: ['en', 'fr'], collections: { @@ -58,21 +46,18 @@ const BASE_CONFIG = { }, }; -const LOADED_CONFIG = { - config: BASE_CONFIG, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', -}; - describe('find-similar', () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(console, 'error').mockImplementation(() => undefined); vi.spyOn(console, 'warn').mockImplementation(() => undefined); - vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`); - }); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = undefined; }); // --------------------------------------------------------------------------- @@ -84,7 +69,7 @@ describe('find-similar', () => { // These cases pin only what the command adds on top: case folding, the 0.8 // cutoff, and the empty-value fallback. beforeEach(() => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); }); it('reports 100% for an identical multi-character stored value', async () => { @@ -123,43 +108,64 @@ describe('find-similar', () => { // --------------------------------------------------------------------------- describe('findSimilarCommand — guard clauses', () => { - it('returns early without error when loadConfiguration returns null', async () => { - vi.mocked(loadConfiguration).mockReturnValue(null); + it('exits 1 without searching when the configuration is missing', async () => { + vi.mocked(loadConfig).mockImplementation(() => { + throw new ConfigNotFoundError('/project/.lingo-tracker.json'); + }); await findSimilarCommand({ collection: 'tracker', value: 'hello' }); expect(searchTranslations).not.toHaveBeenCalled(); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('exits with code 1 when --value is missing', async () => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); - await expect(findSimilarCommand({ collection: 'tracker' })).rejects.toThrow('process.exit(1)'); - expect(console.error).toHaveBeenCalledWith('Error: --value is required'); + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + await findSimilarCommand({ collection: 'tracker' }); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --value'); + expect(searchTranslations).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('exits with code 1 when --value is an empty string', async () => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); - await expect(findSimilarCommand({ collection: 'tracker', value: '' })).rejects.toThrow('process.exit(1)'); - expect(console.error).toHaveBeenCalledWith('Error: --value is required'); + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + await findSimilarCommand({ collection: 'tracker', value: '' }); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --value'); + expect(process.exitCode).toBe(1); }); it('exits with code 1 when --value is whitespace only', async () => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); - await expect(findSimilarCommand({ collection: 'tracker', value: ' ' })).rejects.toThrow('process.exit(1)'); - expect(console.error).toHaveBeenCalledWith('Error: --value is required'); + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + await findSimilarCommand({ collection: 'tracker', value: ' ' }); + expect(console.log).toHaveBeenCalledWith('❌ --value must not be blank'); + expect(searchTranslations).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('uses the only collection when --collection is missing', async () => { + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + vi.mocked(searchTranslations).mockReturnValue([]); + await findSimilarCommand({ value: 'hello' }); + expect(searchTranslations).toHaveBeenCalledWith( + expect.objectContaining({ translationsFolder: '/project/src/assets/i18n' }), + ); + expect(process.exitCode).toBe(0); }); - it('exits with code 1 when --collection is missing', async () => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); - await expect(findSimilarCommand({ value: 'hello' })).rejects.toThrow('process.exit(1)'); - expect(console.error).toHaveBeenCalledWith('Error: --collection is required'); + it('exits with code 1 when --collection is missing and several collections exist', async () => { + vi.mocked(loadConfig).mockReturnValue({ + ...BASE_CONFIG, + collections: { tracker: { translationsFolder: 'a' }, admin: { translationsFolder: 'b' } }, + }); + await findSimilarCommand({ value: 'hello' }); + expect(console.log).toHaveBeenCalledWith('❌ Missing required option: --collection'); + expect(searchTranslations).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('exits with code 1 when collection is not found in config', async () => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); - await expect(findSimilarCommand({ collection: 'nonexistent', value: 'hello' })).rejects.toThrow( - 'process.exit(1)', - ); - expect(console.error).toHaveBeenCalledWith('Error: Collection "nonexistent" not found'); + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + await findSimilarCommand({ collection: 'nonexistent', value: 'hello' }); + expect(console.log).toHaveBeenCalledWith('❌ Collection "nonexistent" not found'); + expect(process.exitCode).toBe(1); }); }); @@ -169,7 +175,7 @@ describe('find-similar', () => { describe('findSimilarCommand — output messages', () => { beforeEach(() => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); }); it('prints "No similar values found" when no candidates pass the 0.8 threshold', async () => { @@ -209,7 +215,7 @@ describe('find-similar', () => { // whose key contains the query is labelled a key match even when its value // matches too. Every candidate is scored on its base value regardless. beforeEach(() => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); }); it.each([ @@ -280,7 +286,7 @@ describe('find-similar', () => { describe('findSimilarCommand — sorting and maxResults', () => { beforeEach(() => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); }); it('sorts results by score descending', async () => { @@ -332,20 +338,16 @@ describe('find-similar', () => { describe('findSimilarCommand — locale resolution', () => { it('uses collectionConfig.baseLocale when set', async () => { - vi.mocked(loadConfiguration).mockReturnValue({ - config: { - baseLocale: 'en', - locales: ['en', 'fr'], - collections: { - tracker: { - translationsFolder: 'src/i18n', - baseLocale: 'fr', - }, + vi.mocked(loadConfig).mockReturnValue({ + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { + tracker: { + translationsFolder: 'src/i18n', + baseLocale: 'fr', }, }, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', - } as any); + }); vi.mocked(searchTranslations).mockReturnValue([]); await findSimilarCommand({ collection: 'tracker', value: 'bonjour' }); @@ -354,19 +356,15 @@ describe('find-similar', () => { }); it('falls back to config.baseLocale when collectionConfig has no baseLocale', async () => { - vi.mocked(loadConfiguration).mockReturnValue({ - config: { - baseLocale: 'de', - locales: ['de', 'en'], - collections: { - tracker: { - translationsFolder: 'src/i18n', - }, + vi.mocked(loadConfig).mockReturnValue({ + baseLocale: 'de', + locales: ['de', 'en'], + collections: { + tracker: { + translationsFolder: 'src/i18n', }, }, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', - } as any); + }); vi.mocked(searchTranslations).mockReturnValue([]); await findSimilarCommand({ collection: 'tracker', value: 'hallo' }); @@ -375,18 +373,14 @@ describe('find-similar', () => { }); it('falls back to "en" when neither collection nor config specifies baseLocale', async () => { - vi.mocked(loadConfiguration).mockReturnValue({ - config: { - locales: ['en'], - collections: { - tracker: { - translationsFolder: 'src/i18n', - }, + vi.mocked(loadConfig).mockReturnValue({ + locales: ['en'], + collections: { + tracker: { + translationsFolder: 'src/i18n', }, }, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', - } as any); + }); vi.mocked(searchTranslations).mockReturnValue([]); await findSimilarCommand({ collection: 'tracker', value: 'hello' }); @@ -401,7 +395,7 @@ describe('find-similar', () => { describe('findSimilarCommand — searchTranslations arguments', () => { beforeEach(() => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); vi.mocked(searchTranslations).mockReturnValue([]); }); diff --git a/apps/cli/src/commands/find-similar.ts b/apps/cli/src/commands/find-similar.ts index 46ced4c2..15ccc8d9 100644 --- a/apps/cli/src/commands/find-similar.ts +++ b/apps/cli/src/commands/find-similar.ts @@ -1,6 +1,6 @@ -import { loadConfiguration } from '../utils'; -import { type Collection, CollectionNotFoundError, openCollection, searchTranslations } from '@simoncodes-ca/core'; +import { type Collection, searchTranslations } from '@simoncodes-ca/core'; import { normalizedLevenshtein } from '@simoncodes-ca/domain'; +import { defineCommand } from '../runner/command-runner'; /** * How many candidates to score. searchTranslations stops walking once it has @@ -19,33 +19,24 @@ export interface FindSimilarOptions { maxResults?: number; } -export async function findSimilarCommand(options: FindSimilarOptions): Promise { - const loaded = loadConfiguration(); - if (!loaded) return; - const { config, cwd } = loaded; - - if (!options.value || options.value.trim().length === 0) { - console.error('Error: --value is required'); - process.exit(1); - } - - if (!options.collection) { - console.error('Error: --collection is required'); - process.exit(1); - } - - let collection: Collection; - try { - collection = openCollection(config, options.collection, { cwd }); - } catch (error) { - if (!(error instanceof CollectionNotFoundError)) throw error; - console.error(`Error: Collection "${options.collection}" not found`); - process.exit(1); - } +export const findSimilarCommand = defineCommand()({ + name: 'Find similar', + collection: 'read', + prompts: (options) => + options.value?.trim() ? [] : [{ type: 'text', name: 'value', message: 'Base locale text to search for' }], + required: ['value'], + run: ({ collection, answers }) => { + // `required` rejects an absent or empty value; a blank one has nothing to compare either. + const query = answers.value.trim(); + if (query.length === 0) { + throw new Error('--value must not be blank'); + } + reportSimilar(collection, query, answers.maxResults ?? 5); + }, +}); +function reportSimilar(collection: Collection, query: string, displayLimit: number): void { const { translationsFolder, baseLocale } = collection; - const query = options.value.trim(); - const displayLimit = options.maxResults ?? 5; // Use a broad search to get candidates (pass the whole query for substring pre-filter) const candidates = searchTranslations({ diff --git a/apps/cli/src/commands/glossary.spec.ts b/apps/cli/src/commands/glossary.spec.ts index 59758968..69bfee92 100644 --- a/apps/cli/src/commands/glossary.spec.ts +++ b/apps/cli/src/commands/glossary.spec.ts @@ -1,27 +1,13 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { afterEach, describe, it, expect, beforeEach, vi } from 'vitest'; vi.mock('@simoncodes-ca/core', async (importOriginal) => { const actual = await importOriginal(); - return { - // Config loading and collection resolution run for real against the mocked config. - loadConfig: actual.loadConfig, - openCollection: actual.openCollection, - ConfigNotFoundError: actual.ConfigNotFoundError, - ConfigParseError: actual.ConfigParseError, - CollectionNotFoundError: actual.CollectionNotFoundError, - ReadOnlyCollectionError: actual.ReadOnlyCollectionError, - readCollection: vi.fn(), - }; + // Collection resolution runs for real against the mocked config. + return { ...actual, loadConfig: vi.fn(), readCollection: vi.fn() }; }); -vi.mock('../utils', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfiguration: vi.fn(), - resolveCollection: vi.fn(), - }; -}); +// A terminal on stdin by default, so stdin is not read unless a test pipes it. +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false), hasPipedStdin: vi.fn(() => false) })); vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); @@ -42,13 +28,14 @@ vi.mock('fs', async (importOriginal) => { import * as fs from 'fs'; import { type CollectionRead, + ConfigNotFoundError, type LingoTrackerConfig, - openCollection, + loadConfig, readCollection, type StoredResource, } from '@simoncodes-ca/core'; import type { TranslationStatus } from '@simoncodes-ca/domain'; -import { loadConfiguration, resolveCollection } from '../utils'; +import { hasPipedStdin } from '../runner/terminal'; import { glossaryCommand } from './glossary'; /** A root-level stored resource with one status per translated locale. */ @@ -79,14 +66,10 @@ const LOADED = read( stored('settings', 'Settings', { fr: 'Paramètres' }, { fr: 'translated' }), ); -const LOADED_CONFIG = { - config: { - baseLocale: 'en', - locales: ['en', 'fr'], - collections: { app: { translationsFolder: 'i18n' } }, - }, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', +const CONFIG: LingoTrackerConfig = { + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { app: { translationsFolder: 'i18n' } }, }; function writtenContent(): string { @@ -100,23 +83,31 @@ describe('glossaryCommand', () => { vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(console, 'error').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`); - }); - (process.stdin as unknown as { isTTY: boolean }).isTTY = true; - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG as never); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + vi.mocked(hasPipedStdin).mockReturnValue(false); + vi.mocked(loadConfig).mockReturnValue(CONFIG); vi.mocked(readCollection).mockReturnValue(LOADED); }); - it('returns early when configuration is missing', async () => { - vi.mocked(loadConfiguration).mockReturnValue(null); + afterEach(() => { + process.exitCode = undefined; + }); + + it('exits 1 when configuration is missing', async () => { + vi.mocked(loadConfig).mockImplementation(() => { + throw new ConfigNotFoundError('/project/.lingo-tracker.json'); + }); await glossaryCommand({ text: 'Save' }); expect(readCollection).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('exits when no input is provided', async () => { - await expect(glossaryCommand({})).rejects.toThrow('process.exit(1)'); + it('exits 1 when no input is provided', async () => { + await glossaryCommand({}); expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(fs.readFileSync).not.toHaveBeenCalledWith(0, 'utf8'); + expect(process.exitCode).toBe(1); }); it('extracts from --text and writes a glossary file by default', async () => { @@ -128,6 +119,7 @@ describe('glossaryCommand', () => { expect(out.matchCount).toBe(2); const keys = out.terms.map((t: { key: string }) => t.key).sort(); expect(keys).toEqual(['save', 'settings']); + expect(process.exitCode).toBe(0); }); it('writes to a millisecond-precision timestamped file by default (no same-second collisions)', async () => { @@ -146,7 +138,7 @@ describe('glossaryCommand', () => { }); it('reads from piped stdin when no --text/--input and not a TTY', async () => { - (process.stdin as unknown as { isTTY: boolean | undefined }).isTTY = undefined; + vi.mocked(hasPipedStdin).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue('Please Save your work'); await glossaryCommand({}); const out = JSON.parse(writtenContent()); @@ -155,9 +147,10 @@ describe('glossaryCommand', () => { expect(fs.readFileSync).toHaveBeenCalledWith(0, 'utf8'); }); - it('exits when --input file does not exist', async () => { + it('exits 1 when --input file does not exist', async () => { vi.mocked(fs.existsSync).mockReturnValue(false); - await expect(glossaryCommand({ input: 'missing.md' })).rejects.toThrow('process.exit(1)'); + await glossaryCommand({ input: 'missing.md' }); + expect(process.exitCode).toBe(1); }); it('prints JSON to stdout with --stdout and does not write a file', async () => { @@ -174,17 +167,23 @@ describe('glossaryCommand', () => { expect(out.locales).toEqual(['fr']); }); - it('resolves a single collection with --collection', async () => { - vi.mocked(resolveCollection).mockReturnValue( - openCollection(LOADED_CONFIG.config as LingoTrackerConfig, 'app', { cwd: '/project' }), - ); + it('reads only the collection named by --collection', async () => { + vi.mocked(loadConfig).mockReturnValue({ + ...CONFIG, + collections: { app: { translationsFolder: 'i18n' }, admin: { translationsFolder: 'admin' } }, + }); await glossaryCommand({ text: 'Save', collection: 'app' }); - expect(resolveCollection).toHaveBeenCalledWith('app', expect.anything(), '/project'); + expect(readCollection).toHaveBeenCalledTimes(1); + expect(readCollection).toHaveBeenCalledWith( + expect.objectContaining({ name: 'app', translationsFolder: '/project/i18n' }), + ); }); - it('exits when --collection cannot be resolved', async () => { - vi.mocked(resolveCollection).mockReturnValue(null); - await expect(glossaryCommand({ text: 'Save', collection: 'nope' })).rejects.toThrow('process.exit(1)'); + it('exits 1 when --collection cannot be resolved', async () => { + await glossaryCommand({ text: 'Save', collection: 'nope' }); + expect(console.log).toHaveBeenCalledWith('❌ Collection "nope" not found'); + expect(readCollection).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('writes an empty glossary when nothing matches', async () => { @@ -205,15 +204,11 @@ describe('glossaryCommand', () => { }); it('strips a collection base-locale override from translations', async () => { - vi.mocked(loadConfiguration).mockReturnValue({ - config: { - baseLocale: 'en', - locales: ['en', 'fr', 'es'], - collections: { app: { translationsFolder: 'i18n', baseLocale: 'fr' } }, - }, - configPath: '/p/.lingo-tracker.json', - cwd: '/p', - } as never); + vi.mocked(loadConfig).mockReturnValue({ + baseLocale: 'en', + locales: ['en', 'fr', 'es'], + collections: { app: { translationsFolder: 'i18n', baseLocale: 'fr' } }, + }); vi.mocked(readCollection).mockReturnValue( read(stored('save', 'Enregistrer', { fr: 'Enregistrer', es: 'Guardar' }, { fr: 'verified', es: 'verified' })), ); @@ -249,7 +244,10 @@ describe('glossaryCommand', () => { expect(JSON.parse(writtenContent()).matchCount).toBe(1); }); - it('exits with a clear error for the unimplemented ai extractor', async () => { - await expect(glossaryCommand({ text: 'Save', extractor: 'ai' })).rejects.toThrow('process.exit(1)'); + it('exits 1 with a clear error for the unimplemented ai extractor', async () => { + await glossaryCommand({ text: 'Save', extractor: 'ai' }); + expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/^❌ .*ai/i)); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); diff --git a/apps/cli/src/commands/glossary.ts b/apps/cli/src/commands/glossary.ts index 9040a5fd..d4033b0a 100644 --- a/apps/cli/src/commands/glossary.ts +++ b/apps/cli/src/commands/glossary.ts @@ -1,9 +1,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { openCollection, readCollection } from '@simoncodes-ca/core'; -import type { Collection, LingoTrackerConfig } from '@simoncodes-ca/core'; -import { ConsoleFormatter, loadConfiguration, parseCommaSeparatedList, resolveCollection } from '../utils'; -import { exitWithError } from '../utils/report-error'; +import type { LingoTrackerConfig } from '@simoncodes-ca/core'; +import { type CommandResult, defineCommand } from '../runner/command-runner'; +import { hasPipedStdin } from '../runner/terminal'; +import { ConsoleFormatter, parseCommaSeparatedList } from '../utils'; import { resolveExtractor, type CandidateExtractor, type ExtractorMode } from './glossary-extractor'; import { matchGlossary, type FlatEntry } from './glossary-matcher'; @@ -45,7 +46,7 @@ function resolveInputText(options: GlossaryCommandOptions, cwd: string): string } // Fall back to piped stdin when not attached to a terminal. - if (!process.stdin.isTTY) { + if (hasPipedStdin()) { try { const piped = fs.readFileSync(0, 'utf8'); if (piped.trim().length > 0) return piped; @@ -62,17 +63,11 @@ function resolveInputText(options: GlossaryCommandOptions, cwd: string): string * Loads entries from the requested collection(s) through the core Collection Reader, * mapping each stored resource to the matcher's `FlatEntry`. A folder that cannot be read * is reported as a warning and its entries are left out. - * Returns null if a named collection cannot be resolved. + * A named collection that does not exist throws CollectionNotFoundError (the runner exits 1). */ -function loadEntries(options: GlossaryCommandOptions, config: LingoTrackerConfig, cwd: string): FlatEntry[] | null { - let targets: Collection[]; - if (options.collection) { - const resolved = resolveCollection(options.collection, config, cwd); - if (!resolved) return null; - targets = [resolved]; - } else { - targets = Object.keys(config.collections ?? {}).map((name) => openCollection(config, name, { cwd })); - } +function loadEntries(options: GlossaryCommandOptions, config: LingoTrackerConfig, cwd: string): FlatEntry[] { + const names = options.collection ? [options.collection] : Object.keys(config.collections ?? {}); + const targets = names.map((name) => openCollection(config, name, { cwd })); const entries: FlatEntry[] = []; for (const collection of targets) { @@ -101,14 +96,17 @@ function buildOutputPath(options: GlossaryCommandOptions, cwd: string): string { return path.resolve(cwd, `lingo-tracker-glossary-${timestamp}.json`); } -export async function glossaryCommand(options: GlossaryCommandOptions): Promise { - const loaded = loadConfiguration(); - if (!loaded) return; - const { config, cwd } = loaded; +export const glossaryCommand = defineCommand()({ + name: 'Glossary', + // `--collection` is optional here: absent means every collection, so the runner opens nothing. + collection: 'none', + run: ({ config, cwd, answers }) => runGlossary(answers, config, cwd), +}); +function runGlossary(options: GlossaryCommandOptions, config: LingoTrackerConfig, cwd: string): CommandResult { const block = resolveInputText(options, cwd); if (block === null) { - process.exit(1); + return { exitCode: 1 }; } const baseLocale = config.baseLocale || 'en'; @@ -120,16 +118,7 @@ export async function glossaryCommand(options: GlossaryCommandOptions): Promise< } const entries = loadEntries(options, config, cwd); - if (entries === null) { - process.exit(1); - } - - let extractor: CandidateExtractor; - try { - extractor = resolveExtractor(options.extractor ?? 'ngram'); - } catch (error) { - exitWithError(error); - } + const extractor: CandidateExtractor = resolveExtractor(options.extractor ?? 'ngram'); const candidates = extractor(block); const terms = matchGlossary(entries, candidates, { diff --git a/apps/cli/src/commands/import-cmd.spec.ts b/apps/cli/src/commands/import-cmd.spec.ts index 8cdf9e4b..134d5ae8 100644 --- a/apps/cli/src/commands/import-cmd.spec.ts +++ b/apps/cli/src/commands/import-cmd.spec.ts @@ -1,5 +1,4 @@ import * as fs from 'fs'; -import * as path from 'path'; import prompts from 'prompts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type ImportCommandOptions, importCommand } from './import-cmd'; @@ -9,10 +8,6 @@ const fsMocks = vi.hoisted(() => ({ readFileSync: vi.fn(), writeFileSync: vi.fn(), })); -const pathMocks = vi.hoisted(() => ({ - join: vi.fn(), - resolve: vi.fn(), -})); vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); @@ -22,14 +17,6 @@ vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; }); -vi.mock('path', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...pathMocks, default: { ...actual.default, ...pathMocks } }; -}); -vi.mock('node:path', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...pathMocks, default: { ...actual.default, ...pathMocks } }; -}); vi.mock('prompts', () => ({ default: vi.fn(), })); @@ -38,8 +25,8 @@ vi.mock('prompts', () => ({ vi.mock('@simoncodes-ca/core', async (importOriginal) => { const actual = await importOriginal(); return { - // Config loading and collection resolution run for real against the mocked config. - loadConfig: actual.loadConfig, + // Collection resolution runs for real against the mocked config. + loadConfig: vi.fn(), openCollection: actual.openCollection, ConfigNotFoundError: actual.ConfigNotFoundError, ConfigParseError: actual.ConfigParseError, @@ -59,60 +46,30 @@ vi.mock('@simoncodes-ca/core', async (importOriginal) => { }; }); -// Mock utilities -vi.mock('../utils', () => ({ - loadConfiguration: vi.fn(), - isInteractiveTerminal: vi.fn(() => false), - promptForCollection: vi.fn(), - resolveWritableCollection: vi.fn(), +// Real utilities, except a fixed summary path. +vi.mock('../utils', async (importOriginal) => ({ + ...(await importOriginal()), buildSummaryPath: vi.fn(() => '/tmp/lingo-tracker-import-summary-test.md'), - ConsoleFormatter: { - error: vi.fn((message: string) => console.log(`[error] ${message}`)), - success: vi.fn((message: string) => console.log(`[success] ${message}`)), - warning: vi.fn((message: string) => console.log(`[warning] ${message}`)), - info: vi.fn((message: string) => console.log(`[info] ${message}`)), - progress: vi.fn((message: string) => console.log(`[progress] ${message}`)), - section: vi.fn((title: string) => { - console.log(`\n[section] ${title}`); - console.log('─'.repeat(50)); - }), - indent: vi.fn((message: string, level = 1) => { - const spaces = ' '.repeat(level); - console.log(`${spaces}${message}`); - }), - keyValue: vi.fn((key: string, value: string | number, indent = 1) => { - const spaces = ' '.repeat(indent); - console.log(`${spaces}${key}: ${value}`); - }), - }, - ErrorMessages: { - OPERATION_CANCELLED: vi.fn((op: string) => `${op} cancelled.`), - MISSING_OPTION: vi.fn((opt: string) => `Missing required option: --${opt}`), - }, })); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); // Import the mocked functions import { - type Collection, + ConfigNotFoundError, detectImportFormat, generateImportSummary, importResources, type LingoTrackerCollection, + type LingoTrackerConfig, + loadConfig, loadPreferredTerminology, - openCollection, parseJsonImport, parseXliffImport, } from '@simoncodes-ca/core'; -import { - ConsoleFormatter, - isInteractiveTerminal, - loadConfiguration, - promptForCollection, - resolveWritableCollection, -} from '../utils'; +import { isInteractiveTerminal } from '../runner/terminal'; describe('import-cmd', () => { - const baseConfig = { + const baseConfig: LingoTrackerConfig = { exportFolder: 'dist/lingo-export', importFolder: 'dist/lingo-import', baseLocale: 'en', @@ -124,9 +81,10 @@ describe('import-cmd', () => { }, }; - /** What `resolveWritableCollection` resolves for this collection entry under `baseConfig`. */ - const collectionOf = (name: string, entry: LingoTrackerCollection): Collection => - openCollection({ ...baseConfig, collections: { [name]: entry } }, name, { cwd: '/test/project' }); + /** Configures `baseConfig` with this one collection, so the runner auto-selects it. */ + const onlyCollection = (name: string, entry: LingoTrackerCollection): void => { + vi.mocked(loadConfig).mockReturnValue({ ...baseConfig, collections: { [name]: entry } }); + }; const baseImportResult = { resourcesImported: 10, @@ -144,14 +102,6 @@ describe('import-cmd', () => { beforeEach(() => { vi.clearAllMocks(); - // Mock path functions - vi.mocked(path.join) - .mockReset() - .mockImplementation((...segments) => segments.join('/')); - vi.mocked(path.resolve) - .mockReset() - .mockImplementation((...segments) => segments.join('/')); - // Mock process.cwd vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); @@ -159,18 +109,15 @@ describe('import-cmd', () => { vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); - // Default configuration returned by loadConfiguration for all tests. - vi.mocked(loadConfiguration).mockReturnValue({ - config: baseConfig, - configPath: '/test/project/.lingo-tracker.json', - cwd: '/test/project', - }); + // Default configuration for all tests: a single 'default' collection, auto-selected. + process.env.INIT_CWD = '/test/project'; + process.exitCode = undefined; + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + vi.mocked(loadConfig).mockReturnValue(baseConfig); + }); - // Default collection mocks — most tests use a single 'default' collection - vi.mocked(promptForCollection).mockResolvedValue('default'); - vi.mocked(resolveWritableCollection).mockReturnValue( - collectionOf('default', { translationsFolder: 'src/translations' }), - ); + afterEach(() => { + process.exitCode = undefined; }); describe('Configuration Loading', () => { @@ -185,11 +132,14 @@ describe('import-cmd', () => { await importCommand(options); - expect(loadConfiguration).toHaveBeenCalledWith({ exitOnError: false }); + expect(loadConfig).toHaveBeenCalledWith({ cwd: '/test/project' }); + expect(process.exitCode).toBe(0); }); - it('should return early when loadConfiguration returns null', async () => { - vi.mocked(loadConfiguration).mockReturnValue(null); + it('should exit 1 without importing when the configuration is missing', async () => { + vi.mocked(loadConfig).mockImplementation(() => { + throw new ConfigNotFoundError('/test/project/.lingo-tracker.json'); + }); const options: ImportCommandOptions = { source: '/test/import.json', @@ -200,6 +150,7 @@ describe('import-cmd', () => { await importCommand(options); expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); @@ -258,8 +209,9 @@ describe('import-cmd', () => { await importCommand(options); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Cannot auto-detect format from .txt extension'); + expect(console.log).toHaveBeenCalledWith('❌ Cannot auto-detect format from .txt extension'); expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); @@ -302,6 +254,15 @@ describe('import-cmd', () => { ); }); + it('should resolve a relative --source against the project root', async () => { + process.env.INIT_CWD = '/test/root'; + vi.mocked(importResources).mockReturnValue(baseImportResult); + + await importCommand({ source: 'imports/fr.json', locale: 'es', format: 'json' }); + + expect(parseJsonImport).toHaveBeenCalledWith('/test/root/imports/fr.json', expect.any(Object)); + }); + it('should parse an XLIFF file and import its resources', async () => { vi.mocked(importResources).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -335,8 +296,9 @@ describe('import-cmd', () => { await importCommand(options); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Import failed: Source file not found'); + expect(console.log).toHaveBeenCalledWith('❌ Import failed: Source file not found'); expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should return early with error if the import refuses to run', async () => { @@ -346,10 +308,11 @@ describe('import-cmd', () => { await importCommand({ source: '/test/import.json', locale: 'en', format: 'json' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith( - 'Import failed: Cannot import into base locale "en" with strategy "translation-service".', + expect(console.log).toHaveBeenCalledWith( + '❌ Import failed: Cannot import into base locale "en" with strategy "translation-service".', ); expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); @@ -370,6 +333,7 @@ describe('import-cmd', () => { await importCommand(options); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Import completed successfully!')); + expect(process.exitCode).toBe(0); }); it('should display warnings for import with warnings', async () => { @@ -399,9 +363,6 @@ describe('import-cmd', () => { errors: ['Error 1', 'Error 2'], }); vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`Process exit: ${code}`); - }); const options: ImportCommandOptions = { source: '/test/import.json', @@ -409,7 +370,8 @@ describe('import-cmd', () => { format: 'json', }; - await expect(importCommand(options)).rejects.toThrow('Process exit: 1'); + await importCommand(options); + expect(process.exitCode).toBe(1); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Errors (2)')); }); @@ -420,9 +382,6 @@ describe('import-cmd', () => { errors: ['some error'], }); vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`Process exit: ${code}`); - }); const options: ImportCommandOptions = { source: '/test/import.json', @@ -430,7 +389,8 @@ describe('import-cmd', () => { format: 'json', }; - await expect(importCommand(options)).rejects.toThrow('Process exit: 1'); + await importCommand(options); + expect(process.exitCode).toBe(1); }); it('should display dry-run message', async () => { @@ -453,23 +413,15 @@ describe('import-cmd', () => { describe('Collection Handling', () => { it('should use collection-specific translations folder', async () => { - vi.mocked(loadConfiguration).mockReturnValue({ - config: { - ...baseConfig, - collections: { - ...baseConfig.collections, - admin: { - translationsFolder: 'src/admin-translations', - }, + vi.mocked(loadConfig).mockReturnValue({ + ...baseConfig, + collections: { + ...baseConfig.collections, + admin: { + translationsFolder: 'src/admin-translations', }, }, - configPath: '/test/project/.lingo-tracker.json', - cwd: '/test/project', }); - vi.mocked(promptForCollection).mockResolvedValue('admin'); - vi.mocked(resolveWritableCollection).mockReturnValue( - collectionOf('admin', { translationsFolder: 'src/admin-translations' }), - ); vi.mocked(importResources).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -508,49 +460,61 @@ describe('import-cmd', () => { ); }); - it('should propagate errors thrown by promptForCollection', async () => { - vi.mocked(promptForCollection).mockRejectedValue(new Error('Missing required option: --collection')); + it('should exit 1 when several collections exist and --collection is missing', async () => { + vi.mocked(loadConfig).mockReturnValue({ + ...baseConfig, + collections: { ...baseConfig.collections, admin: { translationsFolder: 'src/admin-translations' } }, + }); - await expect(importCommand({ source: '/test/import.json', locale: 'es', format: 'json' })).rejects.toThrow( - 'Missing required option: --collection', - ); + await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); + + expect(console.log).toHaveBeenCalledWith('❌ Missing required option: --collection'); expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('should return early without error when promptForCollection returns null', async () => { - vi.mocked(promptForCollection).mockResolvedValue(null); - - await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); + it('should exit 1 when the collection does not exist', async () => { + await importCommand({ source: '/test/import.json', locale: 'es', format: 'json', collection: 'nope' }); + expect(console.log).toHaveBeenCalledWith('❌ Collection "nope" not found'); expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('should return early when resolveWritableCollection returns null', async () => { - vi.mocked(resolveWritableCollection).mockReturnValue(null); + it('should exit 1 when the collection is read-only', async () => { + onlyCollection('vendor', { translationsFolder: 'node_modules/x', readOnly: true }); await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); describe('Non-TTY missing required options', () => { - it('should call ConsoleFormatter.error and not import when --source is missing in non-TTY mode', async () => { + it('should exit 1 and not import when --source is missing in non-TTY mode', async () => { await importCommand({ locale: 'es', format: 'json' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith( - 'Source file is required. Use --source or run in interactive mode.', - ); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --source'); expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('should call ConsoleFormatter.error and not import when --locale is missing in non-TTY mode', async () => { + it('should exit 1 and not import when --locale is missing in non-TTY mode', async () => { await importCommand({ source: '/test/import.json', format: 'json' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith( - 'Target locale is required. Use --locale or run in interactive mode.', - ); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --locale'); expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('should name both flags when --source and --locale are missing', async () => { + await importCommand({ format: 'json' }); + + expect(console.log).toHaveBeenCalledWith( + '❌ Missing required options in non-interactive mode: --source, --locale', + ); + expect(process.exitCode).toBe(1); }); }); @@ -562,24 +526,23 @@ describe('import-cmd', () => { vi.spyOn(console, 'log').mockImplementation(() => undefined); }); - afterEach(() => { - vi.mocked(isInteractiveTerminal).mockReturnValue(false); - }); - - /** Choices of the target-locale prompt. */ - const offeredLocales = (): unknown => { - const question = vi - .mocked(prompts) - .mock.calls.map(([asked]) => asked) - .find((asked) => !Array.isArray(asked) && asked.name === 'locale'); - return question && !Array.isArray(question) ? question.choices : undefined; + /** Choices of the target-locale question, evaluated as prompts would (after no earlier answers). */ + const offeredLocales = (values: Record = {}): unknown => { + const [asked] = vi.mocked(prompts).mock.calls[0] ?? []; + const question = (Array.isArray(asked) ? asked : [asked]).find((q) => q?.name === 'locale'); + const choices = question?.choices; + return typeof choices === 'function' ? choices(undefined, values, question) : choices; }; + it('asks every missing value in one prompts call', async () => { + await importCommand({ source: '/test/import.json', format: 'json', strategy: 'translation-service' }); + + expect(prompts).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(0); + }); + it("offers the collection's own locales, minus its base locale", async () => { - vi.mocked(promptForCollection).mockResolvedValue('docs'); - vi.mocked(resolveWritableCollection).mockReturnValue( - collectionOf('docs', { translationsFolder: 'src/docs-translations', baseLocale: 'fr', locales: ['fr', 'de'] }), - ); + onlyCollection('docs', { translationsFolder: 'src/docs-translations', baseLocale: 'fr', locales: ['fr', 'de'] }); await importCommand({ source: '/test/import.json', format: 'json', strategy: 'translation-service' }); @@ -595,6 +558,29 @@ describe('import-cmd', () => { { title: 'fr', value: 'fr' }, ]); }); + + it('offers the base locale too when the chosen strategy is migration', async () => { + await importCommand({ source: '/test/import.json', format: 'json' }); + + expect(offeredLocales({ strategy: 'migration' })).toEqual([ + { title: 'en (base locale)', value: 'en' }, + { title: 'es', value: 'es' }, + { title: 'fr', value: 'fr' }, + ]); + }); + + it('a cancelled prompt prints one cancel line and exits 0', async () => { + vi.mocked(prompts).mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'text', name: 'source', message: 'Source' }, {}); + return {}; + }); + + await importCommand({}); + + expect(console.log).toHaveBeenCalledWith('❌ Import cancelled.'); + expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); }); describe('Preferred terminology', () => { @@ -659,10 +645,7 @@ describe('import-cmd', () => { describe('collection with its own base locale', () => { beforeEach(() => { - vi.mocked(promptForCollection).mockResolvedValue('docs'); - vi.mocked(resolveWritableCollection).mockReturnValue( - collectionOf('docs', { translationsFolder: 'src/docs-translations', baseLocale: 'fr' }), - ); + onlyCollection('docs', { translationsFolder: 'src/docs-translations', baseLocale: 'fr' }); vi.spyOn(console, 'log').mockImplementation(() => undefined); }); diff --git a/apps/cli/src/commands/import-cmd.ts b/apps/cli/src/commands/import-cmd.ts index 4995c5dc..327333f5 100644 --- a/apps/cli/src/commands/import-cmd.ts +++ b/apps/cli/src/commands/import-cmd.ts @@ -13,17 +13,9 @@ import { import type { ImportStrategy } from '@simoncodes-ca/domain'; import * as fs from 'fs'; import * as path from 'path'; -import prompts from 'prompts'; -import { - buildSummaryPath, - ConsoleFormatter, - ErrorMessages, - isInteractiveTerminal, - loadConfiguration, - promptForCollection, - resolveWritableCollection, -} from '../utils'; -import { PromptCancelledError } from '../utils/report-error'; +import type prompts from 'prompts'; +import { defineCommand } from '../runner/command-runner'; +import { buildSummaryPath, ConsoleFormatter } from '../utils'; export const LARGE_FILE_SIZE_THRESHOLD = 5; @@ -42,179 +34,154 @@ export interface ImportCommandOptions { verbose?: boolean; } -export async function importCommand(options: ImportCommandOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - const collectionName = await promptForCollection(config, options.collection); - if (!collectionName) return; - - const collection = resolveWritableCollection(collectionName, config, cwd); - if (!collection) return; - - // The collection's own base locale decides which import writes `source` values. - const { baseLocale, locales } = collection; - - let answers: Partial; - try { - answers = await promptForMissing({ ...options, collection: collectionName }, locales, baseLocale); - } catch (error) { - if (error instanceof PromptCancelledError) { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Import')); - return; - } - throw error; - } - - // Validate required options - if (!answers.source) { - ConsoleFormatter.error('Source file is required. Use --source or run in interactive mode.'); - return; - } - - if (!answers.locale) { - ConsoleFormatter.error('Target locale is required. Use --locale or run in interactive mode.'); - return; - } - - // Check file size and warn if large - try { - const sourceFilePath = path.resolve(process.cwd(), answers.source); - if (fs.existsSync(sourceFilePath)) { - const stats = fs.statSync(sourceFilePath); - const fileSizeMB = stats.size / (1024 * 1024); +export const importCommand = defineCommand()({ + name: 'Import', + collection: 'writable', + prompts: (options, { collection, cwd }) => buildQuestions(options, collection.locales, collection.baseLocale, cwd), + required: ['source', 'locale'], + run: async ({ config, cwd, collection, answers }) => { + // The collection's own base locale decides which import writes `source` values. + const { baseLocale } = collection; + const { source } = answers; + // A relative --source is relative to the project root, like --output on export. + const sourcePath = path.resolve(cwd, source); + + // Check file size and warn if large + try { + if (fs.existsSync(sourcePath)) { + const stats = fs.statSync(sourcePath); + const fileSizeMB = stats.size / (1024 * 1024); - if (fileSizeMB > LARGE_FILE_SIZE_THRESHOLD) { - ConsoleFormatter.warning(`Large import file detected: ${fileSizeMB.toFixed(2)} MB`); - ConsoleFormatter.indent('Import may take longer than usual.'); + if (fileSizeMB > LARGE_FILE_SIZE_THRESHOLD) { + ConsoleFormatter.warning(`Large import file detected: ${fileSizeMB.toFixed(2)} MB`); + ConsoleFormatter.indent('Import may take longer than usual.'); + } } + } catch (_error) { + // File size check is non-critical, continue with import } - } catch (_error) { - // File size check is non-critical, continue with import - } - - const preferredTerminology = loadPreferredTerminology(config, cwd); - const source = answers.source; - const runOptions: ImportRunOptions = { - locale: answers.locale, - strategy: answers.strategy || 'translation-service', - updateComments: answers.updateComments, - updateTags: answers.updateTags, - preserveStatus: answers.preserveStatus, - createMissing: answers.createMissing, - validateBase: answers.validateBase !== false, // Default true - dryRun: answers.dryRun || false, - verbose: answers.verbose || false, - protectedTerms: readEffectiveProtectedTerms(config, collection.config, cwd), - // Only consulted on base-locale imports. A broken file yields no rules, so the - // check is skipped and a config warning is added once the import has run. - preferredTerminology: preferredTerminology.rules, - onProgress: answers.verbose ? (msg: string) => console.log(` ${msg}`) : undefined, - }; - // Auto-detect format if not specified - let format = answers.format; - if (!format) { - try { + const preferredTerminology = loadPreferredTerminology(config, cwd); + const runOptions: ImportRunOptions = { + locale: answers.locale, + strategy: answers.strategy || 'translation-service', + updateComments: answers.updateComments, + updateTags: answers.updateTags, + preserveStatus: answers.preserveStatus, + createMissing: answers.createMissing, + validateBase: answers.validateBase !== false, // Default true + dryRun: answers.dryRun || false, + verbose: answers.verbose || false, + protectedTerms: readEffectiveProtectedTerms(config, collection.config, cwd), + // Only consulted on base-locale imports. A broken file yields no rules, so the + // check is skipped and a config warning is added once the import has run. + preferredTerminology: preferredTerminology.rules, + onProgress: answers.verbose ? (msg: string) => console.log(` ${msg}`) : undefined, + }; + + // Auto-detect format if not specified + let format = answers.format; + if (!format) { format = detectImportFormat(source); if (runOptions.verbose) { console.log(`Detected format: ${format}`); } - } catch (error) { - ConsoleFormatter.error((error as Error).message); - return; } - } - // Display import summary - console.log(''); - ConsoleFormatter.progress('Starting import...'); - ConsoleFormatter.indent(`Format: ${format}`); - ConsoleFormatter.indent(`Source: ${source}`); - ConsoleFormatter.indent(`Locale: ${runOptions.locale}`); - ConsoleFormatter.indent(`Strategy: ${runOptions.strategy}`); - ConsoleFormatter.indent(`Collection: ${collectionName}`); - if (runOptions.dryRun) { - ConsoleFormatter.indent('Mode: DRY RUN (no changes will be made)'); - } - console.log(''); - - // Performance logging for verbose mode - const startTime = runOptions.verbose ? Date.now() : 0; - if (runOptions.verbose) { - console.log(`Started at: ${new Date(startTime).toLocaleTimeString()}`); - } + // Display import summary + console.log(''); + ConsoleFormatter.progress('Starting import...'); + ConsoleFormatter.indent(`Format: ${format}`); + ConsoleFormatter.indent(`Source: ${source}`); + ConsoleFormatter.indent(`Locale: ${runOptions.locale}`); + ConsoleFormatter.indent(`Strategy: ${runOptions.strategy}`); + ConsoleFormatter.indent(`Collection: ${collection.name}`); + if (runOptions.dryRun) { + ConsoleFormatter.indent('Mode: DRY RUN (no changes will be made)'); + } + console.log(''); - let result: ImportResult; - try { - const parseOptions = { onProgress: runOptions.onProgress }; - const resources = - format === 'json' ? parseJsonImport(source, parseOptions) : await parseXliffImport(source, parseOptions); - result = importResources(collection, resources, runOptions); - } catch (error) { - ConsoleFormatter.error(`Import failed: ${(error as Error).message}`); - return; - } + // Performance logging for verbose mode + const startTime = runOptions.verbose ? Date.now() : 0; + if (runOptions.verbose) { + console.log(`Started at: ${new Date(startTime).toLocaleTimeString()}`); + } - // Terminology is only checked when importing into the base locale, so a rule file - // problem only matters then. Surfaced through the result so it reaches the summary. - const terminologyConfigWarning = preferredTerminology.error - ? `Preferred terminology checks skipped: ${preferredTerminology.error}` - : preferredTerminology.warning; - if (terminologyConfigWarning && result.locale === baseLocale) { - result = { ...result, warnings: [terminologyConfigWarning, ...result.warnings] }; - } + let result: ImportResult; + try { + const parseOptions = { onProgress: runOptions.onProgress }; + const resources = + format === 'json' + ? parseJsonImport(sourcePath, parseOptions) + : await parseXliffImport(sourcePath, parseOptions); + result = importResources(collection, resources, runOptions); + } catch (error) { + throw new Error(`Import failed: ${error instanceof Error ? error.message : String(error)}`); + } - // Log elapsed time in verbose mode - if (runOptions.verbose) { - const endTime = Date.now(); - const elapsedMilliseconds = endTime - startTime; - const elapsedSeconds = (elapsedMilliseconds / 1000).toFixed(2); - console.log(`\nCompleted at: ${new Date(endTime).toLocaleTimeString()}`); - console.log(`Elapsed time: ${elapsedSeconds}s (${elapsedMilliseconds}ms)`); - } + // Terminology is only checked when importing into the base locale, so a rule file + // problem only matters then. Surfaced through the result so it reaches the summary. + const terminologyConfigWarning = preferredTerminology.error + ? `Preferred terminology checks skipped: ${preferredTerminology.error}` + : preferredTerminology.warning; + if (terminologyConfigWarning && result.locale === baseLocale) { + result = { ...result, warnings: [terminologyConfigWarning, ...result.warnings] }; + } - // Display results - displayResults(result, runOptions); + // Log elapsed time in verbose mode + if (runOptions.verbose) { + const endTime = Date.now(); + const elapsedMilliseconds = endTime - startTime; + const elapsedSeconds = (elapsedMilliseconds / 1000).toFixed(2); + console.log(`\nCompleted at: ${new Date(endTime).toLocaleTimeString()}`); + console.log(`Elapsed time: ${elapsedSeconds}s (${elapsedMilliseconds}ms)`); + } - // Generate and write summary - const summaryPath = buildSummaryPath('import'); - if (!runOptions.dryRun) { - try { - const summary = generateImportSummary(result, { ...runOptions, format, source }); - fs.writeFileSync(summaryPath, summary, 'utf8'); + // Display results + displayResults(result, runOptions); + + // Generate and write summary + const summaryPath = buildSummaryPath('import'); + if (!runOptions.dryRun) { + try { + const summary = generateImportSummary(result, { ...runOptions, format, source }); + fs.writeFileSync(summaryPath, summary, 'utf8'); + console.log(''); + console.log(`Import summary written to: ${summaryPath}`); + } catch (error) { + ConsoleFormatter.warning(`Failed to write summary file: ${(error as Error).message}`); + } + } else { console.log(''); - console.log(`Import summary written to: ${summaryPath}`); - } catch (error) { - ConsoleFormatter.warning(`Failed to write summary file: ${(error as Error).message}`); + console.log(`Import summary would be written to: ${summaryPath}`); } - } else { - console.log(''); - console.log(`Import summary would be written to: ${summaryPath}`); - } - // Exit with appropriate code - if (result.resourcesFailed > 0 || result.errors.length > 0) { - process.exit(1); - } -} - -async function promptForMissing( + // Exit with appropriate code + return result.resourcesFailed > 0 || result.errors.length > 0 ? { exitCode: 1 } : undefined; + }, +}); + +/** + * The questions for what the flags left out. Later questions depend on earlier answers + * (the format is only asked when the source's extension does not tell it; the locale + * choices and the migration flags depend on the strategy), through prompts' function-valued + * `type` and `choices`. + */ +function buildQuestions( options: ImportCommandOptions, configuredLocales: readonly string[], baseLocale: string, -): Promise { - const answers = { ...options }; - - // If not in TTY mode, return options as-is (non-interactive mode) - if (!isInteractiveTerminal()) { - return answers; - } - - // Prompt for source file - if (!answers.source) { - const sourceAnswer = await prompts({ + cwd: string, +): prompts.PromptObject[] { + const questions: prompts.PromptObject[] = []; + const strategyOf = (values: Record): ImportStrategy => + options.strategy ?? (values.strategy as ImportStrategy | undefined) ?? 'translation-service'; + const localesFor = (strategy: ImportStrategy): readonly string[] => + // For migration, the base locale is a valid target too. + strategy === 'migration' ? configuredLocales : configuredLocales.filter((loc) => loc !== baseLocale); + + if (!options.source) { + questions.push({ type: 'text', name: 'source', message: 'Enter path to import file:', @@ -222,60 +189,37 @@ async function promptForMissing( if (!value || value.trim() === '') { return 'Source file is required'; } - const resolvedPath = path.resolve(process.cwd(), value); - if (!fs.existsSync(resolvedPath)) { + if (!fs.existsSync(path.resolve(cwd, value))) { return `File not found: ${value}`; } return true; }, }); - - if (!sourceAnswer.source) { - throw new PromptCancelledError('Import'); - } - - answers.source = sourceAnswer.source; - } - - // Auto-detect format from source file extension - if (!answers.format && answers.source) { - try { - answers.format = detectImportFormat(answers.source); - } catch { - // Will prompt if detection fails - } } - // Prompt for format if still not determined - if (!answers.format) { - const formatAnswer = await prompts({ - type: 'select', + if (!options.format) { + questions.push({ + // Skipped when the source's extension gives the format; `run` detects it again. + type: (_prev: unknown, values: Record) => { + const source = options.source ?? (typeof values.source === 'string' ? values.source : ''); + try { + detectImportFormat(source); + return null; + } catch { + return 'select'; + } + }, name: 'format', message: 'Select import format:', choices: [ - { - title: 'JSON', - value: 'json', - description: 'JSON format (flat or hierarchical)', - }, - { - title: 'XLIFF 1.2', - value: 'xliff', - description: 'XLIFF format for professional translation services', - }, + { title: 'JSON', value: 'json', description: 'JSON format (flat or hierarchical)' }, + { title: 'XLIFF 1.2', value: 'xliff', description: 'XLIFF format for professional translation services' }, ], }); - - if (!formatAnswer.format) { - throw new PromptCancelledError('Import'); - } - - answers.format = formatAnswer.format; } - // Prompt for import strategy - if (!answers.strategy) { - const strategyAnswer = await prompts({ + if (!options.strategy) { + questions.push({ type: 'select', name: 'strategy', message: 'Select import strategy:', @@ -285,120 +229,59 @@ async function promptForMissing( value: 'translation-service', description: 'Import from professional translation services (default)', }, - { - title: 'Verification', - value: 'verification', - description: 'Language expert verification workflow', - }, - { - title: 'Migration', - value: 'migration', - description: 'Migrate from another translation system', - }, - { - title: 'Update', - value: 'update', - description: 'Bulk update existing translations', - }, + { title: 'Verification', value: 'verification', description: 'Language expert verification workflow' }, + { title: 'Migration', value: 'migration', description: 'Migrate from another translation system' }, + { title: 'Update', value: 'update', description: 'Bulk update existing translations' }, ], }); - - if (!strategyAnswer.strategy) { - throw new PromptCancelledError('Import'); - } - - answers.strategy = strategyAnswer.strategy; } - // For migration strategy, include base locale in the available choices. - // This must be computed after the strategy prompt so answers.strategy reflects the user's choice. - const strategy = answers.strategy || 'translation-service'; - const allowBaseLocale = strategy === 'migration'; - const targetLocales = allowBaseLocale ? configuredLocales : configuredLocales.filter((loc) => loc !== baseLocale); - - // Prompt for target locale - if (!answers.locale) { - const localeAnswer = await prompts({ - type: targetLocales.length > 0 ? 'select' : 'text', + if (!options.locale) { + // `validate` is not given the earlier answers, so the `type` callback records the strategy for it. + let strategy: ImportStrategy = options.strategy ?? 'translation-service'; + questions.push({ + type: (_prev: unknown, values: Record) => { + strategy = strategyOf(values); + return localesFor(strategy).length > 0 ? 'select' : 'text'; + }, name: 'locale', message: 'Select target locale for import:', - choices: - targetLocales.length > 0 - ? targetLocales.map((loc) => ({ - title: loc === baseLocale ? `${loc} (base locale)` : loc, - value: loc, - })) - : undefined, - validate: - targetLocales.length === 0 - ? (value: string) => { - if (!value || value.trim() === '') { - return 'Locale is required'; - } - if (value === baseLocale && !allowBaseLocale) { - return `Cannot import into base locale "${baseLocale}" with strategy "${strategy}"`; - } - return true; - } - : undefined, + choices: (_prev: unknown, values: Record) => + localesFor(strategyOf(values)).map((loc) => ({ + title: loc === baseLocale ? `${loc} (base locale)` : loc, + value: loc, + })), + validate: (value: string) => { + if (localesFor(strategy).length > 0) { + return true; + } + if (!value || value.trim() === '') { + return 'Locale is required'; + } + if (value === baseLocale && strategy !== 'migration') { + return `Cannot import into base locale "${baseLocale}" with strategy "${strategy}"`; + } + return true; + }, }); - - if (!('locale' in localeAnswer)) { - throw new PromptCancelledError('Import'); - } - - answers.locale = localeAnswer.locale; } - // For migration strategy, ask about flags if not already set - if (answers.strategy === 'migration') { - if (answers.updateComments === undefined) { - const updateCommentsAnswer = await prompts({ - type: 'confirm', - name: 'updateComments', - message: 'Update comments from import data?', - initial: true, - }); - - if (!('updateComments' in updateCommentsAnswer)) { - throw new PromptCancelledError('Import'); - } - - answers.updateComments = updateCommentsAnswer.updateComments; - } - - if (answers.updateTags === undefined) { - const updateTagsAnswer = await prompts({ - type: 'confirm', - name: 'updateTags', - message: 'Update tags from import data?', + const migrationFlag = (name: 'updateComments' | 'updateTags' | 'createMissing', message: string) => { + if (options[name] === undefined) { + questions.push({ + type: (_prev: unknown, values: Record) => + strategyOf(values) === 'migration' ? 'confirm' : null, + name, + message, initial: true, }); - - if (!('updateTags' in updateTagsAnswer)) { - throw new PromptCancelledError('Import'); - } - - answers.updateTags = updateTagsAnswer.updateTags; } + }; + migrationFlag('updateComments', 'Update comments from import data?'); + migrationFlag('updateTags', 'Update tags from import data?'); + migrationFlag('createMissing', 'Create missing resources?'); - if (answers.createMissing === undefined) { - const createMissingAnswer = await prompts({ - type: 'confirm', - name: 'createMissing', - message: 'Create missing resources?', - initial: true, - }); - - if (!('createMissing' in createMissingAnswer)) { - throw new PromptCancelledError('Import'); - } - - answers.createMissing = createMissingAnswer.createMissing; - } - } - - return answers; + return questions; } function displayResults(result: ImportResult, options: ImportRunOptions): void { diff --git a/apps/cli/src/commands/install-skill.spec.ts b/apps/cli/src/commands/install-skill.spec.ts index 34780364..5bfcfdc2 100644 --- a/apps/cli/src/commands/install-skill.spec.ts +++ b/apps/cli/src/commands/install-skill.spec.ts @@ -1,7 +1,18 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs'; import path from 'path'; -import { parseCollectionArg, substituteSkillTemplate, readPatternsMdTemplate, getTemplatesDir } from './install-skill'; +import prompts from 'prompts'; +import { isInteractiveTerminal } from '../runner/terminal'; +import { + installSkillCommand, + parseCollectionArg, + substituteSkillTemplate, + readPatternsMdTemplate, + getTemplatesDir, +} from './install-skill'; + +vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); // --------------------------------------------------------------------------- // parseCollectionArg @@ -189,3 +200,74 @@ describe('readPatternsMdTemplate', () => { expect(await readPatternsMdTemplate()).toContain('TOKEN_CONSTANT'); }); }); + +// --------------------------------------------------------------------------- +// installSkillCommand (runner outcome) +// --------------------------------------------------------------------------- + +describe('installSkillCommand', () => { + // Stubbed so a regression can never write real `.claude/…` files. + let mkdir: ReturnType; + let writeFile: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + mkdir = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + writeFile = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + }); + + afterEach(() => { + process.exitCode = undefined; + writeFile.mockRestore(); + }); + + it('exits 1 without --collection in non-interactive mode', async () => { + await installSkillCommand({}); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('❌ Missing required option in non-interactive mode: --collection'), + ); + expect(mkdir).not.toHaveBeenCalled(); + expect(writeFile).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 on a malformed --collection spec', async () => { + await installSkillCommand({ collection: ['only:three:parts'] }); + + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('❌ Invalid collection spec "only:three:parts"')); + expect(mkdir).not.toHaveBeenCalled(); + expect(writeFile).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('cancelling the first interactive question writes nothing and exits 0', async () => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + vi.mocked(prompts).mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'select', name: 'value', message: 'dir' }, {}); + return {}; + }); + + await installSkillCommand({}); + + expect(console.log).toHaveBeenCalledWith('❌ Install skill cancelled.'); + expect(mkdir).not.toHaveBeenCalled(); + expect(writeFile).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); + + it('writes the skill under --dir, resolved against the project root', async () => { + await installSkillCommand({ collection: ['app:core:APP_TOKENS:src/tokens.ts'], dir: '.agents' }); + + expect(mkdir).toHaveBeenCalledWith('/project/.agents/skills/lingo-tracker/references', { recursive: true }); + expect(writeFile).toHaveBeenCalledWith( + '/project/.agents/skills/lingo-tracker/SKILL.md', + expect.stringContaining('APP_TOKENS'), + 'utf-8', + ); + expect(process.exitCode).toBe(0); + }); +}); diff --git a/apps/cli/src/commands/install-skill.ts b/apps/cli/src/commands/install-skill.ts index 2df0ca9e..f51f9efa 100644 --- a/apps/cli/src/commands/install-skill.ts +++ b/apps/cli/src/commands/install-skill.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { type Ask, defineCommand } from '../runner/command-runner'; export interface CollectionSpec { name: string; @@ -28,16 +29,15 @@ export function parseCollectionArg(raw: string): CollectionSpec { return { name, bundle, tokenConstant, tokenFilePath }; } -async function promptForCollections(): Promise { - const prompts = (await import('prompts')).default; +async function promptForCollections(ask: Ask): Promise { const collections: CollectionSpec[] = []; + const text = (value: unknown) => (typeof value === 'string' ? value.trim() : ''); let addMore = true; while (addMore) { - const collectionNum = collections.length + 1; - console.log(`\nCollection ${collectionNum}:`); + console.log(`\nCollection ${collections.length + 1}:`); - const answers = await prompts([ + const answers = await ask([ { type: 'text', name: 'name', @@ -64,25 +64,19 @@ async function promptForCollections(): Promise { }, ]); - if (!answers.name) { - // User cancelled - break; - } - collections.push({ - name: answers.name.trim(), - bundle: answers.bundle.trim(), - tokenConstant: answers.tokenConstant.trim(), - tokenFilePath: answers.tokenFilePath.trim(), + name: text(answers.name), + bundle: text(answers.bundle), + tokenConstant: text(answers.tokenConstant), + tokenFilePath: text(answers.tokenFilePath), }); - const more = await prompts({ + const more = await ask({ type: 'confirm', name: 'value', message: 'Add another collection?', initial: false, }); - if (more.value === undefined) break; addMore = more.value === true; } @@ -221,82 +215,71 @@ export async function readPatternsMdTemplate(): Promise { } } -export async function installSkillCommand(options: InstallSkillOptions): Promise { - const isTTY = process.stdin.isTTY; - - let collections: CollectionSpec[]; - let outputDir: string; - let tokenCasing = options.tokenCasing; // may be overridden by interactive prompt - - if (options.collection && options.collection.length > 0) { - // Non-interactive: parse from flags - try { +export const installSkillCommand = defineCommand()({ + name: 'Install skill', + // Generates a skill file from templates; reads no project configuration. + collection: 'none', + config: false, + run: async ({ answers: options, cwd, interactive, ask }) => { + let collections: CollectionSpec[]; + let outputDir: string; + let tokenCasing = options.tokenCasing; // may be overridden by interactive prompt + + if (options.collection && options.collection.length > 0) { + // Non-interactive: parse from flags (a malformed spec throws; the runner exits 1) collections = options.collection.map(parseCollectionArg); - } catch (err) { - console.error(`Error: ${(err as Error).message}`); - process.exit(1); - } - outputDir = options.dir ?? '.claude'; - } else if (isTTY) { - // Interactive mode - const prompts = (await import('prompts')).default; - - const dirAnswer = await prompts({ - type: 'select', - name: 'value', - message: 'Install skill into which AI tool directory?', - choices: [ - { title: '.claude (default)', value: '.claude' }, - { title: '.agents', value: '.agents' }, - { title: '.cursor', value: '.cursor' }, - ], - initial: 0, - }); - - if (dirAnswer.value === undefined) { - console.log('Cancelled.'); - return; - } - outputDir = dirAnswer.value; - - collections = await promptForCollections(); - if (collections.length === 0) { - console.error('Error: at least one collection is required.'); - process.exit(1); - } - - const casingAnswer = await prompts({ - type: 'select', - name: 'value', - message: 'Token property key casing?', - choices: [ - { title: 'upperCase (default)', value: 'upperCase' }, - { title: 'camelCase', value: 'camelCase' }, - ], - initial: 0, - }); - - if (casingAnswer.value && casingAnswer.value !== 'upperCase') { - tokenCasing = casingAnswer.value; + outputDir = options.dir ?? '.claude'; + } else if (interactive) { + const dirAnswer = await ask({ + type: 'select', + name: 'value', + message: 'Install skill into which AI tool directory?', + choices: [ + { title: '.claude (default)', value: '.claude' }, + { title: '.agents', value: '.agents' }, + { title: '.cursor', value: '.cursor' }, + ], + initial: 0, + }); + outputDir = typeof dirAnswer.value === 'string' ? dirAnswer.value : '.claude'; + + collections = await promptForCollections(ask); + + const casingAnswer = await ask({ + type: 'select', + name: 'value', + message: 'Token property key casing?', + choices: [ + { title: 'upperCase (default)', value: 'upperCase' }, + { title: 'camelCase', value: 'camelCase' }, + ], + initial: 0, + }); + + if (casingAnswer.value === 'camelCase') { + tokenCasing = casingAnswer.value; + } + } else { + throw new Error( + 'Missing required option in non-interactive mode: --collection\n' + + 'Usage: npx lingo-tracker install-skill --collection name:bundle:TokenConstant:tokenFilePath', + ); } - } else { - console.error('Error: --collection is required in non-interactive mode.'); - console.error('Usage: npx lingo-tracker install-skill --collection name:bundle:TokenConstant:tokenFilePath'); - process.exit(1); - } - const skillDir = path.join(outputDir, 'skills', 'lingo-tracker'); - const referencesDir = path.join(skillDir, 'references'); + // Relative to the project root (INIT_CWD under pnpm), not to wherever node was started. + const skillDir = path.resolve(cwd, outputDir, 'skills', 'lingo-tracker'); + const referencesDir = path.join(skillDir, 'references'); - fs.mkdirSync(referencesDir, { recursive: true }); + fs.mkdirSync(referencesDir, { recursive: true }); - const skillMdPath = path.join(skillDir, 'SKILL.md'); - const patternsMdPath = path.join(referencesDir, 'patterns.md'); + const skillMdPath = path.join(skillDir, 'SKILL.md'); + const patternsMdPath = path.join(referencesDir, 'patterns.md'); - fs.writeFileSync(skillMdPath, await generateSkillMd(collections, tokenCasing), 'utf-8'); - fs.writeFileSync(patternsMdPath, await readPatternsMdTemplate(), 'utf-8'); + fs.writeFileSync(skillMdPath, await generateSkillMd(collections, tokenCasing), 'utf-8'); + fs.writeFileSync(patternsMdPath, await readPatternsMdTemplate(), 'utf-8'); - console.log('Skill installed successfully:'); - console.log(` ${skillMdPath}`); - console.log(` ${patternsMdPath}`); -} + console.log('Skill installed successfully:'); + console.log(` ${skillMdPath}`); + console.log(` ${patternsMdPath}`); + }, +}); diff --git a/apps/cli/src/commands/move.test.ts b/apps/cli/src/commands/move.test.ts new file mode 100644 index 00000000..cbe3b85b --- /dev/null +++ b/apps/cli/src/commands/move.test.ts @@ -0,0 +1,132 @@ +import { type LingoTrackerConfig, loadConfig, moveResource } from '@simoncodes-ca/core'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isInteractiveTerminal } from '../runner/terminal'; +import { moveResourceCommand } from './move'; + +vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadConfig: vi.fn(), moveResource: vi.fn() }; +}); + +const CONFIG: LingoTrackerConfig = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { + main: { translationsFolder: 'src/i18n' }, + admin: { translationsFolder: 'src/admin' }, + vendor: { translationsFolder: 'node_modules/x', readOnly: true }, + }, +}; + +const collectionNamed = (name: string) => expect.objectContaining({ name }); + +describe('moveResourceCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + vi.mocked(loadConfig).mockReturnValue(CONFIG); + vi.mocked(moveResource).mockResolvedValue({ movedCount: 1, warnings: [], errors: [], mutations: [] }); + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('moves within the collection with the given flags', async () => { + await moveResourceCommand({ collection: 'main', source: 'a.ok', dest: 'b.ok', override: true }); + + expect(moveResource).toHaveBeenCalledWith(collectionNamed('main'), { + source: 'a.ok', + destination: 'b.ok', + override: true, + destinationCollection: undefined, + }); + expect(console.log).toHaveBeenCalledWith('✅ Moved 1 resource(s)'); + expect(process.exitCode).toBe(0); + }); + + it('opens a destination collection writable', async () => { + await moveResourceCommand({ collection: 'main', source: 'a.ok', dest: 'b.ok', destCollection: 'admin' }); + + expect(moveResource).toHaveBeenCalledWith( + collectionNamed('main'), + expect.objectContaining({ destinationCollection: collectionNamed('admin') }), + ); + }); + + it('exits 1 for a read-only destination collection', async () => { + await moveResourceCommand({ collection: 'main', source: 'a.ok', dest: 'b.ok', destCollection: 'vendor' }); + + expect(moveResource).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Collection "vendor" is read-only. Its resources cannot be modified.'); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 when the move reports errors', async () => { + vi.mocked(moveResource).mockResolvedValue({ + movedCount: 0, + warnings: [], + errors: ['b.ok already exists'], + mutations: [], + }); + + await moveResourceCommand({ collection: 'main', source: 'a.ok', dest: 'b.ok' }); + + expect(console.log).toHaveBeenCalledWith(' - b.ok already exists'); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 with the core message when core throws', async () => { + vi.mocked(moveResource).mockRejectedValue(new Error('Resource not found: a.ok')); + + await moveResourceCommand({ collection: 'main', source: 'a.ok', dest: 'b.ok' }); + + expect(console.log).toHaveBeenCalledWith('❌ Resource not found: a.ok'); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 naming the missing flags in non-interactive mode', async () => { + await moveResourceCommand({ collection: 'main' }); + + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --source, --dest'); + expect(moveResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + describe('interactive', () => { + beforeEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + }); + + it('asks for source and destination', async () => { + vi.mocked(prompts).mockResolvedValueOnce({ source: 'a.*', dest: 'b' }); + + await moveResourceCommand({ collection: 'main' }); + + expect(moveResource).toHaveBeenCalledWith( + collectionNamed('main'), + expect.objectContaining({ source: 'a.*', destination: 'b' }), + ); + }); + + it('cancelling prints one cancel line and exits 0', async () => { + vi.mocked(prompts).mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'text', name: 'source', message: 'Source' }, {}); + return {}; + }); + + await moveResourceCommand({ collection: 'main' }); + + expect(console.log).toHaveBeenCalledWith('❌ Move resource cancelled.'); + expect(moveResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); + }); +}); diff --git a/apps/cli/src/commands/move.ts b/apps/cli/src/commands/move.ts index 6448b68b..f28098c1 100644 --- a/apps/cli/src/commands/move.ts +++ b/apps/cli/src/commands/move.ts @@ -1,11 +1,5 @@ -import type prompts from 'prompts'; -import { type Collection, moveResource } from '@simoncodes-ca/core'; -import { - loadConfiguration, - promptForCollection, - resolveWritableCollection, - executePromptsWithFallback, -} from '../utils'; +import { type Collection, moveResource, openCollection } from '@simoncodes-ca/core'; +import { defineCommand } from '../runner/command-runner'; export interface MoveResourceOptions { collection?: string; @@ -16,35 +10,44 @@ export interface MoveResourceOptions { verbose?: boolean; } -export async function moveResourceCommand(options: MoveResourceOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; +const required = (val: string) => (val && val.trim().length > 0 ? true : 'Required'); - // Prompt for source collection first - const sourceCollectionName = await promptForCollection(config, options.collection); - if (!sourceCollectionName) return; +export const moveResourceCommand = defineCommand()({ + name: 'Move resource', + collection: 'writable', + prompts: (options) => [ + ...(options.source + ? [] + : [ + { + type: 'text' as const, + name: 'source', + message: 'Source key or pattern (e.g. common.buttons.ok or common.buttons.*)', + validate: required, + }, + ]), + ...(options.dest + ? [] + : [ + { + type: 'text' as const, + name: 'dest', + message: 'Destination key (e.g. common.actions.ok)', + validate: required, + }, + ]), + ], + required: ['source', 'dest'], + run: async ({ collection, config, cwd, answers }) => { + const destinationCollection: Collection | undefined = answers.destCollection + ? openCollection(config, answers.destCollection, { cwd, writable: true }) + : undefined; - // Validate source collection exists and is writable - const sourceCollection = resolveWritableCollection(sourceCollectionName, config, cwd); - if (!sourceCollection) return; - - // Prompt for other fields - const answers = await promptForMissing(options); - - // Handle optional destination collection - let destCollection: Collection | undefined; - if (answers.destCollection) { - destCollection = resolveWritableCollection(answers.destCollection, config, cwd); - if (!destCollection) return; - } - - try { - const result = await moveResource(sourceCollection, { + const result = await moveResource(collection, { source: answers.source, destination: answers.dest, - override: options.override, - destinationCollection: destCollection, + override: answers.override, + destinationCollection, }); if (result.movedCount > 0) { @@ -65,47 +68,7 @@ export async function moveResourceCommand(options: MoveResourceOptions): Promise for (const error of result.errors) { console.log(` - ${error}`); } + return { exitCode: 1 }; } - } catch (e: unknown) { - console.log(`❌ ${e instanceof Error ? e.message : 'Failed to move resource'}`); - } -} - -async function promptForMissing(options: MoveResourceOptions): Promise<{ - source: string; - dest: string; - destCollection?: string; -}> { - const questions: prompts.PromptObject[] = []; - - if (!options.source) { - questions.push({ - type: 'text', - name: 'source', - message: 'Source key or pattern (e.g. common.buttons.ok or common.buttons.*)', - validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), - }); - } - - if (!options.dest) { - questions.push({ - type: 'text', - name: 'dest', - message: 'Destination key (e.g. common.actions.ok)', - validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), - }); - } - - const result = await executePromptsWithFallback({ - questions, - currentValues: options, - requiredFields: ['source', 'dest'], - operationName: 'Move resource', - }); - - return { - source: result.source as string, - dest: result.dest as string, - destCollection: result.destCollection as string | undefined, - }; -} + }, +}); diff --git a/apps/cli/src/commands/normalize.test.ts b/apps/cli/src/commands/normalize.test.ts index 7a96835a..4c204e3d 100644 --- a/apps/cli/src/commands/normalize.test.ts +++ b/apps/cli/src/commands/normalize.test.ts @@ -1,79 +1,43 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import prompts from 'prompts'; import { normalizeCommand } from './normalize'; -import { type Collection, normalize } from '@simoncodes-ca/core'; -import { loadConfiguration, resolveCollection, ConsoleFormatter } from '../utils'; +import { type LingoTrackerConfig, loadConfig, normalize } from '@simoncodes-ca/core'; +import { isInteractiveTerminal } from '../runner/terminal'; vi.mock('prompts', () => ({ default: vi.fn(), })); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); vi.mock('@simoncodes-ca/core', async (importOriginal) => { const actual = await importOriginal(); - return { - // Config loading and collection resolution run for real against the mocked config. - loadConfig: actual.loadConfig, - openCollection: actual.openCollection, - ConfigNotFoundError: actual.ConfigNotFoundError, - ConfigParseError: actual.ConfigParseError, - CollectionNotFoundError: actual.CollectionNotFoundError, - ReadOnlyCollectionError: actual.ReadOnlyCollectionError, - normalize: vi.fn(), - }; + // Collection resolution runs for real against the mocked config. + return { ...actual, loadConfig: vi.fn(), normalize: vi.fn() }; }); -vi.mock('../utils', () => ({ - loadConfiguration: vi.fn(), - resolveCollection: vi.fn(), - aggregateNumericFields: vi.fn(() => ({})), - ConsoleFormatter: { - error: vi.fn(), - info: vi.fn(), - progress: vi.fn(), - indent: vi.fn(), - section: vi.fn(), - keyValue: vi.fn(), - warning: vi.fn(), +const CONFIG: LingoTrackerConfig = { + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { + App: { translationsFolder: 'path/App' }, + Lib: { translationsFolder: 'path/Lib', readOnly: true }, }, - ErrorMessages: { - NO_COLLECTIONS: 'no collections', - COLLECTION_READ_ONLY: (name: string) => `❌ Collection "${name}" is read-only. Its resources cannot be modified.`, - MISSING_OPTIONS: (opts: string[]) => `missing: ${opts.join(', ')}`, - OPERATION_CANCELLED: (operation: string) => `❌ ${operation} cancelled.`, - }, -})); - -const LOADED_CONFIG = { - config: { baseLocale: 'en', locales: ['en', 'fr'], collections: { App: {}, Lib: {} } }, - configPath: '/p/.lingo-tracker.json', - cwd: '/p', }; -function resolved(name: string, readOnly: boolean): Collection { - return { - name, - translationsFolder: `/p/path/${name}`, - baseLocale: 'en', - locales: ['en', 'fr'], - targetLocales: ['fr'], - translationConfig: undefined, - tags: [], - protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, - readOnly, - config: { translationsFolder: `path/${name}`, ...(readOnly ? { readOnly: true } : {}) }, - }; -} +const logged = () => vi.mocked(console.log).mock.calls.map(([line]) => String(line)); describe('normalizeCommand', () => { beforeEach(() => { vi.clearAllMocks(); - Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + process.env.INIT_CWD = '/p'; process.exitCode = undefined; - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + vi.mocked(loadConfig).mockReturnValue(CONFIG); vi.mocked(normalize).mockResolvedValue({ entriesProcessed: 0, localesAdded: 0, valuesConverted: 0, + tagsNormalized: 0, filesCreated: 0, filesUpdated: 0, foldersRemoved: 0, @@ -84,49 +48,147 @@ describe('normalizeCommand', () => { process.exitCode = undefined; }); - it('is defined and callable', () => { - expect(typeof normalizeCommand).toBe('function'); + it('normalizes the named collection with its opened settings', async () => { + await normalizeCommand({ collection: 'App', dryRun: true }); + + expect(normalize).toHaveBeenCalledWith({ + translationsFolder: '/p/path/App', + baseLocale: 'en', + locales: ['en', 'fr'], + dryRun: true, + }); + expect(process.exitCode).toBe(0); + }); + + it('exits 1 without --collection or --all in non-interactive mode', async () => { + await normalizeCommand({}); + + expect(normalize).not.toHaveBeenCalled(); + expect(logged()).toContain('❌ Missing required option in non-interactive mode: --collection or --all'); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 for an unknown collection', async () => { + await normalizeCommand({ collection: 'Nope' }); + + expect(normalize).not.toHaveBeenCalled(); + expect(logged()).toContain('❌ Collection "Nope" not found'); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 when no collections are configured', async () => { + vi.mocked(loadConfig).mockReturnValue({ ...CONFIG, collections: {} }); + + await normalizeCommand({ all: true }); + + expect(normalize).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 when normalizing a collection fails', async () => { + vi.mocked(normalize).mockRejectedValue(new Error('disk full')); + + await normalizeCommand({ collection: 'App' }); + + expect(logged()).toContain(' ❌ disk full'); + expect(process.exitCode).toBe(1); + }); + + it('prints only JSON with --json', async () => { + await normalizeCommand({ collection: 'App', json: true }); + + const lines = logged(); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toMatchObject({ + collections: [{ collectionName: 'App' }], + totals: { collectionsProcessed: 1 }, + }); }); describe('read-only collections', () => { it('fails (exit 1) and skips normalize when an explicitly named collection is read-only', async () => { - vi.mocked(resolveCollection).mockReturnValue(resolved('Lib', true)); - await normalizeCommand({ collection: 'Lib', json: false }); expect(normalize).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); - expect(ConsoleFormatter.info).not.toHaveBeenCalled(); + expect(logged()).toContain('❌ Collection "Lib" is read-only. Its resources cannot be modified.'); + expect(logged()).not.toContain('ℹ️ Skipping read-only collection: Lib'); }); it('skips read-only collections during --all WITHOUT failing the run', async () => { - vi.mocked(resolveCollection).mockImplementation((name: string) => resolved(name, name === 'Lib')); - await normalizeCommand({ all: true, json: false }); // App (writable) is normalized; Lib (read-only) is skipped, not failed. expect(normalize).toHaveBeenCalledTimes(1); - expect(process.exitCode).toBeUndefined(); - expect(ConsoleFormatter.info).toHaveBeenCalledWith('Skipping read-only collection: Lib'); + expect(process.exitCode).toBe(0); + expect(logged()).toContain('ℹ️ Skipping read-only collection: Lib'); }); }); - it('reports a cancelled prompt and returns without exiting or normalizing', async () => { - Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - // The user presses Esc: prompts calls onCancel, which throws PromptCancelledError. - vi.mocked(prompts).mockImplementation(async (questions, options) => { - const [question] = Array.isArray(questions) ? questions : [questions]; - options?.onCancel?.(question, {}); - return {}; + describe('interactive', () => { + beforeEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); }); - await expect(normalizeCommand({})).resolves.toBeUndefined(); + it('offers each collection and "All collections"', async () => { + vi.mocked(prompts).mockResolvedValueOnce({ collectionOrAll: 'App' }); + + await normalizeCommand({}); + + expect(prompts).toHaveBeenCalledWith( + [ + expect.objectContaining({ + name: 'collectionOrAll', + choices: [ + { title: 'App', value: 'App' }, + { title: 'Lib', value: 'Lib' }, + { title: 'All collections', value: '__ALL__' }, + ], + }), + ], + expect.anything(), + ); + expect(normalize).toHaveBeenCalledTimes(1); + }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('❌ Normalize cancelled.'); - expect(normalize).not.toHaveBeenCalled(); - expect(exit).not.toHaveBeenCalled(); - expect(process.exitCode).toBeUndefined(); - exit.mockRestore(); + it('confirms --all, and declining cancels with exit 0', async () => { + vi.mocked(prompts).mockResolvedValueOnce({ confirmed: false }); + + await normalizeCommand({ all: true }); + + expect(normalize).not.toHaveBeenCalled(); + expect(logged()).toContain('❌ Normalize cancelled.'); + expect(process.exitCode).toBe(0); + }); + + it('choosing "All collections" asks for confirmation, then normalizes the writable ones', async () => { + vi.mocked(prompts) + .mockResolvedValueOnce({ collectionOrAll: '__ALL__' }) + .mockResolvedValueOnce({ confirmed: true }); + + await normalizeCommand({}); + + expect(prompts).toHaveBeenCalledTimes(2); + expect(normalize).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(0); + }); + + it('reports a cancelled prompt once and returns without exiting or normalizing', async () => { + const exit = vi.spyOn(process, 'exit'); + // The user presses Esc: prompts calls onCancel. + vi.mocked(prompts).mockImplementation(async (questions, options) => { + const [question] = Array.isArray(questions) ? questions : [questions]; + options?.onCancel?.(question, {}); + return {}; + }); + + await expect(normalizeCommand({})).resolves.toBeUndefined(); + + expect(logged().filter((line) => line.includes('cancelled'))).toEqual(['❌ Normalize cancelled.']); + expect(normalize).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + exit.mockRestore(); + }); }); }); diff --git a/apps/cli/src/commands/normalize.ts b/apps/cli/src/commands/normalize.ts index 85a21f37..2cdc2839 100644 --- a/apps/cli/src/commands/normalize.ts +++ b/apps/cli/src/commands/normalize.ts @@ -1,14 +1,6 @@ -import prompts from 'prompts'; -import type { LingoTrackerConfig } from '@simoncodes-ca/core'; -import { normalize } from '@simoncodes-ca/core'; -import { - loadConfiguration, - resolveCollection, - ConsoleFormatter, - ErrorMessages, - aggregateNumericFields, -} from '../utils'; -import { PromptCancelledError } from '../utils/report-error'; +import { normalize, openCollection } from '@simoncodes-ca/core'; +import { CommandCancelledError, defineCommand, NO_COLLECTIONS_MESSAGE } from '../runner/command-runner'; +import { ALL_ITEMS_SENTINEL, aggregateNumericFields, ConsoleFormatter, ErrorMessages } from '../utils'; export interface NormalizeOptions { collection?: string; @@ -42,257 +34,170 @@ interface NormalizeCommandResult { }; } -export async function normalizeCommand(options: NormalizeOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - let answers: Awaited>; - try { - answers = await promptForMissing(options, config); - } catch (error) { - if (error instanceof PromptCancelledError) { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED(error.operation)); - return; - } - throw error; - } - - // Determine which collections to process - const collectionsToProcess: string[] = []; - - if (answers.all) { - const collections = Object.keys(config.collections || {}); - if (collections.length === 0) { - ConsoleFormatter.error(ErrorMessages.NO_COLLECTIONS); - return; +export const normalizeCommand = defineCommand()({ + name: 'Normalize', + // `--collection` or `--all`: the command opens the collections itself. + collection: 'none', + prompts: (options, { config }) => { + const collections = Object.keys(config.collections ?? {}); + if (options.collection || options.all || collections.length === 0) { + return []; } - collectionsToProcess.push(...collections); - } else if (answers.collection) { - collectionsToProcess.push(answers.collection); - } - - // Process each collection - const collectionResults: CollectionNormalizeResult[] = []; - - for (const collectionName of collectionsToProcess) { - const collection = resolveCollection(collectionName, config, cwd); - - if (!collection) { - continue; + return [ + { + type: 'select', + name: 'collectionOrAll', + message: 'Select collection to normalize', + choices: [ + ...collections.map((c) => ({ title: c, value: c })), + { title: 'All collections', value: ALL_ITEMS_SENTINEL }, + ], + }, + ]; + }, + run: async ({ config, cwd, answers, interactive, ask }) => { + const selected = typeof answers.collectionOrAll === 'string' ? answers.collectionOrAll : undefined; + const all = answers.all === true || selected === ALL_ITEMS_SENTINEL; + const collectionName = answers.collection ?? (selected !== ALL_ITEMS_SENTINEL ? selected : undefined); + const collectionNames = Object.keys(config.collections ?? {}); + + if (collectionNames.length === 0) { + throw new Error(NO_COLLECTIONS_MESSAGE); } - - // Read-only collections cannot be normalized (it rewrites resource files). - // In a bulk `--all` run, skip them without failing; when one is explicitly targeted, fail. - if (collection.readOnly) { - if (answers.all) { - if (!options.json) { - console.log(''); - ConsoleFormatter.info(`Skipping read-only collection: ${collectionName}`); - } - } else { - if (!options.json) { - console.log(ErrorMessages.COLLECTION_READ_ONLY(collectionName)); - } - process.exitCode = 1; - } - continue; + if (!all && !collectionName) { + throw new Error('Missing required option in non-interactive mode: --collection or --all'); } - const { translationsFolder, baseLocale, locales } = collection; - - if (!options.json) { + if (all && interactive) { console.log(''); - ConsoleFormatter.progress(`Normalizing collection: ${collectionName}`); - if (options.dryRun) { - ConsoleFormatter.indent('(Dry run - no changes will be made)'); + ConsoleFormatter.warning('This will normalize ALL collections in your project.'); + const confirmed = await ask({ type: 'confirm', name: 'confirmed', message: 'Are you sure?', initial: false }); + if (confirmed.confirmed !== true) { + throw new CommandCancelledError(); } } - try { - const result = await normalize({ - translationsFolder, - baseLocale, - locales, - dryRun: options.dryRun ?? false, - }); - - collectionResults.push({ - collectionName, - entriesProcessed: result.entriesProcessed, - localesAdded: result.localesAdded, - valuesConverted: result.valuesConverted, - tagsNormalized: result.tagsNormalized, - filesCreated: result.filesCreated, - filesUpdated: result.filesUpdated, - foldersRemoved: result.foldersRemoved, - }); - - if (!options.json) { - ConsoleFormatter.indent(`✅ Entries processed: ${result.entriesProcessed}`); - ConsoleFormatter.indent(`✅ Locales added: ${result.localesAdded}`); - ConsoleFormatter.indent(`✅ Values converted to ICU: ${result.valuesConverted}`); - if (result.tagsNormalized > 0) { - ConsoleFormatter.indent(`✅ Tags normalized: ${result.tagsNormalized}`); + // An explicitly named collection that does not exist throws CollectionNotFoundError (exit 1). + const collections = all + ? collectionNames.map((name) => openCollection(config, name, { cwd })) + : [openCollection(config, collectionName ?? '', { cwd })]; + + let failed = false; + const collectionResults: CollectionNormalizeResult[] = []; + + for (const collection of collections) { + const { name } = collection; + + // Read-only collections cannot be normalized (it rewrites resource files). + // In a bulk `--all` run, skip them without failing; when one is explicitly targeted, fail. + if (collection.readOnly) { + if (all) { + if (!answers.json) { + console.log(''); + ConsoleFormatter.info(`Skipping read-only collection: ${name}`); + } + } else { + if (!answers.json) { + console.log(ErrorMessages.COLLECTION_READ_ONLY(name)); + } + failed = true; } - ConsoleFormatter.indent(`✅ Files created: ${result.filesCreated}`); - ConsoleFormatter.indent(`✅ Files updated: ${result.filesUpdated}`); - ConsoleFormatter.indent(`✅ Folders removed: ${result.foldersRemoved}`); + continue; } - } catch (e: unknown) { - if (!options.json) { - ConsoleFormatter.indent(`❌ ${e instanceof Error ? e.message : 'Failed to normalize collection'}`); - } - } - } - // Output results - if (options.json) { - const totals = { - ...aggregateNumericFields(collectionResults, [ - 'entriesProcessed', - 'localesAdded', - 'valuesConverted', - 'tagsNormalized', - 'filesCreated', - 'filesUpdated', - 'foldersRemoved', - ]), - collectionsProcessed: collectionResults.length, - }; - - const output: NormalizeCommandResult = { - collections: collectionResults, - totals, - }; + const { translationsFolder, baseLocale, locales } = collection; - console.log(JSON.stringify(output, null, 2)); - } else if (collectionsToProcess.length > 1) { - // Show summary for multiple collections - const totals = { - ...aggregateNumericFields(collectionResults, [ - 'entriesProcessed', - 'localesAdded', - 'valuesConverted', - 'tagsNormalized', - 'filesCreated', - 'filesUpdated', - 'foldersRemoved', - ]), - collectionsProcessed: collectionResults.length, - }; + if (!answers.json) { + console.log(''); + ConsoleFormatter.progress(`Normalizing collection: ${name}`); + if (answers.dryRun) { + ConsoleFormatter.indent('(Dry run - no changes will be made)'); + } + } - ConsoleFormatter.section(`Summary (${totals.collectionsProcessed} collections)`); - ConsoleFormatter.keyValue('Total entries processed', totals.entriesProcessed); - ConsoleFormatter.keyValue('Total locales added', totals.localesAdded); - ConsoleFormatter.keyValue('Total values converted to ICU', totals.valuesConverted); - if (totals.tagsNormalized > 0) { - ConsoleFormatter.keyValue('Total tags normalized', totals.tagsNormalized); + try { + const result = await normalize({ + translationsFolder, + baseLocale, + locales, + dryRun: answers.dryRun ?? false, + }); + + collectionResults.push({ + collectionName: name, + entriesProcessed: result.entriesProcessed, + localesAdded: result.localesAdded, + valuesConverted: result.valuesConverted, + tagsNormalized: result.tagsNormalized, + filesCreated: result.filesCreated, + filesUpdated: result.filesUpdated, + foldersRemoved: result.foldersRemoved, + }); + + if (!answers.json) { + ConsoleFormatter.indent(`✅ Entries processed: ${result.entriesProcessed}`); + ConsoleFormatter.indent(`✅ Locales added: ${result.localesAdded}`); + ConsoleFormatter.indent(`✅ Values converted to ICU: ${result.valuesConverted}`); + if (result.tagsNormalized > 0) { + ConsoleFormatter.indent(`✅ Tags normalized: ${result.tagsNormalized}`); + } + ConsoleFormatter.indent(`✅ Files created: ${result.filesCreated}`); + ConsoleFormatter.indent(`✅ Files updated: ${result.filesUpdated}`); + ConsoleFormatter.indent(`✅ Folders removed: ${result.foldersRemoved}`); + } + } catch (e: unknown) { + failed = true; + if (!answers.json) { + ConsoleFormatter.indent(`❌ ${e instanceof Error ? e.message : 'Failed to normalize collection'}`); + } + } } - ConsoleFormatter.keyValue('Total files created', totals.filesCreated); - ConsoleFormatter.keyValue('Total files updated', totals.filesUpdated); - ConsoleFormatter.keyValue('Total folders removed', totals.foldersRemoved); - if (options.dryRun) { - console.log(''); - ConsoleFormatter.warning('Dry run completed - no changes were made.'); - } - } else if (options.dryRun) { - console.log(''); - ConsoleFormatter.warning('Dry run completed - no changes were made.'); - } -} + printSummary(collectionResults, collections.length, answers); + return failed ? { exitCode: 1 } : undefined; + }, +}); -async function promptForMissing( +function printSummary( + collectionResults: CollectionNormalizeResult[], + collectionCount: number, options: NormalizeOptions, - config: LingoTrackerConfig, -): Promise<{ - collection?: string; - all: boolean; -}> { - const responses: Partial<{ - collection: string; - all: boolean; - }> = {}; +): void { + const totals = () => ({ + ...aggregateNumericFields(collectionResults, [ + 'entriesProcessed', + 'localesAdded', + 'valuesConverted', + 'tagsNormalized', + 'filesCreated', + 'filesUpdated', + 'foldersRemoved', + ]), + collectionsProcessed: collectionResults.length, + }); - const collections = Object.keys(config.collections || {}); - - const questions: prompts.PromptObject[] = []; - - // If neither collection nor all flag provided, prompt for selection - if (!options.collection && !options.all) { - if (collections.length === 0) { - ConsoleFormatter.error(ErrorMessages.NO_COLLECTIONS); - throw new Error('No collections available'); - } - - // Create choices array with individual collections and "All collections" option - const choices = [ - ...collections.map((c) => ({ title: c, value: c })), - { title: 'All collections', value: '__ALL__' }, - ]; - - questions.push({ - type: 'select', - name: 'collectionOrAll', - message: 'Select collection to normalize', - choices, - }); + if (options.json) { + const output: NormalizeCommandResult = { collections: collectionResults, totals: totals() }; + console.log(JSON.stringify(output, null, 2)); + return; } - if (questions.length > 0 && process.stdout.isTTY) { - const result = await prompts(questions, { - onCancel: () => { - throw new PromptCancelledError('Normalize'); - }, - }); - - if (result.collectionOrAll === '__ALL__') { - responses.all = true; - - // Show confirmation for --all mode - console.log(''); - ConsoleFormatter.warning('This will normalize ALL collections in your project.'); - const confirmed = await prompts({ - type: 'confirm', - name: 'confirmed', - message: 'Are you sure?', - initial: false, - }); - - if (!confirmed.confirmed) { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Normalize')); - process.exit(0); - } - } else { - responses.collection = result.collectionOrAll as string; - } - } else if (questions.length > 0) { - // Non-TTY mode - require explicit flags - if (!options.collection && !options.all) { - throw new Error(ErrorMessages.MISSING_OPTIONS(['collection', 'all'])); + if (collectionCount > 1) { + const summary = totals(); + ConsoleFormatter.section(`Summary (${summary.collectionsProcessed} collections)`); + ConsoleFormatter.keyValue('Total entries processed', summary.entriesProcessed); + ConsoleFormatter.keyValue('Total locales added', summary.localesAdded); + ConsoleFormatter.keyValue('Total values converted to ICU', summary.valuesConverted); + if (summary.tagsNormalized > 0) { + ConsoleFormatter.keyValue('Total tags normalized', summary.tagsNormalized); } + ConsoleFormatter.keyValue('Total files created', summary.filesCreated); + ConsoleFormatter.keyValue('Total files updated', summary.filesUpdated); + ConsoleFormatter.keyValue('Total folders removed', summary.foldersRemoved); } - // Handle --all flag with confirmation - if (options.all && process.stdout.isTTY && !responses.all) { + if (options.dryRun) { console.log(''); - ConsoleFormatter.warning('This will normalize ALL collections in your project.'); - const confirmed = await prompts({ - type: 'confirm', - name: 'confirmed', - message: 'Are you sure?', - initial: false, - }); - - if (!confirmed.confirmed) { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Normalize')); - process.exit(0); - } + ConsoleFormatter.warning('Dry run completed - no changes were made.'); } - - return { - collection: options.collection ?? responses.collection, - all: options.all ?? responses.all ?? false, - }; } diff --git a/apps/cli/src/commands/preferred-terminology.spec.ts b/apps/cli/src/commands/preferred-terminology.spec.ts index aa1c9640..83588054 100644 --- a/apps/cli/src/commands/preferred-terminology.spec.ts +++ b/apps/cli/src/commands/preferred-terminology.spec.ts @@ -1,30 +1,27 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { type LingoTrackerConfig, loadConfig } from '@simoncodes-ca/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConsoleFormatter } from '../utils'; import { preferredTerminologyCommand } from './preferred-terminology'; -vi.mock('../utils', () => ({ - loadConfiguration: vi.fn(), - ConsoleFormatter: { - section: vi.fn(), - keyValue: vi.fn(), - indent: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - success: vi.fn(), - }, +vi.mock('@simoncodes-ca/core', async (importOriginal) => ({ + ...(await importOriginal()), + loadConfig: vi.fn(), })); -import { ConsoleFormatter, loadConfiguration } from '../utils'; +// Spy on the real formatter object, which the runner prints errors through too. +for (const method of ['section', 'keyValue', 'indent', 'error', 'warning', 'success'] as const) { + vi.spyOn(ConsoleFormatter, method).mockImplementation(() => undefined); +} const FILE_NAME = '.lingo-tracker-preferred-terminology.json'; describe('preferredTerminologyCommand', () => { - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); let projectDir: string; let filePath: string; - let config: Record; + let config: LingoTrackerConfig; const writeRules = (content: unknown) => writeFileSync(filePath, typeof content === 'string' ? content : `${JSON.stringify(content, null, 2)}\n`); @@ -36,11 +33,14 @@ describe('preferredTerminologyCommand', () => { projectDir = mkdtempSync(join(tmpdir(), 'lingo-preferred-terminology-')); filePath = join(projectDir, FILE_NAME); config = { baseLocale: 'en', locales: ['en', 'es'], collections: {} }; - vi.mocked(loadConfiguration).mockImplementation(() => ({ config, cwd: projectDir }) as never); + process.env.INIT_CWD = projectDir; + process.exitCode = undefined; + vi.mocked(loadConfig).mockImplementation(() => config); }); afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); + process.exitCode = undefined; }); describe('argument checks', () => { @@ -50,14 +50,14 @@ describe('preferredTerminologyCommand', () => { expect(ConsoleFormatter.error).toHaveBeenCalledWith( 'Provide one of --list, --add --preferred , or --remove ', ); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); it('rejects --add combined with --remove', async () => { await preferredTerminologyCommand({ add: 'Expenditure', preferred: 'Investment', remove: 'Spend' }); expect(ConsoleFormatter.error).toHaveBeenCalledWith('--add and --remove cannot be combined; run them separately'); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); expect(existsSync(filePath)).toBe(false); }); @@ -65,7 +65,7 @@ describe('preferredTerminologyCommand', () => { await preferredTerminologyCommand({ add: 'Expenditure' }); expect(ConsoleFormatter.error).toHaveBeenCalledWith('--add requires --preferred '); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); expect(existsSync(filePath)).toBe(false); }); @@ -73,7 +73,7 @@ describe('preferredTerminologyCommand', () => { await preferredTerminologyCommand({ list: true, preferred: 'Investment' }); expect(ConsoleFormatter.error).toHaveBeenCalledWith('--preferred and --reason can only be used with --add'); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); }); @@ -83,7 +83,7 @@ describe('preferredTerminologyCommand', () => { expect(ConsoleFormatter.keyValue).toHaveBeenCalledWith('File', FILE_NAME); expect(indented()).toEqual(['(none)']); - expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('prints one rule per line, with the reason only when present', async () => { @@ -105,7 +105,7 @@ describe('preferredTerminologyCommand', () => { expect(ConsoleFormatter.error).toHaveBeenCalledWith( expect.stringContaining('Preferred terminology file is not valid JSON'), ); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); it('prints a warning for a missing explicit file and continues', async () => { @@ -118,7 +118,7 @@ describe('preferredTerminologyCommand', () => { ); expect(ConsoleFormatter.keyValue).toHaveBeenCalledWith('File', join('config', 'terms.json')); expect(indented()).toEqual(['(none)']); - expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); }); @@ -130,7 +130,7 @@ describe('preferredTerminologyCommand', () => { expect(ConsoleFormatter.success).toHaveBeenCalledWith( `Added preferred terminology rule: Expenditure → Investment — Brand voice (${FILE_NAME})`, ); - expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('updates an existing rule matched case-insensitively, replacing it entirely', async () => { @@ -161,7 +161,7 @@ describe('preferredTerminologyCommand', () => { expect(ConsoleFormatter.error).toHaveBeenCalledWith('Preferred terminology not saved:'); expect(indented().length).toBeGreaterThan(0); expect(indented()[0]).toContain('"Expenditure → Investment":'); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); expect(ConsoleFormatter.success).not.toHaveBeenCalled(); expect(readFileSync(filePath, 'utf8')).toBe(before); }); @@ -175,7 +175,7 @@ describe('preferredTerminologyCommand', () => { expect(ConsoleFormatter.error).toHaveBeenCalledWith( expect.stringContaining('Preferred terminology file has invalid rules'), ); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); expect(readFileSync(filePath, 'utf8')).toBe(before); }); }); @@ -204,7 +204,7 @@ describe('preferredTerminologyCommand', () => { expect(ConsoleFormatter.error).toHaveBeenCalledWith( `No preferred terminology rule for "Expenditure" (${FILE_NAME})`, ); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); expect(readFileSync(filePath, 'utf8')).toBe(before); }); }); diff --git a/apps/cli/src/commands/preferred-terminology.ts b/apps/cli/src/commands/preferred-terminology.ts index 9f4338ca..01227791 100644 --- a/apps/cli/src/commands/preferred-terminology.ts +++ b/apps/cli/src/commands/preferred-terminology.ts @@ -1,11 +1,13 @@ import { relative } from 'node:path'; import { + type LingoTrackerConfig, loadPreferredTerminology, PreferredTerminologyValidationError, writePreferredTerminology, } from '@simoncodes-ca/core'; import type { PreferredTermRule } from '@simoncodes-ca/domain'; -import { ConsoleFormatter, loadConfiguration } from '../utils'; +import { type CommandResult, defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface PreferredTerminologyOptions { list?: boolean; @@ -35,37 +37,31 @@ function sameTerm(a: string, b: string): boolean { return a.trim().toLowerCase() === b.trim().toLowerCase(); } -function fail(message: string): void { - ConsoleFormatter.error(message); - process.exit(1); -} +export const preferredTerminologyCommand = defineCommand()({ + name: 'Preferred terminology', + collection: 'none', + run: ({ config, cwd, answers }) => run(answers, config, cwd), +}); -export async function preferredTerminologyCommand(options: PreferredTerminologyOptions): Promise { +/** A thrown error ends the command: the runner prints `❌ ` and exits 1. */ +function run(options: PreferredTerminologyOptions, config: LingoTrackerConfig, cwd: string): CommandResult { const hasList = options.list === true; const hasAdd = options.add !== undefined; const hasRemove = options.remove !== undefined; if (!hasList && !hasAdd && !hasRemove) { - fail('Provide one of --list, --add --preferred , or --remove '); - return; + throw new Error('Provide one of --list, --add --preferred , or --remove '); } if (hasAdd && hasRemove) { - fail('--add and --remove cannot be combined; run them separately'); - return; + throw new Error('--add and --remove cannot be combined; run them separately'); } if (!hasAdd && (options.preferred !== undefined || options.reason !== undefined)) { - fail('--preferred and --reason can only be used with --add'); - return; + throw new Error('--preferred and --reason can only be used with --add'); } if (hasAdd && options.preferred === undefined) { - fail('--add requires --preferred '); - return; + throw new Error('--add requires --preferred '); } - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - const result = loadPreferredTerminology(config, cwd); const where = displayPath(result.filePath, cwd); @@ -77,8 +73,7 @@ export async function preferredTerminologyCommand(options: PreferredTerminologyO ConsoleFormatter.section('Preferred Terminology'); ConsoleFormatter.keyValue('File', where); if (result.error) { - fail(result.error); - return; + throw new Error(result.error); } if (result.rules.length === 0) { ConsoleFormatter.indent('(none)'); @@ -93,8 +88,7 @@ export async function preferredTerminologyCommand(options: PreferredTerminologyO // Writing would replace a file we could not read; make the user fix it first. if (result.error) { - fail(result.error); - return; + throw new Error(result.error); } const next = [...result.rules]; @@ -104,8 +98,7 @@ export async function preferredTerminologyCommand(options: PreferredTerminologyO const term = options.remove ?? ''; const index = next.findIndex((rule) => sameTerm(rule.discouraged, term)); if (index === -1) { - fail(`No preferred terminology rule for "${term.trim()}" (${where})`); - return; + throw new Error(`No preferred terminology rule for "${term.trim()}" (${where})`); } const [removed] = next.splice(index, 1); successMessage = `Removed preferred terminology rule: ${formatRule(removed)} (${where})`; @@ -137,11 +130,9 @@ export async function preferredTerminologyCommand(options: PreferredTerminologyO const label = row ? `"${row.discouraged} → ${row.preferred}"` : `row ${ruleError.index + 1}`; ConsoleFormatter.indent(`${label}: ${ruleError.message}`); } - process.exit(1); - return; + return { exitCode: 1 }; } - fail(error instanceof Error ? error.message : String(error)); - return; + throw new Error(error instanceof Error ? error.message : String(error)); } ConsoleFormatter.success(successMessage); diff --git a/apps/cli/src/commands/protected-terms.spec.ts b/apps/cli/src/commands/protected-terms.spec.ts index 13e21697..4614665c 100644 --- a/apps/cli/src/commands/protected-terms.spec.ts +++ b/apps/cli/src/commands/protected-terms.spec.ts @@ -1,7 +1,9 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { protectedTermsCommand } from './protected-terms'; -vi.mock('@simoncodes-ca/core', () => ({ +vi.mock('@simoncodes-ca/core', async (importOriginal) => ({ + ...(await importOriginal()), + loadConfig: vi.fn(), setGlobalProtectedTerms: vi.fn(() => ({ message: 'ok', filePath: '/project/.lingo-tracker-protected-terms.json' })), setCollectionProtectedTerms: vi.fn(() => ({ message: 'ok', filePath: '/project/i18n/terms.json' })), setGlobalProtectedTermsFile: vi.fn(() => ({ message: 'file set', filePath: '/project/custom.json' })), @@ -12,18 +14,14 @@ vi.mock('@simoncodes-ca/core', () => ({ resolveCollectionProtectedTermsFilePath: vi.fn(() => undefined), })); -vi.mock('../utils', async (importOriginal) => ({ - ...(await importOriginal()), - loadConfiguration: vi.fn(), -})); - -import { loadConfiguration, ConsoleFormatter } from '../utils'; +import { ConsoleFormatter } from '../utils'; -// Spy on the real formatter object, which `exitWithError` prints through too. +// Spy on the real formatter object, which the runner prints errors through too. for (const method of ['section', 'keyValue', 'error', 'success'] as const) { vi.spyOn(ConsoleFormatter, method).mockImplementation(() => undefined); } import { + loadConfig, readCollectionProtectedTerms, readGlobalProtectedTerms, resolveCollectionProtectedTermsFilePath, @@ -41,28 +39,26 @@ const BASE_CONFIG = { }, }; -const loaded = () => ({ config: BASE_CONFIG, cwd: '/project' }); - describe('protectedTermsCommand', () => { - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - beforeEach(() => { vi.clearAllMocks(); - vi.mocked(loadConfiguration).mockReturnValue(loaded() as never); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); vi.mocked(readGlobalProtectedTerms).mockReturnValue([]); vi.mocked(readCollectionProtectedTerms).mockReturnValue([]); vi.mocked(resolveCollectionProtectedTermsFilePath).mockReturnValue(undefined); }); afterEach(() => { - exitSpy.mockClear(); + process.exitCode = undefined; }); it('errors when --set is combined with --add', async () => { await protectedTermsCommand({ set: 'iPhone', add: ['C++'] }); expect(ConsoleFormatter.error).toHaveBeenCalledWith('--set cannot be combined with --add or --remove'); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); it('errors when no operation is given', async () => { @@ -71,13 +67,14 @@ describe('protectedTermsCommand', () => { expect(ConsoleFormatter.error).toHaveBeenCalledWith( 'Provide at least one of --add, --remove, --set, --list, or --file', ); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); it('adds globally when no --collection is present, storing terms verbatim', async () => { await protectedTermsCommand({ add: [' iPhone ', 'iPhone'] }); expect(setGlobalProtectedTerms).toHaveBeenCalledWith(['iPhone'], { cwd: '/project' }); + expect(process.exitCode).toBe(0); }); it('adds to a collection when --collection is present', async () => { @@ -112,7 +109,7 @@ describe('protectedTermsCommand', () => { await protectedTermsCommand({ collection: 'missing', add: ['iPhone'] }); expect(ConsoleFormatter.error).toHaveBeenCalledWith('Collection "missing" not found'); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); expect(setCollectionProtectedTerms).not.toHaveBeenCalled(); }); @@ -162,6 +159,8 @@ describe('protectedTermsCommand', () => { const fileOrder = vi.mocked(setGlobalProtectedTermsFile).mock.invocationCallOrder[0]; const termsOrder = vi.mocked(setGlobalProtectedTerms).mock.invocationCallOrder[0]; expect(fileOrder).toBeLessThan(termsOrder); + // The config is read again after the pointer change. + expect(loadConfig).toHaveBeenCalledTimes(2); }); it('reports a malformed terms file instead of writing over it', async () => { @@ -172,7 +171,7 @@ describe('protectedTermsCommand', () => { await protectedTermsCommand({ add: ['iPhone'] }); expect(ConsoleFormatter.error).toHaveBeenCalledWith('Protected terms file is not valid JSON: /project/terms.json'); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); expect(setGlobalProtectedTerms).not.toHaveBeenCalled(); }); @@ -186,6 +185,6 @@ describe('protectedTermsCommand', () => { expect(ConsoleFormatter.error).toHaveBeenCalledWith( 'Collection "main" has no protected terms file. Set one first with --file .', ); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); }); diff --git a/apps/cli/src/commands/protected-terms.ts b/apps/cli/src/commands/protected-terms.ts index 7e4430f6..03a49ffc 100644 --- a/apps/cli/src/commands/protected-terms.ts +++ b/apps/cli/src/commands/protected-terms.ts @@ -1,5 +1,7 @@ import { relative } from 'node:path'; import { + loadConfig, + openCollection, readCollectionProtectedTerms, readGlobalProtectedTerms, resolveCollectionProtectedTermsFilePath, @@ -10,8 +12,8 @@ import { setGlobalProtectedTermsFile, } from '@simoncodes-ca/core'; import { effectiveProtectedTerms, normalizeProtectedTerms } from '@simoncodes-ca/domain'; -import { loadConfiguration, ConsoleFormatter } from '../utils'; -import { exitWithError } from '../utils/report-error'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface ProtectedTermsOptions { collection?: string; @@ -29,113 +31,89 @@ function displayPath(filePath: string, cwd: string): string { return rel && !rel.startsWith('..') ? rel : filePath; } -export async function protectedTermsCommand(options: ProtectedTermsOptions): Promise { - const hasAdd = (options.add ?? []).length > 0; - const hasRemove = (options.remove ?? []).length > 0; - const hasSet = options.set !== undefined; - const hasList = options.list === true; - const hasFile = options.file !== undefined; - - if (hasSet && (hasAdd || hasRemove)) { - ConsoleFormatter.error('--set cannot be combined with --add or --remove'); - process.exit(1); - return; - } - - if (!hasAdd && !hasRemove && !hasSet && !hasList && !hasFile) { - ConsoleFormatter.error('Provide at least one of --add, --remove, --set, --list, or --file'); - process.exit(1); - return; - } +export const protectedTermsCommand = defineCommand()({ + name: 'Protected terms', + // `--collection` is optional here: absent means the global scope, so the runner opens nothing. + collection: 'none', + run: async ({ config, cwd, answers: options }) => { + const hasAdd = (options.add ?? []).length > 0; + const hasRemove = (options.remove ?? []).length > 0; + const hasSet = options.set !== undefined; + const hasList = options.list === true; + const hasFile = options.file !== undefined; + + if (hasSet && (hasAdd || hasRemove)) { + throw new Error('--set cannot be combined with --add or --remove'); + } - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; + if (!hasAdd && !hasRemove && !hasSet && !hasList && !hasFile) { + throw new Error('Provide at least one of --add, --remove, --set, --list, or --file'); + } - const collectionName = options.collection; - const collection = collectionName ? config.collections?.[collectionName] : undefined; - if (collectionName && !collection) { - ConsoleFormatter.error(`Collection "${collectionName}" not found`); - process.exit(1); - return; - } + const collectionName = options.collection; + if (collectionName) { + // Throws CollectionNotFoundError, which the runner reports (exit 1). + openCollection(config, collectionName, { cwd }); + } - // --file runs first so a combined `--file x.json --add Foo` points at the new file, then writes to it. - if (hasFile) { - const pointer = options.file?.trim() ? options.file.trim() : undefined; - try { + // --file runs first so a combined `--file x.json --add Foo` points at the new file, then writes to it. + if (hasFile) { + const pointer = options.file?.trim() ? options.file.trim() : undefined; const result = collectionName ? await setCollectionProtectedTermsFile(collectionName, pointer, { cwd }) : setGlobalProtectedTermsFile(pointer, { cwd }); ConsoleFormatter.success(result.message); - } catch (error) { - exitWithError(error); - return; } - } - - // Re-read after a pointer change so subsequent reads and writes target the new file. - const currentConfig = hasFile ? loadConfiguration({ exitOnError: false })?.config : config; - if (!currentConfig) return; - const currentCollection = collectionName ? currentConfig.collections?.[collectionName] : undefined; - - let globalTerms: string[]; - let collectionTerms: string[]; - try { - globalTerms = readGlobalProtectedTerms(currentConfig, cwd); - collectionTerms = currentCollection ? readCollectionProtectedTerms(currentCollection, cwd) : []; - } catch (error) { - exitWithError(error); - return; - } - const globalFile = resolveGlobalProtectedTermsFilePath(currentConfig, cwd); - const collectionFile = currentCollection - ? resolveCollectionProtectedTermsFilePath(currentCollection, cwd) - : undefined; - - if (hasList) { - ConsoleFormatter.section('Protected Terms'); - if (collectionName) { - ConsoleFormatter.keyValue('Scope', `Collection "${collectionName}" (global + collection)`); - ConsoleFormatter.keyValue('Global file', displayPath(globalFile, cwd)); - ConsoleFormatter.keyValue('Global', globalTerms.length > 0 ? globalTerms.join(', ') : '(none)'); - ConsoleFormatter.keyValue('Collection file', collectionFile ? displayPath(collectionFile, cwd) : '(none)'); - ConsoleFormatter.keyValue( - 'Collection-specific', - collectionTerms.length > 0 ? collectionTerms.join(', ') : '(none)', - ); - ConsoleFormatter.keyValue( - 'Effective', - effectiveProtectedTerms(globalTerms, collectionTerms).join(', ') || '(none)', - ); - } else { - ConsoleFormatter.keyValue('Scope', 'Global'); - ConsoleFormatter.keyValue('File', displayPath(globalFile, cwd)); - ConsoleFormatter.keyValue('Terms', globalTerms.length > 0 ? globalTerms.join(', ') : '(none)'); + // Re-read after a pointer change so subsequent reads and writes target the new file. + const currentConfig = hasFile ? loadConfig({ cwd }) : config; + const currentCollection = collectionName ? currentConfig.collections?.[collectionName] : undefined; + + const globalTerms = readGlobalProtectedTerms(currentConfig, cwd); + const collectionTerms = currentCollection ? readCollectionProtectedTerms(currentCollection, cwd) : []; + + const globalFile = resolveGlobalProtectedTermsFilePath(currentConfig, cwd); + const collectionFile = currentCollection + ? resolveCollectionProtectedTermsFilePath(currentCollection, cwd) + : undefined; + + if (hasList) { + ConsoleFormatter.section('Protected Terms'); + if (collectionName) { + ConsoleFormatter.keyValue('Scope', `Collection "${collectionName}" (global + collection)`); + ConsoleFormatter.keyValue('Global file', displayPath(globalFile, cwd)); + ConsoleFormatter.keyValue('Global', globalTerms.length > 0 ? globalTerms.join(', ') : '(none)'); + ConsoleFormatter.keyValue('Collection file', collectionFile ? displayPath(collectionFile, cwd) : '(none)'); + ConsoleFormatter.keyValue( + 'Collection-specific', + collectionTerms.length > 0 ? collectionTerms.join(', ') : '(none)', + ); + ConsoleFormatter.keyValue( + 'Effective', + effectiveProtectedTerms(globalTerms, collectionTerms).join(', ') || '(none)', + ); + } else { + ConsoleFormatter.keyValue('Scope', 'Global'); + ConsoleFormatter.keyValue('File', displayPath(globalFile, cwd)); + ConsoleFormatter.keyValue('Terms', globalTerms.length > 0 ? globalTerms.join(', ') : '(none)'); + } } - } - if (hasAdd || hasRemove || hasSet) { - let next = collectionName ? [...collectionTerms] : [...globalTerms]; + if (hasAdd || hasRemove || hasSet) { + let next = collectionName ? [...collectionTerms] : [...globalTerms]; - if (hasSet) { - next = normalizeProtectedTerms((options.set ?? '').split(',')); - } else { - if (hasAdd) { + if (hasSet) { + next = normalizeProtectedTerms((options.set ?? '').split(',')); + } else { for (const term of normalizeProtectedTerms(options.add ?? [])) { if (!next.includes(term)) { next.push(term); } } - } - if (hasRemove) { const toRemove = normalizeProtectedTerms(options.remove ?? []); next = next.filter((t) => !toRemove.includes(t)); } - } - try { const result = collectionName ? setCollectionProtectedTerms(collectionName, next, { cwd }) : setGlobalProtectedTerms(next, { cwd }); @@ -147,8 +125,6 @@ export async function protectedTermsCommand(options: ProtectedTermsOptions): Pro } else { ConsoleFormatter.success(`${scopeLabel} protected terms updated: ${next.join(', ')} ${where}`); } - } catch (error) { - exitWithError(error); } - } -} + }, +}); diff --git a/apps/cli/src/commands/remove-locale.spec.ts b/apps/cli/src/commands/remove-locale.spec.ts index a9c6aeca..22250686 100644 --- a/apps/cli/src/commands/remove-locale.spec.ts +++ b/apps/cli/src/commands/remove-locale.spec.ts @@ -1,94 +1,76 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { resolve } from 'node:path'; +import { + ConfigNotFoundError, + type LingoTrackerConfig, + loadConfig, + removeLocaleFromCollection, +} from '@simoncodes-ca/core'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isInteractiveTerminal } from '../runner/terminal'; import { removeLocaleCommand, type RemoveLocaleOptions } from './remove-locale'; -vi.mock('@simoncodes-ca/core', () => ({ - removeLocaleFromCollection: vi.fn(), -})); - -vi.mock('../utils', () => ({ - loadConfiguration: vi.fn(), - promptForCollection: vi.fn(), - resolveWritableCollection: vi.fn(), - ConsoleFormatter: { - error: vi.fn(), - success: vi.fn(), - keyValue: vi.fn(), - }, -})); - -vi.mock('prompts', () => ({ - default: vi.fn(), -})); - -import { type Collection, removeLocaleFromCollection } from '@simoncodes-ca/core'; -import { loadConfiguration, promptForCollection, resolveWritableCollection, ConsoleFormatter } from '../utils'; +vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadConfig: vi.fn(), removeLocaleFromCollection: vi.fn() }; +}); -const BASE_CONFIG = { +const BASE_CONFIG: LingoTrackerConfig = { baseLocale: 'en', - locales: ['en', 'fr', 'de'], + locales: ['en', 'fr'], collections: { main: { translationsFolder: 'src/i18n' }, + vendor: { translationsFolder: 'vendor/i18n', readOnly: true }, }, }; -const LOADED_CONFIG = { - config: BASE_CONFIG, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', -}; - -const RESOLVED_COLLECTION: Collection = { - name: 'main', - translationsFolder: '/project/src/i18n', - baseLocale: 'en', - locales: ['en', 'fr', 'de'], - targetLocales: ['fr', 'de'], - translationConfig: undefined, - tags: [], - protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, - readOnly: false, - config: { translationsFolder: 'src/i18n', locales: ['en', 'fr', 'de'] }, -}; +const mockCore = vi.mocked(removeLocaleFromCollection); describe('removeLocaleCommand', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); - vi.mocked(promptForCollection).mockResolvedValue('main'); - vi.mocked(resolveWritableCollection).mockReturnValue(RESOLVED_COLLECTION); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + vi.mocked(isInteractiveTerminal).mockReturnValue(false); }); - it('returns early when loadConfiguration returns null', async () => { - vi.mocked(loadConfiguration).mockReturnValue(null); - - await removeLocaleCommand({ locale: 'fr' }); - - expect(removeLocaleFromCollection).not.toHaveBeenCalled(); + afterEach(() => { + process.exitCode = undefined; }); - it('returns early when promptForCollection returns null', async () => { - vi.mocked(promptForCollection).mockResolvedValue(null); + it('exits 1 without calling core when the config is missing', async () => { + vi.mocked(loadConfig).mockImplementation(() => { + throw new ConfigNotFoundError(resolve('/project', '.lingo-tracker.json')); + }); - await removeLocaleCommand({ locale: 'fr' }); + await removeLocaleCommand({ collection: 'main', locale: 'fr' }); - expect(removeLocaleFromCollection).not.toHaveBeenCalled(); + expect(mockCore).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('returns early when resolveWritableCollection returns null', async () => { - vi.mocked(resolveWritableCollection).mockReturnValue(null); - - await removeLocaleCommand({ locale: 'fr' }); + it('exits 1 without calling core when the collection does not exist', async () => { + await removeLocaleCommand({ collection: 'nope', locale: 'fr' }); - expect(removeLocaleFromCollection).not.toHaveBeenCalled(); + expect(mockCore).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Collection "nope" not found'); + expect(process.exitCode).toBe(1); }); - describe('non-TTY mode', () => { - beforeEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); - }); + it('exits 1 without calling core when the collection is read-only', async () => { + await removeLocaleCommand({ collection: 'vendor', locale: 'fr' }); + expect(mockCore).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Collection "vendor" is read-only. Its resources cannot be modified.'); + expect(process.exitCode).toBe(1); + }); + + describe('non-interactive mode', () => { it('calls removeLocaleFromCollection and prints success when --locale is provided', async () => { - vi.mocked(removeLocaleFromCollection).mockResolvedValue({ + mockCore.mockResolvedValue({ message: 'Locale "fr" removed from collection "main" successfully', entriesPurged: 5, filesUpdated: 3, @@ -97,34 +79,83 @@ describe('removeLocaleCommand', () => { const options: RemoveLocaleOptions = { collection: 'main', locale: 'fr' }; await removeLocaleCommand(options); - expect(removeLocaleFromCollection).toHaveBeenCalledWith('main', 'fr', { cwd: '/project' }); - expect(ConsoleFormatter.success).toHaveBeenCalledWith('Locale "fr" removed from collection "main" successfully'); - expect(ConsoleFormatter.keyValue).toHaveBeenCalledWith('Entries purged', 5); - expect(ConsoleFormatter.keyValue).toHaveBeenCalledWith('Files updated', 3); + expect(mockCore).toHaveBeenCalledWith('main', 'fr', { cwd: '/project' }); + expect(console.log).toHaveBeenCalledWith('✅ Locale "fr" removed from collection "main" successfully'); + expect(console.log).toHaveBeenCalledWith(' Entries purged: 5'); + expect(console.log).toHaveBeenCalledWith(' Files updated: 3'); + expect(process.exitCode).toBe(0); }); - it('prints error and returns without calling core when --locale is missing', async () => { - const options: RemoveLocaleOptions = { collection: 'main' }; - await removeLocaleCommand(options); + it('exits 1 without calling core when --locale is missing', async () => { + await removeLocaleCommand({ collection: 'main' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Missing required option: --locale'); - expect(removeLocaleFromCollection).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --locale'); + expect(mockCore).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('prints error via ConsoleFormatter.error when core function throws', async () => { - vi.mocked(removeLocaleFromCollection).mockRejectedValue(new Error('Locale "fr" not found in collection "main"')); + it('prints the core error and exits 1 when the core function throws', async () => { + mockCore.mockRejectedValue(new Error('Locale "fr" not found in collection "main"')); await removeLocaleCommand({ collection: 'main', locale: 'fr' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Locale "fr" not found in collection "main"'); + expect(console.log).toHaveBeenCalledWith('❌ Locale "fr" not found in collection "main"'); + expect(process.exitCode).toBe(1); }); - it('prints generic error message when core throws a non-Error value', async () => { - vi.mocked(removeLocaleFromCollection).mockRejectedValue('unexpected'); + it('prints a non-Error thrown value and exits 1', async () => { + mockCore.mockRejectedValue('unexpected'); await removeLocaleCommand({ collection: 'main', locale: 'fr' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Failed to remove locale'); + expect(console.log).toHaveBeenCalledWith('❌ unexpected'); + expect(process.exitCode).toBe(1); + }); + }); + + it('exits 1 naming the reason when the collection has no removable locale', async () => { + vi.mocked(loadConfig).mockReturnValue({ ...BASE_CONFIG, locales: ['en'] }); + + await removeLocaleCommand({ collection: 'main' }); + + expect(console.log).toHaveBeenCalledWith('❌ No removable locales in collection "main".'); + expect(mockCore).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + describe('interactive mode', () => { + beforeEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + }); + + it('prompts for the locale when --locale is missing', async () => { + mockCore.mockResolvedValue({ + message: 'Locale "fr" removed from collection "main" successfully', + entriesPurged: 5, + filesUpdated: 3, + }); + vi.mocked(prompts).mockResolvedValueOnce({ locale: 'fr' }); + + await removeLocaleCommand({ collection: 'main' }); + + expect(prompts).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'locale', type: 'select' })], + expect.anything(), + ); + expect(mockCore).toHaveBeenCalledWith('main', 'fr', { cwd: '/project' }); + }); + + it('cancelling the prompt prints one cancel line and exits 0', async () => { + vi.mocked(prompts).mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'text', name: 'locale', message: 'Locale' }, {}); + return {}; + }); + + await removeLocaleCommand({ collection: 'main' }); + + expect(mockCore).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Remove locale cancelled.'); + expect(process.exitCode).toBe(0); }); }); }); diff --git a/apps/cli/src/commands/remove-locale.ts b/apps/cli/src/commands/remove-locale.ts index d3e6ab69..fb6ac614 100644 --- a/apps/cli/src/commands/remove-locale.ts +++ b/apps/cli/src/commands/remove-locale.ts @@ -1,59 +1,37 @@ -import prompts from 'prompts'; import { removeLocaleFromCollection } from '@simoncodes-ca/core'; -import { loadConfiguration, promptForCollection, resolveWritableCollection, ConsoleFormatter } from '../utils'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface RemoveLocaleOptions { collection?: string; locale?: string; } -export async function removeLocaleCommand(options: RemoveLocaleOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - const collectionName = await promptForCollection(config, options.collection); - if (!collectionName) return; - - const collection = resolveWritableCollection(collectionName, config, cwd); - if (!collection) return; - - const effectiveLocales = collection.targetLocales; - - let locale = options.locale; - - if (!locale) { - if (!process.stdout.isTTY) { - ConsoleFormatter.error('Missing required option: --locale'); - return; +export const removeLocaleCommand = defineCommand()({ + name: 'Remove locale', + collection: 'writable', + prompts: (options, { collection }) => { + if (options.locale) { + return []; } - - if (effectiveLocales.length === 0) { - ConsoleFormatter.error(`No removable locales in collection "${collectionName}".`); - return; + // Called before `required` is checked, so this reason wins over "missing --locale". + if (collection.targetLocales.length === 0) { + throw new Error(`No removable locales in collection "${collection.name}".`); } - - const answer = await prompts( + return [ { type: 'select', name: 'locale', message: 'Select locale to remove', - choices: effectiveLocales.map((l) => ({ title: l, value: l })), + choices: collection.targetLocales.map((locale) => ({ title: locale, value: locale })), }, - { onCancel: () => process.exit(0) }, - ); - - locale = answer.locale as string; - } - - if (!locale) return; - - try { - const result = await removeLocaleFromCollection(collectionName, locale, { cwd }); + ]; + }, + required: ['locale'], + run: async ({ collection, cwd, answers }) => { + const result = await removeLocaleFromCollection(collection.name, answers.locale, { cwd }); ConsoleFormatter.success(result.message); ConsoleFormatter.keyValue('Entries purged', result.entriesPurged); ConsoleFormatter.keyValue('Files updated', result.filesUpdated); - } catch (e: unknown) { - ConsoleFormatter.error(e instanceof Error ? e.message : 'Failed to remove locale'); - } -} + }, +}); diff --git a/apps/cli/src/commands/translate-locale.test.ts b/apps/cli/src/commands/translate-locale.test.ts new file mode 100644 index 00000000..e2bccca9 --- /dev/null +++ b/apps/cli/src/commands/translate-locale.test.ts @@ -0,0 +1,157 @@ +import { type LingoTrackerConfig, loadConfig, type TranslateLocaleResult, translateLocale } from '@simoncodes-ca/core'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isInteractiveTerminal } from '../runner/terminal'; +import { translateLocaleCommand } from './translate-locale'; + +vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadConfig: vi.fn(), translateLocale: vi.fn() }; +}); + +const CONFIG: LingoTrackerConfig = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'en', + locales: ['en', 'fr', 'de'], + translation: { enabled: true, provider: 'google-translate', apiKeyEnv: 'KEY' }, + collections: { main: { translationsFolder: 'src/i18n' } }, +}; + +const RESULT: TranslateLocaleResult = { + totalResources: 4, + translatedCount: 3, + skippedCount: 1, + failedCount: 0, + warnings: [], + failures: [], + skippedKeys: ['a.plural'], +}; + +describe('translateLocaleCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + vi.mocked(loadConfig).mockReturnValue(CONFIG); + vi.mocked(translateLocale).mockResolvedValue(RESULT); + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('translates the locale in the only collection', async () => { + await translateLocaleCommand({ locale: 'fr' }); + + expect(translateLocale).toHaveBeenCalledWith( + expect.objectContaining({ name: 'main', translationsFolder: '/project/src/i18n' }), + { targetLocale: 'fr', onProgress: undefined }, + ); + expect(console.log).toHaveBeenCalledWith(' Translated: 3'); + expect(process.exitCode).toBe(0); + }); + + it('passes a progress callback with --verbose', async () => { + await translateLocaleCommand({ locale: 'fr', verbose: true }); + + expect(translateLocale).toHaveBeenCalledWith(expect.anything(), { + targetLocale: 'fr', + onProgress: expect.any(Function), + }); + }); + + it('exits 1 when some entries failed', async () => { + vi.mocked(translateLocale).mockResolvedValue({ + ...RESULT, + failedCount: 1, + failures: [{ key: 'a.b', error: 'quota' }], + }); + + await translateLocaleCommand({ locale: 'fr' }); + + expect(console.log).toHaveBeenCalledWith(' a.b: quota'); + expect(process.exitCode).toBe(1); + }); + + it('prefixes a run that cannot start with "Translation failed:" and exits 1', async () => { + vi.mocked(translateLocale).mockRejectedValue(new Error('API key missing')); + + await translateLocaleCommand({ locale: 'fr' }); + + expect(console.log).toHaveBeenCalledWith('❌ Translation failed: API key missing'); + expect(process.exitCode).toBe(1); + }); + + it.each([ + ['the base locale', 'en', '❌ Cannot translate to the base locale "en".'], + ['an unconfigured locale', 'ja', '❌ Locale "ja" is not configured. Available locales: en, fr, de'], + ])('exits 1 for %s', async (_label, locale, message) => { + await translateLocaleCommand({ locale }); + + expect(console.log).toHaveBeenCalledWith(message); + expect(translateLocale).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('reports disabled translation before a missing --locale', async () => { + vi.mocked(loadConfig).mockReturnValue({ ...CONFIG, translation: undefined }); + + await translateLocaleCommand({}); + + expect(console.log).toHaveBeenCalledWith( + '❌ Auto-translation is not enabled for collection "main". Set translation.enabled = true in your configuration.', + ); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 without --locale in non-interactive mode', async () => { + await translateLocaleCommand({}); + + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --locale'); + expect(translateLocale).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + describe('interactive', () => { + beforeEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + }); + + it('offers the target locales', async () => { + vi.mocked(prompts).mockResolvedValueOnce({ locale: 'de' }); + + await translateLocaleCommand({}); + + expect(prompts).toHaveBeenCalledWith( + [ + expect.objectContaining({ + name: 'locale', + choices: [ + { title: 'fr', value: 'fr' }, + { title: 'de', value: 'de' }, + ], + }), + ], + expect.anything(), + ); + expect(translateLocale).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ targetLocale: 'de' })); + }); + + it('cancelling prints one cancel line and exits 0', async () => { + vi.mocked(prompts).mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'select', name: 'locale', message: 'Locale' }, {}); + return {}; + }); + + await translateLocaleCommand({}); + + expect(console.log).toHaveBeenCalledWith('❌ Translate locale cancelled.'); + expect(translateLocale).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); + }); +}); diff --git a/apps/cli/src/commands/translate-locale.ts b/apps/cli/src/commands/translate-locale.ts index b7edbcb7..58425991 100644 --- a/apps/cli/src/commands/translate-locale.ts +++ b/apps/cli/src/commands/translate-locale.ts @@ -1,7 +1,6 @@ -import prompts from 'prompts'; -import { translateLocale } from '@simoncodes-ca/core'; -import { loadConfiguration, resolveWritableCollection, ConsoleFormatter, ErrorMessages } from '../utils'; -import { exitWithError } from '../utils/report-error'; +import { type Collection, translateLocale } from '@simoncodes-ca/core'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface TranslateLocaleOptions { collection?: string; @@ -13,126 +12,61 @@ export interface TranslateLocaleOptions { * CLI command that auto-translates all `new` and `stale` resources for a * single target locale within a collection. * - * In TTY mode, missing `collection` and `locale` options trigger interactive - * prompts. In non-TTY mode both flags are required. + * When interactive, a missing `locale` is prompted for (the runner prompts for the + * collection). When non-interactive, `--locale` is required, and `--collection` too + * when several collections are configured. */ -export async function translateLocaleCommand(options: TranslateLocaleOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - // ------------------------------------------------------------------------- - // Resolve collection - // ------------------------------------------------------------------------- - - let collectionName = options.collection; - - if (!collectionName) { - const collectionNames = Object.keys(config.collections ?? {}); - - if (collectionNames.length === 0) { - ConsoleFormatter.error(ErrorMessages.NO_COLLECTIONS); - return; +export const translateLocaleCommand = defineCommand()({ + name: 'Translate locale', + collection: 'writable', + // Called in both modes before `required` is checked, so a collection that cannot be + // translated is reported as such rather than as a missing --locale. + prompts: (options, { collection }) => { + assertTranslatable(collection); + return options.locale + ? [] + : [ + { + type: 'select', + name: 'locale', + message: 'Select target locale to translate', + choices: collection.targetLocales.map((locale) => ({ title: locale, value: locale })), + }, + ]; + }, + required: ['locale'], + run: async ({ collection, answers }) => { + const { name: collectionName, baseLocale, locales: allLocales } = collection; + const targetLocale = answers.locale; + + if (targetLocale === baseLocale) { + throw new Error(`Cannot translate to the base locale "${baseLocale}".`); } - if (!process.stdout.isTTY) { - ConsoleFormatter.error(ErrorMessages.MISSING_OPTION('collection')); - return; + if (!allLocales.includes(targetLocale)) { + throw new Error(`Locale "${targetLocale}" is not configured. Available locales: ${allLocales.join(', ')}`); } - const answer = await prompts( - { - type: 'select', - name: 'collection', - message: 'Select collection to translate', - choices: collectionNames.map((name) => ({ title: name, value: name })), - }, - { onCancel: () => process.exit(0) }, - ); - - collectionName = answer.collection as string; - } - - const collection = resolveWritableCollection(collectionName, config, cwd); - if (!collection) return; - - // ------------------------------------------------------------------------- - // Validate translation is enabled - // ------------------------------------------------------------------------- - - const { translationConfig, baseLocale, locales: allLocales, targetLocales: nonBaseLocales } = collection; - if (!translationConfig?.enabled) { - ConsoleFormatter.error( - `Auto-translation is not enabled for collection "${collectionName}". ` + - `Set translation.enabled = true in your configuration.`, - ); - return; - } - - if (nonBaseLocales.length === 0) { - ConsoleFormatter.error(`No target locales configured. Add locales other than the base locale "${baseLocale}".`); - return; - } - - // ------------------------------------------------------------------------- - // Resolve target locale - // ------------------------------------------------------------------------- - - let targetLocale = options.locale; - - if (!targetLocale) { - if (!process.stdout.isTTY) { - ConsoleFormatter.error(ErrorMessages.MISSING_OPTION('locale')); - return; + console.log(''); + ConsoleFormatter.progress(`Translating locale '${targetLocale}' in collection '${collectionName}'...`); + + let result: Awaited>; + try { + result = await translateLocale(collection, { + targetLocale, + onProgress: answers.verbose + ? (progress) => { + ConsoleFormatter.indent( + `[batch ${progress.currentBatch}/${progress.totalBatches}] ` + + `translated: ${progress.translatedCount}, skipped: ${progress.skippedCount}, failed: ${progress.failedCount}`, + ); + } + : undefined, + }); + } catch (error) { + throw new Error(`Translation failed: ${error instanceof Error ? error.message : String(error)}`); } - const answer = await prompts( - { - type: 'select', - name: 'locale', - message: 'Select target locale to translate', - choices: nonBaseLocales.map((locale) => ({ title: locale, value: locale })), - }, - { onCancel: () => process.exit(0) }, - ); - - targetLocale = answer.locale as string; - } - - if (targetLocale === baseLocale) { - ConsoleFormatter.error(`Cannot translate to the base locale "${baseLocale}".`); - return; - } - - if (!allLocales.includes(targetLocale)) { - ConsoleFormatter.error(`Locale "${targetLocale}" is not configured. Available locales: ${allLocales.join(', ')}`); - return; - } - - // ------------------------------------------------------------------------- - // Run translation - // ------------------------------------------------------------------------- - - console.log(''); - ConsoleFormatter.progress(`Translating locale '${targetLocale}' in collection '${collectionName}'...`); - - try { - const result = await translateLocale(collection, { - targetLocale, - onProgress: options.verbose - ? (progress) => { - ConsoleFormatter.indent( - `[batch ${progress.currentBatch}/${progress.totalBatches}] ` + - `translated: ${progress.translatedCount}, skipped: ${progress.skippedCount}, failed: ${progress.failedCount}`, - ); - } - : undefined, - }); - - // ------------------------------------------------------------------------- - // Summary - // ------------------------------------------------------------------------- - console.log(''); ConsoleFormatter.success(`Translated locale '${targetLocale}' in collection '${collectionName}'`); ConsoleFormatter.keyValue('Translated', result.translatedCount); @@ -154,10 +88,19 @@ export async function translateLocaleCommand(options: TranslateLocaleOptions): P } } - if (result.failedCount > 0) { - process.exit(1); - } - } catch (error) { - exitWithError(error, 'Translation failed: '); + return result.failedCount > 0 ? { exitCode: 1 } : undefined; + }, +}); + +/** Auto-translation must be enabled, and there must be a locale other than the base locale. */ +function assertTranslatable(collection: Collection): void { + if (!collection.translationConfig?.enabled) { + throw new Error( + `Auto-translation is not enabled for collection "${collection.name}". ` + + `Set translation.enabled = true in your configuration.`, + ); + } + if (collection.targetLocales.length === 0) { + throw new Error(`No target locales configured. Add locales other than the base locale "${collection.baseLocale}".`); } } diff --git a/apps/cli/src/commands/validate.icu.test.ts b/apps/cli/src/commands/validate.icu.test.ts index d6019aba..f664bede 100644 --- a/apps/cli/src/commands/validate.icu.test.ts +++ b/apps/cli/src/commands/validate.icu.test.ts @@ -1,26 +1,11 @@ -import * as fs from 'node:fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { validateCommand } from './validate'; -const fsMocks = vi.hoisted(() => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), -})); - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); - vi.mock('@simoncodes-ca/core', async (importOriginal) => { const actual = await importOriginal(); return { - // Config loading and collection resolution run for real against the mocked config. - loadConfig: actual.loadConfig, + // Collection resolution runs for real against the mocked config. + loadConfig: vi.fn(), openCollection: actual.openCollection, ConfigNotFoundError: actual.ConfigNotFoundError, ConfigParseError: actual.ConfigParseError, @@ -54,16 +39,15 @@ function icuOptions() { describe('validateCommand ICU options', () => { const originalLog = console.log; - const originalExit = process.exit; beforeEach(() => { vi.clearAllMocks(); console.log = vi.fn(); console.warn = vi.fn(); - process.exit = vi.fn() as unknown as (code?: number | string | null | undefined) => never; + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG)); + vi.mocked(core.loadConfig).mockReturnValue(CONFIG); mockGenerateValidationSummary.mockReturnValue('summary'); mockValidateResources.mockReturnValue({ totalResourcesValidated: 0, @@ -80,7 +64,7 @@ describe('validateCommand ICU options', () => { afterEach(() => { console.log = originalLog; - process.exit = originalExit; + process.exitCode = undefined; }); it('checks ICU by default', async () => { diff --git a/apps/cli/src/commands/validate.test.ts b/apps/cli/src/commands/validate.test.ts index 7e9e2b2e..c22ccb68 100644 --- a/apps/cli/src/commands/validate.test.ts +++ b/apps/cli/src/commands/validate.test.ts @@ -1,26 +1,11 @@ -import * as fs from 'node:fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { validateCommand } from './validate'; -const fsMocks = vi.hoisted(() => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), -})); - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; -}); - vi.mock('@simoncodes-ca/core', async (importOriginal) => { const actual = await importOriginal(); return { - // Config loading and collection resolution run for real against the mocked config. - loadConfig: actual.loadConfig, + // Collection resolution runs for real against the mocked config. + loadConfig: vi.fn(), openCollection: actual.openCollection, ConfigNotFoundError: actual.ConfigNotFoundError, ConfigParseError: actual.ConfigParseError, @@ -59,17 +44,16 @@ describe('validateCommand', () => { const originalLog = console.log; const originalError = console.error; const originalWarn = console.warn; - const originalExit = process.exit; beforeEach(() => { vi.clearAllMocks(); console.log = vi.fn(); console.error = vi.fn(); console.warn = vi.fn(); - process.exit = vi.fn() as unknown as (code?: number | string | null | undefined) => never; + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(core.loadConfig).mockReturnValue(mockConfig); mockGenerateValidationSummary.mockReturnValue('Validation summary output'); }); @@ -78,29 +62,29 @@ describe('validateCommand', () => { console.log = originalLog; console.error = originalError; console.warn = originalWarn; - process.exit = originalExit; + process.exitCode = undefined; }); describe('configuration validation', () => { it('should error when config file is missing', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); + vi.mocked(core.loadConfig).mockImplementation(() => { + throw new core.ConfigNotFoundError('/project/.lingo-tracker.json'); }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); expect(console.error).toHaveBeenCalledWith('❌ Configuration file .lingo-tracker.json not found.'); expect(console.error).toHaveBeenCalledWith('Run "lingo-tracker init" to initialize a project.'); }); it('should error when config file is malformed', async () => { - vi.mocked(fs.readFileSync).mockReturnValue('invalid json'); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); + vi.mocked(core.loadConfig).mockImplementation(() => { + throw new core.ConfigParseError('/project/.lingo-tracker.json', 'Unexpected token i in JSON'); }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); expect(console.error).toHaveBeenCalledWith(expect.stringContaining('❌ Failed to parse configuration file')); }); @@ -110,12 +94,10 @@ describe('validateCommand', () => { ...mockConfig, collections: {}, }; - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(configWithoutCollections)); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); + vi.mocked(core.loadConfig).mockReturnValue(configWithoutCollections); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); expect(console.error).toHaveBeenCalledWith('❌ No collections found in configuration.'); }); @@ -125,12 +107,10 @@ describe('validateCommand', () => { ...mockConfig, locales: ['en'], // Only base locale }; - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(configWithoutTargetLocales)); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); + vi.mocked(core.loadConfig).mockReturnValue(configWithoutTargetLocales); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); expect(console.error).toHaveBeenCalledWith('❌ No target locales found in configuration.'); expect(console.error).toHaveBeenCalledWith( @@ -229,7 +209,7 @@ describe('validateCommand', () => { expect(mockGenerateValidationSummary.mock.calls[0]?.[1]).toBe(mockValidateResources.mock.calls[0]?.[1]); expect(console.log).toHaveBeenCalledWith('Validation summary output'); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('should validate all collections from configuration', async () => { @@ -323,11 +303,9 @@ describe('validateCommand', () => { }; mockValidateResources.mockReturnValue(failureResult); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); expect(console.log).toHaveBeenCalledWith('Validation summary output'); expect(mockGenerateValidationSummary).toHaveBeenCalledWith(failureResult, { @@ -376,11 +354,9 @@ describe('validateCommand', () => { }; mockValidateResources.mockReturnValue(failureResult); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); expect(console.log).toHaveBeenCalledWith('Validation summary output'); expect(mockGenerateValidationSummary).toHaveBeenCalledWith(failureResult, { @@ -429,11 +405,9 @@ describe('validateCommand', () => { }; mockValidateResources.mockReturnValue(failureResult); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); expect(mockValidateResources).toHaveBeenCalledWith(expect.any(Array), { allowTranslated: false, @@ -517,11 +491,9 @@ describe('validateCommand', () => { }; mockValidateResources.mockReturnValue(failureResult); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); // Verify that all failures are passed to the summary generator expect(mockGenerateValidationSummary).toHaveBeenCalledWith( @@ -634,11 +606,9 @@ describe('validateCommand', () => { }; mockValidateResources.mockReturnValue(failureResult); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); // Verify failures from both collections are included expect(mockGenerateValidationSummary).toHaveBeenCalledWith( @@ -710,7 +680,7 @@ describe('validateCommand', () => { }); expect(console.log).toHaveBeenCalledWith('Validation summary output'); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('should pass validation with warnings when allowTranslated is true', async () => { @@ -773,7 +743,7 @@ describe('validateCommand', () => { await validateCommand({ allowTranslated: true }); expect(console.log).toHaveBeenCalledWith('Validation summary output'); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('should use allowTranslated: false by default', async () => { @@ -852,7 +822,7 @@ describe('validateCommand', () => { }); describe('comprehensive validation behavior', () => { - it('should display summary output before exiting', async () => { + it('prints the summary and exits 1 when validation fails', async () => { const failureResult = { totalResourcesValidated: 3, totalUniqueKeys: 1, @@ -890,18 +860,14 @@ describe('validateCommand', () => { }; mockValidateResources.mockReturnValue(failureResult); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); - // Verify summary is logged before exit expect(console.log).toHaveBeenCalledWith('Validation summary output'); - expect(process.exit).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); - it('should exit with code 1 only after validation completes', async () => { + it('passes every failure to the summary and exits 1', async () => { const failureResult = { totalResourcesValidated: 100, totalUniqueKeys: 100, @@ -925,11 +891,9 @@ describe('validateCommand', () => { }; mockValidateResources.mockReturnValue(failureResult); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); // Verify all 100 failures were passed to summary generator expect(mockGenerateValidationSummary).toHaveBeenCalledWith( @@ -1037,11 +1001,9 @@ describe('validateCommand', () => { }; mockValidateResources.mockReturnValue(mixedResult); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); + expect(process.exitCode).toBe(1); // Verify comprehensive reporting of all statuses expect(mockGenerateValidationSummary).toHaveBeenCalledWith( @@ -1119,15 +1081,13 @@ describe('validateCommand', () => { }); it('should accept a locale from a collection override without an unknown-locale warning', async () => { - vi.mocked(fs.readFileSync).mockReturnValue( - JSON.stringify({ - ...mockConfig, - collections: { - ...mockConfig.collections, - admin: { translationsFolder: 'translations/admin', locales: ['en', 'ja'] }, - }, - }), - ); + vi.mocked(core.loadConfig).mockReturnValue({ + ...mockConfig, + collections: { + ...mockConfig.collections, + admin: { translationsFolder: 'translations/admin', locales: ['en', 'ja'] }, + }, + }); mockValidateResources.mockReturnValue(successResult); await validateCommand({ skipLocales: ['ja'] }); @@ -1143,13 +1103,8 @@ describe('validateCommand', () => { }); it('should exit with code 1 when all target locales are skipped', async () => { - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - - await expect(validateCommand({ skipLocales: ['fr', 'es', 'de'] })).rejects.toThrow( - 'process.exit called with code 1', - ); + await validateCommand({ skipLocales: ['fr', 'es', 'de'] }); + expect(process.exitCode).toBe(1); expect(console.error).toHaveBeenCalledWith('❌ All target locales were skipped; nothing to validate.'); expect(mockValidateResources).not.toHaveBeenCalled(); @@ -1190,16 +1145,13 @@ describe('validateCommand', () => { }; mockValidateResources.mockReturnValue(failureResult); - vi.mocked(process.exit).mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`process.exit called with code ${code}`); - }); - await expect(validateCommand({})).rejects.toThrow('process.exit called with code 1'); + await validateCommand({}); - expect(process.exit).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); - it('should exit with code 0 (implicit) when validation passes', async () => { + it('should exit with code 0 when validation passes', async () => { const successResult = { totalResourcesValidated: 6, totalUniqueKeys: 2, @@ -1216,7 +1168,7 @@ describe('validateCommand', () => { await validateCommand({}); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('should exit with code 0 when validation passes with warnings', async () => { @@ -1255,7 +1207,7 @@ describe('validateCommand', () => { await validateCommand({ allowTranslated: true }); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); }); @@ -1275,15 +1227,13 @@ describe('validateCommand', () => { }; it('loads the rules and passes them with each collection base locale', async () => { - vi.mocked(fs.readFileSync).mockReturnValue( - JSON.stringify({ - ...mockConfig, - collections: { - common: { translationsFolder: 'translations/common' }, - legacy: { translationsFolder: 'translations/legacy', baseLocale: 'en-GB' }, - }, - }), - ); + vi.mocked(core.loadConfig).mockReturnValue({ + ...mockConfig, + collections: { + common: { translationsFolder: 'translations/common' }, + legacy: { translationsFolder: 'translations/legacy', baseLocale: 'en-GB' }, + }, + }); mockLoadPreferredTerminology.mockReturnValueOnce({ rules, filePath }); mockValidateResources.mockReturnValue(passingResult); @@ -1323,7 +1273,7 @@ describe('validateCommand', () => { await validateCommand({}); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('passes a load error through and exits 1 when validation reports it', async () => { @@ -1342,7 +1292,7 @@ describe('validateCommand', () => { terminology: expect.objectContaining({ rules: [], loadError: 'not valid JSON' }), }), ); - expect(process.exit).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); it('prints the missing-explicit-file warning and skips the check', async () => { @@ -1359,7 +1309,7 @@ describe('validateCommand', () => { '⚠️ Preferred terminology file not found: /project/terms.json. Treating as an empty list.', ); expect(mockValidateResources.mock.calls[0]?.[1].terminology).toBeUndefined(); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('omits the check entirely when there are no rules', async () => { diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index 02ed6d32..fa3c77da 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -2,10 +2,11 @@ import { generateValidationSummary, loadPreferredTerminology, openCollection, + type LingoTrackerConfig, type ValidationOptions, validateResources, } from '@simoncodes-ca/core'; -import { loadConfiguration } from '../utils'; +import { type CommandResult, defineCommand } from '../runner/command-runner'; /** * Options for the validate command. @@ -111,8 +112,7 @@ export interface ValidateCommandOptions { * - Prevent deployment of incomplete translations * - Enforce translation verification requirements * - * @param options - Validation options (status strictness, locale and ICU flags) - * @throws Never throws - exits process with appropriate code instead + * Options: status strictness, locale and ICU flags. Every failure sets exit code 1. * * @example * ```typescript @@ -147,16 +147,18 @@ export interface ValidateCommandOptions { * $ lingo-tracker validate || exit 1 * ``` */ -export async function validateCommand(options: ValidateCommandOptions): Promise { - const loaded = loadConfiguration(); - if (!loaded) return; - const { config, cwd } = loaded; +export const validateCommand = defineCommand()({ + name: 'Validate', + collection: 'none', + run: ({ config, cwd, answers }) => validate(answers, config, cwd), +}); +function validate(options: ValidateCommandOptions, config: LingoTrackerConfig, cwd: string): CommandResult { const collections = Object.keys(config.collections || {}).map((name) => openCollection(config, name, { cwd })); if (collections.length === 0) { console.error('❌ No collections found in configuration.'); - process.exit(1); + return { exitCode: 1 }; } // Each collection is validated against its own target locales (its locales without its base locale). @@ -165,7 +167,7 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< if (targetLocales.length === 0) { console.error('❌ No target locales found in configuration.'); console.error("Target locales are each collection's locales except its base locale."); - process.exit(1); + return { exitCode: 1 }; } const baseLocales = new Set(collections.map((collection) => collection.baseLocale)); @@ -186,7 +188,7 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< if (targetLocales.every((locale) => effectiveSkipped.includes(locale))) { console.error('❌ All target locales were skipped; nothing to validate.'); - process.exit(1); + return { exitCode: 1 }; } // Terminology findings are advisory, but a broken rule file is a failure: @@ -224,7 +226,5 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< console.log(summary); - if (!validationResult.passed) { - process.exit(1); - } + return validationResult.passed ? undefined : { exitCode: 1 }; } diff --git a/apps/cli/src/delete-collection/delete-collection.test.ts b/apps/cli/src/delete-collection/delete-collection.test.ts index 329b6148..b18bb456 100644 --- a/apps/cli/src/delete-collection/delete-collection.test.ts +++ b/apps/cli/src/delete-collection/delete-collection.test.ts @@ -1,26 +1,30 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { deleteCollectionCommand } from './delete-collection'; import * as core from '@simoncodes-ca/core'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isInteractiveTerminal } from '../runner/terminal'; +import { deleteCollectionCommand } from './delete-collection'; -vi.mock('@simoncodes-ca/core', async () => { - const actual = await vi.importActual('@simoncodes-ca/core'); +vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, + loadConfig: vi.fn(), deleteCollectionByName: vi.fn(), }; }); -vi.mock('../utils', () => ({ - loadConfiguration: vi.fn(), - promptForCollection: vi.fn(), -})); - -import { loadConfiguration, promptForCollection } from '../utils'; - describe('deleteCollectionCommand', () => { beforeEach(() => { vi.clearAllMocks(); process.env.INIT_CWD = '/test/project'; + process.exitCode = undefined; + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + }); + + afterEach(() => { + process.exitCode = undefined; }); const mockConfig = { @@ -40,12 +44,7 @@ describe('deleteCollectionCommand', () => { }; it('should delete specified collection from config', async () => { - vi.mocked(loadConfiguration).mockReturnValue({ - config: mockConfig, - configPath: '/test/project/.lingo-tracker.json', - cwd: '/test/project', - }); - vi.mocked(promptForCollection).mockResolvedValue('Collection1'); + vi.mocked(core.loadConfig).mockReturnValue(mockConfig); vi.mocked(core.deleteCollectionByName).mockReturnValue({ message: 'Collection "Collection1" deleted successfully', }); @@ -59,6 +58,7 @@ describe('deleteCollectionCommand', () => { expect(core.deleteCollectionByName).toHaveBeenCalledWith('Collection1', { cwd: '/test/project', }); + expect(process.exitCode).toBe(0); }); it('should handle deletion of last remaining collection', async () => { @@ -71,12 +71,7 @@ describe('deleteCollectionCommand', () => { }, }; - vi.mocked(loadConfiguration).mockReturnValue({ - config: singleCollectionConfig, - configPath: '/test/project/.lingo-tracker.json', - cwd: '/test/project', - }); - vi.mocked(promptForCollection).mockResolvedValue('OnlyCollection'); + vi.mocked(core.loadConfig).mockReturnValue(singleCollectionConfig); vi.mocked(core.deleteCollectionByName).mockReturnValue({ message: 'Collection "OnlyCollection" deleted successfully', }); @@ -90,10 +85,13 @@ describe('deleteCollectionCommand', () => { expect(core.deleteCollectionByName).toHaveBeenCalledWith('OnlyCollection', { cwd: '/test/project', }); + expect(process.exitCode).toBe(0); }); it('should not write file if config does not exist', async () => { - vi.mocked(loadConfiguration).mockReturnValue(null); + vi.mocked(core.loadConfig).mockImplementation(() => { + throw new core.ConfigNotFoundError('/test/project/.lingo-tracker.json'); + }); const options = { collectionName: 'Collection1', @@ -102,10 +100,13 @@ describe('deleteCollectionCommand', () => { await deleteCollectionCommand(options); expect(core.deleteCollectionByName).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should not write file if config is invalid JSON', async () => { - vi.mocked(loadConfiguration).mockReturnValue(null); + vi.mocked(core.loadConfig).mockImplementation(() => { + throw new core.ConfigParseError('/test/project/.lingo-tracker.json', 'Unexpected token'); + }); const options = { collectionName: 'Collection1', @@ -114,6 +115,7 @@ describe('deleteCollectionCommand', () => { await deleteCollectionCommand(options); expect(core.deleteCollectionByName).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should not write file if no collections exist', async () => { @@ -122,12 +124,7 @@ describe('deleteCollectionCommand', () => { collections: {}, }; - vi.mocked(loadConfiguration).mockReturnValue({ - config: emptyCollectionsConfig, - configPath: '/test/project/.lingo-tracker.json', - cwd: '/test/project', - }); - vi.mocked(promptForCollection).mockResolvedValue(null); + vi.mocked(core.loadConfig).mockReturnValue(emptyCollectionsConfig); const options = { collectionName: 'Collection1', @@ -136,6 +133,7 @@ describe('deleteCollectionCommand', () => { await deleteCollectionCommand(options); expect(core.deleteCollectionByName).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should not write file if collections property is missing', async () => { @@ -146,12 +144,7 @@ describe('deleteCollectionCommand', () => { locales: ['en', 'fr'], }; - vi.mocked(loadConfiguration).mockReturnValue({ - config: noCollectionsConfig, - configPath: '/test/project/.lingo-tracker.json', - cwd: '/test/project', - }); - vi.mocked(promptForCollection).mockResolvedValue(null); + vi.mocked(core.loadConfig).mockReturnValue(noCollectionsConfig); const options = { collectionName: 'Collection1', @@ -160,15 +153,11 @@ describe('deleteCollectionCommand', () => { await deleteCollectionCommand(options); expect(core.deleteCollectionByName).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should not write file if specified collection does not exist', async () => { - vi.mocked(loadConfiguration).mockReturnValue({ - config: mockConfig, - configPath: '/test/project/.lingo-tracker.json', - cwd: '/test/project', - }); - vi.mocked(promptForCollection).mockResolvedValue(null); + vi.mocked(core.loadConfig).mockReturnValue(mockConfig); const options = { collectionName: 'NonExistentCollection', @@ -176,7 +165,9 @@ describe('deleteCollectionCommand', () => { await deleteCollectionCommand(options); + expect(console.log).toHaveBeenCalledWith('❌ Collection "NonExistentCollection" not found'); expect(core.deleteCollectionByName).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('should handle single collection when no collection name provided', async () => { @@ -189,12 +180,7 @@ describe('deleteCollectionCommand', () => { }, }; - vi.mocked(loadConfiguration).mockReturnValue({ - config: singleCollectionConfig, - configPath: '/test/project/.lingo-tracker.json', - cwd: '/test/project', - }); - vi.mocked(promptForCollection).mockResolvedValue('OnlyCollection'); + vi.mocked(core.loadConfig).mockReturnValue(singleCollectionConfig); vi.mocked(core.deleteCollectionByName).mockReturnValue({ message: 'Collection "OnlyCollection" deleted successfully', }); @@ -206,6 +192,7 @@ describe('deleteCollectionCommand', () => { expect(core.deleteCollectionByName).toHaveBeenCalledWith('OnlyCollection', { cwd: '/test/project', }); + expect(process.exitCode).toBe(0); }); it('should preserve other config properties when deleting collection', async () => { @@ -215,12 +202,7 @@ describe('deleteCollectionCommand', () => { anotherProperty: 42, }; - vi.mocked(loadConfiguration).mockReturnValue({ - config: configWithExtraProps, - configPath: '/test/project/.lingo-tracker.json', - cwd: '/test/project', - }); - vi.mocked(promptForCollection).mockResolvedValue('Collection1'); + vi.mocked(core.loadConfig).mockReturnValue(configWithExtraProps); vi.mocked(core.deleteCollectionByName).mockReturnValue({ message: 'Collection "Collection1" deleted successfully', }); @@ -234,5 +216,39 @@ describe('deleteCollectionCommand', () => { expect(core.deleteCollectionByName).toHaveBeenCalledWith('Collection1', { cwd: '/test/project', }); + expect(process.exitCode).toBe(0); + }); + + it('exits 1 when core refuses the deletion', async () => { + vi.mocked(core.loadConfig).mockReturnValue(mockConfig); + vi.mocked(core.deleteCollectionByName).mockImplementation(() => { + throw new Error('Cannot delete'); + }); + + await deleteCollectionCommand({ collectionName: 'Collection1' }); + + expect(console.log).toHaveBeenCalledWith('❌ Cannot delete'); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 naming --collection-name when several collections exist and none is given', async () => { + vi.mocked(core.loadConfig).mockReturnValue(mockConfig); + + await deleteCollectionCommand({}); + + expect(core.deleteCollectionByName).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Missing required option: --collection-name'); + expect(process.exitCode).toBe(1); + }); + + it('prompts for one of several collections when interactive', async () => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + vi.mocked(core.loadConfig).mockReturnValue(mockConfig); + vi.mocked(prompts).mockResolvedValueOnce({ collection: 'Collection2' }); + vi.mocked(core.deleteCollectionByName).mockReturnValue({ message: 'deleted' }); + + await deleteCollectionCommand({}); + + expect(core.deleteCollectionByName).toHaveBeenCalledWith('Collection2', { cwd: '/test/project' }); }); }); diff --git a/apps/cli/src/delete-collection/delete-collection.ts b/apps/cli/src/delete-collection/delete-collection.ts index ed3ea454..9bb7c7c8 100644 --- a/apps/cli/src/delete-collection/delete-collection.ts +++ b/apps/cli/src/delete-collection/delete-collection.ts @@ -1,22 +1,17 @@ import { deleteCollectionByName } from '@simoncodes-ca/core'; -import { loadConfiguration, promptForCollection, ConsoleFormatter } from '../utils'; +import { defineCommand } from '../runner/command-runner'; export interface DeleteCollectionOptions { collectionName?: string; } -export async function deleteCollectionCommand(options: DeleteCollectionOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - const collectionName = await promptForCollection(config, options.collectionName); - if (!collectionName) return; - - try { - const result = deleteCollectionByName(collectionName, { cwd }); +/** Removes a collection's registration, so a read-only collection may be deleted too. */ +export const deleteCollectionCommand = defineCommand()({ + name: 'Delete collection', + collection: 'read', + collectionOption: 'collectionName', + run: ({ collection, cwd }) => { + const result = deleteCollectionByName(collection.name, { cwd }); console.log(result.message); - } catch (e: unknown) { - ConsoleFormatter.error(e instanceof Error ? e.message : 'Failed to delete collection'); - } -} + }, +}); diff --git a/apps/cli/src/init/init.test.ts b/apps/cli/src/init/init.test.ts index 0028d288..cf1b0249 100644 --- a/apps/cli/src/init/init.test.ts +++ b/apps/cli/src/init/init.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { existsSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import prompts from 'prompts'; +import { isInteractiveTerminal } from '../runner/terminal'; import { initCommand } from './init'; const fsMocks = vi.hoisted(() => ({ @@ -17,28 +19,22 @@ vi.mock('node:fs', async (importOriginal) => { }; }); vi.mock('prompts'); +// Non-interactive by default, so tests never trigger prompts by accident. +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); const mockExistsSync = vi.mocked(existsSync); const mockWriteFileSync = vi.mocked(writeFileSync); describe('initCommand', () => { - let originalStdinIsTTY: boolean | undefined; - let originalStdoutIsTTY: boolean | undefined; - beforeEach(() => { vi.clearAllMocks(); process.env.INIT_CWD = '/test/project'; - // Capture original TTY state and explicitly mark streams as non-interactive - // so tests never accidentally trigger interactive prompt fallback paths. - originalStdinIsTTY = process.stdin.isTTY; - originalStdoutIsTTY = process.stdout.isTTY; - process.stdin.isTTY = undefined; - process.stdout.isTTY = undefined; + process.exitCode = undefined; + vi.mocked(isInteractiveTerminal).mockReturnValue(false); }); afterEach(() => { - process.stdin.isTTY = originalStdinIsTTY; - process.stdout.isTTY = originalStdoutIsTTY; + process.exitCode = undefined; }); it('should write config file with provided parameters', async () => { @@ -338,4 +334,42 @@ describe('initCommand', () => { expect(writtenConfig.locales).toEqual(['en', 'fr']); }); + + it('exits 1 naming the missing flags in non-interactive mode', async () => { + mockExistsSync.mockReturnValue(false); + + await initCommand({}); + + expect(console.log).toHaveBeenCalledWith( + '❌ Missing required options in non-interactive mode: --collection-name, --translations-folder', + ); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('asks nothing and exits 0 in an initialized folder, even without flags', async () => { + mockExistsSync.mockReturnValue(true); + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + + await initCommand({}); + + expect(prompts).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); + + it('prompts for missing values when interactive and writes the answers', async () => { + mockExistsSync.mockReturnValue(false); + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + vi.mocked(prompts).mockResolvedValueOnce({ collectionName: 'Main', translationsFolder: 'src/i18n' }); + + await initCommand({ baseLocale: 'en', locales: ['en'] }); + + expect(prompts).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ name: 'collectionName' })]), + expect.anything(), + ); + const [, written] = mockWriteFileSync.mock.calls[0]; + expect(JSON.parse(String(written)).collections).toEqual({ Main: { translationsFolder: 'src/i18n' } }); + }); }); diff --git a/apps/cli/src/init/init.ts b/apps/cli/src/init/init.ts index 1c4827f1..2cc62e1f 100644 --- a/apps/cli/src/init/init.ts +++ b/apps/cli/src/init/init.ts @@ -11,14 +11,25 @@ import { type BundleDefinition, } from '@simoncodes-ca/core'; import type { TokenCasing } from '@simoncodes-ca/domain'; -import { getCwd, ConsoleFormatter, executePromptsWithFallback } from '../utils'; +import { type Answers, defineCommand, requireOptions } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; const DEFAULT_BUNDLE_DIST = './src/assets/i18n'; const DEFAULT_BUNDLE_NAME = '{locale}'; const DEFAULT_TYPE_DIST_FILE = './src/generated/tokens.ts'; -export async function initCommand(options: InitOptions): Promise { - const cwd = getCwd(); +export const initCommand = defineCommand()({ + name: 'Initialization', + // Writes `.lingo-tracker.json`, so there is none to load yet. + collection: 'none', + config: false, + // Nothing to ask in an initialized folder: `run` reports it. + prompts: (options, { cwd }) => (existsSync(resolve(cwd, CONFIG_FILENAME)) ? [] : buildQuestions(options)), + // No `required`: the name and folder are only needed when there is a config to write. + run: ({ cwd, answers, interactive }) => writeConfig(cwd, answers, interactive), +}); + +function writeConfig(cwd: string, result: Answers, interactive: boolean): void { const configPath = resolve(cwd, CONFIG_FILENAME); if (existsSync(configPath)) { @@ -26,7 +37,8 @@ export async function initCommand(options: InitOptions): Promise { return; } - const answers = await promptForMissing(options); + requireOptions(result, ['collectionName', 'translationsFolder'], interactive); + const answers = resolveAnswers(result); // for the initial collection, store only the translationsFolder so all other properties live in the global config const collection: LingoTrackerCollection = { @@ -89,7 +101,7 @@ function buildBundleDefinition(bundleAnswers: BundleAnswers): BundleDefinition { }; } -async function promptForMissing(options: InitOptions): Promise { +function buildQuestions(options: InitOptions): prompts.PromptObject[] { const questions: prompts.PromptObject[] = []; if (!options.collectionName) { @@ -242,24 +254,16 @@ async function promptForMissing(options: InitOptions): Promise { }); } - const result = await executePromptsWithFallback({ - questions, - currentValues: options, - requiredFields: ['collectionName', 'translationsFolder'], - operationName: 'Initialization', - }); - - const isAutoTranslationEnabled = Boolean(result.enableAutoTranslation ?? options.enableAutoTranslation); + return questions; +} - const translation: TranslationConfig | undefined = isAutoTranslationEnabled +/** Flags merged with prompt answers, with defaults filled in. */ +function resolveAnswers(answers: InitOptions & { collectionName: string; translationsFolder: string }): InitAnswers { + const translation: TranslationConfig | undefined = answers.enableAutoTranslation ? { enabled: true, - provider: - (result.translationProvider as string | undefined) ?? options.translationProvider ?? 'google-translate', - apiKeyEnv: - (result.translationApiKeyEnv as string | undefined) ?? - options.translationApiKeyEnv ?? - 'GOOGLE_TRANSLATE_API_KEY', + provider: answers.translationProvider ?? 'google-translate', + apiKeyEnv: answers.translationApiKeyEnv ?? 'GOOGLE_TRANSLATE_API_KEY', } : undefined; @@ -269,35 +273,27 @@ async function promptForMissing(options: InitOptions): Promise { // This flag only controls whether the custom bundle definition or the default bundle is written to // the config file. const hasBundleFlags = - options.bundleDist || - options.bundleName || - options.tokenCasing || - options.typeDistFile || - options.tokenConstantName; - const setupBundle = Boolean(result.setupBundle ?? options.setupBundle ?? hasBundleFlags); + answers.bundleDist || + answers.bundleName || + answers.tokenCasing || + answers.typeDistFile || + answers.tokenConstantName; + const setupBundle = Boolean(answers.setupBundle ?? hasBundleFlags); return { - collectionName: result.collectionName as string, - translationsFolder: result.translationsFolder as string, - exportFolder: (result.exportFolder as string | undefined) ?? DEFAULT_CONFIG.exportFolder, - importFolder: (result.importFolder as string | undefined) ?? DEFAULT_CONFIG.importFolder, - baseLocale: (result.baseLocale as string | undefined) ?? DEFAULT_CONFIG.baseLocale, - locales: ((result.locales as string[] | undefined) ?? options.locales ?? DEFAULT_CONFIG.locales) - .map((l) => l.trim()) - .filter((l) => l.length > 0), + collectionName: answers.collectionName, + translationsFolder: answers.translationsFolder, + exportFolder: answers.exportFolder ?? DEFAULT_CONFIG.exportFolder, + importFolder: answers.importFolder ?? DEFAULT_CONFIG.importFolder, + baseLocale: answers.baseLocale ?? DEFAULT_CONFIG.baseLocale, + locales: (answers.locales ?? DEFAULT_CONFIG.locales).map((l) => l.trim()).filter((l) => l.length > 0), translation, setupBundle, - bundleDist: - nonEmptyString(result.bundleDist as string | undefined) ?? - nonEmptyString(options.bundleDist) ?? - DEFAULT_BUNDLE_DIST, - bundleName: - nonEmptyString(result.bundleName as string | undefined) ?? - nonEmptyString(options.bundleName) ?? - DEFAULT_BUNDLE_NAME, - tokenCasing: (result.tokenCasing as TokenCasing | undefined) ?? options.tokenCasing, - typeDistFile: nonEmptyString((result.typeDistFile as string | undefined) ?? options.typeDistFile), - tokenConstantName: nonEmptyString((result.tokenConstantName as string | undefined) ?? options.tokenConstantName), + bundleDist: nonEmptyString(answers.bundleDist) ?? DEFAULT_BUNDLE_DIST, + bundleName: nonEmptyString(answers.bundleName) ?? DEFAULT_BUNDLE_NAME, + tokenCasing: answers.tokenCasing, + typeDistFile: nonEmptyString(answers.typeDistFile), + tokenConstantName: nonEmptyString(answers.tokenConstantName), }; } diff --git a/apps/cli/src/main.spec.ts b/apps/cli/src/main.spec.ts new file mode 100644 index 00000000..b28c4f69 --- /dev/null +++ b/apps/cli/src/main.spec.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// main.ts parses process.argv when imported and lazy-imports each command module; +// the command modules are replaced so only the flag wiring is under test. +vi.mock('./commands/validate', () => ({ validateCommand: vi.fn() })); +vi.mock('./add-resource/add-resource', () => ({ addResourceCommand: vi.fn() })); + +import { addResourceCommand } from './add-resource/add-resource'; +import { validateCommand } from './commands/validate'; + +const originalArgv = process.argv; + +/** Imports main.ts afresh with these arguments and waits for the lazy action to finish. */ +async function runCli(...args: string[]): Promise { + process.argv = ['node', 'lingo-tracker', ...args]; + vi.resetModules(); + await import('./main'); + await vi.waitFor(() => { + if (vi.mocked(validateCommand).mock.calls.length + vi.mocked(addResourceCommand).mock.calls.length === 0) { + throw new Error('command not called yet'); + } + }); +} + +describe('main.ts flag wiring', () => { + afterEach(() => { + process.argv = originalArgv; + vi.clearAllMocks(); + }); + + it('passes --skip-placeholders to validate', async () => { + await runCli('validate', '--skip-placeholders', '--skip-locales', 'fr,de'); + + expect(validateCommand).toHaveBeenCalledWith({ + allowTranslated: false, + skipLocales: ['fr', 'de'], + skipIcu: false, + skipPlaceholders: true, + requirePortablePlurals: false, + }); + }); + + it('passes the raw --translations string to add-resource (parsed inside the command)', async () => { + await runCli('add-resource', '--key', 'a.b', '--value', 'OK', '--translations', '[{"locale":'); + + expect(addResourceCommand).toHaveBeenCalledWith(expect.objectContaining({ translations: '[{"locale":' })); + }); +}); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 3bb502c9..ce6a4582 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -100,11 +100,7 @@ program ) .action(async (options) => { const { addResourceCommand } = await import('./add-resource/add-resource'); - const processedOptions = { - ...options, - translations: options.translations ? JSON.parse(options.translations) : undefined, - }; - await addResourceCommand(processedOptions); + await addResourceCommand(options); }); program @@ -482,6 +478,7 @@ Notes: allowTranslated: options.allowTranslated, skipLocales, skipIcu: options.skipIcu, + skipPlaceholders: options.skipPlaceholders, requirePortablePlurals: options.requirePortablePlurals, }); }); diff --git a/apps/cli/src/runner/command-runner.spec.ts b/apps/cli/src/runner/command-runner.spec.ts new file mode 100644 index 00000000..92913f76 --- /dev/null +++ b/apps/cli/src/runner/command-runner.spec.ts @@ -0,0 +1,513 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + ConfigNotFoundError, + ConfigParseError, + InvalidResourceKeyError, + type LingoTrackerConfig, + loadConfig, +} from '@simoncodes-ca/core'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type CollectionNeed, CommandCancelledError, type CommandSpec, defineCommand } from './command-runner'; +import { isInteractiveTerminal } from './terminal'; + +vi.mock('prompts'); +vi.mock('./terminal', () => ({ isInteractiveTerminal: vi.fn(() => false), hasPipedStdin: vi.fn(() => true) })); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadConfig: vi.fn() }; +}); + +const mockPrompts = vi.mocked(prompts); +const mockLoadConfig = vi.mocked(loadConfig); +const mockInteractive = vi.mocked(isInteractiveTerminal); + +const twoCollections: LingoTrackerConfig = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { + main: { translationsFolder: 'src/i18n' }, + vendor: { translationsFolder: 'node_modules/x/i18n', readOnly: true }, + }, +}; + +interface Options { + collection?: string; + key?: string; + targetFolder?: string; +} + +type Spec = CommandSpec; + +function command(overrides: Partial = {}) { + const run = vi.fn(); + const spec: Spec = { name: 'Do thing', collection: 'read', run, ...overrides }; + return { invoke: defineCommand()(spec), run }; +} + +describe('defineCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + mockLoadConfig.mockReturnValue(twoCollections); + mockInteractive.mockReturnValue(false); + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + describe('interactive rule', () => { + it('reads the terminal once and hands the result to run', async () => { + mockInteractive.mockReturnValue(true); + const { invoke, run } = command({ collection: 'none' }); + + await invoke({}); + + expect(mockInteractive).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith(expect.objectContaining({ interactive: true, cwd: '/project' })); + }); + + it('checks both stdin and stdout', async () => { + const actual = await vi.importActual('./terminal'); + const stdin = process.stdin.isTTY; + const stdout = process.stdout.isTTY; + try { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + expect(actual.isInteractiveTerminal()).toBe(false); + expect(actual.hasPipedStdin()).toBe(false); + + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + expect(actual.isInteractiveTerminal()).toBe(false); + expect(actual.hasPipedStdin()).toBe(true); + + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + expect(actual.isInteractiveTerminal()).toBe(true); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: stdin, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: stdout, configurable: true }); + } + }); + }); + + describe('config', () => { + it('reads the config from INIT_CWD and passes it with its path', async () => { + const { invoke, run } = command({ collection: 'none' }); + + await invoke({}); + + expect(mockLoadConfig).toHaveBeenCalledWith({ cwd: '/project' }); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ config: twoCollections, configPath: resolve('/project', '.lingo-tracker.json') }), + ); + expect(process.exitCode).toBe(0); + }); + + it('a missing config exits 1 with the init hint', async () => { + mockLoadConfig.mockImplementation(() => { + throw new ConfigNotFoundError('/project/.lingo-tracker.json'); + }); + const { invoke, run } = command(); + + await invoke({}); + + expect(run).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('❌ Configuration file .lingo-tracker.json not found.'); + expect(console.error).toHaveBeenCalledWith('Run "lingo-tracker init" to initialize a project.'); + expect(process.exitCode).toBe(1); + }); + + it('an unparsable config exits 1 with the reason', async () => { + mockLoadConfig.mockImplementation(() => { + throw new ConfigParseError('/project/.lingo-tracker.json', 'Unexpected token'); + }); + const { invoke } = command(); + + await invoke({}); + + expect(console.error).toHaveBeenCalledWith('❌ Failed to parse configuration file: Unexpected token'); + expect(process.exitCode).toBe(1); + }); + + it('falls back to process.cwd() when INIT_CWD is unset', async () => { + delete process.env.INIT_CWD; + const cwd = vi.spyOn(process, 'cwd').mockReturnValue('/elsewhere'); + const { invoke, run } = command({ collection: 'none' }); + + await invoke({}); + + expect(mockLoadConfig).toHaveBeenCalledWith({ cwd: '/elsewhere' }); + expect(run).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/elsewhere' })); + cwd.mockRestore(); + }); + + describe('through the real loadConfig', () => { + let dir: string; + + beforeEach(async () => { + const actual = await vi.importActual('@simoncodes-ca/core'); + mockLoadConfig.mockImplementation(actual.loadConfig); + dir = mkdtempSync(join(tmpdir(), 'lingo-runner-')); + process.env.INIT_CWD = dir; + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reports a file that is not JSON as a parse failure', async () => { + writeFileSync(join(dir, '.lingo-tracker.json'), '{ not json'); + const { invoke, run } = command(); + + await invoke({}); + + expect(run).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/^❌ Failed to parse configuration file: /)); + expect(process.exitCode).toBe(1); + }); + + it('reports another I/O error (EISDIR) as a parse failure too', async () => { + mkdirSync(join(dir, '.lingo-tracker.json')); + const { invoke, run } = command(); + + await invoke({}); + + expect(run).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringMatching(/^❌ Failed to parse configuration file: EISDIR/), + ); + expect(process.exitCode).toBe(1); + }); + }); + + it('config: false skips loading', async () => { + const run = vi.fn(); + await defineCommand()({ name: 'Init', collection: 'none', config: false, run })({}); + + expect(mockLoadConfig).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledWith(expect.not.objectContaining({ config: expect.anything() })); + }); + }); + + describe('collection resolution', () => { + it('opens the collection named by --collection', async () => { + const { invoke, run } = command(); + + await invoke({ collection: 'main' }); + + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + collection: expect.objectContaining({ name: 'main', translationsFolder: resolve('/project', 'src/i18n') }), + }), + ); + }); + + it('fails with exit 1 when no collection is configured', async () => { + mockLoadConfig.mockReturnValue({ ...twoCollections, collections: {} }); + const { invoke, run } = command(); + + await invoke({}); + + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ No collections found. Run `lingo-tracker add-collection` first.'); + expect(process.exitCode).toBe(1); + }); + + it('auto-selects the only collection', async () => { + mockLoadConfig.mockReturnValue({ ...twoCollections, collections: { main: { translationsFolder: 'src/i18n' } } }); + const { invoke, run } = command(); + + await invoke({}); + + expect(mockPrompts).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ collection: expect.objectContaining({ name: 'main' }) }), + ); + }); + + it('prompts for one of several collections when interactive', async () => { + mockInteractive.mockReturnValue(true); + mockPrompts.mockResolvedValueOnce({ collection: 'vendor' }); + const { invoke, run } = command(); + + await invoke({}); + + expect(mockPrompts).toHaveBeenCalledWith( + expect.objectContaining({ type: 'select', choices: [expect.anything(), expect.anything()] }), + expect.anything(), + ); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ collection: expect.objectContaining({ name: 'vendor' }) }), + ); + }); + + it('fails with exit 1 on several collections when non-interactive', async () => { + const { invoke, run } = command(); + + await invoke({}); + + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Missing required option: --collection'); + expect(process.exitCode).toBe(1); + }); + + it('names a custom collection option in kebab case', async () => { + const run = vi.fn(); + await defineCommand<{ collectionName?: string }>()({ + name: 'Delete collection', + collection: 'read', + collectionOption: 'collectionName', + run, + })({}); + + expect(console.log).toHaveBeenCalledWith('❌ Missing required option: --collection-name'); + }); + + it("treats --collection '' as not given", async () => { + const { invoke, run } = command(); + + await invoke({ collection: '' }); + + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Missing required option: --collection'); + expect(process.exitCode).toBe(1); + }); + + it('cancelling the collection select is a cancel (exit 0)', async () => { + mockInteractive.mockReturnValue(true); + mockPrompts.mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'select', name: 'collection', message: 'Select collection' }, {}); + return {}; + }); + const { invoke, run } = command(); + + await invoke({}); + + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Do thing cancelled.'); + expect(process.exitCode).toBe(0); + }); + + it('an unknown collection exits 1', async () => { + const { invoke, run } = command(); + + await invoke({ collection: 'nope' }); + + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Collection "nope" not found'); + expect(process.exitCode).toBe(1); + }); + + it("'writable' refuses a read-only collection with exit 1", async () => { + const { invoke, run } = command({ collection: 'writable' }); + + await invoke({ collection: 'vendor' }); + + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + '❌ Collection "vendor" is read-only. Its resources cannot be modified.', + ); + expect(process.exitCode).toBe(1); + }); + + it("'read' opens a read-only collection", async () => { + const { invoke, run } = command(); + + await invoke({ collection: 'vendor' }); + + expect(run).toHaveBeenCalled(); + }); + + it("'none' opens no collection", async () => { + const { invoke, run } = command({ collection: 'none' }); + + await invoke({}); + + expect(run).toHaveBeenCalledWith(expect.not.objectContaining({ collection: expect.anything() })); + expect(process.exitCode).toBe(0); + }); + }); + + describe('prompts and required options', () => { + const keyQuestion = (options: Options): prompts.PromptObject[] => + options.key ? [] : [{ type: 'text', name: 'key', message: 'Key' }]; + + it('asks the questions when interactive and merges the answers over the flags', async () => { + mockInteractive.mockReturnValue(true); + mockPrompts.mockResolvedValueOnce({ key: 'a.b' }); + const { invoke, run } = command({ prompts: keyQuestion, required: ['key'] }); + + await invoke({ collection: 'main' }); + + expect(run).toHaveBeenCalledWith(expect.objectContaining({ answers: { collection: 'main', key: 'a.b' } })); + }); + + it('passes the opened collection to the question builder', async () => { + const builder = vi.fn(() => []); + const { invoke } = command({ prompts: builder }); + + await invoke({ collection: 'main' }); + + expect(builder).toHaveBeenCalledWith( + { collection: 'main' }, + expect.objectContaining({ collection: expect.objectContaining({ name: 'main' }) }), + ); + }); + + it('fails with exit 1 naming every missing required flag when non-interactive', async () => { + const { invoke, run } = command({ + prompts: () => [{ type: 'text', name: 'key', message: 'Key' }], + required: ['key', 'targetFolder'], + }); + + await invoke({ collection: 'main' }); + + expect(mockPrompts).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + '❌ Missing required options in non-interactive mode: --key, --target-folder', + ); + expect(process.exitCode).toBe(1); + }); + + it('checks required options even when there are no questions', async () => { + const { invoke, run } = command({ prompts: () => [], required: ['key'] }); + + await invoke({ collection: 'main' }); + + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --key'); + expect(process.exitCode).toBe(1); + }); + + it("counts '' as missing", async () => { + const { invoke, run } = command({ required: ['key'] }); + + await invoke({ collection: 'main', key: '' }); + + expect(run).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('checks required options after prompting: an empty interactive answer exits 1', async () => { + mockInteractive.mockReturnValue(true); + mockPrompts.mockResolvedValueOnce({ key: '' }); + const { invoke, run } = command({ prompts: keyQuestion, required: ['key'] }); + + await invoke({ collection: 'main' }); + + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Missing required options: --key'); + expect(process.exitCode).toBe(1); + }); + + it('lets the question builder fail before required options are checked', async () => { + const { invoke, run } = command({ + prompts: () => { + throw new Error('Nothing to choose from'); + }, + required: ['key'], + }); + + await invoke({ collection: 'main' }); + + expect(run).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Nothing to choose from'); + expect(process.exitCode).toBe(1); + }); + + it('a cancelled prompt prints one cancel line and exits 0', async () => { + mockInteractive.mockReturnValue(true); + mockPrompts.mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'text', name: 'key', message: 'Key' }, {}); + return {}; + }); + const { invoke, run } = command({ prompts: keyQuestion }); + + await invoke({ collection: 'main' }); + + expect(run).not.toHaveBeenCalled(); + const cancelLines = vi.mocked(console.log).mock.calls.filter(([line]) => String(line).includes('cancelled')); + expect(cancelLines).toEqual([['❌ Do thing cancelled.']]); + expect(process.exitCode).toBe(0); + }); + + it('ask() inside run follows the same cancel rule', async () => { + mockPrompts.mockImplementationOnce(async (_questions, options) => { + options?.onCancel?.({ type: 'confirm', name: 'ok', message: 'Sure?' }, {}); + return {}; + }); + const after = vi.fn(); + const { invoke } = command({ + collection: 'none', + run: async ({ ask }) => { + await ask({ type: 'confirm', name: 'ok', message: 'Sure?' }); + after(); + }, + }); + + await invoke({}); + + expect(after).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('❌ Do thing cancelled.'); + expect(process.exitCode).toBe(0); + }); + }); + + describe('outcome', () => { + it('a thrown LingoTrackerError prints its message and exits 1', async () => { + const { invoke } = command({ + collection: 'none', + run: () => { + throw new InvalidResourceKeyError('bad..key', 'Invalid key "bad..key"'); + }, + }); + + await invoke({}); + + expect(console.log).toHaveBeenCalledWith('❌ Invalid key "bad..key"'); + expect(process.exitCode).toBe(1); + }); + + it('CommandCancelledError thrown by run is a cancel (exit 0)', async () => { + const { invoke } = command({ + collection: 'none', + run: () => { + throw new CommandCancelledError(); + }, + }); + + await invoke({}); + + expect(console.log).toHaveBeenCalledWith('❌ Do thing cancelled.'); + expect(process.exitCode).toBe(0); + }); + + it('{ exitCode: 1 } from run propagates', async () => { + const { invoke } = command({ collection: 'none', run: async () => ({ exitCode: 1 }) }); + + await invoke({}); + + expect(process.exitCode).toBe(1); + }); + + it('never calls process.exit', async () => { + const exit = vi.spyOn(process, 'exit'); + mockLoadConfig.mockImplementation(() => { + throw new ConfigNotFoundError('/project/.lingo-tracker.json'); + }); + + await command().invoke({}); + + expect(exit).not.toHaveBeenCalled(); + exit.mockRestore(); + }); + }); +}); diff --git a/apps/cli/src/runner/command-runner.ts b/apps/cli/src/runner/command-runner.ts new file mode 100644 index 00000000..d941a3f8 --- /dev/null +++ b/apps/cli/src/runner/command-runner.ts @@ -0,0 +1,301 @@ +import * as path from 'path'; +import prompts from 'prompts'; +import { + type Collection, + CONFIG_FILENAME, + ConfigNotFoundError, + ConfigParseError, + type LingoTrackerConfig, + loadConfig, + openCollection, +} from '@simoncodes-ca/core'; +import { ConsoleFormatter } from '../utils/console-formatter'; +import { isInteractiveTerminal } from './terminal'; + +/** + * What a command needs opened before it runs. + * - `'writable'` — one collection, opened with `writable: true` (a read-only one fails). + * - `'read'` — one collection, opened for reading. + * - `'none'` — no collection; the command reads `config` itself (or nothing, with `config: false`). + */ +export type CollectionNeed = 'writable' | 'read' | 'none'; + +/** What `run` may return: nothing (exit 0), or `{ exitCode: 1 }` for a failure it has already reported. */ +export type CommandResult = { readonly exitCode: 0 | 1 } | undefined; + +/** Flags merged with prompt answers. Prompt names match option keys; extra answers are `unknown`. */ +export type Answers = Options & Readonly>; + +/** The answers `run` gets: the `required` options are present (checked by the runner). */ +export type CheckedAnswers = Answers & { + readonly [K in Required]-?: NonNullable; +}; + +/** `config: false` is only allowed with `collection: 'none'`: a collection is opened from the config. */ +export type ConfigFlag = Need extends 'none' ? boolean : true; + +/** Runs follow-up prompts (confirmations, loops) under the runner's cancel rule. */ +export type Ask = (questions: prompts.PromptObject | prompts.PromptObject[]) => Promise>; + +interface BaseContext { + /** The project root: `INIT_CWD` (set by pnpm), else `process.cwd()`. */ + readonly cwd: string; + /** The runner's interactive rule, read once: stdin and stdout are both a terminal. */ + readonly interactive: boolean; + /** Prompts inside `run`. Cancelling (Ctrl+C) ends the command like any other cancel. */ + readonly ask: Ask; +} + +interface ConfigResources { + readonly config: LingoTrackerConfig; + /** Absolute path of `.lingo-tracker.json`. */ + readonly configPath: string; +} + +interface CollectionResources { + /** The collection named by the collection flag (or selected), opened by core `openCollection`. */ + readonly collection: Collection; +} + +type Resources = (WithConfig extends true + ? ConfigResources + : unknown) & + (Need extends 'none' ? unknown : CollectionResources); + +/** What `prompts` receives: everything but the answers. */ +export type PromptContext = BaseContext & + Resources; + +/** What `run` receives. */ +export type CommandContext< + Options, + Need extends CollectionNeed, + WithConfig extends boolean = true, + Required extends keyof Options = never, +> = PromptContext & { readonly answers: CheckedAnswers }; + +export interface CommandSpec< + Options extends object, + Need extends CollectionNeed, + WithConfig extends boolean, + Required extends keyof Options & string = never, +> { + /** Operation name for messages: `❌ cancelled.` */ + readonly name: string; + readonly collection: Need; + /** Option holding the collection name (default `collection`); named in the missing-option message. */ + readonly collectionOption?: keyof Options & string; + /** + * `false` skips loading `.lingo-tracker.json` (only `init` and `install-skill`). Only + * allowed with `collection: 'none'`; anything else does not compile. + */ + readonly config?: WithConfig; + /** + * Questions for missing values. Called in both modes, before `required` is checked, so + * it may throw to fail early (for example "nothing to choose from"). The questions are + * asked only when interactive. + */ + readonly prompts?: ( + options: Options, + ctx: PromptContext, + ) => prompts.PromptObject[] | Promise; + /** + * Options that must have a value before `run`: checked after the questions when + * interactive, and against the flags when not. `undefined`, `null` and `''` count as + * missing. `run` sees them typed as present. + */ + readonly required?: readonly Required[]; + /** The core call(s) and the output. Throw to fail with `❌ `. */ + readonly run: ( + ctx: CommandContext, + ) => Promise | Promise | CommandResult | void; +} + +/** + * Thrown to end a command as cancelled: the runner prints `❌ cancelled.` and + * exits 0. The runner throws it when a prompt is cancelled; a command throws it when the + * user declines a confirmation. + */ +export class CommandCancelledError extends Error { + constructor() { + super('Cancelled'); + this.name = 'CommandCancelledError'; + } +} + +/** + * Throws `Missing required options[ in non-interactive mode]: --a, --b` when any of + * `fields` is `undefined`, `null` or `''`. The runner uses it for `required`; a command + * whose required options depend on its state (`init`) calls it itself. + */ +export function requireOptions( + values: Options, + fields: readonly Field[], + interactive: boolean, +): asserts values is Options & { readonly [K in Field]-?: NonNullable } { + const missing = fields.filter((field) => isMissing(values[field])); + if (missing.length > 0) { + const mode = interactive ? '' : ' in non-interactive mode'; + throw new Error(`Missing required options${mode}: ${missing.map(toFlag).join(', ')}`); + } +} + +/** The project root: `INIT_CWD` (set by pnpm to where the command was typed), else `process.cwd()`. */ +function getCwd(): string { + return process.env.INIT_CWD || process.cwd(); +} + +/** + * Defines a CLI command. The returned function is what `main.ts` calls with the parsed + * Commander options. Curried so the options type is given and the rest is inferred: + * + * ```ts + * export const addLocaleCommand = defineCommand()({ + * name: 'Add locale', + * collection: 'writable', + * prompts: (options) => (options.locale ? [] : [{ type: 'text', name: 'locale', message: 'Locale' }]), + * required: ['locale'], + * run: async ({ collection, answers }) => { ... }, + * }); + * ``` + * + * The runner owns, in order: the project root and the interactive rule; loading the + * config; resolving and opening the collection; asking the questions; checking the + * required options; cancellation; and turning a thrown error into `❌ ` and exit + * code 1. It sets `process.exitCode` and returns; it never calls `process.exit()`. + */ +export function defineCommand() { + return < + const Need extends CollectionNeed, + const WithConfig extends ConfigFlag = true, + const Required extends keyof Options & string = never, + >( + spec: CommandSpec, + ): ((options: Options) => Promise) => { + return async (options: Options) => { + process.exitCode = await execute(spec, options); + }; + }; +} + +async function execute< + Options extends object, + Need extends CollectionNeed, + WithConfig extends boolean, + Required extends keyof Options & string, +>(spec: CommandSpec, options: Options): Promise<0 | 1> { + try { + const cwd = getCwd(); + const interactive = isInteractiveTerminal(); + let resources: Partial = {}; + + if (spec.config !== false) { + const config = readConfig(cwd); + resources = { config, configPath: path.join(cwd, CONFIG_FILENAME) }; + + if (spec.collection !== 'none') { + const flag = spec.collectionOption ?? 'collection'; + const given: unknown = options[flag as keyof Options]; + const name = + typeof given === 'string' && given.length > 0 ? given : await selectCollection(config, interactive, flag); + resources = { + ...resources, + collection: openCollection(config, name, { cwd, writable: spec.collection === 'writable' }), + }; + } + } + + // `resources` holds exactly what Need and WithConfig promise; the type cannot follow the branches above. + const promptContext = { cwd, interactive, ask, ...resources } as PromptContext; + + const questions = spec.prompts ? await spec.prompts(options, promptContext) : []; + const merged: Options = interactive && questions.length > 0 ? { ...options, ...(await ask(questions)) } : options; + requireOptions(merged, spec.required ?? [], interactive); + // requireOptions has just checked what CheckedAnswers claims; the type cannot follow it. + const answers = merged as CheckedAnswers; + + const result = await spec.run({ ...promptContext, answers }); + return result ? result.exitCode : 0; + } catch (error) { + return report(error, spec.name); + } +} + +/** Core `loadConfig`; an I/O failure other than a missing file reads as a parse failure, as before. */ +function readConfig(cwd: string): LingoTrackerConfig { + try { + return loadConfig({ cwd }); + } catch (error) { + if (error instanceof ConfigNotFoundError || error instanceof ConfigParseError) throw error; + throw new ConfigParseError(path.join(cwd, CONFIG_FILENAME), error instanceof Error ? error.message : String(error)); + } +} + +/** The runner's error for a command that needs a collection when the config has none. */ +export const NO_COLLECTIONS_MESSAGE = 'No collections found. Run `lingo-tracker add-collection` first.'; + +/** No `--collection`: none configured fails, one is used, several are prompted for (interactive) or fail. */ +async function selectCollection(config: LingoTrackerConfig, interactive: boolean, flag: string): Promise { + const names = Object.keys(config.collections ?? {}); + if (names.length === 0) { + throw new Error(NO_COLLECTIONS_MESSAGE); + } + if (names.length === 1) { + return names[0]; + } + if (!interactive) { + throw new Error(`Missing required option: ${toFlag(flag)}`); + } + const { collection } = await ask({ + type: 'select', + name: 'collection', + message: 'Select collection', + choices: names.map((name) => ({ title: name, value: name })), + }); + if (typeof collection !== 'string') { + throw new CommandCancelledError(); + } + return collection; +} + +async function ask(questions: prompts.PromptObject | prompts.PromptObject[]): Promise> { + let cancelled = false; + const answers: Record = await prompts(questions, { + onCancel: () => { + cancelled = true; + return false; + }, + }); + if (cancelled) { + throw new CommandCancelledError(); + } + return answers; +} + +/** Prints the failure and returns the exit code: 0 for a cancel, 1 for anything else. */ +function report(error: unknown, name: string): 0 | 1 { + if (error instanceof CommandCancelledError) { + ConsoleFormatter.error(`${name} cancelled.`); + return 0; + } + if (error instanceof ConfigNotFoundError) { + console.error(`❌ Configuration file ${CONFIG_FILENAME} not found.`); + console.error('Run "lingo-tracker init" to initialize a project.'); + return 1; + } + if (error instanceof ConfigParseError) { + console.error(`❌ Failed to parse configuration file: ${error.reason}`); + return 1; + } + ConsoleFormatter.error(error instanceof Error ? error.message : String(error)); + return 1; +} + +function isMissing(value: unknown): boolean { + return value === undefined || value === null || value === ''; +} + +/** `targetFolder` → `--target-folder`. */ +function toFlag(option: string): string { + return `--${option.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; +} diff --git a/apps/cli/src/runner/terminal.ts b/apps/cli/src/runner/terminal.ts new file mode 100644 index 00000000..ece18e72 --- /dev/null +++ b/apps/cli/src/runner/terminal.ts @@ -0,0 +1,22 @@ +/** + * The one place the CLI asks the terminal what it is attached to. The Command Runner + * reads it once per command; unit suites mock this module rather than the streams. + */ + +/** + * The CLI's single interactive rule: both stdin and stdout are a terminal. A pipe or a + * redirect on either side (CI, `| tee log.txt`, `> out.json`) means non-interactive: + * nothing is prompted, and required flags must be given. + */ +export function isInteractiveTerminal(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +/** + * True when stdin is not a terminal, so it can be read without waiting for a keyboard. + * A different question from {@link isInteractiveTerminal}: `glossary > out.json` is + * non-interactive, yet reading stdin there would wait for the keyboard. + */ +export function hasPipedStdin(): boolean { + return !process.stdin.isTTY; +} diff --git a/apps/cli/src/utils/collection-prompts.spec.ts b/apps/cli/src/utils/collection-prompts.spec.ts deleted file mode 100644 index 1a479961..00000000 --- a/apps/cli/src/utils/collection-prompts.spec.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { promptForCollection } from './collection-prompts'; -import prompts from 'prompts'; -import type { LingoTrackerConfig } from '@simoncodes-ca/core'; - -vi.mock('prompts'); - -describe('collection-prompts', () => { - const mockConfig: LingoTrackerConfig = { - exportFolder: 'dist/export', - importFolder: 'dist/import', - baseLocale: 'en', - locales: ['en', 'fr'], - collections: { - main: { - translationsFolder: 'src/i18n', - }, - admin: { - translationsFolder: 'src/admin/i18n', - }, - shared: { - translationsFolder: 'src/shared/i18n', - }, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('No Collections Available', () => { - it('should log error and return null when no collections exist', async () => { - const emptyConfig: LingoTrackerConfig = { - ...mockConfig, - collections: {}, - }; - - const result = await promptForCollection(emptyConfig); - - expect(result).toBeNull(); - expect(console.log).toHaveBeenCalledWith('❌ No collections found. Run `lingo-tracker add-collection` first.'); - expect(prompts).not.toHaveBeenCalled(); - }); - - it('should log error and return null when collections property is undefined', async () => { - const configWithoutCollections: LingoTrackerConfig = { - exportFolder: 'dist/export', - importFolder: 'dist/import', - baseLocale: 'en', - locales: ['en'], - }; - - const result = await promptForCollection(configWithoutCollections); - - expect(result).toBeNull(); - expect(console.log).toHaveBeenCalledWith('❌ No collections found. Run `lingo-tracker add-collection` first.'); - }); - }); - - describe('Value Already Provided', () => { - it('should return provided value without prompting', async () => { - const result = await promptForCollection(mockConfig, 'admin'); - - expect(result).toBe('admin'); - expect(prompts).not.toHaveBeenCalled(); - }); - - it('should return provided value even with single collection', async () => { - const singleCollectionConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - main: { - translationsFolder: 'src/i18n', - }, - }, - }; - - const result = await promptForCollection(singleCollectionConfig, 'main'); - - expect(result).toBe('main'); - expect(prompts).not.toHaveBeenCalled(); - }); - - it('should accept provided value regardless of validity (validation happens elsewhere)', async () => { - const result = await promptForCollection(mockConfig, 'nonexistent'); - - expect(result).toBe('nonexistent'); - expect(prompts).not.toHaveBeenCalled(); - }); - }); - - describe('Single Collection Auto-Selection', () => { - it('should auto-select when only one collection exists', async () => { - const singleCollectionConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - main: { - translationsFolder: 'src/i18n', - }, - }, - }; - - const result = await promptForCollection(singleCollectionConfig); - - expect(result).toBe('main'); - expect(prompts).not.toHaveBeenCalled(); - expect(console.log).not.toHaveBeenCalled(); - }); - - it('should auto-select correct collection name', async () => { - const singleCollectionConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - 'custom-name': { - translationsFolder: 'src/translations', - }, - }, - }; - - const result = await promptForCollection(singleCollectionConfig); - - expect(result).toBe('custom-name'); - }); - }); - - describe('Multiple Collections - TTY Mode', () => { - const originalIsTTY = process.stdout.isTTY; - - beforeEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - }); - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - configurable: true, - }); - }); - - it('should show prompt when multiple collections exist', async () => { - vi.mocked(prompts).mockResolvedValue({ collection: 'admin' }); - - const result = await promptForCollection(mockConfig); - - expect(result).toBe('admin'); - expect(prompts).toHaveBeenCalledWith({ - type: 'select', - name: 'collection', - message: 'Select collection', - choices: [ - { title: 'main', value: 'main' }, - { title: 'admin', value: 'admin' }, - { title: 'shared', value: 'shared' }, - ], - }); - }); - - it('should return selected collection from prompt', async () => { - vi.mocked(prompts).mockResolvedValue({ collection: 'shared' }); - - const result = await promptForCollection(mockConfig); - - expect(result).toBe('shared'); - }); - - it('should pass all collection names as choices in correct order', async () => { - vi.mocked(prompts).mockResolvedValue({ collection: 'main' }); - - await promptForCollection(mockConfig); - - const call = vi.mocked(prompts).mock.calls[0][0]; - expect(call.choices).toEqual([ - { title: 'main', value: 'main' }, - { title: 'admin', value: 'admin' }, - { title: 'shared', value: 'shared' }, - ]); - }); - - it('should return undefined when prompt is cancelled', async () => { - vi.mocked(prompts).mockResolvedValue({}); - - const result = await promptForCollection(mockConfig); - - expect(result).toBeUndefined(); - }); - }); - - describe('Multiple Collections - Non-TTY Mode', () => { - const originalIsTTY = process.stdout.isTTY; - - beforeEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - configurable: true, - }); - }); - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - configurable: true, - }); - }); - - it('should throw error when multiple collections exist without provided value', async () => { - await expect(promptForCollection(mockConfig)).rejects.toThrow('Missing required option: --collection'); - - expect(prompts).not.toHaveBeenCalled(); - }); - - it('should return provided value in non-TTY mode', async () => { - const result = await promptForCollection(mockConfig, 'admin'); - - expect(result).toBe('admin'); - expect(prompts).not.toHaveBeenCalled(); - }); - - it('should auto-select single collection even in non-TTY mode', async () => { - const singleCollectionConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - main: { - translationsFolder: 'src/i18n', - }, - }, - }; - - const result = await promptForCollection(singleCollectionConfig); - - expect(result).toBe('main'); - expect(prompts).not.toHaveBeenCalled(); - }); - }); - - describe('Edge Cases', () => { - it('should handle empty string as currentValue by treating it as not provided', async () => { - const singleCollectionConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - main: { - translationsFolder: 'src/i18n', - }, - }, - }; - - const result = await promptForCollection(singleCollectionConfig, ''); - - // Empty string is falsy, so should auto-select - expect(result).toBe('main'); - expect(prompts).not.toHaveBeenCalled(); - }); - - it('should preserve whitespace in collection names', async () => { - const configWithSpaces: LingoTrackerConfig = { - ...mockConfig, - collections: { - 'main collection': { - translationsFolder: 'src/i18n', - }, - }, - }; - - const result = await promptForCollection(configWithSpaces); - - expect(result).toBe('main collection'); - }); - - it('should handle special characters in collection names', async () => { - const configWithSpecialChars: LingoTrackerConfig = { - ...mockConfig, - collections: { - 'main-collection_v2': { - translationsFolder: 'src/i18n', - }, - }, - }; - - const result = await promptForCollection(configWithSpecialChars); - - expect(result).toBe('main-collection_v2'); - }); - - it('should maintain collection order from configuration', async () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - - vi.mocked(prompts).mockResolvedValue({ collection: 'zebra' }); - - const orderedConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - zebra: { translationsFolder: 'src/z' }, - alpha: { translationsFolder: 'src/a' }, - beta: { translationsFolder: 'src/b' }, - }, - }; - - await promptForCollection(orderedConfig); - - const call = vi.mocked(prompts).mock.calls[0][0]; - expect(call.choices.map((c: { value: string }) => c.value)).toEqual(['zebra', 'alpha', 'beta']); - }); - }); -}); diff --git a/apps/cli/src/utils/collection-prompts.ts b/apps/cli/src/utils/collection-prompts.ts deleted file mode 100644 index 57c04eec..00000000 --- a/apps/cli/src/utils/collection-prompts.ts +++ /dev/null @@ -1,55 +0,0 @@ -import prompts from 'prompts'; -import type { LingoTrackerConfig } from '@simoncodes-ca/core'; - -/** - * Prompts user to select a collection, with smart auto-selection behavior. - * - * - If value already provided → returns it - * - If only 1 collection → auto-selects it - * - If multiple collections → shows prompt (in TTY mode) - * - If no collections → logs error and returns null - * - If non-TTY mode without value → throws error - * - * @param config - LingoTracker configuration - * @param currentValue - Already provided collection value (from CLI option) - * @returns Selected collection name, or null if no collections available - * @throws Error if required in non-TTY mode without currentValue - * - * @example - * const collection = await promptForCollection(config, options.collection); - * if (!collection) return; - */ -export async function promptForCollection(config: LingoTrackerConfig, currentValue?: string): Promise { - const collections = Object.keys(config.collections || {}); - - // No collections available - if (collections.length === 0) { - console.log('❌ No collections found. Run `lingo-tracker add-collection` first.'); - return null; - } - - // Value already provided - if (currentValue) { - return currentValue; - } - - // Auto-select if only one collection - if (collections.length === 1) { - return collections[0]; - } - - // Non-TTY mode requires explicit value - if (!process.stdout.isTTY) { - throw new Error('Missing required option: --collection'); - } - - // Show prompt - const result = await prompts({ - type: 'select', - name: 'collection', - message: 'Select collection', - choices: collections.map((name) => ({ title: name, value: name })), - }); - - return result.collection; -} diff --git a/apps/cli/src/utils/collection-resolver.spec.ts b/apps/cli/src/utils/collection-resolver.spec.ts deleted file mode 100644 index b8428676..00000000 --- a/apps/cli/src/utils/collection-resolver.spec.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { resolveCollection, resolveWritableCollection } from './collection-resolver'; -import type { LingoTrackerConfig } from '@simoncodes-ca/core'; -import * as path from 'node:path'; - -describe('collection-resolver', () => { - const mockConfig: LingoTrackerConfig = { - exportFolder: 'dist/export', - importFolder: 'dist/import', - baseLocale: 'en', - locales: ['en', 'fr', 'de'], - collections: { - main: { - translationsFolder: 'src/i18n', - }, - admin: { - translationsFolder: 'src/admin/translations', - }, - shared: { - translationsFolder: '../shared/i18n', - }, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('Valid Collection Resolution', () => { - it('should resolve collection with correct name and config', () => { - const result = resolveCollection('main', mockConfig, '/project'); - - expect(result).not.toBeNull(); - expect(result?.name).toBe('main'); - expect(result?.config).toEqual({ - translationsFolder: 'src/i18n', - }); - }); - - it('should resolve translations folder path correctly', () => { - const result = resolveCollection('main', mockConfig, '/project'); - - expect(result?.translationsFolder).toBe(path.resolve('/project', 'src/i18n')); - }); - - it('should handle different collection names', () => { - const adminResult = resolveCollection('admin', mockConfig, '/project'); - const sharedResult = resolveCollection('shared', mockConfig, '/project'); - - expect(adminResult?.name).toBe('admin'); - expect(adminResult?.config.translationsFolder).toBe('src/admin/translations'); - - expect(sharedResult?.name).toBe('shared'); - expect(sharedResult?.config.translationsFolder).toBe('../shared/i18n'); - }); - - it('should resolve absolute paths correctly', () => { - const baseDir = '/Users/developer/projects/myapp'; - const result = resolveCollection('main', mockConfig, baseDir); - - expect(result?.translationsFolder).toBe(path.resolve(baseDir, 'src/i18n')); - }); - - it('should resolve relative paths correctly', () => { - const result = resolveCollection('shared', mockConfig, '/project'); - - expect(result?.translationsFolder).toBe(path.resolve('/project', '../shared/i18n')); - }); - - it('should handle Windows-style paths', () => { - const windowsConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - windows: { - translationsFolder: 'src\\translations\\i18n', - }, - }, - }; - - const result = resolveCollection('windows', windowsConfig, 'C:\\Projects\\App'); - - expect(result?.translationsFolder).toBe(path.resolve('C:\\Projects\\App', 'src\\translations\\i18n')); - }); - }); - - describe('Collection Not Found', () => { - it('should return null when collection does not exist', () => { - const result = resolveCollection('nonexistent', mockConfig, '/project'); - - expect(result).toBeNull(); - }); - - it('should log error message when collection not found', () => { - resolveCollection('nonexistent', mockConfig, '/project'); - - expect(console.log).toHaveBeenCalledWith('❌ Collection "nonexistent" not found.'); - }); - - it('should return null when collections object is empty', () => { - const emptyConfig: LingoTrackerConfig = { - ...mockConfig, - collections: {}, - }; - - const result = resolveCollection('main', emptyConfig, '/project'); - - expect(result).toBeNull(); - expect(console.log).toHaveBeenCalledWith('❌ Collection "main" not found.'); - }); - - it('should return null when collections property is undefined', () => { - const configWithoutCollections: LingoTrackerConfig = { - exportFolder: 'dist/export', - importFolder: 'dist/import', - baseLocale: 'en', - locales: ['en'], - }; - - const result = resolveCollection('main', configWithoutCollections, '/project'); - - expect(result).toBeNull(); - expect(console.log).toHaveBeenCalledWith('❌ Collection "main" not found.'); - }); - }); - - describe('Different Base Directories', () => { - it('should resolve paths correctly with different base directories', () => { - const baseDirs = ['/var/www/app', '/home/user/projects/myapp', 'C:\\Projects\\App', './relative/path']; - - baseDirs.forEach((baseDir) => { - const result = resolveCollection('main', mockConfig, baseDir); - - expect(result?.translationsFolder).toBe(path.resolve(baseDir, 'src/i18n')); - }); - }); - - it('should handle base directory with trailing slash', () => { - const result = resolveCollection('main', mockConfig, '/project/'); - - expect(result?.translationsFolder).toBe(path.resolve('/project/', 'src/i18n')); - }); - - it('should handle empty base directory', () => { - const result = resolveCollection('main', mockConfig, ''); - - expect(result?.translationsFolder).toBe(path.resolve('', 'src/i18n')); - }); - }); - - describe('Collection Config Variations', () => { - it('should preserve full collection config object', () => { - const complexConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - complex: { - translationsFolder: 'i18n', - baseLocale: 'de', - locales: ['de', 'en'], - }, - }, - }; - - const result = resolveCollection('complex', complexConfig, '/project'); - - expect(result?.config).toEqual({ - translationsFolder: 'i18n', - baseLocale: 'de', - locales: ['de', 'en'], - }); - }); - - it('should handle minimal collection config', () => { - const minimalConfig: LingoTrackerConfig = { - baseLocale: 'en', - locales: ['en'], - collections: { - minimal: { - translationsFolder: 'translations', - }, - }, - }; - - const result = resolveCollection('minimal', minimalConfig, '/app'); - - expect(result?.config).toEqual({ - translationsFolder: 'translations', - }); - }); - }); - - describe('Edge Cases', () => { - it('should handle collection names with special characters', () => { - const specialConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - 'main-collection_v2': { - translationsFolder: 'src/i18n', - }, - }, - }; - - const result = resolveCollection('main-collection_v2', specialConfig, '/project'); - - expect(result?.name).toBe('main-collection_v2'); - expect(result).not.toBeNull(); - }); - - it('should handle collection names with spaces', () => { - const spaceConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - 'main collection': { - translationsFolder: 'src/i18n', - }, - }, - }; - - const result = resolveCollection('main collection', spaceConfig, '/project'); - - expect(result?.name).toBe('main collection'); - expect(result).not.toBeNull(); - }); - - it('should handle translation folder paths with dots', () => { - const dotConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - dotted: { - translationsFolder: './src/./i18n', - }, - }, - }; - - const result = resolveCollection('dotted', dotConfig, '/project'); - - expect(result?.translationsFolder).toBe(path.resolve('/project', './src/./i18n')); - }); - - it('should handle translation folder paths starting with slash', () => { - const slashConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - absolute: { - translationsFolder: '/absolute/path/i18n', - }, - }, - }; - - const result = resolveCollection('absolute', slashConfig, '/project'); - - expect(result?.translationsFolder).toBe(path.resolve('/project', '/absolute/path/i18n')); - }); - - it('should not modify the original config object', () => { - const originalConfig = { ...mockConfig }; - const originalCollections = { ...mockConfig.collections }; - - resolveCollection('main', mockConfig, '/project'); - - expect(mockConfig).toEqual(originalConfig); - expect(mockConfig.collections).toEqual(originalCollections); - }); - }); - - describe('Console Output', () => { - it('should only log error for non-existent collection', () => { - resolveCollection('missing', mockConfig, '/project'); - - expect(console.log).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenCalledWith('❌ Collection "missing" not found.'); - }); - - it('should not log anything for successful resolution', () => { - resolveCollection('main', mockConfig, '/project'); - - expect(console.log).not.toHaveBeenCalled(); - }); - - it('should include correct collection name in error message', () => { - resolveCollection('wrong-collection-name', mockConfig, '/project'); - - expect(console.log).toHaveBeenCalledWith('❌ Collection "wrong-collection-name" not found.'); - }); - }); - - describe('resolveWritableCollection', () => { - const readOnlyConfig: LingoTrackerConfig = { - ...mockConfig, - collections: { - ...mockConfig.collections, - vendor: { translationsFolder: 'node_modules/@scope/lib/i18n', readOnly: true }, - }, - }; - - beforeEach(() => { - process.exitCode = undefined; - }); - - afterEach(() => { - process.exitCode = undefined; - }); - - it('returns the resolved collection when it is writable', () => { - const result = resolveWritableCollection('main', mockConfig, '/project'); - - expect(result?.name).toBe('main'); - expect(process.exitCode).toBeUndefined(); - }); - - it('returns null and sets a non-zero exit code for a read-only collection', () => { - const result = resolveWritableCollection('vendor', readOnlyConfig, '/project'); - - expect(result).toBeNull(); - expect(process.exitCode).toBe(1); - expect(console.log).toHaveBeenCalledWith( - '❌ Collection "vendor" is read-only. Its resources cannot be modified.', - ); - }); - - it('returns null without setting exit code when the collection does not exist', () => { - const result = resolveWritableCollection('missing', mockConfig, '/project'); - - expect(result).toBeNull(); - expect(process.exitCode).toBeUndefined(); - }); - }); -}); diff --git a/apps/cli/src/utils/collection-resolver.ts b/apps/cli/src/utils/collection-resolver.ts deleted file mode 100644 index 7f3e0b85..00000000 --- a/apps/cli/src/utils/collection-resolver.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - type Collection, - CollectionNotFoundError, - type LingoTrackerConfig, - openCollection, - ReadOnlyCollectionError, -} from '@simoncodes-ca/core'; -import { ErrorMessages } from './error-messages'; - -/** - * Opens a collection for a CLI command: core `openCollection` resolves it (effective base - * locale, locales, translation config, absolute translations folder); this wrapper turns - * a missing collection into CLI output. - * - * @param collectionName - Name of collection to resolve - * @param config - LingoTracker configuration - * @param baseDirectory - Directory the translations folder resolves against - * @returns The resolved collection, or null (after printing an error) if not found - * - * @example - * const collection = resolveCollection('main', config, cwd); - * if (!collection) return; - * // Use: collection.translationsFolder, collection.baseLocale, collection.locales - */ -export function resolveCollection( - collectionName: string, - config: LingoTrackerConfig, - baseDirectory: string, -): Collection | null { - return open(collectionName, config, baseDirectory, false); -} - -/** - * Resolves a collection for a mutating operation. Behaves like {@link resolveCollection}, - * but additionally refuses read-only collections: it prints an error, sets a non-zero exit - * code (so the failure is detectable in CI), and returns null. - * - * Use this in commands that modify resources (add/edit/delete/move/normalize/import, - * locale changes, auto-translate). Commands that only read, or that operate on the - * collection's registration (delete-collection), should use {@link resolveCollection}. - * - * @example - * const collection = resolveWritableCollection('main', config, cwd); - * if (!collection) return; - */ -export function resolveWritableCollection( - collectionName: string, - config: LingoTrackerConfig, - baseDirectory: string, -): Collection | null { - return open(collectionName, config, baseDirectory, true); -} - -function open( - collectionName: string, - config: LingoTrackerConfig, - baseDirectory: string, - writable: boolean, -): Collection | null { - try { - return openCollection(config, collectionName, { cwd: baseDirectory, writable }); - } catch (error) { - if (error instanceof CollectionNotFoundError) { - console.log(ErrorMessages.COLLECTION_NOT_FOUND(collectionName)); - return null; - } - if (error instanceof ReadOnlyCollectionError) { - console.log(ErrorMessages.COLLECTION_READ_ONLY(collectionName)); - process.exitCode = 1; - return null; - } - throw error; - } -} diff --git a/apps/cli/src/utils/config-loader.spec.ts b/apps/cli/src/utils/config-loader.spec.ts deleted file mode 100644 index 1be0a983..00000000 --- a/apps/cli/src/utils/config-loader.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { loadConfiguration } from './config-loader'; - -describe('config-loader', () => { - const mockConfig = { - exportFolder: 'dist/lingo-export', - importFolder: 'dist/lingo-import', - baseLocale: 'en', - locales: ['en', 'es', 'fr'], - collections: { - default: { - translationsFolder: 'src/translations', - }, - }, - }; - - let projectDir: string; - - function writeConfig(content: string, dir = projectDir): void { - writeFileSync(join(dir, '.lingo-tracker.json'), content, 'utf8'); - } - - beforeEach(() => { - vi.clearAllMocks(); - projectDir = mkdtempSync(join(tmpdir(), 'lingo-config-loader-')); - vi.spyOn(process, 'cwd').mockReturnValue(projectDir); - - // Mock console methods - vi.spyOn(console, 'error').mockImplementation(() => undefined); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - - // Reset environment variables - delete process.env.INIT_CWD; - }); - - afterEach(() => { - vi.restoreAllMocks(); - rmSync(projectDir, { recursive: true, force: true }); - }); - - function mockExit(): void { - vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`Process exit: ${code}`); - }); - } - - describe('Happy Path', () => { - it('should successfully load valid configuration', () => { - writeConfig(JSON.stringify(mockConfig)); - - const result = loadConfiguration(); - - expect(result).not.toBeNull(); - expect(result?.config).toEqual(mockConfig); - expect(result?.configPath).toBe(join(projectDir, '.lingo-tracker.json')); - expect(result?.cwd).toBe(projectDir); - }); - - it('should parse complex configuration with bundles', () => { - const complexConfig = { - ...mockConfig, - bundles: { - 'admin-bundle': { - collections: ['admin', 'shared'], - outputFormat: 'single-file', - }, - }, - }; - writeConfig(JSON.stringify(complexConfig)); - - const result = loadConfiguration(); - - expect(result?.config).toEqual(complexConfig); - expect(result?.config.bundles).toBeDefined(); - }); - }); - - describe('File Not Found', () => { - it('should exit with code 1 when config file not found (exitOnError: true)', () => { - mockExit(); - - expect(() => loadConfiguration()).toThrow('Process exit: 1'); - expect(console.error).toHaveBeenCalledWith('❌ Configuration file .lingo-tracker.json not found.'); - expect(console.error).toHaveBeenCalledWith('Run "lingo-tracker init" to initialize a project.'); - expect(process.exit).toHaveBeenCalledWith(1); - }); - - it('should return null when config file not found (exitOnError: false)', () => { - mockExit(); - - const result = loadConfiguration({ exitOnError: false }); - - expect(result).toBeNull(); - expect(console.error).toHaveBeenCalledWith('❌ Configuration file .lingo-tracker.json not found.'); - expect(console.error).toHaveBeenCalledWith('Run "lingo-tracker init" to initialize a project.'); - expect(process.exit).not.toHaveBeenCalled(); - }); - }); - - describe('Invalid JSON', () => { - it('should exit with code 1 when config file has invalid JSON (exitOnError: true)', () => { - writeConfig('{ invalid json'); - mockExit(); - - expect(() => loadConfiguration()).toThrow('Process exit: 1'); - expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/^❌ Failed to parse configuration file:/)); - expect(process.exit).toHaveBeenCalledWith(1); - }); - - it('should return null when config file has invalid JSON (exitOnError: false)', () => { - writeConfig('{ invalid json'); - mockExit(); - - const result = loadConfiguration({ exitOnError: false }); - - expect(result).toBeNull(); - expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/^❌ Failed to parse configuration file:/)); - expect(process.exit).not.toHaveBeenCalled(); - }); - - it('should include specific parse error message', () => { - writeConfig('{ invalid json'); - - const result = loadConfiguration({ exitOnError: false }); - - expect(result).toBeNull(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Failed to parse configuration file:')); - // Verify the error message contains some parsing error detail - const errorCall = vi.mocked(console.error).mock.calls.find((call) => String(call[0]).includes('Failed to parse')); - expect(errorCall?.[0]).toMatch(/Expected|Unexpected|position|JSON/i); - }); - }); - - describe('INIT_CWD Handling', () => { - it('should use INIT_CWD environment variable when set (pnpm compatibility)', () => { - const pnpmDir = mkdtempSync(join(tmpdir(), 'lingo-config-loader-init-cwd-')); - try { - writeConfig(JSON.stringify(mockConfig), pnpmDir); - process.env.INIT_CWD = pnpmDir; - - const result = loadConfiguration(); - - expect(result).not.toBeNull(); - expect(result?.cwd).toBe(pnpmDir); - expect(result?.configPath).toBe(join(pnpmDir, '.lingo-tracker.json')); - } finally { - rmSync(pnpmDir, { recursive: true, force: true }); - } - }); - - it('should fall back to process.cwd() when INIT_CWD not set', () => { - writeConfig(JSON.stringify(mockConfig)); - - const result = loadConfiguration(); - - expect(result).not.toBeNull(); - expect(result?.cwd).toBe(projectDir); - expect(result?.configPath).toBe(join(projectDir, '.lingo-tracker.json')); - }); - }); - - describe('Error Messages', () => { - it('should display exact error message for file not found', () => { - loadConfiguration({ exitOnError: false }); - - expect(console.error).toHaveBeenCalledWith('❌ Configuration file .lingo-tracker.json not found.'); - expect(console.error).toHaveBeenCalledWith('Run "lingo-tracker init" to initialize a project.'); - expect(console.error).toHaveBeenCalledTimes(2); - }); - - it('should display exact error message format for parse failure', () => { - writeConfig('{ invalid json'); - - loadConfiguration({ exitOnError: false }); - - const errorCalls = vi.mocked(console.error).mock.calls; - expect(errorCalls.length).toBe(1); - expect(errorCalls[0][0]).toMatch(/^❌ Failed to parse configuration file: /); - }); - }); - - describe('Edge Cases', () => { - it('should handle empty configuration file', () => { - writeConfig('{}'); - - const result = loadConfiguration(); - - expect(result).not.toBeNull(); - expect(result?.config).toEqual({}); - }); - - it('should handle configuration with minimal properties', () => { - const minimalConfig = { - baseLocale: 'en', - locales: ['en'], - collections: {}, - }; - writeConfig(JSON.stringify(minimalConfig)); - - const result = loadConfiguration(); - - expect(result).not.toBeNull(); - expect(result?.config).toEqual(minimalConfig); - }); - - it('should handle file read errors other than not found', () => { - // A directory in place of the file: it exists, but reading it fails (EISDIR). - mkdirSync(join(projectDir, '.lingo-tracker.json')); - - const result = loadConfiguration({ exitOnError: false }); - - expect(result).toBeNull(); - expect(console.error).toHaveBeenCalledWith( - expect.stringMatching(/^❌ Failed to parse configuration file: .*EISDIR/), - ); - }); - }); -}); diff --git a/apps/cli/src/utils/config-loader.ts b/apps/cli/src/utils/config-loader.ts deleted file mode 100644 index 196be402..00000000 --- a/apps/cli/src/utils/config-loader.ts +++ /dev/null @@ -1,119 +0,0 @@ -import * as path from 'path'; -import { - CONFIG_FILENAME, - ConfigNotFoundError, - ConfigParseError, - type LingoTrackerConfig, - loadConfig, -} from '@simoncodes-ca/core'; - -/** - * Gets the current working directory, respecting INIT_CWD for pnpm compatibility. - * - * This utility centralizes the directory resolution logic used across CLI commands. - * The INIT_CWD environment variable is set by pnpm and contains the directory where - * the command was originally invoked, before pnpm changed to the package directory. - * - * @returns Absolute path to the current working directory - * - * @example - * ```typescript - * const cwd = getCwd(); - * const configPath = path.join(cwd, '.lingo-tracker.json'); - * ``` - */ -export function getCwd(): string { - return process.env.INIT_CWD || process.cwd(); -} - -/** - * Result returned from successful configuration loading. - */ -export interface ConfigLoadResult { - /** - * The parsed LingoTracker configuration object. - */ - config: LingoTrackerConfig; - - /** - * Absolute path to the configuration file. - */ - configPath: string; - - /** - * Current working directory where configuration was loaded from. - * Respects INIT_CWD environment variable for pnpm compatibility. - */ - cwd: string; -} - -/** - * Options for configuration loading behavior. - */ -export interface ConfigLoadOptions { - /** - * When true (default), exits the process with code 1 on errors. - * When false, returns null on errors instead of exiting. - */ - exitOnError?: boolean; -} - -/** - * Loads the LingoTracker configuration file (.lingo-tracker.json) for a CLI command. - * - * Reading and parsing is done by core `loadConfig`, the single config reader. This - * wrapper only picks the directory and turns failures into CLI output. - * - * **Directory Resolution:** - * - Respects INIT_CWD environment variable (pnpm compatibility) - * - Falls back to process.cwd() if INIT_CWD not set - * - * **Error Handling:** - * - File not found: Displays helpful message suggesting to run `lingo-tracker init` - * - Parse or read errors: Shows the specific error message - * - Behavior controlled by `exitOnError` option (default: exit process) - * - * @param options - Configuration loading options - * @returns Configuration result on success, null on error (when exitOnError is false) - * - * @example - * ```typescript - * // Default behavior - exits on error - * const loaded = loadConfiguration(); - * if (!loaded) return; // TypeScript guard (never reached in practice) - * const { config, cwd } = loaded; - * ``` - * - * @example - * ```typescript - * // Custom error handling - returns null on error - * const loaded = loadConfiguration({ exitOnError: false }); - * if (!loaded) { - * // Handle error gracefully - * return; - * } - * const { config, cwd } = loaded; - * ``` - */ -export function loadConfiguration(options?: ConfigLoadOptions): ConfigLoadResult | null { - const exitOnError = options?.exitOnError ?? true; - const cwd = getCwd(); - - try { - return { config: loadConfig({ cwd }), configPath: path.join(cwd, CONFIG_FILENAME), cwd }; - } catch (error) { - if (error instanceof ConfigNotFoundError) { - console.error(`❌ Configuration file ${CONFIG_FILENAME} not found.`); - console.error('Run "lingo-tracker init" to initialize a project.'); - } else { - const reason = - error instanceof ConfigParseError ? error.reason : error instanceof Error ? error.message : String(error); - console.error(`❌ Failed to parse configuration file: ${reason}`); - } - - if (exitOnError) { - process.exit(1); - } - return null; - } -} diff --git a/apps/cli/src/utils/error-messages.spec.ts b/apps/cli/src/utils/error-messages.spec.ts index de5f4906..e8070d5f 100644 --- a/apps/cli/src/utils/error-messages.spec.ts +++ b/apps/cli/src/utils/error-messages.spec.ts @@ -20,46 +20,12 @@ describe('ErrorMessages', () => { }); describe('Collection Errors', () => { - it('should provide collection not found message', () => { - expect(ErrorMessages.COLLECTION_NOT_FOUND('main')).toBe('❌ Collection "main" not found.'); - }); - - it('should provide no collections message', () => { - expect(ErrorMessages.NO_COLLECTIONS).toBe('❌ No collections found. Run `lingo-tracker add-collection` first.'); - }); - - it('should provide collection exists message', () => { - expect(ErrorMessages.COLLECTION_EXISTS('main')).toBe('❌ Collection "main" already exists.'); - }); - it('should provide no collections available message', () => { expect(ErrorMessages.NO_COLLECTIONS_AVAILABLE).toBe('❌ No collections available.'); }); }); - describe('Option Errors', () => { - it('should provide missing single option message', () => { - expect(ErrorMessages.MISSING_OPTION('collection')).toBe('❌ Missing required option: --collection'); - }); - - it('should provide missing multiple options message', () => { - expect(ErrorMessages.MISSING_OPTIONS(['collection', 'key'])).toBe( - '❌ Missing required options: --collection, --key', - ); - }); - - it('should provide non-interactive missing options message', () => { - expect(ErrorMessages.MISSING_OPTIONS_NON_INTERACTIVE(['format', 'output'])).toBe( - '❌ Missing required options in non-interactive mode: --format, --output', - ); - }); - }); - describe('Operation Errors', () => { - it('should provide operation cancelled message', () => { - expect(ErrorMessages.OPERATION_CANCELLED('Add resource')).toBe('❌ Add resource cancelled.'); - }); - it('should provide operation failed message without reason', () => { expect(ErrorMessages.OPERATION_FAILED('Build')).toBe('❌ Build failed.'); }); diff --git a/apps/cli/src/utils/error-messages.ts b/apps/cli/src/utils/error-messages.ts index 8d48e2db..96df7b68 100644 --- a/apps/cli/src/utils/error-messages.ts +++ b/apps/cli/src/utils/error-messages.ts @@ -13,8 +13,6 @@ * import { ErrorMessages } from './error-messages'; * * console.log(ErrorMessages.CONFIG_NOT_FOUND); - * console.log(ErrorMessages.COLLECTION_NOT_FOUND('main')); - * console.log(ErrorMessages.MISSING_OPTIONS(['collection', 'key'])); * ``` */ @@ -37,22 +35,6 @@ export const ErrorMessages = { CONFIG_PARSE_FAILED: (error: string) => `❌ Failed to parse configuration file: ${error}`, // Collection Errors - /** - * Error when specified collection does not exist - * @param name - Name of the collection that was not found - */ - COLLECTION_NOT_FOUND: (name: string) => `❌ Collection "${name}" not found.`, - - /** - * Error when no collections exist in configuration - */ - NO_COLLECTIONS: '❌ No collections found. Run `lingo-tracker add-collection` first.', - - /** - * Error when trying to create a collection that already exists - * @param name - Name of the existing collection - */ - COLLECTION_EXISTS: (name: string) => `❌ Collection "${name}" already exists.`, /** * Error when no collections are available for an operation @@ -66,31 +48,8 @@ export const ErrorMessages = { COLLECTION_READ_ONLY: (name: string) => `❌ Collection "${name}" is read-only. Its resources cannot be modified.`, // Option Errors - /** - * Error when a single required option is missing - * @param option - Name of the missing option (without -- prefix) - */ - MISSING_OPTION: (option: string) => `❌ Missing required option: --${option}`, - - /** - * Error when multiple required options are missing - * @param options - Array of missing option names (without -- prefix) - */ - MISSING_OPTIONS: (options: string[]) => `❌ Missing required options: ${options.map((o) => `--${o}`).join(', ')}`, - - /** - * Error when required option is missing in non-interactive mode - * @param options - Array of missing option names (without -- prefix) - */ - MISSING_OPTIONS_NON_INTERACTIVE: (options: string[]) => - `❌ Missing required options in non-interactive mode: ${options.map((o) => `--${o}`).join(', ')}`, // Operation Errors - /** - * Error when user cancels an operation - * @param operation - Name of the operation that was cancelled - */ - OPERATION_CANCELLED: (operation: string) => `❌ ${operation} cancelled.`, /** * Generic error for failed operations diff --git a/apps/cli/src/utils/index.ts b/apps/cli/src/utils/index.ts index 0e776165..377012ff 100644 --- a/apps/cli/src/utils/index.ts +++ b/apps/cli/src/utils/index.ts @@ -1,11 +1,7 @@ -export * from './collection-prompts'; -export * from './collection-resolver'; -export * from './config-loader'; export * from './console-formatter'; export * from './error-messages'; export * from './preferred-terminology-warnings'; export * from './prompt-utils'; -export * from './report-error'; export * from './result-aggregator'; export * from './string-parsers'; export * from './summary-path'; diff --git a/apps/cli/src/utils/prompt-utils.spec.ts b/apps/cli/src/utils/prompt-utils.spec.ts index fe4cf032..c96c5c28 100644 --- a/apps/cli/src/utils/prompt-utils.spec.ts +++ b/apps/cli/src/utils/prompt-utils.spec.ts @@ -1,18 +1,5 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { - ALL_ITEMS_SENTINEL, - processMultiselectWithAll, - multiselectResultToString, - isInteractiveTerminal, - executePromptsWithFallback, -} from './prompt-utils'; - -// Mock prompts module -vi.mock('prompts', () => ({ - default: vi.fn(), -})); - -import prompts from 'prompts'; +import { describe, expect, it } from 'vitest'; +import { ALL_ITEMS_SENTINEL, multiselectResultToString, processMultiselectWithAll } from './prompt-utils'; describe('ALL_ITEMS_SENTINEL', () => { it('should have the correct sentinel value', () => { @@ -21,60 +8,58 @@ describe('ALL_ITEMS_SENTINEL', () => { }); describe('processMultiselectWithAll', () => { - const allAvailableItems = ['en', 'fr', 'de', 'es']; - it('should return selected items when specific items are chosen', () => { - const result = processMultiselectWithAll(['en', 'fr'], allAvailableItems); + const result = processMultiselectWithAll(['en', 'fr']); expect(result).toEqual(['en', 'fr']); }); it('should return undefined when __ALL__ is selected', () => { - const result = processMultiselectWithAll([ALL_ITEMS_SENTINEL], allAvailableItems); + const result = processMultiselectWithAll([ALL_ITEMS_SENTINEL]); expect(result).toBeUndefined(); }); it('should return undefined when __ALL__ plus other items are selected (All takes precedence)', () => { - const result = processMultiselectWithAll([ALL_ITEMS_SENTINEL, 'en', 'fr'], allAvailableItems); + const result = processMultiselectWithAll([ALL_ITEMS_SENTINEL, 'en', 'fr']); expect(result).toBeUndefined(); }); it('should return undefined when __ALL__ is in the middle of selections', () => { - const result = processMultiselectWithAll(['en', ALL_ITEMS_SENTINEL, 'fr'], allAvailableItems); + const result = processMultiselectWithAll(['en', ALL_ITEMS_SENTINEL, 'fr']); expect(result).toBeUndefined(); }); it('should return undefined for empty array', () => { - const result = processMultiselectWithAll([], allAvailableItems); + const result = processMultiselectWithAll([]); expect(result).toBeUndefined(); }); it('should return undefined for undefined input', () => { - const result = processMultiselectWithAll(undefined, allAvailableItems); + const result = processMultiselectWithAll(undefined); expect(result).toBeUndefined(); }); it('should return single item in array when one item is selected', () => { - const result = processMultiselectWithAll(['en'], allAvailableItems); + const result = processMultiselectWithAll(['en']); expect(result).toEqual(['en']); }); it('should return all items when all are manually selected (no __ALL__)', () => { - const result = processMultiselectWithAll(['en', 'fr', 'de', 'es'], allAvailableItems); + const result = processMultiselectWithAll(['en', 'fr', 'de', 'es']); expect(result).toEqual(['en', 'fr', 'de', 'es']); }); it('should preserve order of selected items', () => { - const result = processMultiselectWithAll(['es', 'en', 'de'], allAvailableItems); + const result = processMultiselectWithAll(['es', 'en', 'de']); expect(result).toEqual(['es', 'en', 'de']); }); it('should handle empty available items list', () => { - const result = processMultiselectWithAll(['en', 'fr'], []); + const result = processMultiselectWithAll(['en', 'fr']); expect(result).toEqual(['en', 'fr']); }); it('should return undefined when __ALL__ is selected with empty available items', () => { - const result = processMultiselectWithAll([ALL_ITEMS_SENTINEL], []); + const result = processMultiselectWithAll([ALL_ITEMS_SENTINEL]); expect(result).toBeUndefined(); }); }); @@ -115,296 +100,3 @@ describe('multiselectResultToString', () => { expect(result).toBe('a,b,c,d,e,f'); }); }); - -describe('isInteractiveTerminal', () => { - let originalStdinIsTTY: boolean | undefined; - let originalStdoutIsTTY: boolean | undefined; - - beforeEach(() => { - // Save original values - originalStdinIsTTY = process.stdin.isTTY; - originalStdoutIsTTY = process.stdout.isTTY; - }); - - afterEach(() => { - // Restore original values - Object.defineProperty(process.stdin, 'isTTY', { - value: originalStdinIsTTY, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: originalStdoutIsTTY, - configurable: true, - }); - }); - - it('should return true when both stdin and stdout are TTY', () => { - Object.defineProperty(process.stdin, 'isTTY', { - value: true, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - - expect(isInteractiveTerminal()).toBe(true); - }); - - it('should return false when stdin is not TTY', () => { - Object.defineProperty(process.stdin, 'isTTY', { - value: false, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - - expect(isInteractiveTerminal()).toBe(false); - }); - - it('should return false when stdout is not TTY', () => { - Object.defineProperty(process.stdin, 'isTTY', { - value: true, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - configurable: true, - }); - - expect(isInteractiveTerminal()).toBe(false); - }); - - it('should return false when both stdin and stdout are not TTY', () => { - Object.defineProperty(process.stdin, 'isTTY', { - value: false, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - configurable: true, - }); - - expect(isInteractiveTerminal()).toBe(false); - }); - - it('should return false when stdin.isTTY is undefined', () => { - Object.defineProperty(process.stdin, 'isTTY', { - value: undefined, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - - expect(isInteractiveTerminal()).toBe(false); - }); - - it('should return false when stdout.isTTY is undefined', () => { - Object.defineProperty(process.stdin, 'isTTY', { - value: true, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: undefined, - configurable: true, - }); - - expect(isInteractiveTerminal()).toBe(false); - }); -}); - -describe('executePromptsWithFallback', () => { - let originalStdinIsTTY: boolean | undefined; - let originalStdoutIsTTY: boolean | undefined; - - beforeEach(() => { - // Save original values - originalStdinIsTTY = process.stdin.isTTY; - originalStdoutIsTTY = process.stdout.isTTY; - vi.clearAllMocks(); - }); - - afterEach(() => { - // Restore original values - Object.defineProperty(process.stdin, 'isTTY', { - value: originalStdinIsTTY, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: originalStdoutIsTTY, - configurable: true, - }); - }); - - describe('when no questions are provided', () => { - it('should return current values without prompting', async () => { - const currentValues = { key: 'test', value: 'hello' }; - const result = await executePromptsWithFallback({ - questions: [], - currentValues, - }); - - expect(result).toEqual(currentValues); - }); - }); - - describe('in interactive mode (TTY)', () => { - beforeEach(() => { - Object.defineProperty(process.stdin, 'isTTY', { - value: true, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - }); - - it('should show prompts and merge with current values', async () => { - const currentValues = { existing: 'value' }; - const promptResponse = { key: 'newkey', value: 'newvalue' }; - - vi.mocked(prompts).mockResolvedValue(promptResponse); - - const questions = [ - { type: 'text', name: 'key', message: 'Enter key' }, - { type: 'text', name: 'value', message: 'Enter value' }, - ]; - - const result = await executePromptsWithFallback({ - questions, - currentValues, - }); - - expect(result).toEqual({ - existing: 'value', - key: 'newkey', - value: 'newvalue', - }); - }); - - it('should throw error with operation name when cancelled', async () => { - vi.mocked(prompts).mockImplementation(async (_questions: any, options: any) => { - options.onCancel(); - return {}; - }); - - const questions = [{ type: 'text', name: 'key', message: 'Enter key' }]; - - await expect( - executePromptsWithFallback({ - questions, - currentValues: {}, - operationName: 'Add resource', - }), - ).rejects.toThrow('Add resource cancelled'); - }); - - it('should use default operation name when cancelled without operationName', async () => { - vi.mocked(prompts).mockImplementation(async (_questions: any, options: any) => { - options.onCancel(); - return {}; - }); - - const questions = [{ type: 'text', name: 'key', message: 'Enter key' }]; - - await expect( - executePromptsWithFallback({ - questions, - currentValues: {}, - }), - ).rejects.toThrow('Operation cancelled'); - }); - }); - - describe('in non-interactive mode (non-TTY)', () => { - beforeEach(() => { - Object.defineProperty(process.stdin, 'isTTY', { - value: false, - configurable: true, - }); - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - }); - - it('should return current values when all required fields are present', async () => { - const currentValues = { key: 'test', value: 'hello', collection: 'main' }; - const questions = [ - { type: 'text', name: 'key', message: 'Enter key' }, - { type: 'text', name: 'value', message: 'Enter value' }, - ]; - - const result = await executePromptsWithFallback({ - questions, - currentValues, - requiredFields: ['key', 'value'], - }); - - expect(result).toEqual(currentValues); - }); - - it('should throw error when required fields are missing', async () => { - const currentValues = { key: 'test' }; - const questions = [ - { type: 'text', name: 'key', message: 'Enter key' }, - { type: 'text', name: 'value', message: 'Enter value' }, - ]; - - await expect( - executePromptsWithFallback({ - questions, - currentValues, - requiredFields: ['key', 'value', 'collection'], - }), - ).rejects.toThrow('Missing required options in non-interactive mode: --value, --collection'); - }); - - it('should throw error listing all missing fields', async () => { - const currentValues = {}; - const questions = [{ type: 'text', name: 'key', message: 'Enter key' }]; - - await expect( - executePromptsWithFallback({ - questions, - currentValues, - requiredFields: ['key', 'value', 'collection'], - }), - ).rejects.toThrow('Missing required options in non-interactive mode: --key, --value, --collection'); - }); - - it('should return current values when no required fields specified', async () => { - const currentValues = { key: 'test' }; - const questions = [{ type: 'text', name: 'value', message: 'Enter value' }]; - - const result = await executePromptsWithFallback({ - questions, - currentValues, - }); - - expect(result).toEqual(currentValues); - }); - - it('should handle falsy values correctly (0, empty string)', async () => { - const currentValues = { count: 0, name: '' }; - const questions = [ - { type: 'number', name: 'count', message: 'Enter count' }, - { type: 'text', name: 'name', message: 'Enter name' }, - ]; - - const result = await executePromptsWithFallback({ - questions, - currentValues, - requiredFields: ['count', 'name'], - }); - - // Should not throw - empty string and 0 are valid values - expect(result).toEqual({ count: 0, name: '' }); - }); - }); -}); diff --git a/apps/cli/src/utils/prompt-utils.ts b/apps/cli/src/utils/prompt-utils.ts index 598d2727..42fe41bb 100644 --- a/apps/cli/src/utils/prompt-utils.ts +++ b/apps/cli/src/utils/prompt-utils.ts @@ -1,6 +1,3 @@ -import prompts from 'prompts'; -import { PromptCancelledError } from './report-error'; - /** * Sentinel value used to represent "all items" in multiselect prompts */ @@ -10,26 +7,22 @@ export const ALL_ITEMS_SENTINEL = '__ALL__'; * Processes multiselect prompt results that may include an "All" option. * * @param selectedValues - Array of selected values from prompt (may include __ALL__) - * @param allAvailableItems - Complete list of all possible items * @returns Array of items to process, or undefined if "All" was selected * * @example * // User selected specific items - * processMultiselectWithAll(["en", "fr"], ["en", "fr", "de", "es"]) + * processMultiselectWithAll(["en", "fr"]) * // → ["en", "fr"] * * // User selected "All" - * processMultiselectWithAll(["__ALL__"], ["en", "fr", "de", "es"]) + * processMultiselectWithAll(["__ALL__"]) * // → undefined (meaning process all) * * // User selected "All" plus other items (All takes precedence) - * processMultiselectWithAll(["__ALL__", "en"], ["en", "fr", "de", "es"]) + * processMultiselectWithAll(["__ALL__", "en"]) * // → undefined (meaning process all) */ -export function processMultiselectWithAll( - selectedValues: string[] | undefined, - _allAvailableItems: string[], -): string[] | undefined { +export function processMultiselectWithAll(selectedValues: string[] | undefined): string[] | undefined { if (!selectedValues || selectedValues.length === 0) { return undefined; } @@ -58,85 +51,3 @@ export function multiselectResultToString(items: string[] | undefined): string | } return items.join(','); } - -/** - * Checks if the current terminal session is interactive (has both stdin and stdout as TTY). - * Use this to determine whether to show interactive prompts or require command-line options. - * - * @returns true if both stdin and stdout are TTY (interactive terminal) - * - * @example - * if (isInteractiveTerminal()) { - * // Show prompts - * } else { - * // Require CLI options - * } - */ -export function isInteractiveTerminal(): boolean { - return Boolean(process.stdin.isTTY && process.stdout.isTTY); -} - -/** - * Options for executing prompts with automatic fallback to validation in non-interactive mode - */ -export interface PromptExecutionOptions> { - /** Array of prompts to show in interactive mode */ - questions: prompts.PromptObject[]; - /** Current values (from CLI options) */ - currentValues: TValues; - /** Fields that must be present in non-interactive mode */ - requiredFields?: string[]; - /** Name of the operation for error messages (e.g., "Add resource") */ - operationName?: string; -} - -/** - * Executes prompts in interactive mode or validates required fields in non-interactive mode. - * Provides consistent behavior across CLI commands for TTY vs non-TTY environments. - * - * @param params - Prompt execution configuration - * @returns Merged values from currentValues and prompt responses - * @throws PromptCancelledError if the user cancels; Error if required fields are missing in non-interactive mode - * - * @example - * const answers = await executePromptsWithFallback({ - * questions: [ - * { type: 'text', name: 'key', message: 'Resource key' } - * ], - * currentValues: options, - * requiredFields: ['collection', 'key'], - * operationName: 'Add resource' - * }); - */ -export async function executePromptsWithFallback( - params: PromptExecutionOptions, -): Promise> { - const { questions, currentValues, requiredFields, operationName } = params; - const values = currentValues as Record; - - // No questions needed - return current values - if (questions.length === 0) { - return values; - } - - // Interactive mode - show prompts - if (isInteractiveTerminal()) { - const result = await prompts(questions, { - onCancel: () => { - throw new PromptCancelledError(operationName || 'Operation'); - }, - }); - - return { ...values, ...result }; - } - - // Non-interactive mode - validate required fields - if (requiredFields) { - const missing = requiredFields.filter((field) => values[field] === undefined || values[field] === null); - if (missing.length > 0) { - throw new Error(`Missing required options in non-interactive mode: ${missing.map((f) => `--${f}`).join(', ')}`); - } - } - - return values; -} diff --git a/apps/cli/src/utils/report-error.spec.ts b/apps/cli/src/utils/report-error.spec.ts deleted file mode 100644 index 3f792b8c..00000000 --- a/apps/cli/src/utils/report-error.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CollectionNotFoundError } from '@simoncodes-ca/core'; -import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; -import { exitWithError, PromptCancelledError } from './report-error'; - -describe('exitWithError', () => { - let log: MockInstance; - let exit: MockInstance; - - beforeEach(() => { - log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('prints the error message and exits with code 1', () => { - exitWithError(new Error('Output directory is not writable.')); - - expect(log).toHaveBeenCalledWith('❌ Output directory is not writable.'); - expect(exit).toHaveBeenCalledWith(1); - }); - - it('prints a typed core error by its message', () => { - exitWithError(new CollectionNotFoundError('app')); - - expect(log).toHaveBeenCalledWith('❌ Collection "app" not found'); - }); - - it('adds the prefix and stringifies a non-Error value', () => { - exitWithError('boom', 'Translation failed: '); - - expect(log).toHaveBeenCalledWith('❌ Translation failed: boom'); - expect(exit).toHaveBeenCalledWith(1); - }); -}); - -describe('PromptCancelledError', () => { - it('keeps the " cancelled" message and the operation', () => { - const error = new PromptCancelledError('Import'); - - expect(error).toBeInstanceOf(Error); - expect(error.name).toBe('PromptCancelledError'); - expect(error.message).toBe('Import cancelled'); - expect(error.operation).toBe('Import'); - }); -}); diff --git a/apps/cli/src/utils/report-error.ts b/apps/cli/src/utils/report-error.ts deleted file mode 100644 index 182d335b..00000000 --- a/apps/cli/src/utils/report-error.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ConsoleFormatter } from './console-formatter'; - -/** - * Thrown when the user cancels an interactive prompt. Commands catch it with `instanceof`; - * the message is ` cancelled`, as before. - */ -export class PromptCancelledError extends Error { - readonly operation: string; - - constructor(operation: string) { - super(`${operation} cancelled`); - this.name = 'PromptCancelledError'; - this.operation = operation; - } -} - -/** - * Reports a failure that ends the command: prints `❌ ` and exits with - * code 1. A core `LingoTrackerError` carries the user-facing text in its message, so the - * CLI shows it as is. - */ -export function exitWithError(error: unknown, prefix = ''): never { - ConsoleFormatter.error(`${prefix}${error instanceof Error ? error.message : String(error)}`); - process.exit(1); -} diff --git a/architecture-docs/README.md b/architecture-docs/README.md index 58642c21..dbf4415c 100644 --- a/architecture-docs/README.md +++ b/architecture-docs/README.md @@ -116,7 +116,7 @@ apps (cli, api, tracker) | [`user-flows.md`](user-flows.md) | Available | End-to-end sequence diagrams and flowcharts for the six primary user flows: resource lifecycle, import/export, frontend browse-and-edit, search, drag-and-drop move, and cache indexing. | | [`data-flows.md`](data-flows.md) | Placeholder | Sequence diagrams for import/export pipelines, bundle generation, and the checksum-based staleness detection flow. | | [`apps-cli.md`](apps-cli.md) | Placeholder | Full CLI command inventory with options, interactive vs. non-interactive modes, and usage examples. | -| [`cli.md`](cli.md) | Available | CLI command table, interactive vs. non-interactive TTY decision flowchart, config loading and collection resolution flow, and shared utilities overview. | +| [`cli.md`](cli.md) | Available | CLI command table, the Command Runner (interactive rule, config loading, collection resolution, exit codes) with its flowchart, and shared utilities overview. | | [`api.md`](api.md) | Available | REST API endpoint reference, NestJS module structure, mapper pattern, the Collection Index (in-memory tree per open collection), and Swagger location. | | [`apps-tracker.md`](apps-tracker.md) | Placeholder | Angular UI architecture: route structure, NgRx Signal Store feature files, component hierarchy, and Transloco integration. | | [`frontend.md`](frontend.md) | Available | Tracker UI deep-dive: component trees for both feature areas, BrowserStore feature composition diagram, store feature breakdown, virtual scrolling, optimistic updates, drag-and-drop, lazy dialogs, theming (light/dark/system, Material M2 watercolor palette), and Transloco typed-token integration. | diff --git a/architecture-docs/cli.md b/architecture-docs/cli.md index 64f4b75d..022342dd 100644 --- a/architecture-docs/cli.md +++ b/architecture-docs/cli.md @@ -1,6 +1,6 @@ # CLI (`apps/cli`) -The LingoTracker CLI is a Node.js command-line binary built with [Commander](https://github.com/tj/commander.js). It provides every day-to-day translation management operation — from project initialization and resource CRUD through bundle generation, import/export, and CI/CD validation — as a single `lingo-tracker` executable. Commands are thin orchestration shells: they handle user interaction (TTY detection, prompts, output formatting) and then delegate all business logic to `@simoncodes-ca/core`. No filesystem logic lives in the CLI layer itself. +The LingoTracker CLI is a Node.js command-line binary built with [Commander](https://github.com/tj/commander.js). It provides every day-to-day translation management operation — from project initialization and resource CRUD through bundle generation, import/export, and CI/CD validation — as a single `lingo-tracker` executable. Each command is a prompt schema plus a call to `@simoncodes-ca/core`, run by one [Command Runner](glossary.md#command-runner). The runner owns config loading, collection resolution, the interactive rule, cancellation and exit codes. The command owns its questions, its core call and its output formatting. Resource files are read and written only through core. The CLI itself writes a few files of its own: `init` writes `.lingo-tracker.json`, `export` and `import` write their summary files, `glossary` writes its JSON output, and `install-skill` writes the skill templates. Return to [architecture README](README.md). @@ -9,21 +9,22 @@ Return to [architecture README](README.md). ## Table of Contents - [Command Inventory](#command-inventory) +- [Command Runner](#command-runner) + - [Defining a Command](#defining-a-command) + - [What Each Command Opens](#what-each-command-opens) - [Interactive vs Non-Interactive Mode](#interactive-vs-non-interactive-mode) - - [TTY Detection](#tty-detection) - - [Interactive Mode Flowchart](#interactive-mode-flowchart) + - [The Interactive Rule](#the-interactive-rule) + - [Runner Flowchart](#runner-flowchart) - [Errors and Exit Codes](#errors-and-exit-codes) - [Config Loading and Collection Resolution](#config-loading-and-collection-resolution) - [Config Loading](#config-loading) - [Collection Resolution](#collection-resolution) - [Resolution Flowchart](#resolution-flowchart) +- [Testing Commands](#testing-commands) - [Shared Utilities](#shared-utilities) - - [Prompts Wrapper (`prompt-utils.ts`)](#prompts-wrapper-prompt-utilsts) - - [Collection Prompts (`collection-prompts.ts`)](#collection-prompts-collection-promptsts) + - [Multiselect Helpers (`prompt-utils.ts`)](#multiselect-helpers-prompt-utilsts) - [Output Formatting (`console-formatter.ts`)](#output-formatting-console-formatterts) - [Error Messages (`error-messages.ts`)](#error-messages-error-messagests) - - [Config Loader (`config-loader.ts`)](#config-loader-config-loaderts) - - [Collection Resolver (`collection-resolver.ts`)](#collection-resolver-collection-resolverts) - [String Parsers (`string-parsers.ts`)](#string-parsers-string-parsersts) - [Result Aggregator (`result-aggregator.ts`)](#result-aggregator-result-aggregatorts) @@ -38,21 +39,23 @@ All commands are registered in `apps/cli/src/main.ts`. Each row below lists the | `init` | `--collection-name`, `--translations-folder`, `--base-locale`, `--locales`, `--setup-bundle`, `--bundle-dist`, `--bundle-name`, `--token-casing`, `--type-dist-file`, `--enable-auto-translation`, `--translation-provider`, `--translation-api-key-env` | Writes `.lingo-tracker.json` directly (no `@simoncodes-ca/core` function — uses `CONFIG_FILENAME`, `DEFAULT_CONFIG` constants) | | `add-collection` | `--collection-name`, `--translations-folder`, `--base-locale`, `--locales` | `addCollection()` | | `delete-collection` | `--collection-name` | `deleteCollectionByName()` | +| `edit-collection` | `` (argument), `--add-tag` (repeatable), `--remove-tag` (repeatable), `--set-tags` | `updateCollection()` with the stored collection and the new `tags` | | `add-locale` | `--collection`, `--locale` | `addLocaleToCollection()` | | `remove-locale` | `--collection`, `--locale` | `removeLocaleFromCollection()` | -| `add-resource` | `--collection`, `--key`, `--value`, `--comment`, `--tags`, `--target-folder`, `--translations ` | `addResource()` (locales without a `--translations` value are seeded by core: [locale seeding](glossary.md#locale-seeding)) | +| `add-resource` | `--collection`, `--key`, `--value`, `--comment`, `--tags`, `--target-folder`, `--translations ` | `addResource()` (locales without a `--translations` value are seeded by core: [locale seeding](glossary.md#locale-seeding)). `--translations` is parsed inside the command; malformed JSON, or anything but an array of `{ locale, value, status }`, exits 1 with `❌ Invalid --translations …` | | `edit-resource` | `--collection`, `--key` (full key), `--base-value`, `--comment`, `--tags`, `--target-folder` (moves the entry into this folder; core `moveTo`), `--locale`, `--locale-value` | `editResource()` | | `delete-resource` | `--collection`, `--key`, `--yes` | `deleteResource()` | | `move` | `--collection`, `--source`, `--dest`, `--override`, `--verbose` | `moveResource()` | | `normalize` | `--collection`, `--all`, `--dry-run`, `--json` | `normalize()` | | `translate-locale` | `--collection`, `--locale`, `--verbose` | `translateLocale(collection, { targetLocale, onProgress })` (through the [Translator](glossary.md#translator)); the summary prints `Skipped (needs human translation)` for complex ICU, lost placeholders and dropped protected terms | -| `bundle` | `--name`, `--locale`, `--verbose`, `--token-casing`, `--token-constant-name`, `--no-transform-icu-to-transloco`, `--debug-keys` | `generateBundle()` (with the project `cwd`) | +| `bundle` | `--name`, `--locale`, `--quiet`, `--verbose`, `--token-casing`, `--token-constant-name`, `--no-transform-icu-to-transloco`, `--debug-keys` | `generateBundle()` (with the project `cwd`) | | `export` | `-f/--format`, `-c/--collection`, `-l/--locale`, `-s/--status`, `-t/--tags`, `-o/--output`, `--structure`, `--rich`, `--include-base`, `--include-status`, `--include-comment`, `--include-tags`, `--base-property-name`, `--filename`, `--no-protect-notes`, `--dry-run`, `--verbose` | `runExport()` | | `import` | `-f/--format`, `-s/--source`, `-l/--locale`, `-c/--collection`, `--strategy`, `--update-comments`, `--update-tags`, `--preserve-status`, `--create-missing`, `--validate-base`, `--dry-run`, `--verbose` | `parseJsonImport()` / `parseXliffImport()` → `importResources()` | | `validate` | `--allow-translated`, `--skip-locales`, `--skip-icu`, `--skip-placeholders`, `--require-portable-plurals` | `openCollection()` for each collection → `validateResources()`, `generateValidationSummary()` | | `find-similar` | `--collection`, `--value`, `--max-results` | `searchTranslations()` | | `glossary` | `--text`, `--input`, `--output`, `--stdout`, `--collection`, `--locales`, `--include-all`, `--extractor` | `readCollection()` (matching/extraction done in the command, not core) | | `protected-terms` | `--collection`, `--add` (repeatable), `--remove` (repeatable), `--set`, `--list`, `--file` | `setGlobalProtectedTerms()` / `setCollectionProtectedTerms()` / `setGlobalProtectedTermsFile()` / `setCollectionProtectedTermsFile()`, reading via `readGlobalProtectedTerms()` / `readCollectionProtectedTerms()` | +| `preferred-terminology` | `--list`, `--add `, `--preferred`, `--reason`, `--remove ` | `loadPreferredTerminology()` / `writePreferredTerminology()` | | `install-skill` | `--collection ` (repeatable), `--dir`, `--token-casing` | No core call — generates a `.claude/` skill file by template | ### `protected-terms` scoping @@ -66,7 +69,7 @@ Both scopes read through the same core helpers. The command itself parses no ter `--list` on a collection prints three lists: the global terms, the collection's terms, and `effectiveProtectedTerms()` of the two. It names the resolved file behind each list. Paths inside the project root print as relative paths. -The core layer raises errors for a malformed file, for a collection with no file, and for a missing parent directory. The command catches each one and calls `exitWithError` (prints `❌ `, exits 1). It writes no partial result. +The core layer raises errors for a malformed file, for a collection with no file, and for a missing parent directory. The command lets each one reach the runner, which prints `❌ ` and sets exit code 1. It writes no partial result. An unknown `--collection` exits 1 with `❌ Collection "x" not found`. ### `validate` locales @@ -90,83 +93,149 @@ For the import and export sequence diagrams showing the full end-to-end flow, se --- +## Command Runner + +`apps/cli/src/runner/command-runner.ts` is the one place that runs a command. `main.ts` keeps the Commander option declarations and calls the function that `defineCommand` returns. That function does the same steps for every command, in this order: + +1. Finds the project root: `INIT_CWD` (set by pnpm to the directory where the command was typed), else `process.cwd()`. +2. Reads the [interactive rule](#the-interactive-rule) once. +3. Loads `.lingo-tracker.json` with core `loadConfig({ cwd })`, unless the command sets `config: false`. +4. Resolves and opens the collection, when the command needs one ([Collection Resolution](#collection-resolution)). +5. Builds the command's questions (in both modes; the builder may throw to fail early) and asks them when interactive. +6. Checks the `required` options against the flags merged with the answers. `undefined`, `null` and `''` count as missing, so an empty interactive answer fails the same way as an absent flag. +7. Calls `run`, and turns the result or the thrown error into output and an exit code ([Errors and Exit Codes](#errors-and-exit-codes)). + +The runner sets `process.exitCode` and returns. No CLI code calls `process.exit()`, so Commander finishes normally. + +### Defining a Command + +```typescript +// apps/cli/src/commands/add-locale.ts +export const addLocaleCommand = defineCommand()({ + name: 'Add locale', // used in "❌ Add locale cancelled." + collection: 'writable', // 'writable' | 'read' | 'none' + prompts: (options) => // questions for missing values; asked only when interactive + options.locale ? [] : [{ type: 'text', name: 'locale', message: 'Enter locale to add (e.g. fr-ca, de, es)' }], + required: ['locale'], // checked after the questions; `run` sees it as a string + run: async ({ collection, cwd, answers }) => { + const result = await addLocaleToCollection(collection.name, answers.locale, { cwd }); + ConsoleFormatter.success(result.message); + }, +}); +``` + +`defineCommand()` is curried: the options type is given, and the rest is inferred from the spec. The spec fields: + +| Field | Meaning | +|---|---| +| `name` | Operation name for the cancel line. | +| `collection` | `'writable'` opens the collection with `writable: true`. `'read'` opens it for reading. `'none'` opens no collection. | +| `collectionOption` | The option that holds the collection name. Default `collection`. `delete-collection` uses `collectionName`; `edit-collection` uses its positional ``. | +| `config` | `false` skips loading the config. Only `init` and `install-skill` set it. It is only allowed with `collection: 'none'`: `config: false` with `'writable'` or `'read'` does not compile. | +| `prompts(options, ctx)` | Returns the questions for the values the flags left out. It receives the same context as `run`, without the answers, so it can use the opened collection (for example the locale choices). It is called in both modes, before `required` is checked, so it can throw a better reason than "missing flag": `remove-locale` reports `No removable locales in collection "x".` and `translate-locale` reports disabled translation this way. `init` returns `[]` in an initialized folder. | +| `required` | Options that must have a value before `run`: checked after the questions when interactive, against the flags when not. `undefined`, `null` and `''` count as missing. `run` sees these options typed as present. `init` declares none: it needs `--collection-name` and `--translations-folder` only when there is a config to write, and checks them itself with the same `requireOptions` helper. | +| `run(ctx)` | The core call(s) and the output. It returns nothing, or `{ exitCode: 1 }` for a failure it has already reported. It throws to fail with `❌ `. | + +The context (`CommandContext`) has `cwd`, `interactive`, `ask`, and `answers` (the flags merged with the prompt answers). It has `config` and `configPath` unless `config: false`. It has `collection` (the core `Collection`) only when `collection` is `'writable'` or `'read'`. The type follows the spec, so a `'none'` command cannot read `ctx.collection`. + +`ask(questions)` runs follow-up prompts inside `run`: confirmations (`delete-resource`, `normalize --all`, the `add-resource` override), the `add-resource` translations loop, the `add-collection` read-only question, and the `install-skill` loop. A cancel in `ask` is the same cancel as in the declared questions. A command throws `CommandCancelledError` when the user declines a confirmation. + +### What Each Command Opens + +| Command | `collection` | Notes | +|---|---|---| +| `add-resource`, `edit-resource`, `delete-resource`, `move`, `add-locale`, `remove-locale`, `translate-locale`, `import` | `'writable'` | `move` opens an optional destination collection itself, also writable. | +| `delete-collection`, `edit-collection`, `find-similar` | `'read'` | `delete-collection` and `edit-collection` change the registration, not the resources, so a read-only collection is allowed. | +| `add-collection`, `normalize`, `bundle`, `export`, `validate`, `glossary`, `protected-terms`, `preferred-terminology` | `'none'` | `normalize` takes `--collection` or `--all`. `export` takes a list. `glossary` and `protected-terms` take an optional `--collection` (absent means every collection, or the global scope). These commands call core `openCollection` themselves; a name that is not configured still ends as `❌ Collection "x" not found`, exit 1. | +| `init`, `install-skill` | `'none'`, `config: false` | Neither reads `.lingo-tracker.json`. | + +--- + ## Interactive vs Non-Interactive Mode -### TTY Detection +### The Interactive Rule -The CLI is designed to run in two modes. A single boolean check determines which mode is active: +The CLI has one definition of "interactive", in `apps/cli/src/runner/terminal.ts`: ```typescript -// apps/cli/src/utils/prompt-utils.ts export function isInteractiveTerminal(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } ``` -Both `stdin` and `stdout` must be TTY for interactive mode. A single pipe or redirect (e.g., `lingo-tracker add-resource | tee log.txt`) drops the CLI into non-interactive mode, which is identical to CI/CD behavior. +The runner reads it once per command and passes it to the command as `ctx.interactive`. No other CLI code reads `isTTY`. + +Both `stdin` and `stdout` must be a terminal. A pipe or a redirect on either side (for example `lingo-tracker add-resource | tee log.txt`) makes the command non-interactive, which is the same as CI/CD. + +- **Interactive** — a question is asked for each value the flags left out. +- **Non-interactive (CI/CD)** — nothing is asked. When a required flag is absent, the command prints `❌ Missing required options in non-interactive mode: --flag1, --flag2` and exits 1. It does not wait for input. (Interactive, an empty answer to a required question prints `❌ Missing required options: --flag` and exits 1.) -**Interactive mode** — the terminal is attached to a real user. Any option not supplied as a CLI flag triggers a `prompts` question. The user fills in missing fields at runtime. +Three missing-flag messages keep their own wording, because the rule is not "this flag is required": -**Non-interactive mode (CI/CD)** — all required options must be supplied as flags. If any required flag is absent the command fails immediately with a clear `--option-name` error message rather than hanging waiting for input. +- `❌ Missing required option: --collection` (or `--collection-name`): several collections, none named, non-interactive. With one collection, it would have been selected. +- `❌ Missing required option in non-interactive mode: --collection or --all`: `normalize` needs one of the two. +- `❌ Missing required option in non-interactive mode: --collection` followed by a `Usage:` line: `install-skill`, whose `--collection` takes a `name:bundle:TokenConstant:tokenFilePath` spec. -### Interactive Mode Flowchart +`terminal.ts` has one more function, `hasPipedStdin()` (`!process.stdin.isTTY`). Only `glossary` uses it, to read its input block from a pipe. This is a different question: `glossary > out.json` is non-interactive, but reading stdin there would wait for the keyboard. -The diagram below shows the decision path that every command follows. Step 1 (config loading) and Step 2 (collection resolution) are deterministic — no prompts involved. The interactive branch point occurs at Step 3, when the command checks whether any required fields were omitted. +### Runner Flowchart ```mermaid flowchart TD - START([Command invoked\ne.g. lingo-tracker add-resource]) --> LOAD_CONFIG - - LOAD_CONFIG["loadConfiguration()\nRead .lingo-tracker.json\nfrom cwd / INIT_CWD"] - LOAD_CONFIG --> CONFIG_OK{"Config found\nand valid?"} - CONFIG_OK -- No --> EXIT_CONFIG(["Exit 1\n❌ Run lingo-tracker init first"]) - CONFIG_OK -- Yes --> RESOLVE_COLLECTION - - RESOLVE_COLLECTION["promptForCollection()\nRead --collection flag"] - RESOLVE_COLLECTION --> HAS_COLLECTION{"--collection\nprovided?"} - HAS_COLLECTION -- Yes --> VALIDATE_COLLECTION - HAS_COLLECTION -- No --> SINGLE_COLLECTION{"Exactly one\ncollection in config?"} - SINGLE_COLLECTION -- Yes --> AUTO_SELECT["Auto-select the\nonly collection"] - SINGLE_COLLECTION -- No --> CHECK_TTY_COLLECTION - - CHECK_TTY_COLLECTION{"process.stdout.isTTY?"} - CHECK_TTY_COLLECTION -- No --> EXIT_COLLECTION(["Throw:\n❌ Missing required option: --collection"]) - CHECK_TTY_COLLECTION -- Yes --> PROMPT_COLLECTION["prompts: select\nfrom available collections"] - PROMPT_COLLECTION --> VALIDATE_COLLECTION - AUTO_SELECT --> VALIDATE_COLLECTION - - VALIDATE_COLLECTION["resolveCollection()\ncore openCollection():\neffective locales + absolute folder"] - VALIDATE_COLLECTION --> COLLECTION_OK{"Collection\nfound?"} - COLLECTION_OK -- No --> EXIT_RESOLVE(["Exit\n❌ Collection not found"]) - COLLECTION_OK -- Yes --> CHECK_FLAGS - - CHECK_FLAGS["Check which required fields\nare missing from CLI flags\n(key, value, locale, etc.)"] - CHECK_FLAGS --> ALL_FLAGS{"All required\nfields present?"} - ALL_FLAGS -- Yes --> CALL_CORE - - ALL_FLAGS -- No --> CHECK_TTY_MAIN{"isInteractiveTerminal()\nstdin.isTTY && stdout.isTTY?"} - - CHECK_TTY_MAIN -- No --> NON_INTERACTIVE["Non-interactive path:\nvalidate required fields\nfrom CLI options only"] - NON_INTERACTIVE --> MISSING_FLAGS{"Any required\nflags missing?"} - MISSING_FLAGS -- Yes --> EXIT_FLAGS(["Throw:\n❌ Missing required options:\n--flag1, --flag2"]) - MISSING_FLAGS -- No --> CALL_CORE - - CHECK_TTY_MAIN -- Yes --> INTERACTIVE["Interactive path:\nprompts() for each\nmissing required field"] - INTERACTIVE --> USER_INPUT{"User completes\nall fields?"} - USER_INPUT -- "Ctrl+C / cancel" --> EXIT_CANCEL(["Throw PromptCancelledError:\n❌ Operation cancelled"]) - USER_INPUT -- Completes --> CALL_CORE - - CALL_CORE["Call @simoncodes-ca/core function\ne.g. addResource() / normalize() / validateResources()"] - CALL_CORE --> FORMAT_OUTPUT["ConsoleFormatter:\n✅ success / ❌ error / ⚠️ warning"] - FORMAT_OUTPUT --> DONE([Exit 0 on success\nExit 1 on failure]) + START([Command invoked\ne.g. lingo-tracker add-resource]) --> ROOT["cwd = INIT_CWD or process.cwd()\ninteractive = stdin.isTTY && stdout.isTTY"] + + ROOT --> NEEDS_CONFIG{"config: false?"} + NEEDS_CONFIG -- Yes --> QUESTIONS + NEEDS_CONFIG -- No --> LOAD_CONFIG["core loadConfig({ cwd })"] + LOAD_CONFIG --> CONFIG_OK{"Found and valid?"} + CONFIG_OK -- No --> EXIT_CONFIG(["Exit 1\n❌ Configuration file ... not found\n/ ❌ Failed to parse ..."]) + CONFIG_OK -- Yes --> NEEDS_COLLECTION{"collection:\n'writable' / 'read'?"} + NEEDS_COLLECTION -- "'none'" --> QUESTIONS + + NEEDS_COLLECTION -- Yes --> HAS_COLLECTION{"Collection flag\ngiven?"} + HAS_COLLECTION -- Yes --> OPEN + HAS_COLLECTION -- No --> COUNT{"Collections\nin config?"} + COUNT -- None --> EXIT_NONE(["Exit 1\n❌ No collections found"]) + COUNT -- One --> AUTO_SELECT["Auto-select it"] + COUNT -- Several --> TTY_COLLECTION{"interactive?"} + TTY_COLLECTION -- No --> EXIT_COLLECTION(["Exit 1\n❌ Missing required option: --collection"]) + TTY_COLLECTION -- Yes --> PROMPT_COLLECTION["select prompt"] + AUTO_SELECT --> OPEN + PROMPT_COLLECTION --> OPEN + + OPEN["core openCollection(config, name,\n{ cwd, writable })"] + OPEN --> OPEN_OK{"Opened?"} + OPEN_OK -- "Not found" --> EXIT_RESOLVE(["Exit 1\n❌ Collection 'x' not found"]) + OPEN_OK -- "Read-only, 'writable'" --> EXIT_RO(["Exit 1\n❌ Collection 'x' is read-only..."]) + OPEN_OK -- Yes --> QUESTIONS + + QUESTIONS["prompts(options, ctx)\nquestions for missing values\n(may throw a reason)"] + QUESTIONS --> ANY{"interactive and\nany questions?"} + ANY -- No --> MISSING + ANY -- Yes --> ASK["prompts(questions, { onCancel })"] + ASK -- "Ctrl+C" --> EXIT_CANCEL(["Exit 0\n❌ Name cancelled."]) + ASK -- Answered --> MISSING + MISSING{"A required option\nundefined, null or ''?"} + MISSING -- Yes --> EXIT_FLAGS(["Exit 1\n❌ Missing required options\n[in non-interactive mode]: --a, --b"]) + MISSING -- No --> RUN + + RUN["run(ctx): core call + ConsoleFormatter output"] + RUN -- "returns" --> DONE(["Exit 0"]) + RUN -- "returns { exitCode: 1 }" --> EXIT_REPORTED(["Exit 1\n(failure already reported)"]) + RUN -- "throws Error" --> EXIT_THROW(["Exit 1\n❌ message"]) + RUN -- "throws CommandCancelledError" --> EXIT_CANCEL style EXIT_CONFIG fill:#f8d7da,stroke:#dc3545,color:#000 + style EXIT_NONE fill:#f8d7da,stroke:#dc3545,color:#000 style EXIT_COLLECTION fill:#f8d7da,stroke:#dc3545,color:#000 style EXIT_RESOLVE fill:#f8d7da,stroke:#dc3545,color:#000 + style EXIT_RO fill:#f8d7da,stroke:#dc3545,color:#000 style EXIT_FLAGS fill:#f8d7da,stroke:#dc3545,color:#000 - style EXIT_CANCEL fill:#f8d7da,stroke:#dc3545,color:#000 + style EXIT_REPORTED fill:#f8d7da,stroke:#dc3545,color:#000 + style EXIT_THROW fill:#f8d7da,stroke:#dc3545,color:#000 + style EXIT_CANCEL fill:#fff3cd,stroke:#ffc107,color:#000 style AUTO_SELECT fill:#d4edda,stroke:#28a745,color:#000 - style CALL_CORE fill:#d1ecf1,stroke:#17a2b8,color:#000 + style RUN fill:#d1ecf1,stroke:#17a2b8,color:#000 style DONE fill:#d4edda,stroke:#28a745,color:#000 ``` @@ -174,25 +243,57 @@ flowchart TD ## Errors and Exit Codes -Core raises [typed errors](glossary.md#typed-errors) whose message is already the user-facing text, so the CLI prints the message and does not branch on the class, except in the resolvers below. Helpers in `utils/report-error.ts`: +Core raises [typed errors](glossary.md#typed-errors) whose message is already the user-facing text. A command does not catch them: it lets them reach the runner, which prints them. The runner branches on the class only for the config errors (stderr, with a hint) and a cancel; every other error takes one path: -- **`exitWithError(error, prefix?)`** — prints `❌ ` through `ConsoleFormatter.error` and calls `process.exit(1)`. Used where a failure ends the command: `export` (invalid `--base-property-name`, unwritable output directory, a run that cannot start), `glossary` (unknown extractor), `protected-terms` (file errors), and `translate-locale` (prefix `Translation failed: `). -- **`PromptCancelledError(operation)`** — thrown from a prompt's `onCancel` (`executePromptsWithFallback`, `add-resource`, `export`, `import`, `normalize`, `bundle`). The message is ` cancelled`. `export` and `import` catch it with `instanceof` and print `❌ ❌ cancelled.` (exit code 0). +| Thrown | Printed | Exit code | +|---|---|---| +| `ConfigNotFoundError` | `❌ Configuration file .lingo-tracker.json not found.` and `Run "lingo-tracker init" to initialize a project.` (stderr) | 1 | +| `ConfigParseError`, or another error reading the file | `❌ Failed to parse configuration file: ` (stderr) | 1 | +| `CommandCancelledError` (a cancelled prompt, or a declined confirmation) | `❌ cancelled.` (one line) | 0 | +| Any other error (`CollectionNotFoundError` → `❌ Collection "x" not found`, `ReadOnlyCollectionError`, `ResourceNotFoundError`, a plain `Error`, …) | `❌ ` | 1 | + +A cancel is not a failure: the user chose to stop, so the exit code is 0. Exit codes: | Situation | Exit code | |---|---| | Success | 0 | -| Config file missing or unreadable (`loadConfiguration`) | 1 | -| Read-only collection on a mutating command (`resolveWritableCollection`, `normalize`) | 1 (`process.exitCode`) | -| `exitWithError` sites above; missing or conflicting flags in `edit-collection`, `find-similar`, `install-skill`, `protected-terms`, `preferred-terminology` | 1 | -| `validate` failed, or had nothing to validate; `translate-locale` with failed entries; `export` with errors or hierarchical conflicts (not with `--dry-run`); `import` with errors | 1 | -| Unknown collection: `resolveCollection` prints `❌ Collection "x" not found.` and the command returns | 0 (`glossary` exits 1) | -| Core error in `add-collection`, `delete-collection`, `add-resource`, `edit-resource`, `delete-resource`, `move`, `add-locale`, `remove-locale` | 0 (prints `❌ `) | -| Prompt cancelled | 0 | - -The last three rows are kept as they were: those commands report a failure but do not set an exit code. +| Prompt cancelled (Ctrl+C), or a confirmation declined (`delete-resource`, `add-resource` override, `normalize --all`) | 0 | +| `init` in a folder that is already initialized | 0 | +| Config file missing or unreadable | 1 | +| No collections configured, on a command that needs one | 1 | +| Several collections, no `--collection`, non-interactive | 1 | +| Unknown collection (every command) | 1 | +| Read-only collection on a mutating command (runner `'writable'`, `move` destination, `normalize --collection`) | 1 | +| A required flag missing in non-interactive mode (every command, including `import --source` and `--locale`, `export --format`, `normalize` without `--collection`/`--all`, `install-skill` without `--collection`), or an empty interactive answer to a required question | 1 | +| `remove-locale` without `--locale` on a collection with no target locale (`No removable locales in collection "x".`); `translate-locale` on a collection with translation disabled or no target locale | 1 | +| `bundle --token-constant-name` with several bundles | 1 | +| `add-resource --translations` that is not valid JSON or not an array of `{ locale, value, status }` | 1 | +| Missing or conflicting flags in `edit-collection`, `find-similar`, `protected-terms`, `preferred-terminology` | 1 | +| Core error in any command (for example in `add-collection`, `delete-collection`, `add-resource`, `edit-resource`, `delete-resource`, `move`, `add-locale`, `remove-locale`) | 1 | +| Partial failure: `delete-resource` or `move` reports per-key errors; `normalize` fails on a collection; `bundle` fails on a bundle, names an unknown bundle, or finds no bundles | 1 | +| `validate` failed, or had nothing to validate; `translate-locale` with failed entries (`Translation failed: ` when the run cannot start); `export` with errors or hierarchical conflicts (not with `--dry-run`); `import` with errors or failed resources (`Import failed: ` when parsing fails) | 1 | + +`normalize --all` skips a read-only collection with an info line and does not fail. + +### Changes Introduced by the Command Runner + +For scripts written against the earlier CLI: + +- These cases exit 1 and exited 0 before: a core error in `add-collection`, `delete-collection`, `add-resource`, `edit-resource`, `delete-resource`, `move`, `add-locale`, `remove-locale`; an unknown collection (every command but `glossary`); a missing required flag in non-interactive mode (for example `import` without `--source`); partial failures in `delete-resource`, `move`, `normalize` and `bundle`; `export` with an unknown `--collection` (it printed `No matching collections found.`). +- A cancel prints one `❌ cancelled.` line (it was `❌ ❌ …` in several commands) and exits 0. +- `process.exit()` is no longer called; Commander returns normally. +- One interactive rule (stdin and stdout both terminals). Commands that checked only stdout, or only stdin, change mode when one side is piped. +- The collection prompt reads `Select collection` for every command. +- `find-similar` auto-selects a single collection when `--collection` is omitted (it failed before), and prompts for the collection and `--value` when interactive. `translate-locale` auto-selects a single collection instead of prompting. +- `edit-collection`, `protected-terms` and `preferred-terminology` check their flags after the config is loaded and the collection resolved, so a missing config is reported first. +- An empty interactive answer to a required question exits 1. +- `remove-locale` with no target locale and no `--locale` says `No removable locales in collection "x".` in both modes. +- `bundle --token-constant-name` with several bundles exits 1. +- `import --source` and `install-skill --dir` resolve a relative path against the project root (`INIT_CWD`, else `process.cwd()`), like `export --output` and `glossary --input`. +- `add-resource --translations` is parsed inside the command: bad JSON exits 1 with a message instead of an unhandled rejection. +- `validate --skip-placeholders` is passed through (it was declared but ignored). --- @@ -200,67 +301,72 @@ The last three rows are kept as they were: those commands report a failure but d ### Config Loading -`loadConfiguration()` in `apps/cli/src/utils/config-loader.ts` is called at the top of nearly every command. It picks the directory and turns failures into CLI output; reading and parsing is done by core `loadConfig({ cwd })`, the single config reader shared with the API (see [core-library.md — Config and Collection Resolution](core-library.md#config-and-collection-resolution)). +The runner loads the config before anything else, unless the command sets `config: false` (`init`, `install-skill`). Reading and parsing is done by core `loadConfig({ cwd })`, the single config reader shared with the API (see [core-library.md — Config and Collection Resolution](core-library.md#config-and-collection-resolution)). -Key behaviors: +- **Directory** — the runner's private `getCwd()`: `process.env.INIT_CWD`, else `process.cwd()`. pnpm sets `INIT_CWD` to the user's directory even when it runs the script from the package directory. The command gets it as `ctx.cwd`, and resolves every relative path option against it: `export --output`, `import --source`, `glossary --input`/`--output`, `install-skill --dir`. +- **File not found** — `❌ Configuration file .lingo-tracker.json not found.` and `Run "lingo-tracker init" to initialize a project.`, exit 1. +- **Parse or read error** — `❌ Failed to parse configuration file: ` (the JSON parser's message, or the I/O error), exit 1. +- **Context** — `ctx.config` and `ctx.configPath` (absolute path of `.lingo-tracker.json`). -- **Directory resolution** — reads `process.env.INIT_CWD` first, falling back to `process.cwd()`. `INIT_CWD` is set by pnpm and points to the user's project root even when pnpm changes directory to the package location during script execution. The `getCwd()` helper centralizes this logic and is used wherever an absolute path is needed. -- **File not found** — logs `❌ Configuration file .lingo-tracker.json not found. Run "lingo-tracker init" to initialize a project.` then exits with code 1 (or returns `null` when `exitOnError: false`). -- **Parse or read error** — logs `❌ Failed to parse configuration file: ` (the JSON parser's message, or the I/O error) and exits or returns `null`. -- **Return type** — `ConfigLoadResult` carries `{ config, configPath, cwd }` so callers never repeat path resolution. - -The `exitOnError` option (default `true`) allows commands like `add-resource` to do their own error handling without the process terminating mid-operation. +`protected-terms` reads the config again with core `loadConfig` after `--file` changes a pointer, so the next reads and writes use the new file. ### Collection Resolution -After loading config, most commands call `promptForCollection()` followed by `resolveCollection()`. These two steps are always sequential and together constitute the standard collection resolution flow. - -`promptForCollection()` in `collection-prompts.ts` applies smart auto-selection: +For a command with `collection: 'writable'` or `'read'`, the runner resolves the name from the collection option (`--collection`, unless `collectionOption` names another): -1. If `--collection` was provided, return it immediately. -2. If exactly one [collection](glossary.md#collection) exists in config, auto-select it (no prompt, no TTY check needed). -3. If multiple collections exist and `process.stdout.isTTY` is false, throw `Missing required option: --collection`. -4. If multiple collections exist and TTY is true, show an interactive `select` prompt. +1. If the option is given, use it. +2. If no [collection](glossary.md#collection) is configured, fail: `❌ No collections found. Run \`lingo-tracker add-collection\` first.`, exit 1. +3. If exactly one collection is configured, use it (no prompt). +4. If several are configured and the command is interactive, show a `select` prompt. +5. If several are configured and the command is non-interactive, fail: `❌ Missing required option: --collection`, exit 1. -`resolveCollection()` in `collection-resolver.ts` then opens the selected name with core `openCollection(config, name, { cwd })`, and logs `❌ Collection "name" not found.` (returning `null`) if it is absent. The result is the core `Collection`: the absolute `translationsFolder` and the effective `baseLocale`, `locales`, `targetLocales`, and `translationConfig`. Commands read those fields; none of them applies the collection-then-global fallback itself. +It then opens the name with core `openCollection(config, name, { cwd, writable })`, where `writable` is `true` for `'writable'`. The result, `ctx.collection`, is the core `Collection`: the absolute `translationsFolder` and the effective `baseLocale`, `locales`, `targetLocales`, and `translationConfig`. Commands read those fields; none of them applies the collection-then-global fallback itself. -**Read-only enforcement.** Commands that mutate resources call `resolveWritableCollection()` instead of `resolveCollection()`. It opens the collection with `{ writable: true }` and, when core throws `ReadOnlyCollectionError`, prints `❌ Collection "name" is read-only...`, sets `process.exitCode = 1` (so CI fails), and returns `null`. This is the single CLI choke-point for read-only enforcement — no per-command checks. Read-only commands (`bundle`, `export`, `validate`, `find-similar`, `glossary`) and `delete-collection` keep using plain `resolveCollection()`, since they either don't mutate resources or operate on the collection's registration rather than its contents. +**Read-only enforcement.** `collection: 'writable'` is the CLI choke-point for read-only collections: core throws `ReadOnlyCollectionError`, and the runner prints `❌ Collection "name" is read-only. Its resources cannot be modified.` and exits 1. Commands do not check `readOnly` themselves, with two exceptions that open collections without the runner: `move` opens its destination with `writable: true`, and `normalize` fails on a read-only `--collection` and skips read-only collections under `--all`. ### Resolution Flowchart ```mermaid flowchart LR - FLAGS["CLI flags\n(--collection, --key, etc.)"] --> LOAD["loadConfiguration()\n.lingo-tracker.json"] - LOAD --> GETCONFIG["config: LingoTrackerConfig\ncwd: string"] - GETCONFIG --> PROMPT["promptForCollection(config, options.collection)"] - PROMPT --> NAME["collectionName: string"] - NAME --> RESOLVE["resolveCollection(collectionName, config, cwd)"] - RESOLVE --> RESOLVED["Collection (core)\n{ name, translationsFolder, baseLocale,\nlocales, targetLocales, translationConfig, ... }"] - RESOLVED --> CORE["@simoncodes-ca/core function\ne.g. addResource(collection, params)"] + FLAGS["CLI flags\n(--collection, --key, etc.)"] --> LOAD["runner: loadConfig({ cwd })\n.lingo-tracker.json"] + LOAD --> GETCONFIG["ctx.config, ctx.configPath, ctx.cwd"] + GETCONFIG --> SELECT["runner: flag, else the only collection,\nelse select prompt (interactive)"] + SELECT --> NAME["collection name"] + NAME --> OPEN["core openCollection(config, name, { cwd, writable })"] + OPEN --> RESOLVED["ctx.collection (core Collection)\n{ name, translationsFolder, baseLocale,\nlocales, targetLocales, translationConfig, ... }"] + RESOLVED --> CORE["run(ctx) → @simoncodes-ca/core\ne.g. addResource(collection, params)"] ``` The `Collection` itself is the first argument passed to every core resource and folder operation (`addResource(collection, …)`, `editResource(collection, key, …)`, `moveResource(collection, …)`, `deleteResource(collection, …)`). Commands never construct filesystem paths or effective settings themselves, and they do not decide what untranslated locales get: core's [locale seeding](glossary.md#locale-seeding) does. --- -## Shared Utilities +## Testing Commands -All shared utilities live in `apps/cli/src/utils/` and are re-exported from `apps/cli/src/utils/index.ts` as a flat namespace. Commands import from `'../utils'`. +A command spec gives flags in and checks the core call and the exit code: -### Prompts Wrapper (`prompt-utils.ts`) +- Feed the config by mocking core `loadConfig` (keep the real `openCollection` and error classes, so collection resolution runs for real), or with a real temporary `.lingo-tracker.json` and `INIT_CWD`. +- Control the interactive rule by mocking `runner/terminal` (`isInteractiveTerminal`), not by setting `isTTY`. +- Mock `prompts` for answers. A cancel is `onCancel` called by the mock. +- Reset `process.exitCode` before and after each test, and assert it. Nothing calls `process.exit`, so no spec mocks it. -`executePromptsWithFallback(params)` is the primary entry point for commands that have multiple optional fields. It accepts a `questions` array (prompts definitions), `currentValues` (the parsed CLI options), and `requiredFields` (field names that must be present in non-interactive mode). +`runner/command-runner.spec.ts` covers the runner itself: the interactive rule, config errors (mocked, and through the real `loadConfig`: invalid JSON, `EISDIR`), the `process.cwd()` fallback, the collection branches (including `--collection ''` and a cancelled select), required options (non-interactive, after prompting, `''`), cancel, thrown errors and `{ exitCode: 1 }`. `main.spec.ts` covers the flag wiring in `main.ts`, which the runner cannot see. -- In TTY mode: runs `prompts(questions, { onCancel })`, merges results with `currentValues`, and throws `PromptCancelledError` (message `" cancelled"`, default `"Operation cancelled"`) on Ctrl+C. -- In non-TTY mode: skips all prompts, checks that every `requiredField` is non-null in `currentValues`, and throws a `Missing required options: --field1, --field2` error if any are absent. +--- -`processMultiselectWithAll(selectedValues, allAvailableItems)` handles multiselect prompts that include an "All" option. If the sentinel `__ALL__` is among the selected values, it returns `undefined` (meaning "process everything"), otherwise returns the selected subset. +## Shared Utilities -`multiselectResultToString(items)` converts `string[] | undefined` to a comma-separated string or `undefined`, which is the format expected by options like `--locale` and `--name` on the bundle and export commands. +All shared utilities live in `apps/cli/src/utils/` and are re-exported from `apps/cli/src/utils/index.ts` as a flat namespace. Commands import from `'../utils'`. -### Collection Prompts (`collection-prompts.ts`) +The Command Runner and the interactive rule live in `apps/cli/src/runner/` ([Command Runner](#command-runner)); commands import them from `'../runner/command-runner'`. -`promptForCollection(config, currentValue)` is the smart collection selector described above. It is the only place that reads `process.stdout.isTTY` directly for collection selection; all other TTY checks go through `isInteractiveTerminal()`. +### Multiselect Helpers (`prompt-utils.ts`) + +Prompting itself is done by the runner (`prompts` in the spec, `ctx.ask` in `run`). This file keeps two helpers for the `export` multiselect questions. + +`processMultiselectWithAll(selectedValues)` handles multiselect prompts that include an "All" option. If the sentinel `__ALL__` is among the selected values, it returns `undefined` (meaning "process everything"), otherwise returns the selected subset. + +`multiselectResultToString(items)` converts `string[] | undefined` to a comma-separated string or `undefined`, which is the format expected by `--collection` and `--locale` on the export command. ### Output Formatting (`console-formatter.ts`) @@ -285,26 +391,15 @@ All methods write to `console.log`. The object is `as const` so TypeScript enfor Selected entries: +The runner owns the collection, missing-option and cancel messages; they are not in `ErrorMessages`. + ```typescript ErrorMessages.CONFIG_NOT_FOUND // static string -ErrorMessages.COLLECTION_NOT_FOUND(name) // factory → "❌ Collection "name" not found." -ErrorMessages.MISSING_OPTION(option) // factory → "❌ Missing required option: --option" -ErrorMessages.MISSING_OPTIONS(options[]) // factory → "❌ Missing required options: --a, --b" -ErrorMessages.OPERATION_CANCELLED(op) // factory → "❌ Op cancelled." +ErrorMessages.COLLECTION_READ_ONLY(name) // factory → "❌ Collection "name" is read-only. …" (normalize) ErrorMessages.OPERATION_FAILED(op, why?) // factory → "❌ Op failed: reason" ErrorMessages.RESOURCE_NOT_FOUND(key) // factory → "❌ Resource key "key" not found." ``` -### Config Loader (`config-loader.ts`) - -`loadConfiguration(options?)` — described in detail in [Config Loading](#config-loading) above. - -`getCwd()` — returns `process.env.INIT_CWD ?? process.cwd()`. Used by any code that needs to construct an absolute path relative to the user's project root. - -### Collection Resolver (`collection-resolver.ts`) - -`resolveCollection(collectionName, config, baseDirectory)` — thin wrapper over core `openCollection()`. Returns the resolved [collection](glossary.md#collection) (`Collection` from `@simoncodes-ca/core`), or `null` (after logging an error) if the collection is not found, so callers use a `if (!collection) return;` guard pattern. `resolveWritableCollection()` is the same with `{ writable: true }`. - ### String Parsers (`string-parsers.ts`) `parseCommaSeparatedList(input)` — splits a comma-separated string into a trimmed, non-empty `string[]`. Returns `undefined` for empty or missing input. Used by commands that accept multi-value flags like `--locale en,fr,de` and `--key key1,key2`. diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 5d213449..34ede23f 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -81,6 +81,14 @@ Explained in context: [`core-library.md`](core-library.md#collection-reader) --- +### Command Runner + +The one place that runs a CLI command. In code, `defineCommand()(spec)` in `apps/cli/src/runner/command-runner.ts` returns the function `main.ts` calls. A command is a spec: a `name`, what it opens (`collection: 'writable' | 'read' | 'none'`, and `config: false` for `init` and `install-skill`), its `prompts` for missing values, the options it `required` (an absent flag, or an empty answer, fails; `run` sees them typed as present), and `run`, which makes the core call and prints. The runner does the rest the same way for every command. It finds the project root (`INIT_CWD`, else `process.cwd()`) and reads the one interactive rule (stdin and stdout are both a terminal, `runner/terminal.ts`). It loads the config with core `loadConfig`, then resolves the [collection](#collection): the flag, else the only one, else a prompt when interactive, else `Missing required option: --collection`. It opens the collection with core `openCollection`, asks the questions when interactive, checks the required options, and calls `run`. A cancel prints `❌ cancelled.` once and exits 0. A thrown error prints `❌ ` and exits 1, as does an unknown collection or a missing required flag. `run` returns `{ exitCode: 1 }` for a failure it has already reported. The runner sets `process.exitCode` and never calls `process.exit()`. + +Explained in context: [`cli.md`](cli.md#command-runner) + +--- + ## E ### Export Run From 6ec3d9e71b26effae3d26457940388b50785b399 Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 23:01:17 -0700 Subject: [PATCH 15/20] refactor: one Resource Search that ranks, then limits searchResources(resources, collection, query, { mode: 'text' | 'similar-value', limit }) in libs/core is the one matcher over any iterable of resources: the Collection Reader for the disk, or treeResources(tree) for the API's index tree. Every match is ranked before the limit is applied, so a better match found late is never dropped. Text mode keeps the four match types; similar-value mode compares the base value with the query (normalizedLevenshtein >= 0.8, or whole-word containment scoring shorter/longer with a 0.4 floor), ranked by similarity, then key-contains-query, then key. - deleted searchTranslations, searchResourceTree and their params; SearchResult drops `status` and no longer copies the base value into `translations`; gains `similarity`; MatchType gains 'similar-value' - API: GET .../resources/search?mode=similar; SearchResultDto.similarity; CollectionIndex.search(collection, query, { mode, limit }) over the index tree or readCollection (reader problems logged); maxResults is validated as a positive integer (else 100), capped at 500 - CLI find-similar uses the shared rule: no 500-candidate cap, prints similarity from the matcher, warns on unreadable folders - Tracker: similar-value-filter deleted; the editor dialog asks the API for 11 similar hits, drops the entry being edited, shows 10 - a hand-edited entry without a string source no longer crashes search Behaviour changes: broad text searches return a better-ranked page; the base value is always searched; find-similar may list whole-word containment hits below 80% (>= 40%); the Tracker "Similar values" list is ranked by similarity instead of substring containment on a 25-hit text search; an invalid maxResults falls back to 100 (a non-numeric value used to return everything, a negative one almost everything). Co-Authored-By: Claude Fable 5.1 --- .claude/skills/lingo-tracker/SKILL.md | 2 +- .../cache/collection-index.service.spec.ts | 42 +- .../src/app/cache/collection-index.service.ts | 31 +- .../resources/resources.controller.spec.ts | 64 +- .../resources/resources.controller.ts | 10 +- .../src/app/mappers/search-result.mapper.ts | 1 + .../src/commands/find-similar.real-fs.spec.ts | 53 +- apps/cli/src/commands/find-similar.spec.ts | 282 +++--- apps/cli/src/commands/find-similar.ts | 65 +- .../commands/install-skill-templates/SKILL.md | 2 +- .../similar-value-filter.ts | 45 - .../translation-editor-dialog.spec.ts | 36 +- .../translation-editor-dialog.ts | 34 +- .../services/browser-api.service.spec.ts | 18 + .../browser/services/browser-api.service.ts | 14 +- architecture-docs/api.md | 10 +- architecture-docs/cli.md | 11 +- architecture-docs/core-library.md | 32 +- architecture-docs/frontend.md | 4 +- architecture-docs/glossary.md | 12 +- architecture-docs/user-flows.md | 2 +- docs/cli.md | 18 +- libs/core/src/index.ts | 11 +- libs/core/src/lib/resource/index.ts | 9 +- libs/core/src/lib/resource/search.spec.ts | 891 +++++++----------- libs/core/src/lib/resource/search.ts | 531 ++++------- .../src/lib/search-result.dto.ts | 8 +- .../src/lib/search-translations.dto.ts | 7 + 28 files changed, 981 insertions(+), 1264 deletions(-) delete mode 100644 apps/tracker/src/app/browser/dialogs/translation-editor/similar-value-filter.ts diff --git a/.claude/skills/lingo-tracker/SKILL.md b/.claude/skills/lingo-tracker/SKILL.md index 639af970..f5e93c3a 100644 --- a/.claude/skills/lingo-tracker/SKILL.md +++ b/.claude/skills/lingo-tracker/SKILL.md @@ -52,7 +52,7 @@ Before calling `add-resource` for any detected string, run: bash .claude/skills/lingo-tracker/scripts/find-similar.sh "" ``` -**If the command returns any results, evaluate each match** (the tool already filters to ≥ 80% similarity): +**If the command returns any results, evaluate each match** (the tool shows values that are ≥ 80% similar, or that contain the string or are contained in it as whole words with ≥ 40% similarity; such a containment match shows its real similarity, between 40% and 80%): - **Reuse the existing key if**: - The stored value is identical or nearly identical (≥ 95% similarity) diff --git a/apps/api/src/app/cache/collection-index.service.spec.ts b/apps/api/src/app/cache/collection-index.service.spec.ts index dca15fb7..7d9fba4a 100644 --- a/apps/api/src/app/cache/collection-index.service.spec.ts +++ b/apps/api/src/app/cache/collection-index.service.spec.ts @@ -141,10 +141,48 @@ describe('CollectionIndex', () => { }); it('searches the disk before indexing, without starting it, and the index after', () => { - expect(index.search(collection(), 'cancel', 10).map((result) => result.key)).toEqual(['common.cancel']); + expect(index.search(collection(), 'cancel', { limit: 10 }).map((result) => result.key)).toEqual([ + 'common.cancel', + ]); expect(index.tree(collection())).toEqual({ status: 'not-started' }); - expect(index.search(collection(), 'cancel', 10).map((result) => result.key)).toEqual(['common.cancel']); + readyTree(); + expect(index.search(collection(), 'cancel', { limit: 10 }).map((result) => result.key)).toEqual([ + 'common.cancel', + ]); + }); + + it('searches in similar-value mode on the disk and in the index alike', () => { + writeEntry('main', 'apps.saveDraft', 'Save draft'); + const similar = (): Array<[string, number | undefined]> => + index.search(collection(), 'save', { mode: 'similar-value' }).map((result) => [result.key, result.similarity]); + + const fromDisk = similar(); + readyTree(); + + expect(fromDisk).toEqual([['apps.saveDraft', 0.4]]); + expect(similar()).toEqual(fromDisk); + }); + + it('ranks before the limit on the disk and in the index alike', () => { + // The exact value match is read after the partial key match `common.cancel`. + writeEntry('main', 'zz.dismiss', 'Cancel'); + const search = (): string[] => index.search(collection(), 'cancel', { limit: 1 }).map((result) => result.key); + + expect(search()).toEqual(['zz.dismiss']); + readyTree(); + expect(search()).toEqual(['zz.dismiss']); + }); + + it('logs the folders a disk search could not read, and searches the rest', () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + fs.mkdirSync(path.join(root, 'main', 'broken'), { recursive: true }); + fs.writeFileSync(path.join(root, 'main', 'broken', 'resource_entries.json'), '{ nope'); + + expect(index.search(collection(), 'cancel').map((result) => result.key)).toEqual(['common.cancel']); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('1 unreadable folder(s) in collection main')); + warn.mockRestore(); }); }); diff --git a/apps/api/src/app/cache/collection-index.service.ts b/apps/api/src/app/cache/collection-index.service.ts index d09b2955..2c5f47d2 100644 --- a/apps/api/src/app/cache/collection-index.service.ts +++ b/apps/api/src/app/cache/collection-index.service.ts @@ -5,13 +5,16 @@ import { computeTreeFingerprint, extractSubtree, loadResourceTree, + readCollection, type ResourceMutation, type ResourceTreeNode, + type SearchableResource, + type SearchOptions, type SearchResult, - searchResourceTree, - searchTranslations, + searchResources, type TreeFingerprint, treeFingerprintsMatch, + treeResources, } from '@simoncodes-ca/core'; import type { CacheStatusDto } from '@simoncodes-ca/data-transfer'; @@ -104,14 +107,26 @@ export class CollectionIndex { return { status: 'ready', tree: extractSubtree(entry.tree, path) }; } - /** Searches the indexed tree, or the disk when the collection is not indexed. Never starts indexing. */ - search(collection: Collection, query: string, maxResults: number): SearchResult[] { + /** + * Runs Resource Search over the indexed tree, or over the disk (the Collection Reader) when the + * collection is not indexed. Never starts indexing. Folders the reader could not read are logged. + */ + search(collection: Collection, query: string, options: SearchOptions = {}): SearchResult[] { const entry = this.#read(collection); - const options = { query, maxResults, baseLocale: collection.baseLocale }; + return searchResources(this.#searchSource(collection, entry), collection, query, options); + } - return entry?.status === 'ready' && entry.tree - ? searchResourceTree({ tree: entry.tree, ...options }) - : searchTranslations({ translationsFolder: collection.translationsFolder, ...options }); + #searchSource(collection: Collection, entry: IndexEntry | undefined): Iterable { + if (entry?.status === 'ready' && entry.tree) return treeResources(entry.tree); + + const { resources, problems } = readCollection(collection); + if (problems.length > 0) { + this.#logger.warn( + `Search skipped ${problems.length} unreadable folder(s) in collection ${collection.name}: ` + + problems.map((problem) => problem.message).join('; '), + ); + } + return resources; } /** Index state for the cache-status endpoint. Starts indexing when the collection is not indexed. */ diff --git a/apps/api/src/app/collections/resources/resources.controller.spec.ts b/apps/api/src/app/collections/resources/resources.controller.spec.ts index dbe36f4c..604f5313 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -4,7 +4,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import type { Response } from 'express'; import * as core from '@simoncodes-ca/core'; import { TranslationError } from '@simoncodes-ca/core'; -import type { ResourceTreeDto } from '@simoncodes-ca/data-transfer'; +import type { ResourceTreeDto, SearchTranslationsDto } from '@simoncodes-ca/data-transfer'; import type { TranslationStatus } from '@simoncodes-ca/domain'; import { CollectionIndex } from '../../cache/collection-index.service'; import { ConfigService } from '../../config/config.service'; @@ -1089,7 +1089,10 @@ describe('ResourcesController', () => { ['es', 'translated', true], ]); expect(result.limited).toBe(false); - expect(mockIndex.search).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-collection' }), 'lingo', 101); + expect(mockIndex.search).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-collection' }), 'lingo', { + mode: 'text', + limit: 101, + }); }); it('should return empty results for empty query', async () => { @@ -1105,10 +1108,65 @@ describe('ResourcesController', () => { const result = await resourcesController.search('test-collection', { query: 'test', maxResults: 1000 }); - expect(mockIndex.search).toHaveBeenCalledWith(expect.anything(), 'test', 501); + expect(mockIndex.search).toHaveBeenCalledWith(expect.anything(), 'test', { mode: 'text', limit: 501 }); expect(result.limited).toBe(true); expect(result.results).toHaveLength(500); }); + + it('should run a similar-value search for mode=similar and return the similarity', async () => { + mockIndex.search.mockReturnValue([ + { + key: 'common.save', + source: 'Save', + translations: {}, + metadata: {}, + matchType: 'similar-value', + matchedLocales: ['en'], + similarity: 0.4, + }, + ]); + + const result = await resourcesController.search('test-collection', { + query: 'Save draft', + maxResults: 11, + mode: 'similar', + }); + + expect(mockIndex.search).toHaveBeenCalledWith(expect.anything(), 'Save draft', { + mode: 'similar-value', + limit: 12, + }); + expect(result.results.map((r) => [r.fullKey, r.matchType, r.similarity])).toEqual([ + ['common.save', 'similar-value', 0.4], + ]); + }); + + it.each(['abc', '-2', '0', '2.5'])('should fall back to 100 results for maxResults=%s', async (maxResults) => { + mockIndex.search.mockReturnValue([]); + const dto = { query: 'save', maxResults } as unknown as SearchTranslationsDto; + + await resourcesController.search('test-collection', dto); + + expect(mockIndex.search).toHaveBeenCalledWith(expect.anything(), 'save', { mode: 'text', limit: 101 }); + }); + + it('should read maxResults from its query-string form', async () => { + mockIndex.search.mockReturnValue([]); + const dto = { query: 'save', maxResults: '7' } as unknown as SearchTranslationsDto; + + await resourcesController.search('test-collection', dto); + + expect(mockIndex.search).toHaveBeenCalledWith(expect.anything(), 'save', { mode: 'text', limit: 8 }); + }); + + it('should run a text search for an unknown mode', async () => { + mockIndex.search.mockReturnValue([]); + const dto = { query: 'save', mode: 'fuzzy' } as unknown as SearchTranslationsDto; + + await resourcesController.search('test-collection', dto); + + expect(mockIndex.search).toHaveBeenCalledWith(expect.anything(), 'save', { mode: 'text', limit: 101 }); + }); }); describe('translateResource', () => { diff --git a/apps/api/src/app/collections/resources/resources.controller.ts b/apps/api/src/app/collections/resources/resources.controller.ts index b249148a..574ed181 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -296,11 +296,15 @@ export class ResourcesController { }; } - // Default maxResults to 100, cap at 500 - const maxResults = Math.min(dto.maxResults || 100, 500); + // Query parameters arrive as strings. A value that is not a positive integer is 100; the cap is 500. + const requested = Number(dto.maxResults); + const maxResults = Number.isInteger(requested) && requested > 0 ? Math.min(requested, 500) : 100; + + // Anything but `similar` is a text search, as a bad maxResults falls back to the default. + const mode = dto.mode === 'similar' ? 'similar-value' : 'text'; // Request one extra result to detect whether the results were limited. - const searchResults = this.#index.search(collection, dto.query, maxResults + 1); + const searchResults = this.#index.search(collection, dto.query, { mode, limit: maxResults + 1 }); // Check if results were limited const limited = searchResults.length > maxResults; diff --git a/apps/api/src/app/mappers/search-result.mapper.ts b/apps/api/src/app/mappers/search-result.mapper.ts index a8718215..37d92cfc 100644 --- a/apps/api/src/app/mappers/search-result.mapper.ts +++ b/apps/api/src/app/mappers/search-result.mapper.ts @@ -8,6 +8,7 @@ export function mapSearchResultToDto(searchResult: SearchResult, collection: Col ...buildResourceSummary(searchResult.key, searchResult, collection), matchType: searchResult.matchType, matchedLocales: searchResult.matchedLocales, + ...(searchResult.similarity !== undefined && { similarity: searchResult.similarity }), }; } diff --git a/apps/cli/src/commands/find-similar.real-fs.spec.ts b/apps/cli/src/commands/find-similar.real-fs.spec.ts index 0db55dba..2ebcd040 100644 --- a/apps/cli/src/commands/find-similar.real-fs.spec.ts +++ b/apps/cli/src/commands/find-similar.real-fs.spec.ts @@ -79,9 +79,6 @@ describe('find-similar (real fs)', () => { expect(loggedLines()).toContain(' labels.singleChar → "x" (similarity: 100%)'); }); - // The keys below deliberately avoid containing the query text: searchTranslations - // classifies a key match as 'partial-key', which find-similar filters out before - // scoring, so a key-shaped fixture would never reach the threshold logic at all. it('reports a near match that clears the threshold', async () => { await addResource('labels.pastTense', 'saved'); @@ -90,26 +87,36 @@ describe('find-similar (real fs)', () => { expect(loggedLines()).toContain(' labels.pastTense → "saved" (similarity: 80%)'); }); - it('rejects a candidate that reaches scoring but falls below the threshold', async () => { - // 'delete risk' contains the query, so it survives the substring pre-filter - // in searchTranslations and is actually scored: 1 - 5/11 ≈ 0.545 < 0.8. + it('rejects a value of the same length that falls below the threshold', async () => { + // Same length, no containment, so the Levenshtein score decides: 1 - 4/11 ≈ 0.636 < 0.8. await addResource('labels.destructiveAction', 'delete risk'); - // The same fixture matches on its exact value, proving the candidate is - // reachable and that the rejection above is the threshold, not the pre-filter. + // The same fixture matches on its exact value, proving the entry is read and + // that the rejection below is the threshold. await findSimilarCommand({ collection: 'main', value: 'delete risk' }); expect(loggedLines()).toContain(' labels.destructiveAction → "delete risk" (similarity: 100%)'); vi.mocked(console.log).mockClear(); + await findSimilarCommand({ collection: 'main', value: 'remove risk' }); + + expect(loggedLines()).toContain('No similar values found for "remove risk".'); + }); + + it('reports values that contain the query as whole words, but not as a word fragment', async () => { + await addResource('labels.destructiveAction', 'delete risk'); + await addResource('labels.deletedItems', 'deleted items'); + await findSimilarCommand({ collection: 'main', value: 'delete' }); - expect(loggedLines()).toContain('No similar values found for "delete".'); + const lines = loggedLines(); + expect(lines).toContain(' labels.destructiveAction → "delete risk" (similarity: 55%)'); + expect(lines.some((line) => line.includes('labels.deletedItems'))).toBe(false); }); it('suggests an entry whose key contains the query text', async () => { - // Regression for #75: searchTranslations classifies this entry as a key - // match, which used to suppress it before scoring — hiding exactly the - // well-named canonical key a caller most wants to reuse. + // Regression for #75: a key that contains the query used to suppress the + // entry before scoring — hiding exactly the well-named canonical key a + // caller most wants to reuse. await addResource('common.button.connect', 'Connect'); await findSimilarCommand({ collection: 'main', value: 'Connect' }); @@ -132,26 +139,26 @@ describe('find-similar (real fs)', () => { ); }); - it('finds a match that many key hits would otherwise crowd out', async () => { - // Regression for the candidate-budget half of #75: searchTranslations stops - // walking once it has maxResults hits, so the candidate budget must exceed - // the number of hits that precede a real match in the walk. 55 noise entries - // are enough to exhaust any budget at or below the old cap of 50, and far - // more than the display limit of 5. 'noise' sorts before 'zz', so the match - // is walked last. + it('ranks every match before the limit, so a match read last still comes first', async () => { + // Regression for the candidate-budget half of #75. The 55 noise entries all + // match ("Cancel" is a word of "Cancel 12", scored 6 / 9), far more than the + // display limit of 5, and 'noise' sorts before 'zz', so the exact match is read last. for (let i = 0; i < 55; i++) { - await addResource(`noise.cancelVariant${i}`, `Cancel the ${i} pending upload`); + await addResource(`noise.cancelVariant${i}`, `Cancel ${i}`); } await addResource('zz.dismiss', 'Cancel'); await findSimilarCommand({ collection: 'main', value: 'Cancel' }); - expect(loggedLines()).toContain(' zz.dismiss → "Cancel" (similarity: 100%)'); + const results = loggedLines().filter((line) => line.startsWith(' ')); + expect(results).toHaveLength(5); + expect(results[0]).toBe(' zz.dismiss → "Cancel" (similarity: 100%)'); }); it('still rejects a key-matched entry whose value is not similar', async () => { - // The key contains the query but the value does not resemble it, so the - // threshold must still discard it — key hits are scored, not waved through. + // The key contains the query but the value does not resemble it (it holds + // "connection", not the word "connect"), so the rule must still discard it — + // key hits are scored, not waved through. await addResource('errors.connectTimeout', 'The connection attempt timed out'); // The entry is reachable on its own value, so the rejection below is the diff --git a/apps/cli/src/commands/find-similar.spec.ts b/apps/cli/src/commands/find-similar.spec.ts index fb9777f7..fbcd96f8 100644 --- a/apps/cli/src/commands/find-similar.spec.ts +++ b/apps/cli/src/commands/find-similar.spec.ts @@ -3,8 +3,8 @@ import { findSimilarCommand } from './find-similar'; vi.mock('@simoncodes-ca/core', async (importOriginal) => { const actual = await importOriginal(); - // Collection resolution runs for real against the mocked config. - return { ...actual, loadConfig: vi.fn(), searchTranslations: vi.fn() }; + // Collection resolution and Resource Search run for real; only the config and the disk read are mocked. + return { ...actual, loadConfig: vi.fn(), readCollection: vi.fn() }; }); vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); @@ -20,22 +20,30 @@ vi.mock('path', async (importOriginal) => { }; }); -import { ConfigNotFoundError, loadConfig, searchTranslations } from '@simoncodes-ca/core'; -import type { LingoTrackerConfig, MatchType, SearchResult } from '@simoncodes-ca/core'; +import { ConfigNotFoundError, loadConfig, readCollection } from '@simoncodes-ca/core'; +import type { CollectionReadProblem, LingoTrackerConfig, StoredResource } from '@simoncodes-ca/core'; -/** - * Builds a fully typed SearchResult so the mocked searchTranslations return - * value stays bound to the real contract and shape drift fails to compile. - */ -function searchResult(key: string, matchType: MatchType, baseValue: string): SearchResult { +/** A fully typed stored resource, so shape drift in the Collection Reader fails to compile. */ +function stored(fullKey: string, baseValue: string): StoredResource { + const segments = fullKey.split('.'); + const entryKey = segments[segments.length - 1] ?? ''; return { - key, - matchType, - translations: { en: baseValue }, - status: {}, + fullKey, + folderPath: segments.slice(0, -1).join('.'), + entryKey, + entry: { key: entryKey, source: baseValue, translations: { fr: `fr:${baseValue}` }, metadata: {} }, + effectiveTags: [], }; } +function collectionHolds(...resources: StoredResource[]): void { + vi.mocked(readCollection).mockReturnValue({ resources, problems: [] }); +} + +function loggedLines(): string[] { + return vi.mocked(console.log).mock.calls.map((call) => String(call[0])); +} + const BASE_CONFIG: LingoTrackerConfig = { baseLocale: 'en', locales: ['en', 'fr'], @@ -61,46 +69,58 @@ describe('find-similar', () => { }); // --------------------------------------------------------------------------- - // threshold behaviour — tested indirectly via findSimilarCommand output + // the similar-value rule, as the command reports it // --------------------------------------------------------------------------- - describe('threshold behaviour (via findSimilarCommand output)', () => { - // Absolute scores are covered in libs/domain/src/lib/normalized-levenshtein.spec.ts. - // These cases pin only what the command adds on top: case folding, the 0.8 - // cutoff, and the empty-value fallback. + describe('similar-value rule (via findSimilarCommand output)', () => { + // The rule itself is specified in libs/core/src/lib/resource/search.spec.ts and the + // Levenshtein scores in libs/domain/src/lib/normalized-levenshtein.spec.ts. These cases + // pin what the command prints for it. beforeEach(() => { vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); }); it('reports 100% for an identical multi-character stored value', async () => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('common.button.addItem', 'exact-value', 'Add Item')]); + collectionHolds(stored('common.button.addItem', 'Add Item')); await findSimilarCommand({ collection: 'tracker', value: 'Add Item' }); expect(console.log).toHaveBeenCalledWith(' common.button.addItem → "Add Item" (similarity: 100%)'); }); it('folds case before scoring', async () => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('labels.greeting', 'exact-value', 'Hello World')]); + collectionHolds(stored('labels.greeting', 'Hello World')); await findSimilarCommand({ collection: 'tracker', value: 'hello world' }); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('(similarity: 100%)')); }); it('keeps a candidate sitting exactly on the 0.8 threshold', async () => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('btn.save', 'exact-value', 'saved')]); + collectionHolds(stored('btn.save', 'saved')); await findSimilarCommand({ collection: 'tracker', value: 'save' }); expect(console.log).toHaveBeenCalledWith(' btn.save → "saved" (similarity: 80%)'); }); - it('drops a candidate below the 0.8 threshold', async () => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('btn.delete', 'exact-value', 'delete risk')]); + it('drops a candidate below the 0.8 threshold that does not contain the query as a word', async () => { + collectionHolds(stored('btn.delete', 'deleted items')); await findSimilarCommand({ collection: 'tracker', value: 'delete' }); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('No similar values found')); }); - it('drops a candidate whose base-locale value is missing', async () => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('x.key', 'exact-value', '')]); + it('drops a candidate whose base-locale value is empty', async () => { + collectionHolds(stored('x.key', '')); await findSimilarCommand({ collection: 'tracker', value: 'a' }); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('No similar values found')); }); + + it('reports a stored value that contains the query as whole words, with its similarity', async () => { + collectionHolds(stored('btn.saveDraft', 'Save draft')); + await findSimilarCommand({ collection: 'tracker', value: 'Save' }); + expect(console.log).toHaveBeenCalledWith(' btn.saveDraft → "Save draft" (similarity: 40%)'); + }); + + it('reports a stored value that the query contains as whole words', async () => { + collectionHolds(stored('common.actions.save', 'Save')); + await findSimilarCommand({ collection: 'tracker', value: 'Save draft' }); + expect(console.log).toHaveBeenCalledWith(' common.actions.save → "Save" (similarity: 40%)'); + }); }); // --------------------------------------------------------------------------- @@ -113,7 +133,7 @@ describe('find-similar', () => { throw new ConfigNotFoundError('/project/.lingo-tracker.json'); }); await findSimilarCommand({ collection: 'tracker', value: 'hello' }); - expect(searchTranslations).not.toHaveBeenCalled(); + expect(readCollection).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); }); @@ -121,7 +141,7 @@ describe('find-similar', () => { vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); await findSimilarCommand({ collection: 'tracker' }); expect(console.log).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --value'); - expect(searchTranslations).not.toHaveBeenCalled(); + expect(readCollection).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); }); @@ -136,15 +156,15 @@ describe('find-similar', () => { vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); await findSimilarCommand({ collection: 'tracker', value: ' ' }); expect(console.log).toHaveBeenCalledWith('❌ --value must not be blank'); - expect(searchTranslations).not.toHaveBeenCalled(); + expect(readCollection).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); }); it('uses the only collection when --collection is missing', async () => { vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); - vi.mocked(searchTranslations).mockReturnValue([]); + collectionHolds(); await findSimilarCommand({ value: 'hello' }); - expect(searchTranslations).toHaveBeenCalledWith( + expect(readCollection).toHaveBeenCalledWith( expect.objectContaining({ translationsFolder: '/project/src/assets/i18n' }), ); expect(process.exitCode).toBe(0); @@ -157,7 +177,7 @@ describe('find-similar', () => { }); await findSimilarCommand({ value: 'hello' }); expect(console.log).toHaveBeenCalledWith('❌ Missing required option: --collection'); - expect(searchTranslations).not.toHaveBeenCalled(); + expect(readCollection).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); }); @@ -178,20 +198,20 @@ describe('find-similar', () => { vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); }); - it('prints "No similar values found" when no candidates pass the 0.8 threshold', async () => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('a.key', 'exact-value', 'hello world')]); + it('prints "No similar values found" when no stored value matches', async () => { + collectionHolds(stored('a.key', 'hello world')); await findSimilarCommand({ collection: 'tracker', value: 'hi' }); expect(console.log).toHaveBeenCalledWith('No similar values found for "hi".'); }); - it('prints "No similar values found" when candidates list is empty', async () => { - vi.mocked(searchTranslations).mockReturnValue([]); + it('prints "No similar values found" when the collection is empty', async () => { + collectionHolds(); await findSimilarCommand({ collection: 'tracker', value: 'hello' }); expect(console.log).toHaveBeenCalledWith('No similar values found for "hello".'); }); - it('prints header and matched results when a candidate is above threshold', async () => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('btn.ok', 'exact-value', 'Ok')]); + it('prints header and matched results when a stored value matches', async () => { + collectionHolds(stored('btn.ok', 'Ok')); await findSimilarCommand({ collection: 'tracker', value: 'Ok' }); expect(console.log).toHaveBeenCalledWith('Similar values found for "Ok":'); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('btn.ok')); @@ -199,84 +219,60 @@ describe('find-similar', () => { expect(console.log).toHaveBeenCalledWith(expect.stringContaining('(similarity: 100%)')); }); - it('formats each result as " key → \\"value\\" (similarity: N%)"', async () => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('common.ok', 'exact-value', 'Cancel')]); + it('formats each result as " key → \\"value\\" (similarity: N%)" with the base value', async () => { + collectionHolds(stored('common.ok', 'Cancel')); await findSimilarCommand({ collection: 'tracker', value: 'Cancel' }); expect(console.log).toHaveBeenCalledWith(' common.ok → "Cancel" (similarity: 100%)'); }); }); // --------------------------------------------------------------------------- - // findSimilarCommand — matchType is not a filter + // findSimilarCommand — keys that contain the query // --------------------------------------------------------------------------- - describe('findSimilarCommand — matchType handling', () => { - // searchTranslations assigns one matchType per entry, key first, so an entry - // whose key contains the query is labelled a key match even when its value - // matches too. Every candidate is scored on its base value regardless. + describe('findSimilarCommand — keys that contain the query', () => { beforeEach(() => { vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); }); - it.each([ - 'exact-value', - 'partial-value', - 'exact-key', - 'partial-key', - ])('scores a %s candidate on its base value', async (matchType) => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('btn.connect', matchType, 'Connect')]); + it('scores an entry whose key contains the query on its base value', async () => { + collectionHolds(stored('btn.connect', 'Connect')); await findSimilarCommand({ collection: 'tracker', value: 'Connect' }); expect(console.log).toHaveBeenCalledWith(' btn.connect → "Connect" (similarity: 100%)'); }); - it.each([ - 'exact-key', - 'partial-key', - ])('still drops a %s candidate whose value is below the threshold', async (matchType) => { - vi.mocked(searchTranslations).mockReturnValue([ - searchResult('errors.connectTimeout', matchType, 'The connection attempt timed out'), - ]); + it('still drops an entry whose key contains the query when its value is not similar', async () => { + collectionHolds(stored('errors.connectTimeout', 'The connection attempt timed out')); await findSimilarCommand({ collection: 'tracker', value: 'Connect' }); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('No similar values found')); }); - it('ranks the key-matched entry first when scores tie', async () => { - // searchTranslations orders exact-value above partial-key, so the canonical - // key arrives second; on an equal score it should still be listed first. - vi.mocked(searchTranslations).mockReturnValue([ - searchResult('dialogs.secondaryAction', 'exact-value', 'Connect'), - searchResult('common.button.connect', 'partial-key', 'Connect'), - ]); + it('ranks the entry whose key contains the query first when scores tie', async () => { + collectionHolds(stored('dialogs.secondaryAction', 'Connect'), stored('common.button.connect', 'Connect')); await findSimilarCommand({ collection: 'tracker', value: 'Connect' }); - const calls = vi.mocked(console.log).mock.calls.map((c) => c[0] as string); - const canonicalIdx = calls.findIndex((c) => c.includes('common.button.connect')); - const otherIdx = calls.findIndex((c) => c.includes('dialogs.secondaryAction')); + const lines = loggedLines(); + const canonicalIdx = lines.findIndex((line) => line.includes('common.button.connect')); + const otherIdx = lines.findIndex((line) => line.includes('dialogs.secondaryAction')); expect(canonicalIdx).toBeGreaterThan(-1); expect(canonicalIdx).toBeLessThan(otherIdx); }); it('does not let a key match outrank a strictly better value match', async () => { - vi.mocked(searchTranslations).mockReturnValue([ - searchResult('common.button.connect', 'partial-key', 'Connects'), - searchResult('dialogs.secondaryAction', 'exact-value', 'Connect'), - ]); + collectionHolds(stored('common.button.connect', 'Connects'), stored('dialogs.secondaryAction', 'Connect')); await findSimilarCommand({ collection: 'tracker', value: 'Connect' }); - const calls = vi.mocked(console.log).mock.calls.map((c) => c[0] as string); - const exactIdx = calls.findIndex((c) => c.includes('dialogs.secondaryAction')); - const keyIdx = calls.findIndex((c) => c.includes('common.button.connect')); + const lines = loggedLines(); + const exactIdx = lines.findIndex((line) => line.includes('dialogs.secondaryAction')); + const keyIdx = lines.findIndex((line) => line.includes('common.button.connect')); expect(exactIdx).toBeGreaterThan(-1); expect(exactIdx).toBeLessThan(keyIdx); }); it('returns a key-matched and a value-matched entry holding the same value', async () => { - vi.mocked(searchTranslations).mockReturnValue([ - searchResult('common.button.connect', 'partial-key', 'Connect'), - searchResult('dialogs.secondaryAction', 'exact-value', 'Connect'), - ]); + collectionHolds(stored('common.button.connect', 'Connect'), stored('dialogs.secondaryAction', 'Connect')); await findSimilarCommand({ collection: 'tracker', value: 'Connect' }); - const calls = vi.mocked(console.log).mock.calls.map((c) => c[0] as string); - expect(calls.some((c) => c.includes('common.button.connect'))).toBe(true); - expect(calls.some((c) => c.includes('dialogs.secondaryAction'))).toBe(true); + const lines = loggedLines(); + expect(lines.some((line) => line.includes('common.button.connect'))).toBe(true); + expect(lines.some((line) => line.includes('dialogs.secondaryAction'))).toBe(true); }); }); @@ -291,52 +287,52 @@ describe('find-similar', () => { it('sorts results by score descending', async () => { // Query 'save': 'saved' scores 0.8, the exact match scores 1.0. The lower - // scoring candidate is listed first to prove the sort actually reorders. - vi.mocked(searchTranslations).mockReturnValue([ - searchResult('key.near', 'exact-value', 'saved'), - searchResult('key.exact', 'exact-value', 'save'), - ]); + // scoring entry is read first to prove the ranking actually reorders. + collectionHolds(stored('key.near', 'saved'), stored('key.exact', 'save')); await findSimilarCommand({ collection: 'tracker', value: 'save' }); - const calls = vi.mocked(console.log).mock.calls.map((c) => c[0] as string); - const exactIdx = calls.findIndex((c) => c.includes('key.exact')); - const nearIdx = calls.findIndex((c) => c.includes('key.near')); + const lines = loggedLines(); + const exactIdx = lines.findIndex((line) => line.includes('key.exact')); + const nearIdx = lines.findIndex((line) => line.includes('key.near')); expect(exactIdx).toBeGreaterThan(-1); expect(nearIdx).toBeGreaterThan(-1); expect(exactIdx).toBeLessThan(nearIdx); }); it('defaults maxResults to 5', async () => { - const manyCandidates = Array.from({ length: 10 }, (_, i) => searchResult(`key.${i}`, 'exact-value', 'a')); - vi.mocked(searchTranslations).mockReturnValue(manyCandidates); + collectionHolds(...Array.from({ length: 10 }, (_, i) => stored(`key.${i}`, 'a'))); await findSimilarCommand({ collection: 'tracker', value: 'a' }); - const resultLines = vi - .mocked(console.log) - .mock.calls.map((c) => c[0] as string) - .filter((c) => c.startsWith(' key.')); - expect(resultLines).toHaveLength(5); + expect(loggedLines().filter((line) => line.startsWith(' key.'))).toHaveLength(5); }); it('respects custom maxResults', async () => { - const manyCandidates = Array.from({ length: 10 }, (_, i) => searchResult(`key.${i}`, 'exact-value', 'a')); - vi.mocked(searchTranslations).mockReturnValue(manyCandidates); + collectionHolds(...Array.from({ length: 10 }, (_, i) => stored(`key.${i}`, 'a'))); await findSimilarCommand({ collection: 'tracker', value: 'a', maxResults: 3 }); - const resultLines = vi - .mocked(console.log) - .mock.calls.map((c) => c[0] as string) - .filter((c) => c.startsWith(' key.')); - expect(resultLines).toHaveLength(3); + expect(loggedLines().filter((line) => line.startsWith(' key.'))).toHaveLength(3); + }); + + it('compares every stored value, however many precede the best match', async () => { + // There is no candidate cap any more: 600 weaker matches read first cannot crowd out the exact one. + collectionHolds( + ...Array.from({ length: 600 }, (_, i) => stored(`noise.variant${i}`, `Cancel ${i}`)), + stored('zz.dismiss', 'Cancel'), + ); + + await findSimilarCommand({ collection: 'tracker', value: 'Cancel' }); + + expect(loggedLines().find((line) => line.startsWith(' '))).toBe(' zz.dismiss → "Cancel" (similarity: 100%)'); + expect(console.warn).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- - // findSimilarCommand — locale resolution + // findSimilarCommand — the collection it reads // --------------------------------------------------------------------------- - describe('findSimilarCommand — locale resolution', () => { + describe('findSimilarCommand — the collection it reads', () => { it('uses collectionConfig.baseLocale when set', async () => { vi.mocked(loadConfig).mockReturnValue({ baseLocale: 'en', @@ -348,11 +344,11 @@ describe('find-similar', () => { }, }, }); - vi.mocked(searchTranslations).mockReturnValue([]); + collectionHolds(); await findSimilarCommand({ collection: 'tracker', value: 'bonjour' }); - expect(searchTranslations).toHaveBeenCalledWith(expect.objectContaining({ baseLocale: 'fr' })); + expect(readCollection).toHaveBeenCalledWith(expect.objectContaining({ baseLocale: 'fr' })); }); it('falls back to config.baseLocale when collectionConfig has no baseLocale', async () => { @@ -365,11 +361,11 @@ describe('find-similar', () => { }, }, }); - vi.mocked(searchTranslations).mockReturnValue([]); + collectionHolds(); await findSimilarCommand({ collection: 'tracker', value: 'hallo' }); - expect(searchTranslations).toHaveBeenCalledWith(expect.objectContaining({ baseLocale: 'de' })); + expect(readCollection).toHaveBeenCalledWith(expect.objectContaining({ baseLocale: 'de' })); }); it('falls back to "en" when neither collection nor config specifies baseLocale', async () => { @@ -381,63 +377,55 @@ describe('find-similar', () => { }, }, }); - vi.mocked(searchTranslations).mockReturnValue([]); + collectionHolds(); await findSimilarCommand({ collection: 'tracker', value: 'hello' }); - expect(searchTranslations).toHaveBeenCalledWith(expect.objectContaining({ baseLocale: 'en' })); + expect(readCollection).toHaveBeenCalledWith(expect.objectContaining({ baseLocale: 'en' })); }); - }); - // --------------------------------------------------------------------------- - // findSimilarCommand — searchTranslations call arguments - // --------------------------------------------------------------------------- - - describe('findSimilarCommand — searchTranslations arguments', () => { - beforeEach(() => { + it('reads the collection with its translationsFolder resolved from cwd + collectionConfig', async () => { vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); - vi.mocked(searchTranslations).mockReturnValue([]); - }); - - it('calls searchTranslations with translationsFolder resolved from cwd + collectionConfig', async () => { + collectionHolds(); await findSimilarCommand({ collection: 'tracker', value: 'hello' }); - expect(searchTranslations).toHaveBeenCalledWith( + expect(readCollection).toHaveBeenCalledWith( expect.objectContaining({ translationsFolder: '/project/src/assets/i18n', }), ); }); - it('calls searchTranslations with the trimmed query', async () => { + it('compares the trimmed query', async () => { + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + collectionHolds(stored('labels.hello', 'hello')); await findSimilarCommand({ collection: 'tracker', value: ' hello ' }); - expect(searchTranslations).toHaveBeenCalledWith(expect.objectContaining({ query: 'hello' })); + expect(console.log).toHaveBeenCalledWith('Similar values found for "hello":'); + expect(console.log).toHaveBeenCalledWith(' labels.hello → "hello" (similarity: 100%)'); }); - it('requests a candidate budget far larger than the display limit', async () => { - // searchTranslations stops walking at maxResults, so the budget must exceed - // what is displayed by enough that ranking, not discovery order, decides - // which candidates survive. - await findSimilarCommand({ collection: 'tracker', value: 'hello' }); - const [params] = vi.mocked(searchTranslations).mock.calls[0]; - expect(params.maxResults).toBeGreaterThanOrEqual(500); - }); + it('warns about each folder it could not read and still reports the others', async () => { + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + const problem: CollectionReadProblem = { + folderPath: 'broken', + absolutePath: '/project/src/assets/i18n/broken', + message: 'Unexpected token in resource_entries.json', + }; + vi.mocked(readCollection).mockReturnValue({ resources: [stored('common.ok', 'OK')], problems: [problem] }); - it('warns when the candidate budget was exhausted, since results may be incomplete', async () => { - const full = Array.from({ length: 500 }, (_, i) => searchResult(`key.${i}`, 'exact-value', 'a')); - vi.mocked(searchTranslations).mockReturnValue(full); - await findSimilarCommand({ collection: 'tracker', value: 'a' }); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('only the first 500 candidates')); - }); + await findSimilarCommand({ collection: 'tracker', value: 'OK' }); - it('does not warn when the candidate budget was not exhausted', async () => { - vi.mocked(searchTranslations).mockReturnValue([searchResult('key.one', 'exact-value', 'a')]); - await findSimilarCommand({ collection: 'tracker', value: 'a' }); - expect(console.warn).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + '⚠️ Skipped unreadable folder: Unexpected token in resource_entries.json', + ); + expect(console.log).toHaveBeenCalledWith(' common.ok → "OK" (similarity: 100%)'); + expect(process.exitCode).toBe(0); }); - it('calls searchTranslations with the resolved baseLocale', async () => { - await findSimilarCommand({ collection: 'tracker', value: 'hello' }); - expect(searchTranslations).toHaveBeenCalledWith(expect.objectContaining({ baseLocale: 'en' })); + it('prints no warning when every folder was read', async () => { + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + collectionHolds(stored('key.one', 'a')); + await findSimilarCommand({ collection: 'tracker', value: 'a' }); + expect(loggedLines().some((line) => line.startsWith('⚠️'))).toBe(false); }); }); }); diff --git a/apps/cli/src/commands/find-similar.ts b/apps/cli/src/commands/find-similar.ts index 15ccc8d9..cf5cca65 100644 --- a/apps/cli/src/commands/find-similar.ts +++ b/apps/cli/src/commands/find-similar.ts @@ -1,17 +1,6 @@ -import { type Collection, searchTranslations } from '@simoncodes-ca/core'; -import { normalizedLevenshtein } from '@simoncodes-ca/domain'; +import { type Collection, readCollection, searchResources } from '@simoncodes-ca/core'; import { defineCommand } from '../runner/command-runner'; - -/** - * How many candidates to score. searchTranslations stops walking once it has - * this many hits, so the budget must be large enough that ranking, not - * discovery order, decides what survives — while still bounding the walk on a - * very short query that matches most of the store. - */ -const CANDIDATE_LIMIT = 500; - -/** Minimum similarity for a candidate to be reported as a match. */ -const THRESHOLD = 0.8; +import { ConsoleFormatter } from '../utils'; export interface FindSimilarOptions { collection?: string; @@ -35,52 +24,22 @@ export const findSimilarCommand = defineCommand()({ }, }); -function reportSimilar(collection: Collection, query: string, displayLimit: number): void { - const { translationsFolder, baseLocale } = collection; - - // Use a broad search to get candidates (pass the whole query for substring pre-filter) - const candidates = searchTranslations({ - translationsFolder, - query, - maxResults: CANDIDATE_LIMIT, - baseLocale, - }); - - // Score every candidate on its base value, whatever its matchType: a key match - // pre-empts a value match upstream (see MatchType), so filtering on matchType - // discarded the well-named canonical keys this command exists to surface. The - // threshold is what separates a real match from a coincidental one. - const scored = candidates - .map((r) => { - const storedValue = r.translations[baseLocale] ?? ''; - const score = normalizedLevenshtein(query.toLowerCase(), storedValue.toLowerCase()); - const keyMatch = r.matchType === 'exact-key' || r.matchType === 'partial-key'; - return { key: r.key, value: storedValue, score, keyMatch }; - }) - .filter((r) => r.score >= THRESHOLD) - .sort((a, b) => { - // Score decides first: a key hit never outranks a closer value. - if (b.score !== a.score) return b.score - a.score; - // On a tie, prefer the entry whose key is also named after the query — it - // is the canonical, reusable key a caller is looking for. - return Number(b.keyMatch) - Number(a.keyMatch); - }) - .slice(0, displayLimit); - - if (candidates.length >= CANDIDATE_LIMIT) { - console.warn( - `Note: only the first ${CANDIDATE_LIMIT} candidates were compared. Narrow the query for a complete result.`, - ); +/** Prints the collection's base values that Resource Search's similar-value rule matches, best first. */ +function reportSimilar(collection: Collection, query: string, limit: number): void { + const { resources, problems } = readCollection(collection); + for (const problem of problems) { + ConsoleFormatter.warning(`Skipped unreadable folder: ${problem.message}`); } - if (scored.length === 0) { + const matches = searchResources(resources, collection, query, { mode: 'similar-value', limit }); + if (matches.length === 0) { console.log(`No similar values found for "${query}".`); return; } console.log(`Similar values found for "${query}":`); - for (const match of scored) { - const pct = Math.round(match.score * 100); - console.log(` ${match.key} → "${match.value}" (similarity: ${pct}%)`); + for (const match of matches) { + const pct = Math.round((match.similarity ?? 0) * 100); + console.log(` ${match.key} → "${match.source}" (similarity: ${pct}%)`); } } diff --git a/apps/cli/src/commands/install-skill-templates/SKILL.md b/apps/cli/src/commands/install-skill-templates/SKILL.md index 8d4572ec..474b9b55 100644 --- a/apps/cli/src/commands/install-skill-templates/SKILL.md +++ b/apps/cli/src/commands/install-skill-templates/SKILL.md @@ -40,7 +40,7 @@ Before calling `add-resource` for any detected string, run: npx lingo-tracker find-similar --collection {{PRIMARY_COLLECTION}} --value "" ``` -**If the command returns any results, evaluate each match** (the tool already filters to ≥ 80% similarity): +**If the command returns any results, evaluate each match** (the tool shows values that are ≥ 80% similar, or that contain the string or are contained in it as whole words with ≥ 40% similarity; such a containment match shows its real similarity, between 40% and 80%): - **Reuse the existing key if**: - The stored value is identical or nearly identical (≥ 95% similarity) diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-value-filter.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-value-filter.ts deleted file mode 100644 index 9f59c751..00000000 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-value-filter.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { SearchResultDto } from '@simoncodes-ca/data-transfer'; - -/** - * How many hits the dialog asks the API for before filtering. The collection - * search matches keys as well as values and reports a key match in preference to - * a value one, so a page sized to the display limit can come back entirely made - * of key hits and leave the list empty. Asking for more than we show buys the - * filter room to work. - */ -export const SIMILAR_SEARCH_MAX_RESULTS = 25; - -/** How many similar values the context column ever pins. */ -export const SIMILAR_DISPLAY_LIMIT = 10; - -/** - * Keeps only the hits whose base-locale text actually relates to what was typed. - * - * The block is titled "Similar values" and its caption offers the keys as - * something to reuse instead of writing a duplicate. A hit that matched only - * because the query appears in its key — `…translationEditor.saveButton` for - * "Save", whose value is "Create translation" — is a different string under a - * similarly named key, and reusing it would be wrong. Containment is tested both - * ways so a short existing value ("Save") still surfaces against a longer typed - * one ("Save draft"), which is exactly the duplicate worth catching. - */ -export function filterSimilarByValue( - results: readonly SearchResultDto[], - typedValue: string, - limit: number = SIMILAR_DISPLAY_LIMIT, -): SearchResultDto[] { - const typed = typedValue.trim().toLowerCase(); - if (!typed) { - return []; - } - - const kept = results.filter((result) => { - const baseValue = result.base.value.trim().toLowerCase(); - if (!baseValue) { - return false; - } - return baseValue.includes(typed) || typed.includes(baseValue); - }); - - return kept.slice(0, limit); -} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts index b9524dd8..a595647a 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts @@ -1135,7 +1135,7 @@ describe('TranslationEditorDialog', () => { describe('Sticky similar values', () => { const hit = (fullKey: string, value: string): SearchResultDto => ({ ...summary(fullKey, value), - matchType: 'partial-value', + matchType: 'similar-value', }); const searchReturns = (results: ReturnType[]): void => { @@ -1248,38 +1248,31 @@ describe('TranslationEditorDialog', () => { expect(spectator.query('[data-testid="similar-exact-caption"]')).toBeNull(); }); - it('should drop hits that only matched on their key', () => { - searchReturns([ - hit('browser.translationEditor.saveButton', 'Create translation'), - hit('common.actions.save', 'Save'), - ]); - typeAndSettle('Save draft'); - - expect(component.similarResources().map((result) => result.fullKey)).toEqual(['common.actions.save']); - expect(component.similarCount()).toBe(1); - }); - - it('should ask for more hits than it shows and keep at most ten', () => { - searchReturns(Array.from({ length: 25 }, (_, index) => hit(`common.actions.save${index}`, 'Save changes'))); + it('should ask the API for similar values, one more than it shows, and keep at most ten', () => { + searchReturns(Array.from({ length: 11 }, (_, index) => hit(`common.actions.save${index}`, 'Save changes'))); typeAndSettle('Save changes'); - expect(mockBrowserApi.searchTranslations).toHaveBeenCalledWith('test-collection', 'Save changes', 25); + expect(mockBrowserApi.searchTranslations).toHaveBeenCalledWith('test-collection', 'Save changes', 11, 'similar'); expect(component.similarCount()).toBe(10); }); - it('should count only the value matches, so the badge and the list agree', () => { + it('should drop the entry being edited and keep the ranked order of the rest', () => { + vi.useRealTimers(); + renderDialog(createMockData('edit', summary('common.actions.save', 'Save'))); + vi.useFakeTimers(); searchReturns([ - hit('browser.translationEditor.saveButton', 'Create translation'), - hit('browser.translationEditor.saveAnyway', 'Save Anyway'), + hit('common.actions.saveDraft', 'Save draft'), hit('common.actions.save', 'Save'), + hit('browser.translationEditor.saveAnyway', 'Save Anyway'), ]); - typeAndSettle('Save'); - expect(component.similarCount()).toBe(2); + typeAndSettle('Save draft'); + expect(component.similarResources().map((result) => result.fullKey)).toEqual([ + 'common.actions.saveDraft', 'browser.translationEditor.saveAnyway', - 'common.actions.save', ]); + expect(component.similarCount()).toBe(2); }); }); @@ -1622,6 +1615,7 @@ describe('TranslationEditorDialog', () => { 'test-collection', 'Total Investment for the year', expect.any(Number), + 'similar', ); }); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts index 63fdbee0..ccb7601e 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts @@ -70,7 +70,6 @@ import { toUpdateDto, } from './resource-entry-draft'; import { SimilarTranslations } from './similar-translations'; -import { filterSimilarByValue, SIMILAR_SEARCH_MAX_RESULTS } from './similar-value-filter'; /** * The id of the dialog's heading. The MatDialog container is labelled by this id @@ -85,6 +84,9 @@ export const PREFERRED_TERM_ADVISORIES_ID = 'translation-editor-preferred-terms' /** Typing pause before preferred-terminology findings refresh; matches the similar search. */ export const PREFERRED_TERM_DEBOUNCE_MS = 300; +/** How many similar values the context column ever pins. */ +export const SIMILAR_DISPLAY_LIMIT = 10; + export interface TranslationEditorDialogData { mode: 'create' | 'edit'; resource?: ResourceSummaryDto; @@ -667,31 +669,31 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit }); } - return this.browserApi.searchTranslations(this.data.collectionName, query, SIMILAR_SEARCH_MAX_RESULTS).pipe( - catchError(() => - of({ - query: '', - results: [], - totalFound: 0, - limited: false, - }), - ), - ); + // The API ranks by similarity. One extra hit, so a full list survives dropping the entry being edited. + return this.browserApi + .searchTranslations(this.data.collectionName, query, SIMILAR_DISPLAY_LIMIT + 1, 'similar') + .pipe( + catchError(() => + of({ + query: '', + results: [], + totalFound: 0, + limited: false, + }), + ), + ); }), tap(() => this.isSearchingSimilar.set(false)), takeUntil(this.destroy$), ) .subscribe((searchResults) => { - // Filter out current resource in edit mode + // In edit mode the entry itself is not a similar value. const original = this.#originalEntry(); const withoutSelf = original ? searchResults.results.filter((r) => r.fullKey !== original.fullKey) : searchResults.results; - // The API matches keys too, and reports a key match ahead of a value one. - // Everything downstream — the count, the exact-duplicate caption, what - // stays pinned — reads this signal, so the key-only hits go before it. - this.similarResources.set(filterSimilarByValue(withoutSelf, searchResults.query || this.baseValueText())); + this.similarResources.set(withoutSelf.slice(0, SIMILAR_DISPLAY_LIMIT)); }); } diff --git a/apps/tracker/src/app/browser/services/browser-api.service.spec.ts b/apps/tracker/src/app/browser/services/browser-api.service.spec.ts index deb07e92..7fffa5be 100644 --- a/apps/tracker/src/app/browser/services/browser-api.service.spec.ts +++ b/apps/tracker/src/app/browser/services/browser-api.service.spec.ts @@ -188,6 +188,24 @@ describe('BrowserApiService', () => { expect(data).toEqual(mockResults); }); + it('should send mode only when one is given', async () => { + const empty: SearchResultsDto = { query: 'Save', results: [], totalFound: 0, limited: false }; + + const similar$ = service.searchTranslations('my-collection', 'Save', 11, 'similar'); + queueMicrotask(() => { + httpMock + .expectOne((request) => request.url.includes('/search') && request.params.get('mode') === 'similar') + .flush(empty); + }); + await firstValueFrom(similar$); + + const text$ = service.searchTranslations('my-collection', 'Save'); + queueMicrotask(() => { + httpMock.expectOne((request) => request.url.includes('/search') && !request.params.has('mode')).flush(empty); + }); + await firstValueFrom(text$); + }); + it('should use default maxResults of 100', async () => { const collectionName = 'my-collection'; const query = 'test'; diff --git a/apps/tracker/src/app/browser/services/browser-api.service.ts b/apps/tracker/src/app/browser/services/browser-api.service.ts index 1cc53898..d899c878 100644 --- a/apps/tracker/src/app/browser/services/browser-api.service.ts +++ b/apps/tracker/src/app/browser/services/browser-api.service.ts @@ -5,6 +5,7 @@ import type { ResourceTreeDto, TreeStatusResponseDto, SearchResultsDto, + SearchTranslationsDto, CacheStatusDto, CreateResourceDto, CreateResourceResponseDto, @@ -103,10 +104,19 @@ export class BrowserApiService { * @param collectionName - Name of the collection to search * @param query - Search query string * @param maxResults - Maximum results to return (default: 100) + * @param mode - `text` (default): keys and values; `similar`: base values similar to the query, ranked by similarity * @returns Observable of search results */ - searchTranslations(collectionName: string, query: string, maxResults = 100): Observable { - const params = new HttpParams().set('query', query).set('maxResults', maxResults.toString()); + searchTranslations( + collectionName: string, + query: string, + maxResults = 100, + mode?: SearchTranslationsDto['mode'], + ): Observable { + let params = new HttpParams().set('query', query).set('maxResults', maxResults.toString()); + if (mode) { + params = params.set('mode', mode); + } const encodedName = encodeURIComponent(collectionName); return this.#http.get(`${this.#baseUrl}/${encodedName}/resources/search`, { params }); diff --git a/architecture-docs/api.md b/architecture-docs/api.md index c6aa0393..145a52a0 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -59,7 +59,7 @@ All paths are relative to the `/api` global prefix. URL path parameters that con | `POST` | `/collections/:collectionName/resources/translate` | Auto-translate a single resource through the [Translator](glossary.md#translator) (422 when the collection has auto-translation off). Values are stored in ICU format; `skippedLocales` lists the locales it did not store (complex ICU, a lost placeholder, a dropped protected term) | `TranslateResourceDto` | `TranslateResourceResponseDto` | | `GET` | `/collections/:collectionName/resources/tree` | Fetch the resource [tree](glossary.md#resource-tree) (or subtree) from the Collection Index | query: `path`, `includeNested` | `ResourceTreeDto \| TreeStatusResponseDto` | | `GET` | `/collections/:collectionName/resources/cache/status` | Poll the [Collection Index](glossary.md#collection-index) state (starts indexing) | — | `CacheStatusDto` | -| `GET` | `/collections/:collectionName/resources/search` | Full-text search across the collection | query: `SearchTranslationsDto` | `SearchResultsDto` | +| `GET` | `/collections/:collectionName/resources/search` | [Resource Search](glossary.md#resource-search) across the collection. `mode=text` (default) finds the query in keys and in the values of every locale; `mode=similar` finds base values similar to the query and ranks them by `similarity`. Any other `mode` is a text search. `maxResults` must be a positive integer (at most 500; a larger value is 500); anything else (absent, not a number, `0`, negative, a fraction) is 100. All matches are ranked before `maxResults` applies; `limited` is true when more matches exist. | query: `SearchTranslationsDto` (`query`, `maxResults?`, `mode?`) | `SearchResultsDto` | | `POST` | `/collections/:collectionName/resources/translate-locale` | Fire-and-forget: start a bulk locale translation job | `TranslateLocaleRequestDto` | `TranslateLocaleJobDto` (202 Accepted) | | `GET` | `/collections/:collectionName/resources/translate-locale/:jobId` | Poll a translation job by ID | — | `TranslateLocaleJobDto` | @@ -129,7 +129,7 @@ graph TD end subgraph core["@simoncodes-ca/core"] - COREOPS["addResource · editResource · deleteResource\nmoveResource · createFolder · deleteFolder\nmoveFolder · addLocaleToCollection\nremoveLocaleFromCollection · searchTranslations\ntranslateExistingResource · translateLocale\nloadResourceTree · searchResourceTree"] + COREOPS["addResource · editResource · deleteResource\nmoveResource · createFolder · deleteFolder\nmoveFolder · addLocaleToCollection\nremoveLocaleFromCollection · readCollection\ntranslateExistingResource · translateLocale\nloadResourceTree · searchResources · treeResources"] end TRACKER -->|"REST /api/*"| controllers @@ -223,14 +223,14 @@ This means a single `node apps/api/main.js` process serves both the UI and the A ```typescript tree(collection: Collection, path?: string): TreeRead; // { status: 'ready', tree | null } | { status: 'not-started' | 'indexing' | 'error' } -search(collection: Collection, query: string, maxResults: number): SearchResult[]; +search(collection: Collection, query: string, options?: SearchOptions): SearchResult[]; // { mode?: 'text' | 'similar-value', limit? } status(collection: Collection): CacheStatusDto; // for GET .../cache/status apply(mutations: readonly ResourceMutation[]): void; // after every core write ``` Controllers do not know how the index works. They read with `tree()`, `search()` and `status()`, and give the `mutations` of each core write to `apply()`. These items are internal to the index: -- **Indexing.** `tree()` indexes a collection that is not indexed or whose last attempt failed. `status()` indexes only a collection that is not indexed, and reports `error` as it is. Both report the state that they found, so the first read answers `not-started` (and `/tree` returns `202`). `search()` never starts indexing. It searches the disk until the collection is indexed. +- **Indexing.** `tree()` indexes a collection that is not indexed or whose last attempt failed. `status()` indexes only a collection that is not indexed, and reports `error` as it is. Both report the state that they found, so the first read answers `not-started` (and `/tree` returns `202`). `search()` never starts indexing. It runs [Resource Search](glossary.md#resource-search) (`searchResources`) over `treeResources(tree)` when the collection is indexed, and over the disk (`readCollection(collection).resources`) until then. On the disk path it logs the folders the reader could not read with one `Logger.warn` per search. Both sources give the same results, because the same matcher ranks every match before the limit applies. The controller asks for `maxResults + 1` to set `limited`. (Before, both paths stopped at `maxResults + 1` hits in walk order and ranked only those, so a better match found late, such as an exact key, could be lost. A broad query can now return a different, better ranked page. `totalFound` and `limited` mean what they meant.) - **Revalidation.** Before each read, a ready entry compares a stat-only disk fingerprint (`computeTreeFingerprint`) with the fingerprint from its last index or own write. If they differ, the entry is dropped and indexed again. This makes CLI commands, `git checkout` and hand edits visible without a restart. Filesystem watching is not used, because inotify does not fire for Windows-side writes on a WSL `/mnt/c` mount, and the same is true for some network and container mounts. The check runs at most once per `LINGO_TRACKER_REVALIDATE_INTERVAL_MS` (default 2000 ms) for each entry. - **Own writes.** After `apply()` patches an entry, the index refreshes that entry's fingerprint at the end of the tick. A bulk endpoint that applies mutations in a loop causes one scan, not one per resource. A read that comes before the refresh adopts the new fingerprint, so an own write is never read as an outside change. - **Patching.** One tree-walk helper applies each mutation to the tree. When a mutation does not match the tree (for example, a `remove` of a key that the index does not have), the index drops that collection. The next read indexes it again. A wrong patch never stays in memory. @@ -390,7 +390,7 @@ For the entity types that mappers transform, see [domain-and-data-model.md](doma | `collection.mapper.ts` | `LingoTrackerCollectionDto` ↔ `LingoTrackerCollection` | Bidirectional; shallow clone of `locales[]` and `tags[]` arrays to prevent aliasing. Carries the `protectedTermsFile` setting in both directions. Drops resolved `protectedTerms` on the way back to config, because terms live in a file and the controller writes them there separately. | | `config.mapper.ts` | `LingoTrackerConfig` → `LingoTrackerConfigDto` | Delegates collection mapping to `collection.mapper` and bundle mapping to `bundle.mapper`; shallow clone of `locales[]`. Takes an optional `ResolvedProtectedTerms` and `projectName` (basename of the API's working directory) from the controller, so the mapper itself reads no files. | | `bundle.mapper.ts` | `BundleDefinitionDto` ↔ `BundleDefinition`; `BundlePlan` → `BundleDryRunResultDto`; `GenerateBundleResult` → `BundleGenerateJobResultDto` | Bidirectional definition mapping trims strings and drops empty optionals so nothing spurious is written to the config. The plan mapper drops `absolutePath` and caps `conflictKeys` at 50. The job-result mapper rebuilds written file paths from `localesProcessed` plus the types file. | -| `search-result.mapper.ts` | `SearchResult` + `Collection` → `SearchResultDto` | The hit's Resource Summary (from its `key`, `source`, `translations` and `metadata`) plus `matchType` and `matchedLocales` | +| `search-result.mapper.ts` | `SearchResult` + `Collection` → `SearchResultDto` | The hit's Resource Summary (from its `key`, `source`, `translations` and `metadata`) plus `matchType` (`'similar-value'` for `mode=similar`), `matchedLocales`, and `similarity` (0..1) when the search was in similar mode | **Why does `config.mapper.ts` take resolved terms as an argument?** Protected terms live in JSON files outside `.lingo-tracker.json`. Building the DTO therefore requires reading the filesystem. diff --git a/architecture-docs/cli.md b/architecture-docs/cli.md index 022342dd..bafe6d1e 100644 --- a/architecture-docs/cli.md +++ b/architecture-docs/cli.md @@ -52,7 +52,7 @@ All commands are registered in `apps/cli/src/main.ts`. Each row below lists the | `export` | `-f/--format`, `-c/--collection`, `-l/--locale`, `-s/--status`, `-t/--tags`, `-o/--output`, `--structure`, `--rich`, `--include-base`, `--include-status`, `--include-comment`, `--include-tags`, `--base-property-name`, `--filename`, `--no-protect-notes`, `--dry-run`, `--verbose` | `runExport()` | | `import` | `-f/--format`, `-s/--source`, `-l/--locale`, `-c/--collection`, `--strategy`, `--update-comments`, `--update-tags`, `--preserve-status`, `--create-missing`, `--validate-base`, `--dry-run`, `--verbose` | `parseJsonImport()` / `parseXliffImport()` → `importResources()` | | `validate` | `--allow-translated`, `--skip-locales`, `--skip-icu`, `--skip-placeholders`, `--require-portable-plurals` | `openCollection()` for each collection → `validateResources()`, `generateValidationSummary()` | -| `find-similar` | `--collection`, `--value`, `--max-results` | `searchTranslations()` | +| `find-similar` | `--collection`, `--value`, `--max-results` | `readCollection()` → `searchResources(…, { mode: 'similar-value', limit })` ([Resource Search](glossary.md#resource-search)) | | `glossary` | `--text`, `--input`, `--output`, `--stdout`, `--collection`, `--locales`, `--include-all`, `--extractor` | `readCollection()` (matching/extraction done in the command, not core) | | `protected-terms` | `--collection`, `--add` (repeatable), `--remove` (repeatable), `--set`, `--list`, `--file` | `setGlobalProtectedTerms()` / `setCollectionProtectedTerms()` / `setGlobalProtectedTermsFile()` / `setCollectionProtectedTermsFile()`, reading via `readGlobalProtectedTerms()` / `readCollectionProtectedTerms()` | | `preferred-terminology` | `--list`, `--add `, `--preferred`, `--reason`, `--remove ` | `loadPreferredTerminology()` / `writePreferredTerminology()` | @@ -295,6 +295,15 @@ For scripts written against the earlier CLI: - `add-resource --translations` is parsed inside the command: bad JSON exits 1 with a message instead of an unhandled rejection. - `validate --skip-placeholders` is passed through (it was declared but ignored). +### Changes Introduced by Resource Search + +`find-similar` reads the collection with `readCollection` and asks [Resource Search](glossary.md#resource-search) for the `--max-results` best similar-value matches. The output format is unchanged (` key → "value" (similarity: NN%)`). + +- Every base value is compared. The 500-candidate cap and its `Note: only the first 500 candidates were compared` warning are gone. +- A match is a base value at least 80% similar to `--value` (as before), **or** one that contains `--value` or is contained in it as whole words with a similarity of at least 40%. So `--value "Save"` now also reports `"Save draft"` (similarity 40%), but not `"Save and Close"` (29%). A fragment inside a word does not count (`connect` in `connection`, `don` in `don't`). +- Ranking: similarity, then an entry whose key contains `--value`, then key (key order is new; ties were in discovery order before). +- A folder the reader cannot read prints `⚠️ Skipped unreadable folder: ` on stdout (it was a `console.error` line from the search), and the other folders are still searched. + --- ## Config Loading and Collection Resolution diff --git a/architecture-docs/core-library.md b/architecture-docs/core-library.md index da417c47..438a3785 100644 --- a/architecture-docs/core-library.md +++ b/architecture-docs/core-library.md @@ -20,6 +20,7 @@ Return to [architecture README](README.md). - [delete-resource](#delete-resource) - [move-resource](#move-resource) - [Collection Reader](#collection-reader) + - [Resource Search](#resource-search) - [Normalization Pipeline](#normalization-pipeline) - [Auto-Translation Pipeline](#auto-translation-pipeline) - [Provider abstraction](#provider-abstraction) @@ -129,7 +130,7 @@ libs/core/src/ │ ├── resource-folder.ts # openResourceFolder(): the Resource Folder (entries + metadata as a unit) │ ├── read-collection.ts # readCollection(), readCollectionFolders(): the Collection Reader │ ├── load-resource-tree.ts # loadResourceTree(): the API's resource tree (built on readCollectionFolders) - │ ├── search.ts # searchTranslations() (disk), searchResourceTree() (in memory) + │ ├── search.ts # searchResources(), treeResources(): Resource Search over the reader or an index tree │ ├── resource-mutation.ts # ResourceMutation: what a write changed │ └── tree-fingerprint.ts # computeTreeFingerprint(): stat-only change detection │ @@ -247,7 +248,7 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that ## Public Surface -`libs/core/src/index.ts` is the [public surface](glossary.md#public-surface): 185 names, listed one by one and grouped by role. It exports only what the API or CLI uses, plus the types in those names' signatures. It does not re-export `domain` names; callers import `TranslationStatus`, `TokenCasing` and `ImportStrategy` from `@simoncodes-ca/domain`. +`libs/core/src/index.ts` is the [public surface](glossary.md#public-surface): 186 names, listed one by one and grouped by role. It exports only what the API or CLI uses, plus the types in those names' signatures. It does not re-export `domain` names; callers import `TranslationStatus`, `TokenCasing` and `ImportStrategy` from `@simoncodes-ca/domain`. | Group | What it holds | |---|---| @@ -256,7 +257,7 @@ For the entity types (`ResourceEntry`, `TrackerMetadata`, `LocaleMetadata`) that | Collection & config | `loadConfig`, `openCollection`, `Collection`, `CONFIG_FILENAME`, `DEFAULT_CONFIG`, the config types (`LingoTrackerConfig`, `LingoTrackerCollection`, `TranslationConfig`, `BundleDefinition`, ...), and the protected-terms and preferred-terminology file readers and writers. | | ResourceFolder | `openResourceFolder`, `ResourceFolder` and the types in its methods, `resolveResourcePaths`. | | Collection Reader | `readCollection`, `StoredResource`, `CollectionRead`, `CollectionReadProblem`, `CollectionReadTarget`. See [Collection Reader](#collection-reader). | -| Read models | `loadResourceTree`, `extractSubtree`, `extractResourcesRecursively`, `searchTranslations`, `searchResourceTree`, `computeTreeFingerprint`, `treeFingerprintsMatch`, `reindexMutation` and their types. The API's [Collection Index](glossary.md#collection-index) is built from these. A `ResourceTreeEntry` and a `SearchResult` (which carries the entry's `source` and `metadata`) both fit the domain `buildResourceSummary` input, which the API uses to answer with a [Resource Summary](glossary.md#resource-summary). | +| Read models | `loadResourceTree`, `extractSubtree`, `extractResourcesRecursively`, `computeTreeFingerprint`, `treeFingerprintsMatch`, `reindexMutation` and their types, and [Resource Search](#resource-search): `searchResources`, `treeResources`, `SearchableResource`, `SearchMode`, `SearchOptions`, `SearchResult`, `MatchType`. The API's [Collection Index](glossary.md#collection-index) is built from these, and the CLI `find-similar` uses Resource Search. A `ResourceTreeEntry` and a `SearchResult` (which carries the entry's `source`, `translations` and `metadata`) both fit the domain `buildResourceSummary` input, which the API uses to answer with a [Resource Summary](glossary.md#resource-summary). | | Errors | `LingoTrackerError` and every typed subclass, `TranslationError`, `PreferredTerminologyValidationError`. See [Error Model](#error-model). | | Types | Parameter and result types for the operations above (`AddResourceParams`, `GenerateBundleResult`, `ImportResult`, ...). | @@ -414,7 +415,7 @@ A `StoredResource` holds: - `entry`: the `ResourceTreeEntry` that `ResourceFolder.treeEntry()` returns. It has `source`, `translations`, `metadata` per locale, `comment` and `tags`. `translations` holds every locale property stored besides `source`: normally the target locales, but a hand-written base-locale key is kept as stored. A `tags` value that is not an array reads as no tags; - `effectiveTags`: the collection tags united with the entry tags ([Tags](glossary.md#tags)). The reader is the one place this union is made: export filtering, bundle selection rules and type generation read `effectiveTags` and do not compute it again. -`readCollectionFolders(collection, { startPath, maxDepth })` is the same walk, one folder at a time and lazily. `loadResourceTree` builds the tree from it, and `searchTranslations` uses it so that it can stop at `maxResults`. +`readCollectionFolders(collection, { startPath, maxDepth })` is the same walk, one folder at a time and lazily. `loadResourceTree` builds the tree from it. The reader applies these rules for every caller: @@ -435,9 +436,30 @@ The caller decides what a problem means: | `runExport` | Lists it under `malformedFiles` in the result and the summary. The other resources are exported. | | Bundle generation, the dry-run plan and type generation (the [Bundle Selection](#bundle-selection), through `loadCollectionResources`) | Adds a warning to the bundle result or the plan, once for each collection per run. | | `glossary` (CLI) | Writes a warning to stderr. | -| `loadResourceTree`, `searchTranslations` | Log it. The tree keeps the folder, with no resources. | +| `find-similar` (CLI, through [Resource Search](#resource-search)) | Prints one `⚠️ Skipped unreadable folder: ` line for each problem, then the matches from the other folders. | +| `CollectionIndex.search` (API disk search, before the collection is indexed) | Logs one `Logger.warn` line per search that names the count and the messages. The results come from the other folders. | +| `loadResourceTree` | Logs it. The tree keeps the folder, with no resources. | | `translateLocale` | Does not translate the folder's resources and adds one line to `warnings` in the result (`Folder '' was not translated: `). The CLI prints the warnings after the summary; the API translation job logs them with `Logger.warn`. | +### Resource Search + +**Entry point:** `searchResources(resources, collection, query, { mode, limit })` in `lib/resource/search.ts` + +[Resource Search](glossary.md#resource-search) is the one matcher over a collection's resources. It takes any `Iterable` (`{ fullKey, entry }`), so the caller picks the source: `readCollection(collection).resources` for the disk, or `treeResources(tree)` for an index tree (the loaded folders only, each entry keyed from its folder's `folderPathSegments`). It is pure. The reader's `problems` are the caller's to report (see the table above). `collection` is only read for `baseLocale`. + +It collects every match, ranks lightweight candidates, applies `limit` (default 100; a limit that is not a positive integer is 100), and only then builds the `SearchResult`s. A better match is never lost because the walk found it late. A blank query returns `[]`. The query is trimmed and compared case-insensitively. + +| Mode | Compares the query with | Match rule | Ranking | Result | +|---|---|---|---|---| +| `'text'` (default) | The full key, the base value (`source`, always, under `collection.baseLocale`) and every stored translation | Key first: `exact-key`, else `partial-key`; else `exact-value` (some value equals the query), else `partial-value` (some value contains it) | exact-key, exact-value, partial-key, partial-value, then key | `matchType`; `matchedLocales` for value matches | +| `'similar-value'` | The base value only (trimmed, lowercased) | `normalizedLevenshtein` ≥ 0.8 (`SIMILARITY_THRESHOLD`), or one text contains the other as whole words (no letter, digit or apostrophe next to it) with a score of at least 0.4 (`CONTAINMENT_MIN_SCORE`). A contained text scores `shorter / longer` length, which is the same as its Levenshtein score. An empty base value never matches. | Similarity (highest first), then a resource whose key also contains the query, then key | `matchType: 'similar-value'`, `similarity` (0..1), `matchedLocales: [baseLocale]` | + +Why whole words: the search reads the whole collection, so a substring rule matches fragments (`No` in `Cannot`, `connect` in `connection`). The whole-word rule still finds a short existing value in a longer typed one (`Save` / `Save draft`), which is the duplicate the Tracker wants to show. The 0.4 floor keeps that case (4 / 10) and drops a short label inside a long sentence in either direction (`Delete` in "Delete the selected file?" is 0.24). Apostrophes (`'`, `’`) are word characters, so `don` does not match "Don't save". The Levenshtein part keeps the CLI's old 0.8 threshold (`save` / `saved`). A base locale that does not put spaces between words gets only the Levenshtein part. Word characters are tested one UTF-16 code unit at a time, so combining marks and letters outside the Basic Multilingual Plane are approximate. + +A `SearchResult` carries `key`, `source` (`''` when a hand-edited entry has no string `source`; such an entry never matches on its base value), `translations` (a copy of the stored ones, without the base value), `metadata`, `comment`, `tags` and the match fields. It fits the domain `buildResourceSummary` input. + +Callers: `CollectionIndex.search` in the API (the index tree when the collection is indexed, else the reader) and the CLI `find-similar` (the reader, `mode: 'similar-value'`). Before this module, disk search and tree search were two copies of the matcher that stopped at the limit before they ranked, the CLI scored the first 500 text hits with Levenshtein, and the Tracker filtered a 25-hit text search by substring. + --- ## Normalization Pipeline diff --git a/architecture-docs/frontend.md b/architecture-docs/frontend.md index 87bc806c..03f21f43 100644 --- a/architecture-docs/frontend.md +++ b/architecture-docs/frontend.md @@ -114,7 +114,7 @@ flowchart TD TranslationItem -. "lazy on delete (Del key)" .-> ConfirmationDialog2["ConfirmationDialog\n(shared/components/confirmation-dialog)"] TranslationEditorDialog["TranslationEditorDialog\n(browser/dialogs/translation-editor)\nCreate / edit resource. Tabbed locale\nfields, similar-translation sidebar,\nfolder picker, status controls.\nChip input for tag editing with\nper-collection autocomplete.\nRules: resource-entry-draft.ts"] - TranslationEditorDialog --> SimilarTranslations["SimilarTranslations\n(dialogs/translation-editor/similar-translations.ts)\nLive similarity search as user types"] + TranslationEditorDialog --> SimilarTranslations["SimilarTranslations\n(dialogs/translation-editor/similar-translations.ts)\nSimilar values as the user types\n(API search, mode=similar)"] TranslationEditorDialog --> FolderPicker["FolderPicker\n(dialogs/translation-editor/folder-picker)\nTree picker for changing resource folder"] TranslationBrowser -. "lazy on folder delete" .-> ConfirmationDialog3["ConfirmationDialog\n(shared/components/confirmation-dialog)"] @@ -320,6 +320,8 @@ The key field validator is `segmentValidator` (`shared/validators/segment.valida The dialog reads two things directly from `BrowserApiService`: `searchTranslations` for similar values, and `getResourceTree` for the entries of a folder picked in the popover. Both are dialog-local reads. The store's `selectFolder` would move the browser list behind the dialog, so the dialog does not use it. +**Similar values.** After a 300 ms typing pause, and when the base value has at least 3 characters (and, in edit mode, differs from the stored value), the dialog calls `searchTranslations(collectionName, value, SIMILAR_DISPLAY_LIMIT + 1, 'similar')`. The API answers with [Resource Search](glossary.md#resource-search)'s similar-value mode: base values at least 80% similar to the typed text, or that contain it or are contained in it as whole words with a similarity of at least 40%, ranked by similarity. The dialog does no matching of its own. It drops the entry being edited (by `fullKey`) and keeps the first `SIMILAR_DISPLAY_LIMIT` (10) hits in the API's order. It asks for one extra hit so that a full list of 10 remains after it drops the entry itself. The count badge, the exact-duplicate caption and the pinned list all read this one signal. Before, the dialog ran a 25-hit text search and kept the hits whose base value contained the typed text (or was contained in it) by substring, so the list was in text-search order and a key-only hit could use up the 25. The header full-text search (`with-search.feature.ts`) still uses the default text mode. + Status labels in the editor (the status pill, its menu and the context column dots) come from `statusLabelTokenFor` in the shared translation-status presentation module, the same tokens the rows use. ### Translation Status Summary diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 34ede23f..59189650 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -67,7 +67,7 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md), [` ### Collection Index -The API's in-memory copy of each open [collection's](#collection) [resource tree](#resource-tree). In code, `CollectionIndex` in `apps/api/src/app/cache/collection-index.service.ts` has four methods: `tree(collection, path)` and `search(collection, query, maxResults)` read, `status(collection)` answers the `cache/status` endpoint, and `apply(mutations)` takes the [resource mutations](#resource-mutation) of a write. Indexing on first read, revalidation against a disk fingerprint, patching, and the memory cap (least recently used eviction) are internal. When a patch does not match the tree, the index drops that collection and indexes it again on the next read. The HTTP endpoints and the Tracker UI still call it the "cache". +The API's in-memory copy of each open [collection's](#collection) [resource tree](#resource-tree). In code, `CollectionIndex` in `apps/api/src/app/cache/collection-index.service.ts` has four methods: `tree(collection, path)` and `search(collection, query, { mode, limit })` read (search runs [Resource Search](#resource-search) over the index tree, or over the disk before the collection is indexed), `status(collection)` answers the `cache/status` endpoint, and `apply(mutations)` takes the [resource mutations](#resource-mutation) of a write. Indexing on first read, revalidation against a disk fingerprint, patching, and the memory cap (least recently used eviction) are internal. When a patch does not match the tree, the index drops that collection and indexes it again on the next read. The HTTP endpoints and the Tracker UI still call it the "cache". Explained in context: [`api.md`](api.md#collection-index) @@ -75,7 +75,7 @@ Explained in context: [`api.md`](api.md#collection-index) ### Collection Reader -The read side of the [Resource Folder](#resource-folder): the one walk over a [collection's](#collection) `translationsFolder`. In code, `readCollection(collection)` in `libs/core/src/lib/resource/read-collection.ts` opens every folder with the collection's [base locale](#base-locale) and returns `{ resources, problems }`. Each `StoredResource` has an address (`fullKey`, `folderPath`, `entryKey`), the `entry` as `ResourceFolder.treeEntry()` reads it, and `effectiveTags` ([Tags](#tags)). The rules are the same for every caller. Hidden folders are skipped. An entry without metadata is read with `metadata: {}`, so it counts as `new`. A folder whose file is not valid JSON, or that cannot be listed, is left out and returned as a problem, and the caller reports it. Export, validate, the [Bundle Selection](#bundle-selection) (bundle, dry-run plan and type file), the resource tree, disk search and the CLI `glossary` all read through it. +The read side of the [Resource Folder](#resource-folder): the one walk over a [collection's](#collection) `translationsFolder`. In code, `readCollection(collection)` in `libs/core/src/lib/resource/read-collection.ts` opens every folder with the collection's [base locale](#base-locale) and returns `{ resources, problems }`. Each `StoredResource` has an address (`fullKey`, `folderPath`, `entryKey`), the `entry` as `ResourceFolder.treeEntry()` reads it, and `effectiveTags` ([Tags](#tags)). The rules are the same for every caller. Hidden folders are skipped. An entry without metadata is read with `metadata: {}`, so it counts as `new`. A folder whose file is not valid JSON, or that cannot be listed, is left out and returned as a problem, and the caller reports it. Export, validate, the [Bundle Selection](#bundle-selection) (bundle, dry-run plan and type file), the resource tree, [Resource Search](#resource-search) on the disk (the API before indexing, the CLI `find-similar`) and the CLI `glossary` all read through it. Explained in context: [`core-library.md`](core-library.md#collection-reader) @@ -257,6 +257,14 @@ Explained in context: [`libs-domain.md`](libs-domain.md) --- +### Resource Search + +The one matcher over a [collection's](#collection) resources. In code, `searchResources(resources, collection, query, { mode, limit })` in `libs/core/src/lib/resource/search.ts`. It reads any iterable of `{ fullKey, entry }`: the [Collection Reader](#collection-reader)'s `resources` for the disk, or `treeResources(tree)` for the [Collection Index](#collection-index) tree. It is pure, so the reader's problems are the caller's to report. It ranks every match first and then applies `limit` (default 100, also for a limit that is not a positive integer), so a better match is never lost because it was found late. A blank query returns nothing; the query is trimmed and case-insensitive. Text mode (the default) looks in the full key, the base value (always, under the collection's [base locale](#base-locale)) and every translation. Each hit gets one match type, the key first: `exact-key`, `partial-key`, `exact-value`, `partial-value`. They rank in the order exact-key, exact-value, partial-key, partial-value, then key. Similar-value mode compares the query with the base value only. A value matches when its `normalizedLevenshtein` score is at least 0.8 (`save` / `saved`), or when one text contains the other as whole words (`Save` / `Save draft`) with a score (`shorter / longer` length) of at least 0.4 (`CONTAINMENT_MIN_SCORE`), so a short label inside a long sentence does not count. A fragment inside a word does not count either (`connect` in `connection`, `don` in `don't`: apostrophes are word characters). Hits rank by `similarity`, then a key that contains the query, then key, with `matchType: 'similar-value'`. The API's `CollectionIndex.search` (`GET …/resources/search`, `mode=text | similar`) and the CLI `find-similar` use it. The Tracker's "Similar values" block asks the API for similar mode. + +Explained in context: [`core-library.md`](core-library.md#resource-search), [`api.md`](api.md#collection-index) + +--- + ### Resource Summary One [resource entry](#resource-entry) as the API and the Tracker see it: an explicit address — `fullKey` (`apps.common.buttons.ok`), `folderPath` (`apps.common.buttons`, `''` at the root) and `entryKey` (`ok`) — the base locale and value, and one row per target locale of the [collection](#collection), in collection order, with `value`, `status`, `needsWork` (the [staleness rule](#staleness-rule)'s `needsTranslation`) and `sameAsBase` (`isUntranslatedCopy`, compared trimmed). The base locale and target locales come from the opened `Collection`, never from the metadata. In code, `buildResourceSummary(fullKey, entry, collection)` and `summaryTarget(summary, locale)` in `libs/domain/src/lib/resource-summary.ts`; `ResourceSummaryDto` in `data-transfer` is the same type. The Tracker's pure `row-view.ts` turns a summary into what one list row shows. diff --git a/architecture-docs/user-flows.md b/architecture-docs/user-flows.md index 4ca722c5..c6338658 100644 --- a/architecture-docs/user-flows.md +++ b/architecture-docs/user-flows.md @@ -330,7 +330,7 @@ sequenceDiagram BS->>BS: patchState({ isSearchLoading: true, searchError: null }) BS->>API: GET /api/collections/{name}/resources/search?query=confirm - Note right of API: CollectionIndex.search() walks the indexed
ResourceTreeNode in memory (searchResourceTree)
or searches the disk (searchTranslations) if not indexed + Note right of API: CollectionIndex.search() runs searchResources (text mode)
over the indexed tree (treeResources)
or the disk (readCollection) if not indexed;
every match is ranked, then maxResults applies API-->>BS: SearchResultsDto { results: SearchResultDto[] } BS->>BS: patchState({ searchResults, isSearchLoading: false }) diff --git a/docs/cli.md b/docs/cli.md index ed38d8fb..89dffdf3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1194,7 +1194,8 @@ When matches are found: ``` Similar values found for "Save": buttons.save → "Save" (similarity: 100%) - buttons.saveAndClose → "Save and Close" (similarity: 89%) + labels.saved → "Saved" (similarity: 80%) + buttons.saveDraft → "Save draft" (similarity: 40%) ``` When no matches meet the similarity threshold: @@ -1204,15 +1205,16 @@ No similar values found for "Save draft". **How It Works:** -1. Runs a broad substring pre-filter via `searchTranslations` (up to 50 candidates) -2. Scores each candidate using normalised Levenshtein distance (case-insensitive) -3. Keeps only results with a similarity score ≥ 80% -4. Returns top N results sorted by score descending +1. Reads every resource of the collection +2. Scores each base value against `--value` with normalised Levenshtein similarity (case-insensitive, trimmed) +3. Keeps a value when its similarity is ≥ 80%, or when it contains `--value` or is contained in it as whole words with a similarity of at least 40% (for example `Save` and `Save draft`; a fragment inside a word, such as `connect` in `connection` or `don` in `don't`, does not count, nor does a short label inside a long sentence) +4. Ranks every match by similarity (a key that contains `--value` wins a tie), then prints the top N **Notes:** -- Only the base locale value is compared (not translations) -- Only `exact-value` and `partial-value` match types are considered; key-based matches are excluded -- Similarity threshold is fixed at 80% — results below this are not shown +- Only the base locale value is compared (not translations or keys) +- A whole-word containment match is shown with its real similarity, which can be between 40% and 80% (`Save draft` for `Save` is 40%; `Save and Close` at 29% is not shown) +- Folders that cannot be read are reported as `⚠️ Skipped unreadable folder: …` lines; the rest of the collection is still searched +- The same rule backs the Tracker's "Similar values" list and `GET /api/collections/:name/resources/search?mode=similar` - Non-interactive only; does not prompt for missing options --- diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index a55082a6..1d0b136c 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -99,7 +99,7 @@ export { type StoredResource, } from './lib/resource'; -// Read models: the resource tree, search and fingerprints behind the API's CollectionIndex +// Read models: the resource tree, Resource Search and fingerprints behind the API's CollectionIndex and CLI find-similar export { computeTreeFingerprint, extractResourcesRecursively, @@ -111,9 +111,12 @@ export { type ResourceTreeEntry, type ResourceTreeNode, reindexMutation, + type SearchableResource, + type SearchMode, + type SearchOptions, type SearchResult, - searchResourceTree, - searchTranslations, + searchResources, + treeResources, type TreeFingerprint, treeFingerprintsMatch, } from './lib/resource'; @@ -199,8 +202,6 @@ export type { LoadResourceTreeOptions, OpenResourceFolderOptions, ResourcePathResolutionParams, - SearchParams, - SearchTreeParams, } from './lib/resource'; export type { OpenTranslatorOptions, diff --git a/libs/core/src/lib/resource/index.ts b/libs/core/src/lib/resource/index.ts index b4b3f7f5..c59bbc9c 100644 --- a/libs/core/src/lib/resource/index.ts +++ b/libs/core/src/lib/resource/index.ts @@ -31,11 +31,12 @@ export { export { type ResourceMutation, reindexMutation } from './resource-mutation'; export { type MatchType, - type SearchParams, + type SearchableResource, + type SearchMode, + type SearchOptions, type SearchResult, - type SearchTreeParams, - searchResourceTree, - searchTranslations, + searchResources, + treeResources, } from './search'; export { type ComputeTreeFingerprintOptions, diff --git a/libs/core/src/lib/resource/search.spec.ts b/libs/core/src/lib/resource/search.spec.ts index 32799279..56a3537e 100644 --- a/libs/core/src/lib/resource/search.spec.ts +++ b/libs/core/src/lib/resource/search.spec.ts @@ -1,483 +1,195 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { searchTranslations, searchResourceTree, type SearchParams } from './search'; -import type { ResourceTreeNode } from './load-resource-tree'; -import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; - -describe('searchTranslations', () => { - // Outside the workspace, and unique per test. These cases write several hundred - // fixture files and delete them again. Under a fixed path inside `src/`, every one of - // those writes reached the Nx daemon's file watcher, which recomputed the project graph - // mid-run and raced the `afterEach` removal. - let testDir: string; - - beforeEach(() => { - testDir = mkdtempSync(join(tmpdir(), 'lingo-search-')); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); - }); - - const createTestResource = (path: string, entries: Record, meta: Record) => { - const folderPath = join(testDir, ...path.split('.')); - mkdirSync(folderPath, { recursive: true }); - writeFileSync(join(folderPath, 'resource_entries.json'), JSON.stringify(entries, null, 2)); - writeFileSync(join(folderPath, 'tracker_meta.json'), JSON.stringify(meta, null, 2)); +import type { ResourceTreeEntry, ResourceTreeNode } from './load-resource-tree'; +import { readCollection } from './read-collection'; +import { type SearchableResource, searchResources, treeResources } from './search'; + +const EN = { baseLocale: 'en' }; + +function resource( + fullKey: string, + source: string, + extra: Partial> = {}, +): SearchableResource { + return { + fullKey, + entry: { key: fullKey.slice(fullKey.lastIndexOf('.') + 1), source, translations: {}, metadata: {}, ...extra }, }; +} - describe('Search by Key', () => { - it('should find exact key match', () => { - createTestResource( - 'buttons', - { - ok: { source: 'OK', en: 'OK', es: 'Aceptar' }, - }, - { - ok: { - en: { checksum: 'abc', status: 'verified' }, - es: { checksum: 'def', status: 'verified' }, - }, - }, - ); - - const params: SearchParams = { - translationsFolder: testDir, - query: 'buttons.ok', - }; +const keys = (results: { key: string }[]): string[] => results.map((result) => result.key); - const results = searchTranslations(params); +describe('searchResources — text mode', () => { + describe('match types', () => { + it('finds an exact key match, case-insensitively', () => { + const results = searchResources([resource('buttons.ok', 'OK')], EN, 'Buttons.OK'); expect(results).toHaveLength(1); - expect(results[0].key).toBe('buttons.ok'); - expect(results[0].matchType).toBe('exact-key'); + expect(results[0]?.key).toBe('buttons.ok'); + expect(results[0]?.matchType).toBe('exact-key'); + expect(results[0]?.matchedLocales).toBeUndefined(); }); - it('should find partial key match', () => { - createTestResource( - 'common.buttons', - { - save: { source: 'Save', en: 'Save', es: 'Guardar' }, - cancel: { source: 'Cancel', en: 'Cancel', es: 'Cancelar' }, - }, - { - save: { - en: { checksum: 'abc', status: 'verified' }, - es: { checksum: 'def', status: 'verified' }, - }, - cancel: { - en: { checksum: 'ghi', status: 'verified' }, - es: { checksum: 'jkl', status: 'verified' }, - }, - }, - ); - - const params: SearchParams = { - translationsFolder: testDir, - query: 'button', - }; + it('finds a partial key match', () => { + const results = searchResources([resource('common.buttons.save', 'Store')], EN, 'buttons'); - const results = searchTranslations(params); - - expect(results.length).toBeGreaterThan(0); - expect(results.every((r) => r.key.toLowerCase().includes('button'))).toBe(true); + expect(results[0]?.matchType).toBe('partial-key'); }); - it('should be case insensitive', () => { - createTestResource( - 'messages', - { - error: { - source: 'Error occurred', - en: 'Error occurred', - es: 'Ocurrió un error', - }, - }, - { - error: { - en: { checksum: 'abc', status: 'verified' }, - es: { checksum: 'def', status: 'verified' }, - }, - }, - ); - - const params: SearchParams = { - translationsFolder: testDir, - query: 'ERROR', - }; + it('reports a key match even when a value matches too', () => { + const results = searchResources([resource('buttons.save', 'save')], EN, 'save'); - const results = searchTranslations(params); - - expect(results).toHaveLength(1); - expect(results[0].key).toBe('messages.error'); + expect(results[0]?.matchType).toBe('partial-key'); }); - }); - describe('Search by Value', () => { - it('should find exact value match in any locale', () => { - createTestResource( - 'labels', - { - name: { source: 'Name', en: 'Name', es: 'Nombre' }, - }, - { - name: { - en: { checksum: 'abc', status: 'verified' }, - es: { checksum: 'def', status: 'verified' }, - }, - }, + it('finds an exact value match in a translation and names the locale', () => { + const results = searchResources( + [resource('buttons.ok', 'OK', { translations: { es: 'Aceptar' } })], + EN, + 'aceptar', ); - const params: SearchParams = { - translationsFolder: testDir, - query: 'Nombre', - }; - - const results = searchTranslations(params); - - expect(results).toHaveLength(1); - expect(results[0].key).toBe('labels.name'); - expect(results[0].matchType).toBe('exact-value'); - expect(results[0].matchedLocales).toContain('es'); + expect(results[0]?.matchType).toBe('exact-value'); + expect(results[0]?.matchedLocales).toEqual(['es']); }); - it('should find partial value match', () => { - createTestResource( - 'messages', - { - welcome: { - source: 'Welcome to our application', - en: 'Welcome to our application', - es: 'Bienvenido a nuestra aplicación', - }, - }, - { - welcome: { - en: { checksum: 'abc', status: 'verified' }, - es: { checksum: 'def', status: 'verified' }, - }, - }, + it('finds a partial value match in a translation', () => { + const results = searchResources( + [resource('messages.welcome', 'Welcome', { translations: { es: 'Bienvenido a la aplicación' } })], + EN, + 'aplica', ); - const params: SearchParams = { - translationsFolder: testDir, - query: 'application', - }; - - const results = searchTranslations(params); - - expect(results).toHaveLength(1); - expect(results[0].matchType).toBe('partial-value'); - expect(results[0].matchedLocales).toContain('en'); + expect(results[0]?.matchType).toBe('partial-value'); + expect(results[0]?.matchedLocales).toEqual(['es']); }); - it('should return all locale values for matched results', () => { - createTestResource( - 'common', - { - hello: { source: 'Hello', en: 'Hello', es: 'Hola', fr: 'Bonjour' }, - }, - { - hello: { - en: { checksum: 'abc', status: 'verified' }, - es: { checksum: 'def', status: 'verified' }, - fr: { checksum: 'ghi', status: 'verified' }, - }, - }, + it('always searches the base value, under the collection base locale', () => { + const results = searchResources( + [resource('greetings.hello', 'Bonjour tout le monde')], + { baseLocale: 'fr' }, + 'tout', ); - const params: SearchParams = { - translationsFolder: testDir, - query: 'Hola', - }; - - const results = searchTranslations(params); - - expect(results).toHaveLength(1); - expect(results[0].translations).toEqual({ - en: 'Hello', - es: 'Hola', - fr: 'Bonjour', - }); + expect(results[0]?.matchType).toBe('partial-value'); + expect(results[0]?.matchedLocales).toEqual(['fr']); }); - it('should include base locale value from source field when baseLocale is provided', () => { - createTestResource( - 'common', - { - greeting: { - source: 'Hello World', - en: 'Hello World', - es: 'Hola Mundo', - }, - }, - { - greeting: { - en: { checksum: 'abc', status: 'verified' }, - es: { checksum: 'def', status: 'verified' }, - }, - }, + it('lists every locale whose value matched, and prefers exact over partial', () => { + const results = searchResources( + [resource('a.b', 'Cancel', { translations: { es: 'cancel', fr: 'Annuler' } })], + EN, + 'cancel', ); - const params: SearchParams = { - translationsFolder: testDir, - query: 'greeting', - baseLocale: 'en', - }; - - const results = searchTranslations(params); - - expect(results).toHaveLength(1); - expect(results[0].translations).toEqual({ - en: 'Hello World', - es: 'Hola Mundo', - }); - // Verify the base locale value comes from source - expect(results[0].translations.en).toBe('Hello World'); + expect(results[0]?.matchType).toBe('exact-value'); + expect(results[0]?.matchedLocales).toEqual(['en', 'es']); }); - it('should not duplicate base locale value if it already exists in translations', () => { - createTestResource( - 'common', - { - message: { source: 'Original', en: 'Modified', es: 'Modificado' }, - }, - { - message: { - en: { checksum: 'abc', status: 'verified' }, - es: { checksum: 'def', status: 'verified' }, - }, - }, + it('returns no results when nothing matches', () => { + expect(searchResources([resource('buttons.ok', 'OK', { translations: { es: 'Aceptar' } })], EN, 'zzz')).toEqual( + [], ); - - const params: SearchParams = { - translationsFolder: testDir, - query: 'message', - baseLocale: 'en', - }; - - const results = searchTranslations(params); - - expect(results).toHaveLength(1); - // The source value should override the en value in the entry - expect(results[0].translations.en).toBe('Original'); - expect(results[0].translations.es).toBe('Modificado'); }); - it('should search source field when baseLocale is provided', () => { - createTestResource( - 'common', - { - sourceOnly: { source: 'SourceValue', es: 'Valor de origen' }, - }, - { - sourceOnly: { - es: { checksum: 'def', status: 'verified' }, - }, - }, - ); - - const params: SearchParams = { - translationsFolder: testDir, - query: 'SourceValue', - baseLocale: 'en', - }; - - const results = searchTranslations(params); + it.each(['', ' '])('returns no results for the blank query %j', (query) => { + expect(searchResources([resource('buttons.ok', 'OK')], EN, query)).toEqual([]); + }); - expect(results).toHaveLength(1); - expect(results[0].translations).toEqual({ - en: 'SourceValue', - es: 'Valor de origen', - }); + it('trims the query', () => { + expect(keys(searchResources([resource('buttons.ok', 'OK')], EN, ' buttons.ok '))).toEqual(['buttons.ok']); }); }); - describe('Result Ranking', () => { - beforeEach(() => { - createTestResource( - 'buttons', - { - save: { source: 'Save', en: 'Save', es: 'Guardar' }, - saveAs: { source: 'Save As', en: 'Save As', es: 'Guardar como' }, - autoSave: { - source: 'Auto Save', - en: 'Auto Save', - es: 'Guardado automático', - }, - }, - { - save: { - en: { checksum: 'abc', status: 'verified' }, - es: { checksum: 'def', status: 'verified' }, - }, - saveAs: { - en: { checksum: 'ghi', status: 'verified' }, - es: { checksum: 'jkl', status: 'verified' }, - }, - autoSave: { - en: { checksum: 'mno', status: 'verified' }, - es: { checksum: 'pqr', status: 'verified' }, - }, - }, + describe('result', () => { + it('carries the entry: base value, translations, metadata, comment and tags', () => { + const metadata = { es: { checksum: 'b', baseChecksum: 'a', status: 'verified' as const } }; + const results = searchResources( + [ + resource('buttons.cancel', 'Cancel', { + translations: { es: 'Cancelar' }, + metadata, + comment: 'Used in dialogs', + tags: ['dialog'], + }), + ], + EN, + 'cancel', ); - }); - it('should rank exact key matches highest', () => { - const results = searchTranslations({ - translationsFolder: testDir, - query: 'buttons.save', + expect(results[0]).toEqual({ + key: 'buttons.cancel', + source: 'Cancel', + translations: { es: 'Cancelar' }, + metadata, + matchType: 'partial-key', + comment: 'Used in dialogs', + tags: ['dialog'], }); - - // Exact match "buttons.save" should be first - expect(results[0].key).toBe('buttons.save'); - expect(results[0].matchType).toBe('exact-key'); }); - it('should rank exact value matches before partial', () => { - const results = searchTranslations({ - translationsFolder: testDir, - query: 'Save', - }); - - // Find exact value match vs partial - const exactMatch = results.find((r) => r.matchType === 'exact-value'); - const partialMatch = results.find((r) => r.matchType === 'partial-value'); + it('copies the translations, so a caller cannot change the source entry', () => { + const stored = resource('buttons.ok', 'OK', { translations: { es: 'Aceptar' } }); + const [result] = searchResources([stored], EN, 'ok'); - if (exactMatch && partialMatch) { - const exactIdx = results.indexOf(exactMatch); - const partialIdx = results.indexOf(partialMatch); - expect(exactIdx).toBeLessThan(partialIdx); - } + expect(result).toBeDefined(); + if (result) result.translations.es = 'changed'; + expect(stored.entry.translations.es).toBe('Aceptar'); }); }); - describe('Search Limits', () => { - it('should limit results to maxResults parameter', () => { - // Create many resources - for (let i = 0; i < 100; i++) { - createTestResource( - `test.item${i}`, - { - label: { source: `Item ${i}`, en: `Item ${i}` }, - }, - { - label: { en: { checksum: 'abc', status: 'verified' } }, - }, - ); - } - - const results = searchTranslations({ - translationsFolder: testDir, - query: 'item', - maxResults: 10, - }); - - expect(results).toHaveLength(10); - }); - - it('should default to 100 results max', () => { - // Create 150 resources - for (let i = 0; i < 150; i++) { - createTestResource( - `test.item${i}`, - { - label: { source: `Item ${i}`, en: `Item ${i}` }, - }, - { - label: { en: { checksum: 'abc', status: 'verified' } }, - }, - ); - } - - const results = searchTranslations({ - translationsFolder: testDir, - query: 'item', - }); - - expect(results.length).toBeLessThanOrEqual(100); - }); - }); - - describe('Edge Cases', () => { - it('should return empty array for empty query', () => { - const results = searchTranslations({ - translationsFolder: testDir, - query: '', - }); - - expect(results).toEqual([]); - }); - - it('should return empty array for no matches', () => { - createTestResource( - 'test', - { - key: { source: 'value', en: 'value' }, - }, - { - key: { en: { checksum: 'abc', status: 'verified' } }, - }, - ); - - const results = searchTranslations({ - translationsFolder: testDir, - query: 'nonexistent', - }); - - expect(results).toEqual([]); - }); - - it('should handle folders without resource files gracefully', () => { - mkdirSync(join(testDir, 'empty'), { recursive: true }); - - const results = searchTranslations({ - translationsFolder: testDir, - query: 'test', - }); - - expect(results).toEqual([]); + describe('ranking and limit', () => { + const mixed = [ + resource('z.partialValue', 'Press save to continue'), + resource('m.save.button', 'Store'), + resource('b.exactValue', 'Save'), + resource('save', 'Store it'), + resource('a.partialValue', 'Autosave'), + ]; + + it('ranks exact-key, exact-value, partial-key, partial-value, then by key', () => { + const results = searchResources(mixed, EN, 'save'); + + expect(results.map((result) => [result.key, result.matchType])).toEqual([ + ['save', 'exact-key'], + ['b.exactValue', 'exact-value'], + ['m.save.button', 'partial-key'], + ['a.partialValue', 'partial-value'], + ['z.partialValue', 'partial-value'], + ]); }); - }); - describe('Collection Reader rules', () => { - it('skips an unreadable folder, logs it, and searches the others', () => { - const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); - createTestResource('good', { save: { source: 'Save' } }, { save: { en: { checksum: 'a' } } }); - mkdirSync(join(testDir, 'bad'), { recursive: true }); - writeFileSync(join(testDir, 'bad', 'resource_entries.json'), '{ nope'); + it('ranks every match before it applies the limit', () => { + // The exact-key match is read last, behind more partial matches than the limit. + const partials = Array.from({ length: 10 }, (_, i) => resource(`noise.item${i}`, `Save item ${i}`)); - const results = searchTranslations({ translationsFolder: testDir, query: 'save', baseLocale: 'en' }); + const results = searchResources([...partials, resource('save', 'Store it')], EN, 'save', { limit: 3 }); - expect(results.map((result) => result.key)).toEqual(['good.save']); - expect(error).toHaveBeenCalledWith(expect.stringContaining('resource_entries.json')); - error.mockRestore(); + expect(keys(results)).toEqual(['save', 'noise.item0', 'noise.item1']); }); - it('finds an entry without metadata, with empty metadata', () => { - mkdirSync(join(testDir, 'loose'), { recursive: true }); - writeFileSync(join(testDir, 'loose', 'resource_entries.json'), JSON.stringify({ save: { source: 'Save' } })); - - const results = searchTranslations({ translationsFolder: testDir, query: 'save', baseLocale: 'en' }); + it.each([0, -2, 2.5, Number.NaN, Number.POSITIVE_INFINITY])('reads the limit %s as 100', (limit) => { + const many = Array.from({ length: 150 }, (_, i) => resource(`key.item${i}`, 'value')); - expect(results).toHaveLength(1); - expect(results[0]?.metadata).toEqual({}); + expect(searchResources(many, EN, 'item', { limit })).toHaveLength(100); }); - it('skips hidden folders', () => { - mkdirSync(join(testDir, '.hidden'), { recursive: true }); - writeFileSync(join(testDir, '.hidden', 'resource_entries.json'), JSON.stringify({ save: { source: 'Save' } })); + it('returns at most 100 results by default', () => { + const many = Array.from({ length: 150 }, (_, i) => resource(`key.item${i}`, 'value')); - expect(searchTranslations({ translationsFolder: testDir, query: 'save', baseLocale: 'en' })).toEqual([]); + expect(searchResources(many, EN, 'item')).toHaveLength(100); }); }); }); -describe('searchResourceTree', () => { - const createMockTree = (): ResourceTreeNode => ({ +describe('treeResources', () => { + const entry = (key: string, source: string): ResourceTreeEntry => ({ key, source, translations: {}, metadata: {} }); + + const tree: ResourceTreeNode = { folderPathSegments: [], - resources: [], + resources: [entry('rootKey', 'Root')], children: [ { name: 'buttons', @@ -485,190 +197,231 @@ describe('searchResourceTree', () => { loaded: true, tree: { folderPathSegments: ['buttons'], - resources: [ + resources: [entry('ok', 'OK')], + children: [ { - key: 'ok', - source: 'OK', - translations: { es: 'Aceptar', fr: "D'accord" }, - metadata: { - es: { checksum: 'abc', status: 'verified' }, - fr: { checksum: 'def', status: 'translated' }, - }, - }, - { - key: 'cancel', - source: 'Cancel', - translations: { es: 'Cancelar', fr: 'Annuler' }, - comment: 'Used in dialogs', - tags: ['dialog'], - metadata: { - es: { checksum: 'ghi', status: 'verified' }, - fr: { checksum: 'jkl', status: 'verified' }, - }, + name: 'icons', + fullPathSegments: ['buttons', 'icons'], + loaded: true, + tree: { folderPathSegments: ['buttons', 'icons'], resources: [entry('close', 'Close')], children: [] }, }, ], - children: [], - }, - }, - { - name: 'messages', - fullPathSegments: ['messages'], - loaded: true, - tree: { - folderPathSegments: ['messages'], - resources: [ - { - key: 'welcome', - source: 'Welcome to the app', - translations: { es: 'Bienvenido a la aplicación' }, - metadata: { - es: { checksum: 'mno', status: 'translated' }, - }, - }, - ], - children: [], }, }, + { name: 'lazy', fullPathSegments: ['lazy'], loaded: false }, ], + }; + + it('yields every resource of the loaded folders with its full key', () => { + expect([...treeResources(tree)].map((resource) => resource.fullKey)).toEqual([ + 'rootKey', + 'buttons.ok', + 'buttons.icons.close', + ]); }); - describe('Search by Key', () => { - it('should find exact key match', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: 'buttons.ok', - }); + it('skips a child that is not loaded, even when it carries a tree', () => { + const withStaleChild: ResourceTreeNode = { + folderPathSegments: [], + resources: [], + children: [ + { + name: 'stale', + fullPathSegments: ['stale'], + loaded: false, + tree: { folderPathSegments: ['stale'], resources: [entry('hidden', 'Hidden')], children: [] }, + }, + ], + }; - expect(results).toHaveLength(1); - expect(results[0].key).toBe('buttons.ok'); - expect(results[0].matchType).toBe('exact-key'); - }); + expect([...treeResources(withStaleChild)]).toEqual([]); + }); - it('should find partial key match', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: 'cancel', - }); + it('keys a subtree from its own folder path', () => { + const subtree = tree.children[0]?.tree; + expect(subtree).toBeDefined(); - expect(results).toHaveLength(1); - expect(results[0].key).toBe('buttons.cancel'); - expect(results[0].matchType).toBe('partial-key'); - }); + expect([...treeResources(subtree ?? tree)].map((resource) => resource.fullKey)).toEqual([ + 'buttons.ok', + 'buttons.icons.close', + ]); }); - describe('Search by Value', () => { - it('should find exact value match in source', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: 'Welcome to the app', - baseLocale: 'en', - }); + it('is a search source like any other', () => { + expect(keys(searchResources(treeResources(tree), EN, 'close'))).toEqual(['buttons.icons.close']); + }); +}); - expect(results).toHaveLength(1); - expect(results[0].key).toBe('messages.welcome'); - expect(results[0].matchType).toBe('exact-value'); - expect(results[0].matchedLocales).toContain('en'); - }); +describe('searchResources — similar-value mode', () => { + const similar = (resources: SearchableResource[], query: string, limit?: number) => + searchResources(resources, EN, query, { mode: 'similar-value', limit }); - it('should find partial value match in translations', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: 'Bienvenido', - }); + it('scores an identical base value 1, as a similar-value match in the base locale', () => { + const results = similar([resource('common.ok', 'OK')], 'OK'); - expect(results).toHaveLength(1); - expect(results[0].key).toBe('messages.welcome'); - expect(results[0].matchType).toBe('partial-value'); - expect(results[0].matchedLocales).toContain('es'); - }); + expect(results).toHaveLength(1); + expect(results[0]?.matchType).toBe('similar-value'); + expect(results[0]?.similarity).toBe(1); + expect(results[0]?.matchedLocales).toEqual(['en']); }); - describe('Result Metadata', () => { - it('should include comment and tags in results', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: 'cancel', - }); + it('folds case and trims both texts', () => { + expect(similar([resource('labels.greeting', ' Hello World ')], ' hello world')[0]?.similarity).toBe(1); + }); - expect(results[0].comment).toBe('Used in dialogs'); - expect(results[0].tags).toEqual(['dialog']); - }); + it('keeps a Levenshtein score exactly on the 0.8 threshold', () => { + expect(similar([resource('btn.save', 'saved')], 'save')[0]?.similarity).toBeCloseTo(0.8); + }); - it('should include status from metadata', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: 'buttons.ok', - }); + it('drops a value below the threshold that neither text contains', () => { + expect(similar([resource('labels.risk', 'delete risk')], 'remove risk')).toEqual([]); + }); - expect(results[0].status.es).toBe('verified'); - expect(results[0].status.fr).toBe('translated'); - }); + it('keeps a value that contains the query as whole words, scored shorter / longer', () => { + const results = similar([resource('btn.saveDraft', 'Save draft')], 'Save'); - it('should include base locale in translations when provided', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: 'buttons.ok', - baseLocale: 'en', - }); + expect(results[0]?.similarity).toBeCloseTo(0.4); + }); - expect(results[0].translations.en).toBe('OK'); - }); + it('keeps a value that the query contains as whole words', () => { + const results = similar([resource('common.actions.save', 'Save')], 'Save draft'); + + expect(results[0]?.similarity).toBeCloseTo(0.4); }); - describe('Result Limiting', () => { - it('should respect maxResults parameter', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: 'buttons', - maxResults: 1, - }); + it('does not count a word fragment as containment', () => { + expect(similar([resource('errors.lost', 'Connection lost')], 'connect')).toEqual([]); + expect(similar([resource('common.no', 'No')], 'Cannot')).toEqual([]); + }); - expect(results).toHaveLength(1); - }); + it('counts an apostrophe as part of a word', () => { + const results = similar([resource('dialogs.dontSave', "Don't save"), resource('errors.cant', 'can’t')], 'don'); + + expect(results).toEqual([]); + expect(similar([resource('errors.cant', 'I can’t')], 'can')).toEqual([]); }); - describe('Edge Cases', () => { - it('should return empty array for empty query', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: '', - }); + it('keeps a containment match scored exactly at the 0.4 floor', () => { + expect(similar([resource('btn.saveDraft', 'Save draft')], 'save')[0]?.similarity).toBeCloseTo(0.4); + }); - expect(results).toEqual([]); - }); + it('drops a short value inside a long typed sentence', () => { + // "delete" is a word of the query, but 6 / 25 = 0.24 is below the containment floor. + expect(similar([resource('common.delete', 'Delete')], 'Delete the selected file?')).toEqual([]); + }); - it('should return empty array for no matches', () => { - const tree = createMockTree(); - const results = searchResourceTree({ - tree, - query: 'nonexistent', - }); + it('drops a long stored sentence around a short query', () => { + // 4 / 50 = 0.08. + const sentence = 'Save your work often so that nothing is ever lost.'; + expect(sentence).toHaveLength(50); - expect(results).toEqual([]); - }); + expect(similar([resource('help.saveOften', sentence)], 'Save')).toEqual([]); + }); - it('should handle empty tree', () => { - const emptyTree: ResourceTreeNode = { - folderPathSegments: [], - resources: [], - children: [], - }; + it('compares the base value only, not the key or the translations', () => { + const results = similar([resource('buttons.save', 'Store', { translations: { fr: 'save' } })], 'save'); - const results = searchResourceTree({ - tree: emptyTree, - query: 'test', - }); + expect(results).toEqual([]); + }); - expect(results).toEqual([]); - }); + it('never matches an empty base value', () => { + expect(similar([resource('x.empty', '')], 'a')).toEqual([]); + }); + + it('ranks by similarity, highest first', () => { + const results = similar( + [resource('a.contains', 'Save draft'), resource('b.near', 'saved'), resource('c.exact', 'save')], + 'save', + ); + + expect(keys(results)).toEqual(['c.exact', 'b.near', 'a.contains']); + }); + + it('breaks a tie in favour of a key that contains the query, then by key', () => { + const results = similar( + [ + resource('dialogs.secondary', 'Connect'), + resource('b.other', 'Connect'), + resource('common.button.connect', 'Connect'), + ], + 'connect', + ); + + expect(keys(results)).toEqual(['common.button.connect', 'b.other', 'dialogs.secondary']); + }); + + it('ranks every match before it applies the limit', () => { + const weaker = Array.from({ length: 10 }, (_, i) => resource(`noise.item${i}`, `Cancel ${i}`)); + + const results = similar([...weaker, resource('zz.dismiss', 'Cancel')], 'Cancel', 2); + + expect(keys(results)).toEqual(['zz.dismiss', 'noise.item0']); + }); + + it('returns no results for a blank query', () => { + expect(similar([resource('x.empty', '')], ' ')).toEqual([]); + }); +}); + +describe('searchResources over the Collection Reader', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'lingo-search-')); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + }); + + it.each([ + ['text', 'broken', ['broken.noSource', 'broken.numberSource', 'broken.save']], + ['similar-value', 'save', ['broken.save']], + ] as const)('does not fail on a hand-edited entry without a string source (%s mode)', (mode, query, expected) => { + mkdirSync(join(testDir, 'broken'), { recursive: true }); + writeFileSync( + join(testDir, 'broken', 'resource_entries.json'), + JSON.stringify({ noSource: { es: 'Hola' }, numberSource: { source: 5 }, save: { source: 'Save' } }), + ); + const collection = { translationsFolder: testDir, baseLocale: 'en', tags: [] }; + + const results = searchResources(readCollection(collection).resources, collection, query, { mode }); + + expect(keys(results)).toEqual(expected); + expect(results.every((result) => typeof result.source === 'string')).toBe(true); + }); + + it('gives an entry without a string source the base value ""', () => { + mkdirSync(join(testDir, 'broken'), { recursive: true }); + writeFileSync(join(testDir, 'broken', 'resource_entries.json'), JSON.stringify({ hola: { es: 'Bienvenido' } })); + const collection = { translationsFolder: testDir, baseLocale: 'en', tags: [] }; + + const results = searchResources(readCollection(collection).resources, collection, 'hola'); + + expect(results.map((result) => [result.key, result.source, result.matchType])).toEqual([ + ['broken.hola', '', 'partial-key'], + ]); + expect(searchResources(readCollection(collection).resources, collection, 'bienVENIDO')[0]?.matchedLocales).toEqual([ + 'es', + ]); + }); + + it('searches what the reader read and leaves its problems to the caller', () => { + mkdirSync(join(testDir, 'good'), { recursive: true }); + writeFileSync( + join(testDir, 'good', 'resource_entries.json'), + JSON.stringify({ save: { source: 'Save', es: 'Guardar' } }), + ); + mkdirSync(join(testDir, 'bad'), { recursive: true }); + writeFileSync(join(testDir, 'bad', 'resource_entries.json'), '{ nope'); + const collection = { translationsFolder: testDir, baseLocale: 'en', tags: [] }; + + const { resources, problems } = readCollection(collection); + const results = searchResources(resources, collection, 'guardar'); + + expect(problems).toHaveLength(1); + expect(results.map((result) => [result.key, result.matchType, result.metadata])).toEqual([ + ['good.save', 'exact-value', {}], + ]); }); }); diff --git a/libs/core/src/lib/resource/search.ts b/libs/core/src/lib/resource/search.ts index a197ec8a..9842fd5c 100644 --- a/libs/core/src/lib/resource/search.ts +++ b/libs/core/src/lib/resource/search.ts @@ -1,384 +1,243 @@ -import type { TranslationStatus } from '@simoncodes-ca/domain'; -import { DEFAULT_CONFIG } from '../../constants'; -import { readCollectionFolders } from './read-collection'; +import { normalizedLevenshtein } from '@simoncodes-ca/domain'; +import type { Collection } from '../config/open-collection'; import type { ResourceEntryMetadata } from '../../resource/resource-entry-metadata'; import type { ResourceTreeNode } from './load-resource-tree'; +import type { StoredResource } from './read-collection'; /** - * Match type for search results. + * Resource Search — the one matcher over a collection's resources. * - * An entry carries exactly one match type, and the key is checked first: an - * entry whose key contains the query is reported as a key match even when its - * value matches the query too. Callers that care about value similarity must - * therefore inspect the value themselves rather than filtering on this field. + * `searchResources` reads any iterable of resources, so the source is the caller's choice: the + * Collection Reader (`readCollection(collection).resources`) for the disk, or `treeResources(tree)` + * for an index tree. It is pure: a folder the reader could not read is the caller's to report. + * + * Every match is ranked first and the limit is applied after, so a better match is never dropped + * because it was found late. + * + * **Text mode** (the default) looks for the query (case-insensitive, trimmed) in the full key, + * the base value and every stored translation. An entry gets one match type, the key first: + * `exact-key`, else `partial-key`, else `exact-value` (some value equals the query), else + * `partial-value` (some value contains it). Ranking: exact-key, exact-value, partial-key, + * partial-value, then key. + * + * **Similar-value mode** compares the query with the base value only (both trimmed and + * lowercased) and scores it with `normalizedLevenshtein` (0..1). A resource matches when + * - the score is at least {@link SIMILARITY_THRESHOLD} (0.8: `save` / `saved`), or + * - one text contains the other as whole words (`Save` / `Save draft`) and the score, then + * `shorter / longer` length (what `normalizedLevenshtein` gives for a contained text), is at + * least {@link CONTAINMENT_MIN_SCORE} (0.4), so a short label inside a long sentence does not count. + * Whole words, not substrings: over a whole collection a substring rule matches fragments + * (`No` in `Cannot`, `connect` in `connection`). Letters, digits and apostrophes are word + * characters (`don` is not a word of "don't"). Ranking: score (highest first), then a resource + * whose key also contains the query, then key. Each result has `matchType: 'similar-value'` and + * its `similarity`. + * + * An entry whose hand-edited `source` is not a string has the base value `''`: it never matches + * on its value, and its result carries `source: ''`. A limit that is not a positive integer is 100. */ -export type MatchType = 'exact-key' | 'partial-key' | 'exact-value' | 'partial-value'; + +/** How the query is compared with each resource. */ +export type SearchMode = 'text' | 'similar-value'; /** - * Search result with match information. + * How a result matched. A text-mode result has exactly one match type, the key checked first: + * an entry whose key contains the query is a key match even when a value matches too. */ +export type MatchType = 'exact-key' | 'partial-key' | 'exact-value' | 'partial-value' | 'similar-value'; + +export interface SearchOptions { + /** Default: `'text'`. */ + readonly mode?: SearchMode; + /** How many results to return, after ranking. Default (also for a value that is not a positive integer): 100. */ + readonly limit?: number; +} + +/** A resource the search can read: its full key and entry. `StoredResource` fits; `treeResources` yields it. */ +export type SearchableResource = Pick; + +/** One hit. It carries what the Resource Summary builder reads (`source`, `translations`, `metadata`, `comment`, `tags`). */ export interface SearchResult { - /** Full dot-delimited key path */ + /** Full dot-delimited key. */ key: string; - - /** Base locale value (the entry's `source`) */ + /** Base value (the entry's `source`). */ source: string; - - /** Translation values for all locales */ + /** The stored translations, keyed by locale. */ translations: Record; - - /** Translation status for each locale */ - status: Record; - - /** The entry's stored metadata, keyed by locale (read by the Resource Summary builder) */ + /** The entry's stored metadata, keyed by locale. */ metadata: ResourceEntryMetadata; - - /** Type of match found */ matchType: MatchType; - - /** Locales where the match was found (for value matches) */ + /** Locales whose value matched (value and similar-value matches). */ matchedLocales?: string[]; - - /** Optional comment */ + /** Similar-value mode only: the score, 0..1. */ + similarity?: number; comment?: string; - - /** Optional tags */ tags?: string[]; } -/** - * Parameters for translation search. - */ -export interface SearchParams { - /** Root translations folder to search in */ - translationsFolder: string; - - /** Search query (case-insensitive) */ - query: string; - - /** Maximum number of results to return (default: 100) */ - maxResults?: number; - - /** Base locale to include in translations (optional) */ - baseLocale?: string; -} +/** Minimum `normalizedLevenshtein` score for a similar-value match without whole-word containment. */ +export const SIMILARITY_THRESHOLD = 0.8; /** - * Searches translations by key and value across all locales. - * - * Search Algorithm: - * 1. Exact key match (highest priority) - * 2. Partial key match - * 3. Exact value match in any locale - * 4. Partial value match in any locale - * - * Results are ranked by match type and returned up to maxResults limit. - * - * @param params - Search parameters - * @returns Array of search results sorted by relevance - * - * @example - * const results = searchTranslations({ - * translationsFolder: './translations', - * query: 'button', - * maxResults: 50 - * }); + * Minimum score (`shorter / longer` length) for a whole-word containment match. It keeps `Save` / + * `Save draft` (0.4) and drops a short label inside a long sentence, in either direction. */ -export function searchTranslations(params: SearchParams): SearchResult[] { - const { translationsFolder, query, maxResults = 100, baseLocale } = params; +export const CONTAINMENT_MIN_SCORE = 0.4; + +const DEFAULT_LIMIT = 100; + +const TEXT_RANK: Record = { + 'exact-key': 1, + 'exact-value': 2, + 'partial-key': 3, + 'partial-value': 4, + 'similar-value': 5, +}; + +/** A match before it is ranked: just what the ranking reads. It becomes a `SearchResult` only when it survives the limit. */ +interface Candidate { + readonly resource: SearchableResource; + readonly matchType: MatchType; + readonly matchedLocales?: string[]; + readonly similarity?: number; + /** The key contains the query (the similar-value tie-break). */ + readonly keyMatches: boolean; +} - // Empty query returns no results - if (!query || query.trim().length === 0) { +type CandidateMatch = Omit; + +/** Searches `resources` (see the module rules above). A blank query returns no results. */ +export function searchResources( + resources: Iterable, + collection: Pick, + query: string, + options: SearchOptions = {}, +): SearchResult[] { + const { mode = 'text' } = options; + const limit = isPositiveInteger(options.limit) ? options.limit : DEFAULT_LIMIT; + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length === 0) { return []; } - const normalizedQuery = query.toLowerCase().trim(); - const results: SearchResult[] = []; - - // Folders are read through the Collection Reader; the base locale only matters for writes, - // so the default stands in when the caller does not name one. - const folders = readCollectionFolders({ - translationsFolder, - baseLocale: baseLocale ?? DEFAULT_CONFIG.baseLocale, - tags: [], - }); - - for (const folder of folders) { - if (folder.problem) { - // Skip folders that cannot be read - console.error(`Error reading resources in ${folder.absolutePath}: ${folder.problem.message}`); - continue; - } - - for (const { fullKey, entry } of folder.resources) { - const normalizedKey = fullKey.toLowerCase(); - - // Check key matches - let matchType: MatchType | null = null; - const matchedLocales: string[] = []; - - if (normalizedKey === normalizedQuery) { - matchType = 'exact-key'; - } else if (normalizedKey.includes(normalizedQuery)) { - matchType = 'partial-key'; - } - - // Check value matches if no key match - if (!matchType) { - // Search source field if baseLocale is provided - if (baseLocale && entry.source && typeof entry.source === 'string') { - const normalizedValue = entry.source.toLowerCase(); - if (normalizedValue === normalizedQuery) { - matchType = 'exact-value'; - matchedLocales.push(baseLocale); - } else if (normalizedValue.includes(normalizedQuery)) { - matchType = 'partial-value'; - matchedLocales.push(baseLocale); - } - } - - // Search all other locale translations - for (const [locale, value] of Object.entries(entry.translations)) { - const normalizedValue = value.toLowerCase(); - if (normalizedValue === normalizedQuery) { - matchType = 'exact-value'; - matchedLocales.push(locale); - } else if (normalizedValue.includes(normalizedQuery)) { - if (matchType !== 'exact-value') { - matchType = 'partial-value'; - } - matchedLocales.push(locale); - } - } - } - - // Add to results if match found - if (matchType) { - const status: Record = {}; - const translations: Record = { ...entry.translations }; - - for (const locale of Object.keys(entry.translations)) { - status[locale] = entry.metadata[locale]?.status; - } - - // Include base locale value from source field - if (baseLocale && entry.source) { - translations[baseLocale] = entry.source; - } + const match = mode === 'text' ? matchText : matchSimilarValue; + const candidates: Candidate[] = []; + for (const resource of resources) { + const key = resource.fullKey.toLowerCase(); + const found = match(resource, key, normalizedQuery, collection.baseLocale); + if (found) candidates.push({ ...found, resource, keyMatches: key.includes(normalizedQuery) }); + } - results.push({ - key: fullKey, - source: entry.source, - translations, - status, - metadata: entry.metadata, - matchType, - matchedLocales: matchedLocales.length > 0 ? matchedLocales : undefined, - comment: entry.comment, - tags: entry.tags, - }); + candidates.sort((a, b) => + mode === 'text' + ? TEXT_RANK[a.matchType] - TEXT_RANK[b.matchType] || a.resource.fullKey.localeCompare(b.resource.fullKey) + : (b.similarity ?? 0) - (a.similarity ?? 0) || + Number(b.keyMatches) - Number(a.keyMatches) || + a.resource.fullKey.localeCompare(b.resource.fullKey), + ); - if (results.length >= maxResults) { - break; - } - } - } + return candidates.slice(0, limit).map(toResult); +} - if (results.length >= maxResults) { - break; - } +/** Every resource of an index tree with its full key, from the loaded folders only. */ +export function* treeResources(tree: ResourceTreeNode): Generator { + const prefix = tree.folderPathSegments.join('.'); + for (const entry of tree.resources) { + yield { fullKey: prefix ? `${prefix}.${entry.key}` : entry.key, entry }; + } + for (const child of tree.children) { + if (child.loaded && child.tree) yield* treeResources(child.tree); } +} - // Sort results by match type priority - const priority: Record = { - 'exact-key': 1, - 'exact-value': 2, - 'partial-key': 3, - 'partial-value': 4, - }; +function matchText( + resource: SearchableResource, + key: string, + query: string, + baseLocale: string, +): CandidateMatch | undefined { + if (key === query) return { matchType: 'exact-key' }; + if (key.includes(query)) return { matchType: 'partial-key' }; + + const values: Array<[string, unknown]> = [ + [baseLocale, baseValueOf(resource)], + ...Object.entries(resource.entry.translations), + ]; + const matchedLocales: string[] = []; + let exact = false; + for (const [locale, value] of values) { + // A hand-edited file can hold a non-string value; it cannot match. + if (typeof value !== 'string') continue; + const normalizedValue = value.toLowerCase(); + if (!normalizedValue.includes(query)) continue; + matchedLocales.push(locale); + exact ||= normalizedValue === query; + } - results.sort((a, b) => { - const priorityDiff = priority[a.matchType] - priority[b.matchType]; - if (priorityDiff !== 0) return priorityDiff; + return matchedLocales.length > 0 ? { matchType: exact ? 'exact-value' : 'partial-value', matchedLocales } : undefined; +} - // Secondary sort by key alphabetically - return a.key.localeCompare(b.key); - }); +function matchSimilarValue( + resource: SearchableResource, + _key: string, + query: string, + baseLocale: string, +): CandidateMatch | undefined { + const similarity = similarValueScore(query, baseValueOf(resource).trim().toLowerCase()); + return similarity === undefined + ? undefined + : { matchType: 'similar-value', matchedLocales: [baseLocale], similarity }; +} - return results; +/** The entry's base value; `''` when a hand-edited entry has no string `source`. */ +function baseValueOf({ entry }: SearchableResource): string { + return typeof entry.source === 'string' ? entry.source : ''; } -/** - * Parameters for searching the in-memory resource tree. - */ -export interface SearchTreeParams { - /** The cached resource tree to search */ - tree: ResourceTreeNode; +/** The similar-value score of two normalized texts, or `undefined` when they do not match. */ +function similarValueScore(query: string, value: string): number | undefined { + if (value.length === 0) return undefined; - /** Search query (case-insensitive) */ - query: string; + const [shorter, longer] = query.length <= value.length ? [query, value] : [value, query]; + const lengthRatio = shorter.length / longer.length; + if (lengthRatio >= CONTAINMENT_MIN_SCORE && containsAsWords(longer, shorter)) return lengthRatio; + // The Levenshtein score never exceeds the length ratio, so it cannot reach the threshold either. + if (lengthRatio < SIMILARITY_THRESHOLD) return undefined; - /** Maximum number of results to return (default: 100) */ - maxResults?: number; + const score = normalizedLevenshtein(query, value); + return score >= SIMILARITY_THRESHOLD ? score : undefined; +} - /** Base locale code for including source values */ - baseLocale?: string; +/** `text` holds `part` with no word character right before or after it. */ +function containsAsWords(text: string, part: string): boolean { + for (let at = text.indexOf(part); at !== -1; at = text.indexOf(part, at + 1)) { + if (!isWordCharacter(text[at - 1]) && !isWordCharacter(text[at + part.length])) return true; + } + return false; } /** - * Searches translations in an in-memory resource tree. - * - * This function provides the same search functionality as searchTranslations - * but operates on a pre-loaded ResourceTreeNode instead of reading from disk. - * Use this when you have a cached tree to avoid repeated disk I/O. - * - * Search Algorithm: - * 1. Exact key match (highest priority) - * 2. Partial key match - * 3. Exact value match in any locale - * 4. Partial value match in any locale - * - * @param params - Search parameters including the tree to search - * @returns Array of search results sorted by relevance - * - * @example - * const tree = loadResourceTree({ translationsFolder: './translations', baseLocale: 'en', depth: Infinity }); - * const results = searchResourceTree({ - * tree, - * query: 'button', - * maxResults: 50, - * baseLocale: 'en' - * }); + * A letter, a digit, or an apostrophe (`'`, `’`), so `don` is not a word of "don't". Tested one UTF-16 + * code unit at a time, so combining marks and letters outside the Basic Multilingual Plane are approximate. */ -export function searchResourceTree(params: SearchTreeParams): SearchResult[] { - const { tree, query, maxResults = 100, baseLocale } = params; - - // Empty query returns no results - if (!query || query.trim().length === 0) { - return []; - } - - const normalizedQuery = query.toLowerCase().trim(); - const results: SearchResult[] = []; - - /** - * Recursively searches through the tree structure. - */ - function searchNode(node: ResourceTreeNode): boolean { - // Build key prefix from folder path segments - const keyPrefix = node.folderPathSegments.join('.'); - - // Search resources in current node - for (const entry of node.resources) { - const fullKey = keyPrefix ? `${keyPrefix}.${entry.key}` : entry.key; - const normalizedKey = fullKey.toLowerCase(); - - // Check key matches - let matchType: MatchType | null = null; - const matchedLocales: string[] = []; - - if (normalizedKey === normalizedQuery) { - matchType = 'exact-key'; - } else if (normalizedKey.includes(normalizedQuery)) { - matchType = 'partial-key'; - } - - // Check value matches if no key match - if (!matchType) { - // Search source field if baseLocale is provided - if (baseLocale && entry.source) { - const normalizedValue = entry.source.toLowerCase(); - if (normalizedValue === normalizedQuery) { - matchType = 'exact-value'; - matchedLocales.push(baseLocale); - } else if (normalizedValue.includes(normalizedQuery)) { - matchType = 'partial-value'; - matchedLocales.push(baseLocale); - } - } - - // Search all locale translations - for (const [locale, value] of Object.entries(entry.translations)) { - if (typeof value === 'string') { - const normalizedValue = value.toLowerCase(); - if (normalizedValue === normalizedQuery) { - matchType = 'exact-value'; - matchedLocales.push(locale); - } else if (normalizedValue.includes(normalizedQuery)) { - if (matchType !== 'exact-value') { - matchType = 'partial-value'; - } - matchedLocales.push(locale); - } - } - } - } - - // Add to results if match found - if (matchType) { - const status: Record = {}; - const translations: Record = { ...entry.translations }; - - // Extract status from metadata - for (const locale in entry.metadata) { - status[locale] = entry.metadata[locale]?.status; - } - - // Include base locale value from source field - if (baseLocale && entry.source) { - translations[baseLocale] = entry.source; - } - - results.push({ - key: fullKey, - source: entry.source, - translations, - status, - metadata: entry.metadata, - matchType, - matchedLocales: matchedLocales.length > 0 ? matchedLocales : undefined, - comment: entry.comment, - tags: entry.tags, - }); - - // Stop if we've reached max results - if (results.length >= maxResults) { - return true; // Signal to stop searching - } - } - } - - // Recurse into children - for (const child of node.children) { - if (child.loaded && child.tree) { - const shouldStop = searchNode(child.tree); - if (shouldStop) { - return true; - } - } - } - - return false; - } +function isWordCharacter(character: string | undefined): boolean { + return character !== undefined && /[\p{L}\p{N}'’]/u.test(character); +} - // Start search from root - searchNode(tree); +function isPositiveInteger(value: number | undefined): value is number { + return value !== undefined && Number.isInteger(value) && value > 0; +} - // Sort results by match type priority - const priority: Record = { - 'exact-key': 1, - 'exact-value': 2, - 'partial-key': 3, - 'partial-value': 4, +function toResult({ resource, matchType, matchedLocales, similarity }: Candidate): SearchResult { + const { fullKey, entry } = resource; + return { + key: fullKey, + source: baseValueOf(resource), + translations: { ...entry.translations }, + metadata: entry.metadata, + matchType, + ...(matchedLocales && { matchedLocales }), + ...(similarity !== undefined && { similarity }), + comment: entry.comment, + tags: entry.tags, }; - - results.sort((a, b) => { - const priorityDiff = priority[a.matchType] - priority[b.matchType]; - if (priorityDiff !== 0) return priorityDiff; - - // Secondary sort by key alphabetically - return a.key.localeCompare(b.key); - }); - - return results; } diff --git a/libs/data-transfer/src/lib/search-result.dto.ts b/libs/data-transfer/src/lib/search-result.dto.ts index b4900bee..b06b598b 100644 --- a/libs/data-transfer/src/lib/search-result.dto.ts +++ b/libs/data-transfer/src/lib/search-result.dto.ts @@ -1,9 +1,10 @@ import type { ResourceSummaryDto } from './resource-tree.dto'; /** - * Match type for search results. + * How a search result matched: one of the four text-mode types, or `similar-value` for a + * `mode=similar` search. */ -export type MatchType = 'exact-key' | 'partial-key' | 'exact-value' | 'partial-value'; +export type MatchType = 'exact-key' | 'partial-key' | 'exact-value' | 'partial-value' | 'similar-value'; /** * DTO for a single search result: a Resource Summary plus how it matched. @@ -14,6 +15,9 @@ export interface SearchResultDto extends ResourceSummaryDto { /** Locales where the match was found (for value matches) */ matchedLocales?: string[]; + + /** `mode=similar` only: how similar the base value is to the query, 0..1 */ + similarity?: number; } /** diff --git a/libs/data-transfer/src/lib/search-translations.dto.ts b/libs/data-transfer/src/lib/search-translations.dto.ts index 34263b1a..45c77a3a 100644 --- a/libs/data-transfer/src/lib/search-translations.dto.ts +++ b/libs/data-transfer/src/lib/search-translations.dto.ts @@ -13,4 +13,11 @@ export interface SearchTranslationsDto { * Default: 100, Max: 500 */ maxResults?: number; + + /** + * `text` (default): the query in keys and values of every locale. + * `similar`: base values similar to the query (Resource Search's similar-value rule), ranked by similarity. + * Any other value is a text search. + */ + mode?: 'text' | 'similar'; } From 5402dbc3262c2ece476705b57263e66470357dfc Mon Sep 17 00:00:00 2001 From: snodel Date: Wed, 23 Sep 2026 23:28:12 -0700 Subject: [PATCH 16/20] refactor: one bundle definition module in domain The bundle definition type and its rules live once, in libs/domain, and core, the API, the CLI and the Tracker all use them: - types EntrySelectionRule, CollectionBundleDefinition, BundleDefinition and hasTypeDistConfigured moved from core; the data-transfer DTOs are type aliases of them - validateBundleKey, validateBundleDefinition(def, collectionNames) and validateJavaScriptIdentifier moved from core (messages unchanged); new tokenCasing rule - new bundleOutputFile(def, locale) (posix path rule used by generate, plan, the API job result and the Tracker preview), normalizeBundleDefinition (trim, drop empty optionals and unknown fields, migrate legacy typeDist to typeDistFile, tolerate loose input), checkBundleDefinition (normalize + every error, key first) and findBundleDefinition (own-property lookup) - core: config/bundle-definition.ts, validate-bundle-definition.ts and getBundleOutputPath deleted; bundle-definition-operations use the domain check; bundles are written with own properties (a key such as __proto__ or constructor is stored and found as a normal key) - API: bundle.mapper keeps only plan and job-result mapping; config.mapper passes bundles through; the controller drops its own 400/404/409 handling (core typed errors reach the filter); the filter answers InvalidBundleDefinitionError with { statusCode, message, error, errors } - Tracker: the bundle form's field validators call the domain predicates, submit re-runs checkBundleDefinition and shows any remaining messages, the preview uses bundleOutputFile, a legacy typeDist bundle loads with its types file; the store appends `errors` to a bundle 400 message Behaviour changes: - 400 body for an invalid bundle gains statusCode and error (additive) - PUT on a missing bundle with no `bundle` body answers 400 (was 404); POST with neither name nor definition reports the definition first - GET /config passes unknown or deprecated bundle fields through as written; saving a bundle migrates typeDist to typeDistFile - output paths always use `/`; a bundle named constructor is created, found and generated as a normal bundle (was 409 / a bogus job) - tokenCasing other than upperCase or camelCase is rejected Co-Authored-By: Claude Fable 5.1 --- .../api/src/app/bundles/bundle-job.service.ts | 3 +- .../app/bundles/bundles.controller.spec.ts | 203 ++++--- .../api/src/app/bundles/bundles.controller.ts | 123 ++--- .../lingo-tracker-exception.filter.spec.ts | 2 +- .../errors/lingo-tracker-exception.filter.ts | 19 +- .../api/src/app/mappers/bundle.mapper.spec.ts | 111 +--- apps/api/src/app/mappers/bundle.mapper.ts | 136 +---- .../api/src/app/mappers/config.mapper.spec.ts | 13 + apps/api/src/app/mappers/config.mapper.ts | 10 +- apps/cli/src/commands/bundle.ts | 4 +- apps/cli/src/init/init.ts | 3 +- .../bundle-form-dialog.html | 8 + .../bundle-form-dialog.scss | 9 + .../bundle-form-dialog.spec.ts | 50 ++ .../bundle-form-dialog/bundle-form-dialog.ts | 78 ++- .../features/with-bundles.feature.spec.ts | 26 + .../store/features/with-bundles.feature.ts | 6 +- architecture-docs/api.md | 21 +- architecture-docs/core-library.md | 26 +- architecture-docs/domain-and-data-model.md | 38 ++ architecture-docs/frontend.md | 10 + architecture-docs/glossary.md | 8 + libs/core/src/config/bundle-definition.ts | 161 ------ libs/core/src/config/lingo-tracker-config.ts | 3 +- libs/core/src/index.ts | 7 +- .../bundle-definition-operations.spec.ts | 82 ++- .../bundle/bundle-definition-operations.ts | 113 ++-- .../src/lib/bundle/bundle-selection.spec.ts | 2 +- libs/core/src/lib/bundle/bundle-selection.ts | 10 +- .../src/lib/bundle/generate-bundle.spec.ts | 2 +- libs/core/src/lib/bundle/generate-bundle.ts | 21 +- libs/core/src/lib/bundle/index.ts | 2 - .../bundle/mixed-base-locale.real-fs.spec.ts | 2 +- libs/core/src/lib/bundle/plan-bundle.spec.ts | 6 +- libs/core/src/lib/bundle/plan-bundle.ts | 12 +- .../type-generation/generate-types.spec.ts | 2 +- .../bundle/type-generation/generate-types.ts | 10 +- .../type-generation/key-transformer.spec.ts | 59 -- .../bundle/type-generation/key-transformer.ts | 36 -- .../bundle/validate-bundle-definition.spec.ts | 222 -------- .../lib/bundle/validate-bundle-definition.ts | 134 ----- .../src/lib/bundle-definition.dto.ts | 73 +-- libs/domain/src/index.spec.ts | 10 + libs/domain/src/index.ts | 24 +- libs/domain/src/lib/bundle-definition.spec.ts | 518 ++++++++++++++++++ libs/domain/src/lib/bundle-definition.ts | 463 ++++++++++++++++ libs/domain/src/lib/js-identifier.spec.ts | 60 +- libs/domain/src/lib/js-identifier.ts | 40 +- 48 files changed, 1743 insertions(+), 1238 deletions(-) delete mode 100644 libs/core/src/config/bundle-definition.ts delete mode 100644 libs/core/src/lib/bundle/validate-bundle-definition.spec.ts delete mode 100644 libs/core/src/lib/bundle/validate-bundle-definition.ts create mode 100644 libs/domain/src/lib/bundle-definition.spec.ts create mode 100644 libs/domain/src/lib/bundle-definition.ts diff --git a/apps/api/src/app/bundles/bundle-job.service.ts b/apps/api/src/app/bundles/bundle-job.service.ts index b2ef0654..ad5fb9d7 100644 --- a/apps/api/src/app/bundles/bundle-job.service.ts +++ b/apps/api/src/app/bundles/bundle-job.service.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { Injectable, Logger } from '@nestjs/common'; -import type { BundleDefinition, BundleProgressEvent, LingoTrackerConfig } from '@simoncodes-ca/core'; +import type { BundleProgressEvent, LingoTrackerConfig } from '@simoncodes-ca/core'; import { generateBundle } from '@simoncodes-ca/core'; import type { BundleGenerateJobDto, @@ -8,6 +8,7 @@ import type { BundleGenerateJobResultDto, BundleGenerateJobStatus, } from '@simoncodes-ca/data-transfer'; +import type { BundleDefinition } from '@simoncodes-ca/domain'; import { mapGenerateBundleResultToJobResult } from '../mappers/bundle.mapper'; export interface StartBundleJobParams { diff --git a/apps/api/src/app/bundles/bundles.controller.spec.ts b/apps/api/src/app/bundles/bundles.controller.spec.ts index e8089621..77d17b50 100644 --- a/apps/api/src/app/bundles/bundles.controller.spec.ts +++ b/apps/api/src/app/bundles/bundles.controller.spec.ts @@ -1,8 +1,9 @@ -import { ConflictException, HttpException, HttpStatus, NotFoundException } from '@nestjs/common'; +import { HttpStatus, NotFoundException } from '@nestjs/common'; import { Test, type TestingModule } from '@nestjs/testing'; -import type { BundleDefinition, BundlePlan, LingoTrackerConfig } from '@simoncodes-ca/core'; +import type { BundlePlan, LingoTrackerConfig } from '@simoncodes-ca/core'; import * as core from '@simoncodes-ca/core'; import type { BundleDefinitionDto } from '@simoncodes-ca/data-transfer'; +import type { BundleDefinition } from '@simoncodes-ca/domain'; import type { Response } from 'express'; import { ConfigService } from '../config/config.service'; import { toHttpException } from '../errors/lingo-tracker-exception.filter'; @@ -15,9 +16,6 @@ jest.mock('@simoncodes-ca/core', () => ({ updateBundleDefinition: jest.fn(), deleteBundleDefinition: jest.fn(), planBundle: jest.fn(), - validateBundleKey: jest.fn(() => []), - validateBundleDefinition: jest.fn(() => []), - getBundleOutputPath: jest.fn((definition: BundleDefinition, locale: string) => `${definition.dist}/${locale}.json`), })); const existingDefinition: BundleDefinition = { @@ -62,16 +60,19 @@ const plan: BundlePlan = { warnings: [], }; -/** The status the global exception filter answers with for what `fn` throws. */ -const statusOf = (fn: () => unknown): number => { +/** The answer the global exception filter gives for what `fn` throws. */ +const answerOf = (fn: () => unknown): { status: number; body: unknown } => { try { fn(); } catch (error: unknown) { - return toHttpException(error).getStatus(); + const http = toHttpException(error); + return { status: http.getStatus(), body: http.getResponse() }; } throw new Error('expected the handler to throw'); }; +const statusOf = (fn: () => unknown): number => answerOf(fn).status; + const makeResponse = (): { response: Response; status: jest.Mock; json: jest.Mock } => { const json = jest.fn(); const status = jest.fn(() => ({ json })); @@ -100,55 +101,47 @@ describe('BundlesController', () => { beforeEach(() => { jest.clearAllMocks(); configService.getConfig.mockReturnValue(config); - (core.validateBundleKey as jest.Mock).mockReturnValue([]); - (core.validateBundleDefinition as jest.Mock).mockReturnValue([]); }); describe('POST /bundles', () => { - it('validates then adds the mapped definition', () => { + it('hands the trimmed name and the body definition to core, which normalises and validates', () => { (core.addBundleDefinition as jest.Mock).mockReturnValue({ message: 'Bundle "main" added successfully' }); + const bundle = { ...requestDefinition, dist: ' ./dist/i18n ' }; - const result = controller.createBundle({ - name: ' main ', - bundle: { ...requestDefinition, dist: ' ./dist/i18n ' }, - }); + const result = controller.createBundle({ name: ' main ', bundle }); expect(result).toEqual({ message: 'Bundle "main" added successfully' }); - expect(core.validateBundleKey).toHaveBeenCalledWith('main'); - expect(core.validateBundleDefinition).toHaveBeenCalledWith( - { bundleName: 'main.{locale}', dist: './dist/i18n', collections: 'All' }, - config, - ); - expect(core.addBundleDefinition).toHaveBeenCalledWith( - 'main', - { bundleName: 'main.{locale}', dist: './dist/i18n', collections: 'All' }, - { cwd: process.cwd() }, - ); + expect(core.addBundleDefinition).toHaveBeenCalledWith('main', bundle, { cwd: process.cwd() }); + }); + + it('passes a missing name as empty so the key rule reports it', () => { + controller.createBundle({ bundle: requestDefinition } as never); + + expect(core.addBundleDefinition).toHaveBeenCalledWith('', requestDefinition, { cwd: process.cwd() }); }); - it('returns 400 with every validation message and does not write', () => { - (core.validateBundleKey as jest.Mock).mockReturnValue(['Bundle name is required.']); - (core.validateBundleDefinition as jest.Mock).mockReturnValue(['dist (output folder) is required.']); - - try { - controller.createBundle({ name: 'x', bundle: requestDefinition }); - throw new Error('expected an HttpException'); - } catch (error: unknown) { - expect(error).toBeInstanceOf(HttpException); - expect((error as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST); - expect((error as HttpException).getResponse()).toEqual({ + it('returns 400 with every message when core rejects the definition', () => { + (core.addBundleDefinition as jest.Mock).mockImplementation(() => { + throw new core.InvalidBundleDefinitionError(['Bundle name is required.', 'dist (output folder) is required.']); + }); + + expect(answerOf(() => controller.createBundle({ name: 'x', bundle: requestDefinition }))).toEqual({ + status: HttpStatus.BAD_REQUEST, + body: { + statusCode: HttpStatus.BAD_REQUEST, message: 'Invalid bundle definition', + error: 'Bad Request', errors: ['Bundle name is required.', 'dist (output folder) is required.'], - }); - } - expect(core.addBundleDefinition).not.toHaveBeenCalled(); + }, + }); }); - it('returns 400 when the body carries no definition', () => { - expect(statusOf(() => controller.createBundle({ name: 'main' } as never))).toBe(HttpStatus.BAD_REQUEST); - expect(statusOf(() => controller.createBundle({ bundle: requestDefinition } as never))).toBe( - HttpStatus.BAD_REQUEST, - ); + it('returns 400 when the body carries no definition, without calling core', () => { + expect(answerOf(() => controller.createBundle({ name: 'main' } as never))).toMatchObject({ + status: HttpStatus.BAD_REQUEST, + body: { errors: ['bundle definition is required.'] }, + }); + expect(core.addBundleDefinition).not.toHaveBeenCalled(); }); it('returns 409 when core reports the bundle already exists', () => { @@ -176,9 +169,14 @@ describe('BundlesController', () => { }); describe('PUT /bundles/:name', () => { - it('returns 404 when the bundle does not exist', () => { - expect(() => controller.updateBundle('missing', { bundle: requestDefinition })).toThrow(NotFoundException); - expect(core.updateBundleDefinition).not.toHaveBeenCalled(); + it('returns 404 when core reports the bundle missing', () => { + (core.updateBundleDefinition as jest.Mock).mockImplementation(() => { + throw new core.BundleNotFoundError('missing'); + }); + + expect(statusOf(() => controller.updateBundle('missing', { bundle: requestDefinition }))).toBe( + HttpStatus.NOT_FOUND, + ); }); it('updates in place when no rename is requested', () => { @@ -187,39 +185,58 @@ describe('BundlesController', () => { const result = controller.updateBundle('tracker', { bundle: requestDefinition }); expect(result).toEqual({ message: 'updated' }); - expect(core.updateBundleDefinition).toHaveBeenCalledWith( - 'tracker', - { bundleName: 'main.{locale}', dist: './dist/i18n', collections: 'All' }, - { cwd: process.cwd() }, - ); + expect(core.updateBundleDefinition).toHaveBeenCalledWith('tracker', requestDefinition, { cwd: process.cwd() }); + }); + + it('returns 400 for a missing bundle when the body has no definition', () => { + expect(answerOf(() => controller.updateBundle('missing', {} as never))).toMatchObject({ + status: HttpStatus.BAD_REQUEST, + body: { errors: ['bundle definition is required.'] }, + }); + expect(core.updateBundleDefinition).not.toHaveBeenCalled(); + }); + + it('passes a body.name equal to the current name as newKey', () => { + (core.updateBundleDefinition as jest.Mock).mockReturnValue({ message: 'updated' }); + + controller.updateBundle('tracker', { name: 'tracker', bundle: requestDefinition }); + + expect(core.updateBundleDefinition).toHaveBeenCalledWith('tracker', requestDefinition, { + cwd: process.cwd(), + newKey: 'tracker', + }); }); it('URI-decodes the name and renames via body.name', () => { (core.updateBundleDefinition as jest.Mock).mockReturnValue({ message: 'renamed' }); - controller.updateBundle('tracker', { name: 'tracker-v2', bundle: requestDefinition }); + controller.updateBundle('tracker%2Dv1', { name: 'tracker-v2', bundle: requestDefinition }); - expect(core.validateBundleKey).toHaveBeenCalledWith('tracker-v2'); - expect(core.updateBundleDefinition).toHaveBeenCalledWith('tracker', expect.any(Object), { + expect(core.updateBundleDefinition).toHaveBeenCalledWith('tracker-v1', requestDefinition, { cwd: process.cwd(), newKey: 'tracker-v2', }); }); - it('returns 409 when renaming onto an existing bundle', () => { - expect(() => controller.updateBundle('tracker', { name: 'other', bundle: requestDefinition })).toThrow( - ConflictException, + it('returns 409 when core reports a rename collision', () => { + (core.updateBundleDefinition as jest.Mock).mockImplementation(() => { + throw new core.BundleAlreadyExistsError('other'); + }); + + expect(statusOf(() => controller.updateBundle('tracker', { name: 'other', bundle: requestDefinition }))).toBe( + HttpStatus.CONFLICT, ); - expect(core.updateBundleDefinition).not.toHaveBeenCalled(); }); - it('returns 400 when the definition is invalid', () => { - (core.validateBundleDefinition as jest.Mock).mockReturnValue(['bundleName is required.']); + it('returns 400 when core rejects the definition', () => { + (core.updateBundleDefinition as jest.Mock).mockImplementation(() => { + throw new core.InvalidBundleDefinitionError(['bundleName is required.']); + }); - expect(statusOf(() => controller.updateBundle('tracker', { bundle: requestDefinition }))).toBe( - HttpStatus.BAD_REQUEST, - ); - expect(core.updateBundleDefinition).not.toHaveBeenCalled(); + expect(answerOf(() => controller.updateBundle('tracker', { bundle: requestDefinition }))).toMatchObject({ + status: HttpStatus.BAD_REQUEST, + body: { message: 'Invalid bundle definition', errors: ['bundleName is required.'] }, + }); }); }); @@ -231,11 +248,6 @@ describe('BundlesController', () => { expect(core.deleteBundleDefinition).toHaveBeenCalledWith('tracker', { cwd: process.cwd() }); }); - it('returns 404 for an unknown bundle', () => { - expect(() => controller.deleteBundle('missing')).toThrow(NotFoundException); - expect(core.deleteBundleDefinition).not.toHaveBeenCalled(); - }); - it('maps a core not-found error to 404', () => { (core.deleteBundleDefinition as jest.Mock).mockImplementation(() => { throw new core.BundleNotFoundError('tracker'); @@ -247,10 +259,14 @@ describe('BundlesController', () => { }); describe('POST /bundles/dry-run', () => { - it('plans the definition from the request body, not the saved one', () => { + it('plans the normalised definition from the request body, not the saved one', () => { (core.planBundle as jest.Mock).mockReturnValue(plan); - const result = controller.dryRun({ name: 'preview', bundle: requestDefinition, locales: ['en'] }); + const result = controller.dryRun({ + name: ' preview ', + bundle: { ...requestDefinition, dist: ' ./dist/i18n ', typeDistFile: '' }, + locales: ['en'], + }); expect(core.planBundle).toHaveBeenCalledWith({ bundleKey: 'preview', @@ -273,12 +289,35 @@ describe('BundlesController', () => { expect('locales' in (core.planBundle as jest.Mock).mock.calls[0][0]).toBe(false); }); - it('returns 400 for an invalid definition without planning', () => { - (core.validateBundleDefinition as jest.Mock).mockReturnValue(['dist (output folder) is required.']); + it('returns 400 with every domain message without planning', () => { + const bundle: BundleDefinition = { + ...requestDefinition, + dist: '', + collections: [{ name: 'ghost', entriesSelectionRules: 'All' }], + }; - expect(statusOf(() => controller.dryRun({ name: 'preview', bundle: requestDefinition }))).toBe( - HttpStatus.BAD_REQUEST, - ); + expect(answerOf(() => controller.dryRun({ name: 'bad name', bundle }))).toEqual({ + status: HttpStatus.BAD_REQUEST, + body: { + statusCode: HttpStatus.BAD_REQUEST, + message: 'Invalid bundle definition', + error: 'Bad Request', + errors: [ + 'Bundle name may only contain letters, numbers, hyphens and underscores.', + 'dist (output folder) is required.', + "Collection 'ghost' does not exist in the configuration.", + ], + }, + }); + expect(core.planBundle).not.toHaveBeenCalled(); + }); + + it('returns 400 when the name or the definition is missing', () => { + expect(answerOf(() => controller.dryRun({ bundle: requestDefinition } as never))).toMatchObject({ + status: HttpStatus.BAD_REQUEST, + body: { errors: ['Bundle name is required.'] }, + }); + expect(statusOf(() => controller.dryRun({ name: 'preview' } as never))).toBe(HttpStatus.BAD_REQUEST); expect(core.planBundle).not.toHaveBeenCalled(); }); @@ -327,7 +366,15 @@ describe('BundlesController', () => { it('returns 404 for an unknown bundle', () => { const { response } = makeResponse(); - expect(() => controller.generateBundle('missing', {}, response)).toThrow(NotFoundException); + expect(() => controller.generateBundle('missing', {}, response)).toThrow(core.BundleNotFoundError); + expect(statusOf(() => controller.generateBundle('missing', {}, response))).toBe(HttpStatus.NOT_FOUND); + expect(jobService.startJob).not.toHaveBeenCalled(); + }); + + it('returns 404 for a name that only exists on Object.prototype', () => { + const { response } = makeResponse(); + + expect(statusOf(() => controller.generateBundle('constructor', {}, response))).toBe(HttpStatus.NOT_FOUND); expect(jobService.startJob).not.toHaveBeenCalled(); }); diff --git a/apps/api/src/app/bundles/bundles.controller.ts b/apps/api/src/app/bundles/bundles.controller.ts index aa9be94e..0e4d5ec8 100644 --- a/apps/api/src/app/bundles/bundles.controller.ts +++ b/apps/api/src/app/bundles/bundles.controller.ts @@ -1,6 +1,5 @@ import { Body, - ConflictException, Controller, Delete, Get, @@ -12,15 +11,15 @@ import { Put, Res, } from '@nestjs/common'; -import type { BundleDefinition, LingoTrackerConfig } from '@simoncodes-ca/core'; +import type { LingoTrackerConfig } from '@simoncodes-ca/core'; import { addBundleDefinition, + BundleNotFoundError, deleteBundleDefinition, + InvalidBundleDefinitionError, LingoTrackerError, planBundle, updateBundleDefinition, - validateBundleDefinition, - validateBundleKey, } from '@simoncodes-ca/core'; import type { BundleDefinitionDto, @@ -31,13 +30,18 @@ import type { GenerateBundleRequestDto, UpdateBundleDto, } from '@simoncodes-ca/data-transfer'; +import { type BundleDefinition, checkBundleDefinition, findBundleDefinition } from '@simoncodes-ca/domain'; import type { Response } from 'express'; import { ConfigService } from '../config/config.service'; -import { mapBundlePlanToDto, mapDtoToBundleDefinition } from '../mappers/bundle.mapper'; +import { mapBundlePlanToDto } from '../mappers/bundle.mapper'; import { BundleJobService } from './bundle-job.service'; -const INVALID_DEFINITION_MESSAGE = 'Invalid bundle definition'; - +/** + * Bundle definitions are checked by the domain Bundle Definition rules: core's add/update + * operations normalise and validate them, and the dry run (which writes nothing) runs the + * same rules here. Invalid, missing and duplicate bundles surface as typed core errors that + * `LingoTrackerExceptionFilter` maps to 400 (with `errors`), 404 and 409. + */ @Controller('bundles') export class BundlesController { readonly #configService: ConfigService; @@ -53,8 +57,8 @@ export class BundlesController { dryRun(@Body() body: BundleDryRunRequestDto): BundleDryRunResultDto { try { const config = this.#configService.getConfig(); - const name = requireName(body?.name); - const definition = this.#validatedDefinition(name, body?.bundle, config); + const name = nameOf(body?.name); + const definition = validatedDefinition(name, body?.bundle, config); const locales = this.#validatedLocales(body?.locales, config); const plan = planBundle({ @@ -85,11 +89,7 @@ export class BundlesController { @Post() createBundle(@Body() body: CreateBundleDto): { message: string } { try { - const config = this.#configService.getConfig(); - const name = requireName(body?.name); - const definition = this.#validatedDefinition(name, body?.bundle, config); - - return addBundleDefinition(name, definition, { cwd: process.cwd() }); + return addBundleDefinition(nameOf(body?.name), requireDefinition(body?.bundle), { cwd: process.cwd() }); } catch (error: unknown) { this.#rethrow(error, 'Error creating bundle'); } @@ -98,21 +98,11 @@ export class BundlesController { @Put(':name') updateBundle(@Param('name') name: string, @Body() body: UpdateBundleDto): { message: string } { try { - const decodedName = decodeURIComponent(name); - const config = this.#configService.getConfig(); - this.#requireExistingBundle(decodedName, config); + const newName = typeof body?.name === 'string' && body.name.trim().length > 0 ? body.name : undefined; - const newName = typeof body?.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : undefined; - const targetName = newName ?? decodedName; - const definition = this.#validatedDefinition(targetName, body?.bundle, config); - - if (newName !== undefined && newName !== decodedName && config.bundles?.[newName]) { - throw new ConflictException(`Bundle "${newName}" already exists`); - } - - return updateBundleDefinition(decodedName, definition, { + return updateBundleDefinition(decodeURIComponent(name), requireDefinition(body?.bundle), { cwd: process.cwd(), - ...(newName !== undefined && newName !== decodedName && { newKey: newName }), + ...(newName !== undefined && { newKey: newName }), }); } catch (error: unknown) { this.#rethrow(error, 'Error updating bundle'); @@ -122,11 +112,7 @@ export class BundlesController { @Delete(':name') deleteBundle(@Param('name') name: string): { message: string } { try { - const decodedName = decodeURIComponent(name); - const config = this.#configService.getConfig(); - this.#requireExistingBundle(decodedName, config); - - return deleteBundleDefinition(decodedName, { cwd: process.cwd() }); + return deleteBundleDefinition(decodeURIComponent(name), { cwd: process.cwd() }); } catch (error: unknown) { this.#rethrow(error, 'Error deleting bundle'); } @@ -137,7 +123,10 @@ export class BundlesController { generateBundle(@Param('name') name: string, @Body() body: GenerateBundleRequestDto, @Res() response: Response): void { const decodedName = decodeURIComponent(name); const config = this.#configService.getConfig(); - const bundleDefinition = this.#requireExistingBundle(decodedName, config); + const bundleDefinition = findBundleDefinition(config.bundles, decodedName); + if (!bundleDefinition) { + throw new BundleNotFoundError(decodedName); + } const locales = this.#validatedLocales(body?.locales, config); const jobId = this.#jobService.startJob({ @@ -151,39 +140,6 @@ export class BundlesController { response.status(HttpStatus.ACCEPTED).json(job); } - #requireExistingBundle(name: string, config: LingoTrackerConfig): BundleDefinition { - const definition = config.bundles?.[name]; - - if (!definition) { - throw new NotFoundException(`Bundle "${name}" not found`); - } - - return definition; - } - - /** Maps and validates a definition body; throws 400 with every message when invalid. */ - #validatedDefinition( - name: string, - dto: BundleDefinitionDto | undefined, - config: LingoTrackerConfig, - ): BundleDefinition { - if (!dto || typeof dto !== 'object') { - throw new HttpException( - { message: INVALID_DEFINITION_MESSAGE, errors: ['bundle definition is required.'] }, - HttpStatus.BAD_REQUEST, - ); - } - - const definition = mapDtoToBundleDefinition(dto); - const errors = [...validateBundleKey(name), ...validateBundleDefinition(definition, config)]; - - if (errors.length > 0) { - throw new HttpException({ message: INVALID_DEFINITION_MESSAGE, errors }, HttpStatus.BAD_REQUEST); - } - - return definition; - } - /** Accepts an optional locale subset; every entry must be a configured locale. */ #validatedLocales(locales: readonly string[] | undefined, config: LingoTrackerConfig): string[] | undefined { if (locales === undefined) { @@ -218,12 +174,33 @@ export class BundlesController { } } -function requireName(name: unknown): string { - if (typeof name !== 'string' || name.trim().length === 0) { - throw new HttpException( - { message: INVALID_DEFINITION_MESSAGE, errors: ['Bundle name is required.'] }, - HttpStatus.BAD_REQUEST, - ); +/** The trimmed name, or `''` (which the key rule reports as required) when it is not a string. */ +function nameOf(name: unknown): string { + return typeof name === 'string' ? name.trim() : ''; +} + +function requireDefinition(dto: BundleDefinitionDto | undefined): BundleDefinition { + if (!dto || typeof dto !== 'object') { + throw new InvalidBundleDefinitionError(['bundle definition is required.']); } - return name.trim(); + return dto; +} + +/** The domain `checkBundleDefinition`, as core's add/update operations run it; throws every problem at once. */ +function validatedDefinition( + name: string, + dto: BundleDefinitionDto | undefined, + config: LingoTrackerConfig, +): BundleDefinition { + const { definition, errors } = checkBundleDefinition( + requireDefinition(dto), + Object.keys(config.collections ?? {}), + name, + ); + + if (errors.length > 0) { + throw new InvalidBundleDefinitionError(errors); + } + + return definition; } diff --git a/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts b/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts index 8544cec8..7e23e76e 100644 --- a/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts +++ b/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts @@ -116,7 +116,7 @@ describe('toHttpException', () => { [ new InvalidBundleDefinitionError(['a', 'b']), 400, - { message: 'Invalid bundle definition: a; b', error: 'Bad Request', statusCode: 400 }, + { message: 'Invalid bundle definition', error: 'Bad Request', statusCode: 400, errors: ['a', 'b'] }, ], [ new ProtectedTermsFileError('/p/terms.json', 'Protected terms file is not valid JSON: /p/terms.json'), diff --git a/apps/api/src/app/errors/lingo-tracker-exception.filter.ts b/apps/api/src/app/errors/lingo-tracker-exception.filter.ts index 2cd9b221..c042c648 100644 --- a/apps/api/src/app/errors/lingo-tracker-exception.filter.ts +++ b/apps/api/src/app/errors/lingo-tracker-exception.filter.ts @@ -42,11 +42,17 @@ import { * what the class name suggests: locale conflicts and missing locales answer 400 (bundle * conflicts answer 409). * - * Every mapped answer has the same `{ statusCode, message, error }` body. + * Every mapped answer has the same `{ statusCode, message, error }` body. An invalid bundle + * definition also carries `errors`, every problem the domain rules found, under the fixed + * message `Invalid bundle definition`. */ export function lingoTrackerErrorToHttp(error: LingoTrackerError): HttpException { const { message } = error; + if (error instanceof InvalidBundleDefinitionError) { + return invalidBundleDefinitionToHttp(error); + } + if ( error instanceof CollectionNotFoundError || error instanceof ResourceNotFoundError || @@ -72,8 +78,7 @@ export function lingoTrackerErrorToHttp(error: LingoTrackerError): HttpException error instanceof InvalidLocaleError || error instanceof LocaleNotFoundError || error instanceof LocaleAlreadyExistsError || - error instanceof BaseLocaleImmutableError || - error instanceof InvalidBundleDefinitionError + error instanceof BaseLocaleImmutableError ) { return new BadRequestException(message); } @@ -83,6 +88,14 @@ export function lingoTrackerErrorToHttp(error: LingoTrackerError): HttpException return new InternalServerErrorException(message); } +function invalidBundleDefinitionToHttp(error: InvalidBundleDefinitionError): HttpException { + const status = HttpStatus.BAD_REQUEST; + return new BadRequestException({ + ...HttpException.createBody('Invalid bundle definition', 'Bad Request', status), + errors: [...error.errors], + }); +} + /** Translation codes that mean the server's translation setup is wrong, not the request. */ const TRANSLATION_CONFIGURATION_CODES: ReadonlySet = new Set([ 'MISSING_API_KEY', diff --git a/apps/api/src/app/mappers/bundle.mapper.spec.ts b/apps/api/src/app/mappers/bundle.mapper.spec.ts index aacd6452..28064d8e 100644 --- a/apps/api/src/app/mappers/bundle.mapper.spec.ts +++ b/apps/api/src/app/mappers/bundle.mapper.spec.ts @@ -1,113 +1,8 @@ -import type { BundleDefinition, BundlePlan, GenerateBundleResult } from '@simoncodes-ca/core'; -import type { BundleDefinitionDto } from '@simoncodes-ca/data-transfer'; -import { - MAX_CONFLICT_KEYS, - mapBundleDefinitionToDto, - mapBundlePlanToDto, - mapDtoToBundleDefinition, - mapGenerateBundleResultToJobResult, -} from './bundle.mapper'; +import type { BundlePlan, GenerateBundleResult } from '@simoncodes-ca/core'; +import type { BundleDefinition } from '@simoncodes-ca/domain'; +import { MAX_CONFLICT_KEYS, mapBundlePlanToDto, mapGenerateBundleResultToJobResult } from './bundle.mapper'; describe('bundle.mapper', () => { - const fullDefinition: BundleDefinition = { - bundleName: 'main.{locale}', - dist: './dist/i18n', - collections: [ - { - name: 'app', - bundledKeyPrefix: 'app', - entriesSelectionRules: [{ matchingPattern: 'apps.*', matchingTags: ['ui'], matchingTagOperator: 'All' }], - mergeStrategy: 'override', - }, - ], - typeDistFile: './dist/i18n-types/main.ts', - tokenCasing: 'camelCase', - tokenConstantName: 'MAIN_KEYS', - transformICUToTransloco: false, - }; - - describe('mapBundleDefinitionToDto', () => { - it('maps every field and copies nested arrays', () => { - const dto = mapBundleDefinitionToDto(fullDefinition); - - expect(dto).toEqual(fullDefinition); - expect(dto.collections).not.toBe(fullDefinition.collections); - const [collection] = dto.collections as Exclude; - const [sourceCollection] = fullDefinition.collections as Exclude; - expect(collection.entriesSelectionRules).not.toBe(sourceCollection.entriesSelectionRules); - }); - - it("passes 'All' through and omits absent optionals", () => { - const dto = mapBundleDefinitionToDto({ bundleName: '{locale}', dist: './dist', collections: 'All' }); - - expect(dto).toEqual({ bundleName: '{locale}', dist: './dist', collections: 'All' }); - expect('typeDistFile' in dto).toBe(false); - expect('transformICUToTransloco' in dto).toBe(false); - }); - }); - - describe('mapDtoToBundleDefinition', () => { - it('trims strings and drops empty optionals', () => { - const dto: BundleDefinitionDto = { - bundleName: ' main.{locale} ', - dist: ' ./dist ', - collections: [ - { - name: ' app ', - bundledKeyPrefix: ' ', - entriesSelectionRules: [{ matchingPattern: ' apps.* ', matchingTags: [' ui ', ''] }], - }, - ], - typeDistFile: '', - tokenConstantName: ' ', - }; - - const definition = mapDtoToBundleDefinition(dto); - - expect(definition).toEqual({ - bundleName: 'main.{locale}', - dist: './dist', - collections: [{ name: 'app', entriesSelectionRules: [{ matchingPattern: 'apps.*', matchingTags: ['ui'] }] }], - }); - expect('typeDistFile' in definition).toBe(false); - expect('tokenConstantName' in definition).toBe(false); - }); - - it('keeps explicit false for transformICUToTransloco and passes All through', () => { - const definition = mapDtoToBundleDefinition({ - bundleName: '{locale}', - dist: './dist', - collections: 'All', - transformICUToTransloco: false, - tokenCasing: 'upperCase', - }); - - expect(definition.transformICUToTransloco).toBe(false); - expect(definition.tokenCasing).toBe('upperCase'); - expect(definition.collections).toBe('All'); - }); - - it('does not alias arrays from the DTO', () => { - const tags = ['ui']; - const dto: BundleDefinitionDto = { - bundleName: '{locale}', - dist: './dist', - collections: [{ name: 'app', entriesSelectionRules: [{ matchingPattern: '*', matchingTags: tags }] }], - }; - - const definition = mapDtoToBundleDefinition(dto); - const [collection] = definition.collections as Exclude; - const [rule] = collection.entriesSelectionRules as Exclude; - - expect(rule.matchingTags).toEqual(['ui']); - expect(rule.matchingTags).not.toBe(tags); - }); - - it('round-trips a full definition', () => { - expect(mapDtoToBundleDefinition(mapBundleDefinitionToDto(fullDefinition))).toEqual(fullDefinition); - }); - }); - describe('mapBundlePlanToDto', () => { const plan: BundlePlan = { bundleKey: 'main', diff --git a/apps/api/src/app/mappers/bundle.mapper.ts b/apps/api/src/app/mappers/bundle.mapper.ts index 0c948358..26c8b539 100644 --- a/apps/api/src/app/mappers/bundle.mapper.ts +++ b/apps/api/src/app/mappers/bundle.mapper.ts @@ -1,135 +1,11 @@ import * as path from 'node:path'; -import type { - BundleDefinition, - BundlePlan, - CollectionBundleDefinition, - EntrySelectionRule, - GenerateBundleResult, -} from '@simoncodes-ca/core'; -import { getBundleOutputPath } from '@simoncodes-ca/core'; -import type { - BundleDefinitionDto, - BundleDryRunResultDto, - BundleGenerateJobResultDto, - CollectionBundleDefinitionDto, - EntrySelectionRuleDto, -} from '@simoncodes-ca/data-transfer'; +import type { BundlePlan, GenerateBundleResult } from '@simoncodes-ca/core'; +import type { BundleDryRunResultDto, BundleGenerateJobResultDto } from '@simoncodes-ca/data-transfer'; +import { type BundleDefinition, bundleOutputFile } from '@simoncodes-ca/domain'; /** Upper bound on conflict keys returned by a dry run so huge bundles stay cheap to serialise. */ export const MAX_CONFLICT_KEYS = 50; -export function mapBundleDefinitionToDto(definition: BundleDefinition): BundleDefinitionDto { - const dto: BundleDefinitionDto = { - bundleName: definition.bundleName, - dist: definition.dist, - collections: - definition.collections === 'All' - ? 'All' - : definition.collections.map((collection) => mapCollectionDefinitionToDto(collection)), - }; - - if (definition.typeDistFile !== undefined) dto.typeDistFile = definition.typeDistFile; - if (definition.tokenCasing !== undefined) dto.tokenCasing = definition.tokenCasing; - if (definition.tokenConstantName !== undefined) dto.tokenConstantName = definition.tokenConstantName; - if (definition.transformICUToTransloco !== undefined) { - dto.transformICUToTransloco = definition.transformICUToTransloco; - } - - return dto; -} - -function mapCollectionDefinitionToDto(collection: CollectionBundleDefinition): CollectionBundleDefinitionDto { - const dto: CollectionBundleDefinitionDto = { - name: collection.name, - entriesSelectionRules: - collection.entriesSelectionRules === 'All' - ? 'All' - : collection.entriesSelectionRules.map((rule) => mapRuleToDto(rule)), - }; - - if (collection.bundledKeyPrefix !== undefined) dto.bundledKeyPrefix = collection.bundledKeyPrefix; - if (collection.mergeStrategy !== undefined) dto.mergeStrategy = collection.mergeStrategy; - - return dto; -} - -function mapRuleToDto(rule: EntrySelectionRule): EntrySelectionRuleDto { - const dto: EntrySelectionRuleDto = { matchingPattern: rule.matchingPattern }; - - if (rule.matchingTags !== undefined) dto.matchingTags = [...rule.matchingTags]; - if (rule.matchingTagOperator !== undefined) dto.matchingTagOperator = rule.matchingTagOperator; - - return dto; -} - -/** - * Maps an incoming DTO to a core definition. Strings are trimmed and empty or - * undefined optionals are dropped so nothing spurious lands in the config file. - * Structural validation (required fields, unknown collections, …) is left to - * `validateBundleDefinition`; this mapper only normalises what it is given. - */ -export function mapDtoToBundleDefinition(dto: BundleDefinitionDto): BundleDefinition { - const definition: BundleDefinition = { - bundleName: trimString(dto.bundleName), - dist: trimString(dto.dist), - collections: Array.isArray(dto.collections) - ? dto.collections.map((collection) => mapDtoToCollectionDefinition(collection)) - : dto.collections, - }; - - const typeDistFile = optionalString(dto.typeDistFile); - if (typeDistFile !== undefined) definition.typeDistFile = typeDistFile; - - if (dto.tokenCasing !== undefined) definition.tokenCasing = dto.tokenCasing; - - const tokenConstantName = optionalString(dto.tokenConstantName); - if (tokenConstantName !== undefined) definition.tokenConstantName = tokenConstantName; - - if (typeof dto.transformICUToTransloco === 'boolean') { - definition.transformICUToTransloco = dto.transformICUToTransloco; - } - - return definition; -} - -function mapDtoToCollectionDefinition(dto: CollectionBundleDefinitionDto): CollectionBundleDefinition { - const collection: CollectionBundleDefinition = { - name: trimString(dto.name), - entriesSelectionRules: Array.isArray(dto.entriesSelectionRules) - ? dto.entriesSelectionRules.map((rule) => mapDtoToRule(rule)) - : dto.entriesSelectionRules, - }; - - const bundledKeyPrefix = optionalString(dto.bundledKeyPrefix); - if (bundledKeyPrefix !== undefined) collection.bundledKeyPrefix = bundledKeyPrefix; - - if (dto.mergeStrategy !== undefined) collection.mergeStrategy = dto.mergeStrategy; - - return collection; -} - -function mapDtoToRule(dto: EntrySelectionRuleDto): EntrySelectionRule { - const rule: EntrySelectionRule = { matchingPattern: trimString(dto.matchingPattern) }; - - if (Array.isArray(dto.matchingTags)) { - const tags = dto.matchingTags.map((tag) => trimString(tag)).filter((tag) => tag.length > 0); - if (tags.length > 0) rule.matchingTags = tags; - } - - if (dto.matchingTagOperator !== undefined) rule.matchingTagOperator = dto.matchingTagOperator; - - return rule; -} - -function trimString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function optionalString(value: unknown): string | undefined { - const trimmed = trimString(value); - return trimmed.length > 0 ? trimmed : undefined; -} - /** Maps a dry-run plan to its DTO, dropping absolute paths and capping the conflict lists. */ export function mapBundlePlanToDto(plan: BundlePlan): BundleDryRunResultDto { return { @@ -153,8 +29,8 @@ export function mapBundlePlanToDto(plan: BundlePlan): BundleDryRunResultDto { /** * Maps a finished `generateBundle` run to the job result DTO. Core reports a - * file count; the DTO carries paths, rebuilt from the processed locales and the - * bundle definition so the UI can list what was written. Core resolves the + * file count; the DTO carries paths, rebuilt from the processed locales with the + * domain `bundleOutputFile` rule (the one core writes with) so the UI can list what was written. Core resolves the * types file to an absolute path, so every path is normalised relative to `cwd`. */ export function mapGenerateBundleResultToJobResult( @@ -163,7 +39,7 @@ export function mapGenerateBundleResultToJobResult( cwd: string = process.cwd(), ): BundleGenerateJobResultDto { const filesGenerated = result.localesProcessed.map((locale) => - toProjectRelative(getBundleOutputPath(bundleDefinition, locale), cwd), + toProjectRelative(bundleOutputFile(bundleDefinition, locale), cwd), ); const types = result.typeGenerationResult; const typeDistFile = diff --git a/apps/api/src/app/mappers/config.mapper.spec.ts b/apps/api/src/app/mappers/config.mapper.spec.ts index 514ae56f..38a662b4 100644 --- a/apps/api/src/app/mappers/config.mapper.spec.ts +++ b/apps/api/src/app/mappers/config.mapper.spec.ts @@ -80,6 +80,19 @@ describe('config.mapper', () => { }); }); + it('passes bundle definitions through as written, including unknown and deprecated fields', () => { + // GET /config reports the file as it is; `typeDist` migrates to `typeDistFile` only when + // the bundle is saved (the domain `normalizeBundleDefinition`), and the Tracker form reads + // the definition through that same function. + const legacy = { bundleName: '{locale}', dist: './dist', collections: 'All', typeDist: './src/t.ts', extra: 1 }; + const dto = mapConfigToDto({ + ...config, + bundles: { legacy: legacy as unknown as NonNullable[string] }, + }); + + expect(dto.bundles?.['legacy']).toEqual(legacy); + }); + it('omits bundles when the config has none', () => { const dto = mapConfigToDto(config); diff --git a/apps/api/src/app/mappers/config.mapper.ts b/apps/api/src/app/mappers/config.mapper.ts index 82fadde3..6c9fb24c 100644 --- a/apps/api/src/app/mappers/config.mapper.ts +++ b/apps/api/src/app/mappers/config.mapper.ts @@ -1,18 +1,15 @@ import type { - BundleDefinition, LingoTrackerCollection, LingoTrackerConfig, LoadPreferredTerminologyResult, ResolvedProtectedTerms, } from '@simoncodes-ca/core'; import type { - BundleDefinitionDto, LingoTrackerCollectionDto, LingoTrackerConfigDto, PreferredTermRuleDto, UpdateConfigDto, } from '@simoncodes-ca/data-transfer'; -import { mapBundleDefinitionToDto } from './bundle.mapper'; import { mapCollectionToDto } from './collection.mapper'; function mapConfigCollections( @@ -24,10 +21,6 @@ function mapConfigCollections( ); } -function mapConfigBundles(bundles: Record): Record { - return Object.fromEntries(Object.entries(bundles).map(([name, bundle]) => [name, mapBundleDefinitionToDto(bundle)])); -} - /** * Maps the loaded preferred-terminology file onto the config DTO fields. An empty rule * list is omitted, like an empty protected-terms list; the file path is always exposed @@ -73,7 +66,8 @@ export function mapConfigToDto( baseLocale: config.baseLocale, locales: [...config.locales], collections: mapConfigCollections(config.collections, resolved), - ...(config.bundles && { bundles: mapConfigBundles(config.bundles) }), + // The DTO is the domain Bundle Definition type, so bundles pass through unmapped. + ...(config.bundles && { bundles: { ...config.bundles } }), ...(config.tokenCasing && { tokenCasing: config.tokenCasing }), ...(config.transformICUToTransloco !== undefined && { transformICUToTransloco: config.transformICUToTransloco }), translation: config.translation, diff --git a/apps/cli/src/commands/bundle.ts b/apps/cli/src/commands/bundle.ts index 75080120..ea5dbb83 100644 --- a/apps/cli/src/commands/bundle.ts +++ b/apps/cli/src/commands/bundle.ts @@ -1,6 +1,6 @@ import type { LingoTrackerConfig } from '@simoncodes-ca/core'; -import type { TokenCasing } from '@simoncodes-ca/domain'; -import { generateBundle, hasTypeDistConfigured } from '@simoncodes-ca/core'; +import { hasTypeDistConfigured, type TokenCasing } from '@simoncodes-ca/domain'; +import { generateBundle } from '@simoncodes-ca/core'; import { type Answers, type CommandResult, defineCommand } from '../runner/command-runner'; import { ALL_ITEMS_SENTINEL, parseCommaSeparatedList, ConsoleFormatter } from '../utils'; diff --git a/apps/cli/src/init/init.ts b/apps/cli/src/init/init.ts index 2cc62e1f..e7599769 100644 --- a/apps/cli/src/init/init.ts +++ b/apps/cli/src/init/init.ts @@ -8,9 +8,8 @@ import { type LingoTrackerConfig, type LingoTrackerCollection, type TranslationConfig, - type BundleDefinition, } from '@simoncodes-ca/core'; -import type { TokenCasing } from '@simoncodes-ca/domain'; +import type { BundleDefinition, TokenCasing } from '@simoncodes-ca/domain'; import { type Answers, defineCommand, requireOptions } from '../runner/command-runner'; import { ConsoleFormatter } from '../utils'; diff --git a/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.html b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.html index 4bada5f3..62ba9685 100644 --- a/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.html +++ b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.html @@ -679,6 +679,14 @@

{{ TOKENS.BUNDLES.DIALOG.SECTIONS.OPTIONS | transloco }}

+ @if (submitErrors().length > 0) { + + } +