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/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8c761c42..ef1d6e7e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -60,5 +60,8 @@ jobs: - name: Test (affected) run: pnpm nx affected -t test --base=${{ steps.nx-base.outputs.NX_BASE }} + - name: Typecheck (affected) + run: pnpm nx affected -t typecheck --base=${{ steps.nx-base.outputs.NX_BASE }} + - name: Build (affected) run: pnpm nx affected -t build --base=${{ steps.nx-base.outputs.NX_BASE }} diff --git a/CLAUDE.md b/CLAUDE.md index 7145dca7..f610dac1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,8 +33,13 @@ pnpm run test:core pnpm run test:domain pnpm run test:tracker -# Run a single test file (using Nx) -pnpm nx test core --testFile=src/lib/resource/checksum.spec.ts +# Run a single test file (path relative to the project root) +pnpm nx test core --testFile=src/resource/checksum.spec.ts # core, domain, tracker (@nx/vitest) +pnpm nx test cli -- src/commands/move.test.ts # cli (vitest): positional path after -- +pnpm nx test api -- src/app/app.service.spec.ts # api (jest): positional path after -- + +# Typecheck (tracker, core, cli and api also typecheck their specs via typecheck-spec) +pnpm nx typecheck tracker ``` ### Development Servers @@ -63,7 +68,7 @@ pnpm nx # Direct Nx CLI access ## Architecture -### Monorepo Structure (Nx 21.5.3) +### Monorepo Structure (Nx 22.7.9) The codebase follows a **layered architecture** with three applications sharing core business logic: @@ -71,7 +76,7 @@ The codebase follows a **layered architecture** with three applications sharing apps/ ├── cli/ # Command-line interface (Node.js + Commander) ├── api/ # REST API backend (NestJS + Express) -└── tracker/ # Web UI (Angular 20 + Material) +└── tracker/ # Web UI (Angular 21 + Material) libs/ ├── domain/ # Pure business logic (NO Node.js deps — browser-safe) @@ -178,41 +183,42 @@ export class ExampleComponent implements OnInit { ## Key Implementation Patterns -### Adding a Resource (libs/core/src/lib/resource/) +### Adding a Resource (libs/core/src/resource/add-resource.ts) -1. Validate key format and resolve to folder path -2. Load existing `resource_entries.json` and `tracker_meta.json` -3. Compute checksums (MD5) for source and translations -4. Determine translation status based on checksums -5. Write updated files atomically +1. Validate the key (and optional `targetFolder`) and resolve it to a folder path +2. Normalize values to ICU and seed every target locale (supplied value, else auto-translation, else a `new` copy of the base) before touching the disk +3. Write through `openResourceFolder` (`libs/core/src/lib/resource/resource-folder.ts`), the one read-modify-write path for `resource_entries.json` + `tracker_meta.json`: it computes MD5 checksums and applies the staleness rule +4. `save()` writes both files together (not atomically) -### CLI Command Pattern (apps/cli/src/commands/) +### CLI Command Pattern (apps/cli/src/runner/command-runner.ts) ```typescript -export const commandName = new Command('command-name') - .description('Description') - .argument('', 'Arg description') - .action(async (arg) => { - // Use dynamic import for prompts - const prompts = (await import('prompts')).default; - - // Call core library functions - const result = await coreFunction(arg); - }); +export const addLocaleCommand = defineCommand()({ + name: 'Add locale', // "❌ Add locale cancelled." + collection: 'writable', // 'writable' | 'read' | 'none' + prompts: (options) => (options.locale ? [] : [{ type: 'text', name: 'locale', message: 'Locale' }]), + required: ['locale'], // exit 1 when missing; typed as present in run + run: async ({ collection, cwd, answers }) => { + const result = await addLocaleToCollection(collection.name, answers.locale, { cwd }); + ConsoleFormatter.success(result.message); + }, +}); ``` -### API Controller Pattern (apps/api/src/app/controllers/) +- The runner owns config loading, collection resolution, the interactive rule, cancellation and exit codes (`process.exitCode`, never `process.exit`). `run` returns `{ exitCode: 1 }` for a failure it has already reported, or throws. +- Diagnostics go to stderr via `ConsoleFormatter.error/warning`; the payload goes to stdout. +- Commands are registered (flags, help text, lazy import) in `apps/cli/src/main.ts`; `main.spec.ts` covers the flag wiring. + +### API Controller Pattern (apps/api/src/app//.controller.ts) ```typescript @Controller('collections') export class CollectionsController { @Post() - async create(@Body() dto: CreateCollectionDto) { - // Call core library - const result = await addCollection(dto.name, dto.config); - - // Map to DTO if needed - return mapper.toDto(result); + async createCollection(@Body() body: CreateCollectionDto): Promise<{ message: string }> { + const mapped = mapDtoToCollection(body.collection); // DTO → core shape (apps/api/src/app/mappers/) + const result = addCollection(body.name, mapped); // synchronous core call + return { message: result.message }; } } ``` @@ -222,7 +228,7 @@ export class CollectionsController { - **Package Manager**: pnpm 10+ required (enforced by engines) - **Node Version**: >=22.16.0 - **Commits**: Use `pnpm run commit` for conventional commits with commitizen -- **Testing**: Vitest for unit tests, use `--testFile` flag to run single test +- **Testing**: Vitest for unit tests (the API uses Jest); see [Testing](#testing) for running a single file - **API Port**: Default 3030, configurable via `LINGO_TRACKER_PORT` env var - **CORS**: Enabled with wildcard origin in development mode diff --git a/apps/api/project.json b/apps/api/project.json index 6deed354..633e4f9c 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -59,6 +59,18 @@ } } }, + "typecheck": { + "dependsOn": ["^typecheck", "typecheck-spec"] + }, + "typecheck-spec": { + "executor": "nx:run-commands", + "cache": true, + "inputs": ["default", "^default", "{workspaceRoot}/tsconfig.base.json", "{workspaceRoot}/types/**/*"], + "options": { + "cwd": "apps/api", + "command": "tsc --noEmit -p tsconfig.spec.json" + } + }, "test": { "options": { "passWithNoTests": true diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4dd60779..15241927 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,15 +1,17 @@ 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'; 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'; 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, CollectionCacheService, 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/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..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 { @@ -93,6 +94,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/api/src/app/bundles/bundles.controller.spec.ts b/apps/api/src/app/bundles/bundles.controller.spec.ts index 60198bde..77d17b50 100644 --- a/apps/api/src/app/bundles/bundles.controller.spec.ts +++ b/apps/api/src/app/bundles/bundles.controller.spec.ts @@ -1,21 +1,21 @@ -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'; 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(), planBundle: jest.fn(), - validateBundleKey: jest.fn(() => []), - validateBundleDefinition: jest.fn(() => []), - getBundleOutputPath: jest.fn((definition: BundleDefinition, locale: string) => `${definition.dist}/${locale}.json`), })); const existingDefinition: BundleDefinition = { @@ -60,16 +60,19 @@ const plan: BundlePlan = { warnings: [], }; -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) { - if (error instanceof HttpException) return error.getStatus(); - throw error; + const http = toHttpException(error); + return { status: http.getStatus(), body: http.getResponse() }; } - throw new Error('expected an HttpException'); + 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 })); @@ -98,70 +101,82 @@ 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('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('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 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.'], - }); - } + }, + }); + }); + + 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 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 409 when core reports the bundle already exists', () => { + (core.addBundleDefinition as jest.Mock).mockImplementation(() => { + throw new core.BundleAlreadyExistsError('main'); + }); + + expect(() => controller.createBundle({ name: 'main', bundle: requestDefinition })).toThrow( + core.BundleAlreadyExistsError, + ); + expect(statusOf(() => controller.createBundle({ name: 'main', bundle: requestDefinition }))).toBe( + HttpStatus.CONFLICT, ); }); - it('returns 409 when core reports the bundle already exists', () => { + it('returns 400 for a failure core does not type', () => { (core.addBundleDefinition as jest.Mock).mockImplementation(() => { - throw new Error('Bundle "main" already exists'); + throw new Error('Failed to write configuration file'); }); - expect(() => controller.createBundle({ name: 'main', bundle: requestDefinition })).toThrow(ConflictException); + expect(statusOf(() => controller.createBundle({ name: 'main', bundle: requestDefinition }))).toBe( + HttpStatus.BAD_REQUEST, + ); }); }); 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', () => { @@ -170,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.'] }, + }); }); }); @@ -214,25 +248,25 @@ 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 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); }); }); 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', @@ -255,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(); }); @@ -309,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 08a54170..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,14 +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, @@ -30,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; @@ -52,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({ @@ -66,7 +71,7 @@ export class BundlesController { return mapBundlePlanToDto(plan); } catch (error: unknown) { - throw this.#toHttpException(error, 'Error planning bundle'); + this.#rethrow(error, 'Error planning bundle'); } } @@ -84,50 +89,32 @@ 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) { - throw this.#toHttpException(error, 'Error creating bundle'); + this.#rethrow(error, 'Error creating bundle'); } } @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) { - throw this.#toHttpException(error, 'Error updating bundle'); + this.#rethrow(error, 'Error updating bundle'); } } @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) { - throw this.#toHttpException(error, 'Error deleting bundle'); + this.#rethrow(error, 'Error deleting bundle'); } } @@ -136,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({ @@ -150,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) { @@ -204,31 +161,46 @@ export class BundlesController { return [...locales]; } - #toHttpException(error: unknown, fallback: string): HttpException { - if (error instanceof HttpException) { - return error; - } - - const message = error instanceof Error ? error.message : fallback; - - if (message.includes('not found')) { - return new NotFoundException(message); + /** + * 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; } + throw new HttpException(error instanceof Error ? error.message : fallback, HttpStatus.BAD_REQUEST); + } +} - if (message.includes('already exists')) { - return new ConflictException(message); - } +/** 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() : ''; +} - return new HttpException(message, HttpStatus.BAD_REQUEST); +function requireDefinition(dto: BundleDefinitionDto | undefined): BundleDefinition { + if (!dto || typeof dto !== 'object') { + throw new InvalidBundleDefinitionError(['bundle definition is required.']); } + return dto; } -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 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 name.trim(); + + return definition; } 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..7d9fba4a --- /dev/null +++ b/apps/api/src/app/cache/collection-index.service.spec.ts @@ -0,0 +1,368 @@ +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, { cwd: root }); + } + + /** 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, + baseLocale: target.baseLocale, + 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', { cwd: root }); + + 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', { limit: 10 }).map((result) => result.key)).toEqual([ + 'common.cancel', + ]); + expect(index.tree(collection())).toEqual({ status: 'not-started' }); + + 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(); + }); + }); + + describe('applying core mutations', () => { + beforeEach(() => { + readyTree(); + }); + + it('adds a resource, creating its folders', async () => { + const result = await addResource(collection(), { + 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(), '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(), { 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(), { 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', { cwd: root }); + readyTree(other); + + const result = await moveResource(collection(), { + source: 'common.ok', + destination: 'imported.ok', + destinationCollection: other, + }); + 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(), { folderName: 'empty', parentPath: 'apps' }).mutations); + expect(readyTree(collection(), 'apps.empty')).not.toBeNull(); + + const moved = await moveFolder(collection(), { + 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(), { 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(collection('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(), { 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(), { 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, { cwd: root }); + }); + + 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..2c5f47d2 --- /dev/null +++ b/apps/api/src/app/cache/collection-index.service.ts @@ -0,0 +1,394 @@ +import { resolve } from 'node:path'; +import { Injectable, Logger } from '@nestjs/common'; +import { + type Collection, + computeTreeFingerprint, + extractSubtree, + loadResourceTree, + readCollection, + type ResourceMutation, + type ResourceTreeNode, + type SearchableResource, + type SearchOptions, + type SearchResult, + searchResources, + type TreeFingerprint, + treeFingerprintsMatch, + treeResources, +} 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) }; + } + + /** + * 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); + return searchResources(this.#searchSource(collection, entry), collection, query, 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. */ + 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, + baseLocale: collection.baseLocale, + 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 ebea7769..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,9 +1,16 @@ +import { resolve } from 'node:path'; +import { ForbiddenException, HttpException, NotFoundException } from '@nestjs/common'; import { Test, type TestingModule } from '@nestjs/testing'; -import { HttpException, NotFoundException } from '@nestjs/common'; -import { FoldersController } from './folders.controller'; -import { ConfigService } from '../../config/config.service'; -import { CollectionCacheService } from '../../cache/collection-cache.service'; 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 { 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', () => { @@ -20,7 +27,6 @@ describe('FoldersController', () => { let foldersModule: TestingModule; let foldersController: FoldersController; let _configService: ConfigService; - let cacheService: CollectionCacheService; const mockConfig = { exportFolder: 'dist/lingo-export', @@ -41,12 +47,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({ @@ -59,15 +60,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(); @@ -91,18 +91,18 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith('./translations/test', { - sourceFolderPath: 'apps.common.buttons', - destinationFolderPath: 'apps.shared', - override: undefined, - nestUnderDestination: undefined, - destinationTranslationsFolder: undefined, - }); - - expect(cacheService.moveFolderInCache).toHaveBeenCalledWith( - 'test-collection', - 'apps.common.buttons', - 'apps.shared', + 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({ @@ -131,13 +131,19 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith('./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); @@ -161,13 +167,22 @@ describe('FoldersController', () => { const result = await foldersController.move('test-collection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith('./translations/test', { - sourceFolderPath: 'apps.buttons', - destinationFolderPath: 'shared.buttons', - override: undefined, - nestUnderDestination: undefined, - destinationTranslationsFolder: './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); }); @@ -195,57 +210,56 @@ describe('FoldersController', () => { expect(core.moveFolder).not.toHaveBeenCalled(); }); - it('should throw HttpException for validation errors (missing fields)', async () => { + 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: '', + sourceFolderPath: 'apps.buttons', destinationFolderPath: 'apps.actions', + toCollection: 'vendor', }; - await expect(foldersController.move('test-collection', moveFolderDto)).rejects.toThrow(HttpException); + 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 circular dependency errors', async () => { + it('should throw HttpException for validation errors (missing fields)', 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'], + sourceFolderPath: '', + destinationFolderPath: 'apps.actions', }; - (core.moveFolder as jest.Mock).mockResolvedValue(mockMoveResult); - await expect(foldersController.move('test-collection', moveFolderDto)).rejects.toThrow(HttpException); - expect(core.moveFolder).toHaveBeenCalled(); - expect(cacheService.clearCache).not.toHaveBeenCalled(); + expect(core.moveFolder).not.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 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', @@ -262,7 +276,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); @@ -308,7 +321,10 @@ describe('FoldersController', () => { await foldersController.move('test%2Dcollection', moveFolderDto); - expect(core.moveFolder).toHaveBeenCalledWith('./translations/test', expect.any(Object)); + expect(core.moveFolder).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), + expect.any(Object), + ); }); }); @@ -328,16 +344,31 @@ describe('FoldersController', () => { const result = await foldersController.create('test-collection', createFolderDto); - expect(core.createFolder).toHaveBeenCalledWith('./translations/test', { - folderName: 'buttons', - parentPath: 'apps.common', - }); - - expect(cacheService.addFolderToCache).toHaveBeenCalledWith('test-collection', 'buttons', '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'); }); + + 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', () => { @@ -348,22 +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('./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(cacheService.removeFolderFromCache).toHaveBeenCalledWith('test-collection', 'apps.common.buttons'); + const error = await httpErrorOf(foldersController.delete('test-collection', { folderPath: 'apps.missing' })); - expect(result.deleted).toBe(true); - expect(result.resourcesDeleted).toBe(5); + 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 4820e6c0..2cd01fb1 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, @@ -20,15 +10,16 @@ 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'; @UseGuards(WritableCollectionGuard) @Controller('collections/:collectionName/folders') export class FoldersController { constructor( private readonly configService: ConfigService, - private readonly cacheService: CollectionCacheService, + private readonly index: CollectionIndex, ) {} @Post() @@ -36,226 +27,101 @@ export class FoldersController { @Param('collectionName') collectionName: string, @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 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, - ); - } - - // 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 collection = openRouteCollection(this.configService.getConfig(), collectionName); + + const result = createFolder(collection, { + 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, + }; } + /** 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 { - 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 result = deleteFolder(translationsFolder, { - folderPath: deleteFolderDto.folderPath, - }); - - // Update cache incrementally after successful folder deletion - if (result.deleted) { - this.cacheService.removeFolderFromCache(decodedCollectionName, deleteFolderDto.folderPath); - } + const collection = openRouteCollection(this.configService.getConfig(), collectionName); - return { - deleted: result.deleted, - folderPath: result.folderPath, - resourcesDeleted: result.resourcesDeleted, - error: result.error, - }; - } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } + const result = deleteFolder(collection, { + folderPath: deleteFolderDto.folderPath, + }); - if (error instanceof HttpException) { - throw error; - } + this.index.apply(result.mutations); - // 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); - } + return { + deleted: true, + folderPath: result.folderPath, + resourcesDeleted: result.resourcesDeleted, + }; } + /** + * 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 { - 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; - - 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 - 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; - } - - // Perform the move - const result = await moveFolder(translationsFolder, { - sourceFolderPath: moveFolderDto.sourceFolderPath, - destinationFolderPath: moveFolderDto.destinationFolderPath, - override: moveFolderDto.override, - nestUnderDestination: moveFolderDto.nestUnderDestination, - 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); - } - } - } - - // 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 collection = 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); } + + const destinationCollection = moveFolderDto.toCollection + ? openDestinationCollection(config, moveFolderDto.toCollection) + : undefined; + + const result = await moveFolder(collection, { + sourceFolderPath: moveFolderDto.sourceFolderPath, + destinationFolderPath: moveFolderDto.destinationFolderPath, + override: moveFolderDto.override, + nestUnderDestination: moveFolderDto.nestUnderDestination, + destinationCollection, + }); + + this.index.apply(result.mutations); + + return { + movedCount: result.movedCount, + foldersDeleted: result.foldersDeleted, + warnings: result.warnings, + errors: result.errors, + }; } } 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..72c038c2 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,8 @@ 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 { toHttpException } from '../../errors/lingo-tracker-exception.filter'; import * as core from '@simoncodes-ca/core'; jest.mock('@simoncodes-ca/core', () => { @@ -17,7 +18,6 @@ jest.mock('@simoncodes-ca/core', () => { describe('LocalesController', () => { let localesModule: TestingModule; let localesController: LocalesController; - let cacheService: CollectionCacheService; const mockConfig = { baseLocale: 'en', @@ -31,9 +31,7 @@ describe('LocalesController', () => { }, }; - const mockCacheService = { - clearCache: jest.fn(), - }; + const mockIndex = { apply: jest.fn() }; beforeEach(async () => { localesModule = await Test.createTestingModule({ @@ -46,14 +44,13 @@ describe('LocalesController', () => { }, }, { - provide: CollectionCacheService, - useValue: mockCacheService, + provide: CollectionIndex, + useValue: mockIndex, }, ], }).compile(); localesController = localesModule.get(LocalesController); - cacheService = localesModule.get(CollectionCacheService); jest.clearAllMocks(); }); @@ -65,25 +62,27 @@ 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); }); 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); }); @@ -95,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); }); @@ -119,17 +116,23 @@ 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); }); - it('does not clear cache when collection lookup fails before core is called', async () => { + 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(toHttpException); + + expect((error as HttpException).getStatus()).toBe(403); + }); + + 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(); }); }); @@ -140,12 +143,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); }); @@ -155,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); }); @@ -184,15 +189,25 @@ 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); }); - it('does not clear cache when collection lookup fails before core is called', async () => { + 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(toHttpException); + + expect((error as HttpException).getStatus()).toBe(403); + }); + + 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 3e013046..05e99d62 100644 --- a/apps/api/src/app/collections/locales/locales.controller.ts +++ b/apps/api/src/app/collections/locales/locales.controller.ts @@ -1,29 +1,24 @@ -import { - Controller, - Post, - Delete, - Param, - Body, - HttpException, - HttpStatus, - NotFoundException, - UseGuards, -} from '@nestjs/common'; +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 { 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'; +/** + * 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 { 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() @@ -31,39 +26,12 @@ export class LocalesController { @Param('collectionName') collectionName: string, @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 { mutations, ...response } = await addLocaleToCollection(name, body.locale); + this.#index.apply(mutations); - const result = await addLocaleToCollection(collectionName, body.locale); - - this.#cacheService.clearCache(collectionName); - - return result; - } catch (error: unknown) { - if (error instanceof NotFoundException || error instanceof HttpException) { - throw error; - } - - 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') @@ -71,38 +39,11 @@ export class LocalesController { @Param('collectionName') collectionName: string, @Param('locale') locale: string, ): Promise { - try { - const config = this.#configService.getConfig(); - - if (!config.collections || !config.collections[collectionName]) { - throw new NotFoundException(`Collection "${collectionName}" not found`); - } - - const result = await removeLocaleFromCollection(collectionName, locale); - - this.#cacheService.clearCache(collectionName); - - return result; - } catch (error: unknown) { - if (error instanceof NotFoundException || error instanceof HttpException) { - throw error; - } - - 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/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..604f5313 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -1,13 +1,22 @@ -import { Test, type TestingModule } from '@nestjs/testing'; +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 { TranslationStatus, LocaleMetadata } from '@simoncodes-ca/domain'; -import type { ResourceTreeDto } from '@simoncodes-ca/data-transfer'; -import { ResourcesController } from './resources.controller'; +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'; -import { CollectionCacheService, CacheStatus } from '../../cache/collection-cache.service'; +import { toHttpException } from '../../errors/lingo-tracker-exception.filter'; import { TranslationJobService } from '../../translation-job/translation-job.service'; -import * as core from '@simoncodes-ca/core'; +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 => + promise.then(() => { + throw new Error('expected the handler to reject'); + }, toHttpException); // Mock the core module jest.mock('@simoncodes-ca/core', () => { @@ -20,80 +29,14 @@ 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(), }; }); -// 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) => ({ - 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; let configService: ConfigService; - let cacheService: CollectionCacheService; const mockConfig = { exportFolder: 'dist/lingo-export', @@ -109,16 +52,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 () => { @@ -132,8 +70,8 @@ describe('ResourcesController', () => { }, }, { - provide: CollectionCacheService, - useValue: mockCacheService, + provide: CollectionIndex, + useValue: mockIndex, }, { provide: TranslationJobService, @@ -147,7 +85,6 @@ describe('ResourcesController', () => { resourcesController = resourcesModule.get(ResourcesController); configService = resourcesModule.get(ConfigService); - cacheService = resourcesModule.get(CollectionCacheService); }); afterEach(() => { @@ -174,12 +111,19 @@ describe('ResourcesController', () => { created: true, }); expect(addResource).toHaveBeenCalledWith( - './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, + }, ); }); @@ -249,62 +193,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( - './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( - './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({ @@ -329,7 +217,13 @@ describe('ResourcesController', () => { await resourcesController.createResources('My%20Collection', dto); - expect(addResource).toHaveBeenCalledWith('./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 () => { @@ -351,10 +245,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 = { @@ -362,20 +257,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 = { @@ -383,16 +277,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'); @@ -403,13 +304,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 () => { @@ -434,7 +335,11 @@ describe('ResourcesController', () => { created: true, }); expect(addResource).toHaveBeenCalledWith( - './translations/test', + expect.objectContaining({ + name: 'test-collection', + translationsFolder: resolve('./translations/test'), + baseLocale: 'en', + }), expect.objectContaining({ key: 'cancel', baseValue: 'Cancel', @@ -472,7 +377,7 @@ describe('ResourcesController', () => { await resourcesController.createResources('test-collection', dto); expect(addResource).toHaveBeenCalledWith( - './translations/test', + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), expect.objectContaining({ translations: [ { locale: 'fr-ca', value: "D'accord", status: 'translated' }, @@ -481,124 +386,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( - './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( - './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( - './translations/test', - expect.objectContaining({ - translations: undefined, - }), - ); - }); }); describe('delete', () => { @@ -619,9 +406,10 @@ describe('ResourcesController', () => { entriesDeleted: 1, errors: undefined, }); - expect(deleteResource).toHaveBeenCalledWith('./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 () => { @@ -641,9 +429,10 @@ describe('ResourcesController', () => { entriesDeleted: 3, errors: undefined, }); - expect(deleteResource).toHaveBeenCalledWith('./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 () => { @@ -696,7 +485,10 @@ describe('ResourcesController', () => { await resourcesController.delete('My%20Collection', dto); - expect(deleteResource).toHaveBeenCalledWith('./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 () => { @@ -740,7 +532,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'); @@ -750,13 +542,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 () => { @@ -776,9 +563,10 @@ describe('ResourcesController', () => { entriesDeleted: 1, errors: undefined, }); - expect(deleteResource).toHaveBeenCalledWith('./translations/test', { - keys: ['apps.common.buttons.ok'], - }); + expect(deleteResource).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), + { keys: ['apps.common.buttons.ok'] }, + ); }); }); @@ -803,7 +591,7 @@ describe('ResourcesController', () => { errors: [], }); expect(moveResource).toHaveBeenCalledWith( - './translations/test', + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), expect.objectContaining({ source: 'app.button.ok', destination: 'app.actions.ok', @@ -832,7 +620,7 @@ describe('ResourcesController', () => { await resourcesController.move('test-collection', dto); expect(moveResource).toHaveBeenCalledWith( - './translations/test', + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), expect.objectContaining({ source: 'app.button.ok', destination: 'app.actions.ok', @@ -907,11 +695,14 @@ describe('ResourcesController', () => { expect(result.movedCount).toBe(1); expect(moveResource).toHaveBeenCalledWith( - './translations/test', + expect.objectContaining({ name: 'test-collection', translationsFolder: resolve('./translations/test') }), expect.objectContaining({ source: 'app.button.ok', destination: 'app.actions.ok', - destinationTranslationsFolder: './translations/other', + destinationCollection: expect.objectContaining({ + name: 'other-collection', + translationsFolder: resolve('./translations/other'), + }), }), ); }); @@ -934,6 +725,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,15 +768,67 @@ describe('ResourcesController', () => { message: undefined, }); expect(editResource).toHaveBeenCalledWith( - './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 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 }); + + 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 () => { const editResource = core.editResource as jest.Mock; editResource.mockReturnValue({ @@ -988,36 +851,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); }); }); @@ -1038,36 +900,62 @@ 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).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(); }); - 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(tree.resources.map((r) => [r.fullKey, r.folderPath, r.entryKey])).toEqual([ + ['apps.title', 'apps', 'title'], + ]); + 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, { - key: 'save', + key: 'dialog.save', source: 'Save', translations: { es: 'Guardar' }, metadata: { @@ -1077,325 +965,207 @@ 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']); + expect(tree.resources.map((r) => r.fullKey)).toEqual(['title', 'dialog.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(), - }; + 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' } } }, + ]); - await resourcesController.getTree('test-collection', '', mockResponse as any); + const tree = (await resourcesController.getTree( + 'test-collection', + 'apps', + 'true', + mockResponse() as unknown as Response, + )) as ResourceTreeDto; - expect(indexCollection).toHaveBeenCalledWith('test-collection', './translations/test', 3); - expect(mockResponse.status).toHaveBeenCalledWith(202); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'not-ready', - message: expect.stringContaining('indexing started'), - }), - ); + expect(tree.resources.map((r) => [r.fullKey, r.folderPath, r.entryKey])).toEqual([ + ['apps.title', 'apps', 'title'], + ['apps.dialog.save', 'apps.dialog', 'save'], + ]); }); - 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; + it('should return 404 when the path is not in the tree', async () => { + mockIndex.tree.mockReturnValue({ status: 'ready', tree: null }); - getCacheStatus.mockReturnValue(CacheStatus.ERROR); - 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', './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 a generic 500 when reading the index throws', async () => { + mockIndex.tree.mockImplementationOnce(() => { + throw new Error('boom'); + }); - await expect(resourcesController.getTree('test-collection', '')).rejects.toThrow(HttpException); + const error = await httpErrorOf( + resourcesController.getTree('test-collection', '', undefined, mockResponse() as any), + ); + expect(error.getStatus()).toBe(500); + expect(error.message).toBe('Internal server error'); }); }); 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 }); + it('should return the index status', async () => { + const status = { status: 'ready', collectionName: 'test-collection', stats: { totalKeys: 42, localeCount: 3 } }; + mockIndex.status.mockReturnValue(status); - 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'); - - expect(result).toEqual({ - status: 'not-started', - collectionName: 'test-collection', - }); - expect(indexCollection).toHaveBeenCalledWith('test-collection', './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(); + 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', { + mode: 'text', + limit: 101, + }); }); - 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' }); + 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(); + }); - expect(searchTranslations).toHaveBeenCalledWith( - expect.objectContaining({ - translationsFolder: './translations/test', - query: 'lingo', - }), + 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: {} })), ); + + const result = await resourcesController.search('test-collection', { query: 'test', maxResults: 1000 }); + + expect(mockIndex.search).toHaveBeenCalledWith(expect.anything(), 'test', { mode: 'text', limit: 501 }); + expect(result.limited).toBe(true); + expect(result.results).toHaveLength(500); }); - it('should return empty results for empty query', async () => { - const result = await resourcesController.search('test-collection', { query: '' }); - expect(result.results).toEqual([]); + 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('should cap maxResults at 500', async () => { - const searchResourceTree = core.searchResourceTree as jest.Mock; - searchResourceTree.mockReturnValue([]); - (cacheService.getCache as jest.Mock).mockReturnValue({ resources: [], children: [] }); + 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', { query: 'test', maxResults: 1000 }); + await resourcesController.search('test-collection', dto); - expect(searchResourceTree).toHaveBeenCalledWith( - expect.objectContaining({ - maxResults: 501, // 500 + 1 for limited detection - }), - ); + 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 }); }); }); @@ -1421,17 +1191,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 () => { @@ -1446,25 +1213,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 () => { @@ -1481,27 +1250,12 @@ 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 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, @@ -1513,24 +1267,28 @@ describe('ResourcesController', () => { expect(translateExistingResource).toHaveBeenCalledWith( expect.objectContaining({ - translationConfig: collectionTranslationConfig, + name: 'test-collection', + translationsFolder: resolve('./translations/test'), }), + 'buttons.save', ); }); - 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 () => { @@ -1584,6 +1342,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 84034cf6..574ed181 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -9,28 +9,22 @@ import { Body, HttpException, HttpStatus, + ForbiddenException, NotFoundException, Res, - Logger, UseGuards, } from '@nestjs/common'; import type { Response } from 'express'; import { addResource, - createDefaultTranslations, deleteResource, moveResource, editResource, translateExistingResource, - TranslationError, - searchTranslations, - searchResourceTree, - extractSubtree, extractResourcesRecursively, - createResourceMetadata, - type SearchResult, - type ResourceTreeEntry, + type Collection, } from '@simoncodes-ca/core'; +import { buildResourceSummary } from '@simoncodes-ca/domain'; import type { CreateResourceDto, CreateResourceResponseDto, @@ -52,28 +46,23 @@ 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 { 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'; @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; } @@ -82,64 +71,16 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Body() dto: TranslateResourceDto, ): Promise { - try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + const result = await translateExistingResource(collection, dto.key); - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } - - const collection = config.collections[decodedCollectionName]; - const translationConfig = collection.translation ?? config.translation; - - 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, - translationConfig, - allLocales, - baseLocale, - cwd: process.cwd(), - }); - - this.#cacheService.addResourceToCache( - decodedCollectionName, - result.entry, - dto.key.split('.').slice(0, -1).join('.'), - ); - - 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; - } - - if (error instanceof TranslationError) { - throw new HttpException(`Translation provider error: ${error.message}`, HttpStatus.BAD_GATEWAY); - } + this.#index.apply(result.mutations); - const errorMessage = error instanceof Error ? error.message : 'Error translating resource'; - - if (errorMessage.includes('Resource not found')) { - throw new NotFoundException(errorMessage); - } - - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + return { + resource: buildResourceSummary(dto.key, result.entry, collection), + skippedLocales: result.skippedLocales, + translatedCount: result.translatedCount, + }; } @Post() @@ -147,129 +88,46 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Body() body: CreateResourceDto | CreateResourceDto[], ): Promise { - try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } + // Normalize to array + const resources = Array.isArray(body) ? body : [body]; - 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; + 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; + const allSkippedLocales: string[] = []; + + for (const resource of resources) { + const result = await addResource(collection, { + key: resource.key, + baseValue: resource.baseValue, + comment: resource.comment, + tags: resource.tags, + targetFolder: resource.targetFolder, + translations: resource.translations, + }); - if (resources.length === 0) { - throw new HttpException('At least one resource is required', HttpStatus.BAD_REQUEST); + if (result.created) { + entriesCreated++; } - let entriesCreated = 0; - let hasCreated = false; - const allSkippedLocales: string[] = []; - - 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); - } - - // 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); - } 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; - } + if (result.skippedLocales?.length) { + allSkippedLocales.push(...result.skippedLocales); } - const uniqueSkippedLocales = [...new Set(allSkippedLocales)]; - - return { - entriesCreated, - created: hasCreated, - ...(uniqueSkippedLocales.length > 0 && { skippedLocales: uniqueSkippedLocales }), - }; - } catch (error: unknown) { - if (error instanceof HttpException) { - throw error; - } + this.#index.apply(result.mutations); + } - if (error instanceof NotFoundException) { - throw error; - } + const uniqueSkippedLocales = [...new Set(allSkippedLocales)]; - // 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); - } + return { + entriesCreated, + created: entriesCreated > 0, + ...(uniqueSkippedLocales.length > 0 && { skippedLocales: uniqueSkippedLocales }), + }; } @Delete() @@ -277,47 +135,19 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @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`); - } - - 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 collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - const result = deleteResource(translationsFolder, { keys: dto.keys }); - - // Clear cache after successful resource deletion - if (result.entriesDeleted > 0) { - this.#cacheService.clearCache(decodedCollectionName); - } - - 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(collection, { 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') @@ -325,82 +155,53 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @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 result: MoveResourceResponseDto = { - movedCount: 0, - warnings: [], - errors: [], - }; + const config = this.#configService.getConfig(); + const collection = 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: [], + }; - // 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; - - 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 - result.errors = result.errors || []; - result.errors.push(`Destination collection "${destCollectionName}" not found`); - continue; - } - destinationTranslationsFolder = config.collections[destCollectionName].translationsFolder; - affectedCollections.add(destCollectionName); - } + 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, - }); + for (const moveOp of dto.moves) { + let destinationCollection: Collection | undefined; - result.movedCount += moveResult.movedCount; - if (moveResult.warnings && result.warnings) { - result.warnings.push(...moveResult.warnings); - } - if (moveResult.errors && result.errors) { - result.errors.push(...moveResult.errors); + if (moveOp.toCollection) { + try { + 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 + // for this move op and continue. + result.errors = result.errors || []; + result.errors.push(error.message); + continue; } } - // Clear cache after successful resource move - if (result.movedCount > 0) { - for (const affected of affectedCollections) { - this.#cacheService.clearCache(affected); - } - } + const moveResult = await moveResource(collection, { + source: moveOp.source, + destination: moveOp.destination, + override: moveOp.override, + destinationCollection, + }); + this.#index.apply(moveResult.mutations); - return result; - } catch (error: unknown) { - if (error instanceof NotFoundException || error instanceof HttpException) { - throw 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); } - - const errorMessage = error instanceof Error ? error.message : 'Error moving resources'; - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); } + + return result; } @Patch() @@ -408,260 +209,74 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @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 = 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, { - ...dto, - baseLocale, - translationConfig, - allLocales, - }); - - 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); - } - - 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); - } - - if (errorMessage.includes('Invalid')) { - throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); - } + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); + + const result = await editResource(collection, dto.key, { + baseValue: dto.baseValue, + comment: dto.comment, + tags: dto.tags, + translations: dto.locales, + moveTo: dto.moveTo, + }); - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + this.#index.apply(result.mutations); + const resourceDto: ResourceSummaryDto | undefined = + result.updated && result.entry ? buildResourceSummary(result.resolvedKey, result.entry, collection) : undefined; + + return { + resolvedKey: result.resolvedKey, + updated: result.updated, + message: result.message, + resource: resourceDto, + skippedLocales: result.skippedLocales, + }; } @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 { - 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 - // 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 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; - - // 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) - const locales = collection.locales ?? config.locales ?? []; - this.#cacheService.indexCollection(decodedCollectionName, translationsFolder, 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); - } - - // 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); - - 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) => - mapResourceEntryToSummary(res, 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 (responseObj) { - responseObj.status(HttpStatus.OK).json(treeDto); - return treeDto; - } - return treeDto; - } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } + if (!read.tree) { + throw new NotFoundException(`Path "${path}" not found in collection tree`); + } - 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); - const errorMessage = error instanceof Error ? error.message : 'Error loading resource tree'; - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); + 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, treeDto.path, collection), + ); } + + return treeDto; } @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 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); - }); - } - - // 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; - } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } - - 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') @@ -669,77 +284,39 @@ export class ResourcesController { @Param('collectionName') collectionName: string, @Query() dto: SearchTranslationsDto, ): Promise { - try { - const decodedCollectionName = decodeURIComponent(collectionName); - const config = this.#configService.getConfig(); + const collection = openRouteCollection(this.#configService.getConfig(), collectionName); - if (!config.collections || !config.collections[decodedCollectionName]) { - throw new NotFoundException(`Collection "${decodedCollectionName}" not found`); - } + // Validate query + if (!dto.query || dto.query.trim().length === 0) { + return { + query: dto.query || '', + results: [], + totalFound: 0, + limited: false, + }; + } - const collection = config.collections[decodedCollectionName]; - const translationsFolder = collection.translationsFolder; - const baseLocale = collection.baseLocale || config.baseLocale || 'en'; - - // Validate query - if (!dto.query || dto.query.trim().length === 0) { - return { - query: dto.query || '', - results: [], - totalFound: 0, - limited: false, - }; - } + // 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; - // 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, - }); - } + // Anything but `similar` is a text search, as a bad maxResults falls back to the default. + const mode = dto.mode === 'similar' ? 'similar-value' : 'text'; - // Check if results were limited - const limited = searchResults.length > maxResults; - const coreResults = limited ? searchResults.slice(0, maxResults) : searchResults; - const results = mapSearchResultsToDto(coreResults, collection.tags); + // Request one extra result to detect whether the results were limited. + const searchResults = this.#index.search(collection, dto.query, { mode, limit: maxResults + 1 }); - return { - query: dto.query, - results, - totalFound: limited ? maxResults : results.length, - limited, - }; - } catch (error: unknown) { - if (error instanceof NotFoundException) { - throw error; - } + // Check if results were limited + const limited = searchResults.length > maxResults; + const coreResults = limited ? searchResults.slice(0, maxResults) : searchResults; + const results = mapSearchResultsToDto(coreResults, collection); - if (error instanceof HttpException) { - throw error; - } - - const errorMessage = error instanceof Error ? error.message : 'Error searching translations'; - throw new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); - } + return { + query: dto.query, + results, + totalFound: limited ? maxResults : results.length, + limited, + }; } @Post('translate-locale') @@ -748,23 +325,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`, @@ -772,15 +339,7 @@ export class ResourcesController { ); } - const jobId = this.#translationJobService.startJob({ - collectionName: decodedCollectionName, - 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/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/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..7e23e76e --- /dev/null +++ b/apps/api/src/app/errors/lingo-tracker-exception.filter.spec.ts @@ -0,0 +1,268 @@ +import { Controller, Get, HttpException, type INestApplication, Logger, NotFoundException } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import { + AutoTranslationDisabledError, + BaseLocaleImmutableError, + BundleAlreadyExistsError, + BundleNotFoundError, + CollectionNotFoundError, + FolderMoveIntoDescendantError, + FolderNotFoundError, + InvalidBundleDefinitionError, + InvalidFolderPathError, + InvalidLocaleError, + InvalidResourceKeyError, + LingoTrackerError, + LocaleAlreadyExistsError, + LocaleNotFoundError, + ProtectedTermsFileError, + ReadOnlyCollectionError, + ResourceAlreadyExistsError, + 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 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'), + 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 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, + { + message: 'Validation error: Invalid folder name segment "a b". Segments must match pattern [A-Za-z0-9_-]+', + error: 'Bad Request', + 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, + { 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', error: 'Bad Request', statusCode: 400, errors: ['a', 'b'] }, + ], + [ + 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, + { 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..c042c648 --- /dev/null +++ b/apps/api/src/app/errors/lingo-tracker-exception.filter.ts @@ -0,0 +1,158 @@ +import { + type ArgumentsHost, + BadGatewayException, + BadRequestException, + Catch, + ConflictException, + ForbiddenException, + HttpException, + HttpStatus, + InternalServerErrorException, + Logger, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; +import { + AutoTranslationDisabledError, + BaseLocaleImmutableError, + BundleAlreadyExistsError, + BundleNotFoundError, + CollectionNotFoundError, + FolderMoveIntoDescendantError, + FolderNotFoundError, + InvalidBundleDefinitionError, + InvalidFolderPathError, + InvalidLocaleError, + InvalidResourceKeyError, + LingoTrackerError, + LocaleAlreadyExistsError, + LocaleNotFoundError, + ReadOnlyCollectionError, + ResourceAlreadyExistsError, + 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. 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 || + error instanceof FolderNotFoundError || + error instanceof BundleNotFoundError + ) { + return new NotFoundException(message); + } + if (error instanceof ReadOnlyCollectionError) { + return new ForbiddenException(message); + } + if (error instanceof BundleAlreadyExistsError || error instanceof ResourceAlreadyExistsError) { + return new ConflictException(message); + } + if (error instanceof AutoTranslationDisabledError) { + return new UnprocessableEntityException(message); + } + if (error instanceof InvalidFolderPathError || error instanceof FolderMoveIntoDescendantError) { + return new BadRequestException(`Validation error: ${message}`); + } + if ( + error instanceof InvalidResourceKeyError || + error instanceof InvalidLocaleError || + error instanceof LocaleNotFoundError || + error instanceof LocaleAlreadyExistsError || + error instanceof BaseLocaleImmutableError + ) { + return new BadRequestException(message); + } + if (error instanceof TranslationError) { + return translationErrorToHttp(error); + } + 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', + '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/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/api/src/app/mappers/resource-tree.mapper.spec.ts b/apps/api/src/app/mappers/resource-tree.mapper.spec.ts index 52e5556b..ce8a02a5 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,25 @@ +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: [], + protectedTermsFiles: { global: '/nonexistent/.lingo-tracker-protected-terms.json', globalExplicit: false }, + 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 +37,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 +78,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 +110,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 +136,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 +144,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 +162,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 1387a1e9..e7af3d4a 100644 --- a/apps/api/src/app/mappers/resource-tree.mapper.ts +++ b/apps/api/src/app/mappers/resource-tree.mapper.ts @@ -1,64 +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?: 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)), }; } -export function mapResourceEntryToSummary(entry: ResourceTreeEntry, collectionTags?: 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)) { - 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, - }; +/** + * 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, + folderPath: string, + collection: Collection, +): ResourceSummaryDto { + return buildResourceSummary(resolveResourceKey(entry.key, folderPath), entry, collection); } -function mapFolderChildToDto( - child: { - name: string; - fullPathSegments: string[]; - loaded: boolean; - tree?: ResourceTreeNode; - }, - collectionTags?: 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/resource.mapper.ts b/apps/api/src/app/mappers/resource.mapper.ts deleted file mode 100644 index ad51ef3a..00000000 --- a/apps/api/src/app/mappers/resource.mapper.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AddResourceParams } from '@simoncodes-ca/core'; -import type { CreateResourceDto } from '@simoncodes-ca/data-transfer'; - -export function mapDtoToAddResourceParams(dto: CreateResourceDto & { allLocales?: 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/api/src/app/mappers/search-result.mapper.ts b/apps/api/src/app/mappers/search-result.mapper.ts index 7bfc3f5e..37d92cfc 100644 --- a/apps/api/src/app/mappers/search-result.mapper.ts +++ b/apps/api/src/app/mappers/search-result.mapper.ts @@ -1,26 +1,18 @@ -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?: 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, + ...(searchResult.similarity !== undefined && { similarity: searchResult.similarity }), }; } -/** - * Maps an array of SearchResults to SearchResultDto array. - */ -export function mapSearchResultsToDto(searchResults: SearchResult[], collectionTags?: 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/api/src/app/translation-job/translation-job.service.spec.ts b/apps/api/src/app/translation-job/translation-job.service.spec.ts index 864d9987..697455f6 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,7 +1,8 @@ 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(); @@ -9,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), }; }); @@ -20,33 +21,51 @@ 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; 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('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'); @@ -61,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(); @@ -75,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(); @@ -95,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(); @@ -104,12 +123,13 @@ describe('TranslationJobService', () => { expect(job).toBeDefined(); expect(job?.status).toBe('failed'); expect(job?.completedAt).toBeDefined(); + expect(job?.error).toBe('API quota exceeded'); }); 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(); @@ -117,12 +137,50 @@ describe('TranslationJobService', () => { const job = service.getJob(jobId); expect(job).toBeDefined(); expect(job?.status).toBe('failed'); + expect(job?.error).toBe('Unexpected network failure'); + }); + + it('job error is a generic message when translateLocale rejects with a non-Error', async () => { + mockTranslateLocale.mockRejectedValue('boom'); + + const jobId = startJob(service); + + await Promise.resolve(); + await Promise.resolve(); + + expect(service.getJob(jobId)?.error).toBe('An unexpected error occurred'); + }); + + 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(); + + startJob(service); + expect(mockIndex.apply).not.toHaveBeenCalled(); + + await Promise.resolve(); + await Promise.resolve(); + + 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(); @@ -131,13 +189,14 @@ describe('TranslationJobService', () => { expect(job).toBeDefined(); expect(job?.failures).toBeUndefined(); expect(job?.skippedKeys).toBeUndefined(); + expect(job?.error).toBeUndefined(); }); it('updates job counts when onProgress is called', async () => { 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?.({ @@ -151,7 +210,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 a30fcf90..b0bfb326 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,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { 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'; interface TranslationJob { jobId: string; @@ -23,23 +24,25 @@ 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; } /** - * 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, @@ -51,7 +54,7 @@ export class TranslationJobService { this.#jobs.set(jobId, job); - this.#runJob(jobId, params); + this.#runJob(jobId, collection, targetLocale); return jobId; } @@ -67,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); @@ -88,8 +89,15 @@ export class TranslationJobService { runningJob.status = 'running'; runningJob.startedAt = new Date(); - translateLocale({ ...translateParams, onProgress }) + // translateLocale writes resource files (even when it fails part-way), so the index is dropped either way. + const reindex = (): void => this.#index.apply([reindexMutation(collection.translationsFolder)]); + + 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) { @@ -106,6 +114,7 @@ export class TranslationJobService { completedJob.skippedKeys = [...result.skippedKeys]; }) .catch((error: unknown) => { + reindex(); const failedJob = this.#jobs.get(jobId); if (!failedJob) { @@ -115,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'; @@ -139,6 +148,7 @@ export class TranslationJobService { ...(job.skippedKeys.length > 0 && { skippedKeys: job.skippedKeys }), ...(job.startedAt && { startedAt: job.startedAt.toISOString() }), ...(job.completedAt && { completedAt: job.completedAt.toISOString() }), + ...(job.error !== undefined && { error: job.error }), }; } } diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json index c595b5c6..2ddb4eb0 100644 --- a/apps/api/tsconfig.app.json +++ b/apps/api/tsconfig.app.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../dist/out-tsc", + "outDir": "../../dist/out-tsc", "composite": true, "declaration": true, "module": "commonjs", diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json index a5992c7f..1275f148 100644 --- a/apps/api/tsconfig.spec.json +++ b/apps/api/tsconfig.spec.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../dist/out-tsc", + "outDir": "../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", "types": ["jest", "node"] diff --git a/apps/cli/project.json b/apps/cli/project.json index 8e999e04..e2892b13 100644 --- a/apps/cli/project.json +++ b/apps/cli/project.json @@ -61,6 +61,18 @@ "lint": { "executor": "@nx/eslint:lint" }, + "typecheck": { + "dependsOn": ["^typecheck", "typecheck-spec"] + }, + "typecheck-spec": { + "executor": "nx:run-commands", + "cache": true, + "inputs": ["default", "^default", "{workspaceRoot}/tsconfig.base.json", "{workspaceRoot}/types/**/*"], + "options": { + "cwd": "apps/cli", + "command": "tsc --noEmit -p tsconfig.spec.json" + } + }, "test": { "executor": "nx:run-commands", "options": { 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..2e1046f0 --- /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.error).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.error).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.error).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.error).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 9c278a5b..0760add5 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,154 +17,132 @@ 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 } }; + return { ...actual, ...fsMocks, default: { ...actual, ...fsMocks } }; }); vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; + return { ...actual, ...fsMocks, default: { ...actual, ...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', () => { + const configDefaults: Pick = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'en', + locales: ['en'], }; -}); -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({ + ...configDefaults, + 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.error).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({ + ...configDefaults, + 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.error).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({ + ...configDefaults, + 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({ + ...configDefaults, + collections: { TestCollection: { translationsFolder: 'translations' } }, }); - // Should call resolveWritableCollection and get null back - expect(utils.resolveWritableCollection).toHaveBeenCalledWith('NonExistentCollection', config, '/test'); + await addResourceCommand({ collection: 'TestCollection' }); + + expect(console.error).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --key, --value'); + expect(core.addResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('should handle translations array format', async () => { + it('should pass the opened collection and supplied fields through to core', async () => { const config = { + ...configDefaults, collections: { TestCollection: { translationsFolder: 'translations', @@ -174,165 +153,100 @@ 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({ - name: 'TestCollection', - config: config.collections.TestCollection, - translationsFolderPath: '/test/translations', - }); - - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(core.loadConfig).mockReturnValue(config); await addResourceCommand({ collection: 'TestCollection', key: 'buttons.ok', value: 'OK', - translations: [ + comment: 'Primary confirmation action', + tags: 'ui, buttons', + targetFolder: 'common', + translations: JSON.stringify([ { 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' }, + ], + }, ); + expect(process.exitCode).toBe(0); }); - it('should prompt for overwrite confirmation when resource exists in interactive mode', async () => { - const config = { - collections: { - TestCollection: { - translationsFolder: 'translations', - baseLocale: 'en', - }, - }, - baseLocale: 'en', - }; - - // Mock successful config loading - vi.mocked(utils.loadConfiguration).mockReturnValue({ - config, - configPath: '/test/.lingo-tracker.json', - cwd: '/test', + it('should exit 1 with a clear message on malformed --translations JSON', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + ...configDefaults, + collections: { TestCollection: { translationsFolder: 'translations' } }, }); - // Mock promptForCollection to return the collection name - 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(fs.readFileSync).mockImplementation((path: string) => { - if (path.includes('resource_entries.json')) { - return JSON.stringify({ ok: { source: 'OK' } }); - } - throw new Error('File not found'); - }); + await addResourceCommand({ collection: 'TestCollection', key: 'a.b', value: 'OK', translations: '[{"locale":' }); - // Mock resource file exists and contains the entry - vi.mocked(fs.existsSync).mockImplementation((path: string) => { - return path.includes('resource_entries.json'); - }); + expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/^❌ Invalid --translations JSON: /)); + expect(core.addResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); - // Mock TTY to simulate interactive mode - const originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, + it('should exit 1 when --translations is valid JSON of the wrong shape', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + ...configDefaults, + collections: { TestCollection: { translationsFolder: 'translations' } }, }); - // Mock prompt to return false (user cancels) - vi.mocked(prompts).mockResolvedValueOnce({ value: false }); - await addResourceCommand({ collection: 'TestCollection', - key: 'buttons.ok', + key: 'a.b', value: 'OK', + translations: '[{"locale":"fr","value":"Oui","status":"done"}]', }); - expect(prompts).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'confirm', - message: expect.stringContaining('already exists'), - }), + expect(console.error).toHaveBeenCalledWith( + '❌ Invalid --translations: expected a JSON array of { "locale", "value", "status" } with status one of new, translated, stale, verified', ); - - // Restore - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - }); + expect(core.addResource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('should create entries for all locales when no translations provided', async () => { - const config = { + it('should prompt for overwrite confirmation when resource exists in interactive mode', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + ...configDefaults, 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({ - name: 'TestCollection', - config: config.collections.TestCollection, - translationsFolderPath: '/test/translations', + 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'); }); - vi.mocked(fs.existsSync).mockReturnValue(false); + // Mock resource file exists and contains the entry + vi.mocked(fs.existsSync).mockImplementation((path: fs.PathLike) => String(path).includes('resource_entries.json')); - // Mock non-interactive mode - const originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - }); + 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', @@ -340,60 +254,36 @@ describe('addResourceCommand', () => { value: 'OK', }); - // Should call addResource with translations for all non-base locales - expect(core.addResource).toHaveBeenCalledWith( - '/test/translations', + expect(prompts).toHaveBeenCalledWith( 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' }), - ]), + type: 'confirm', + message: expect.stringContaining('already exists'), }), - expect.any(Object), + expect.anything(), ); - - // Restore - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - }); + expect(core.addResource).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('❌ Add resource cancelled.'); + expect(process.exitCode).toBe(0); }); describe('preferred terminology', () => { const filePath = '/test/.lingo-tracker-preferred-terminology.json'; const config = { + ...configDefaults, collections: { TestCollection: { translationsFolder: 'translations', baseLocale: 'en', locales: ['en', 'fr'] } }, baseLocale: 'en', locales: ['en', 'fr'], }; - let originalIsTTY: boolean | undefined; - let logSpy: ReturnType; + // Warnings are diagnostics: they go to stderr. + let stderrSpy: 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({ - name: 'TestCollection', - config: config.collections.TestCollection, - translationsFolderPath: '/test/translations', - }); - originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true }); - logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.mocked(core.loadConfig).mockReturnValue(config); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); }); afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); - logSpy.mockRestore(); + stderrSpy.mockRestore(); }); const add = (value: string) => addResourceCommand({ collection: 'TestCollection', key: 'budget.title', value }); @@ -411,12 +301,12 @@ describe('addResourceCommand', () => { expect(core.addResource).toHaveBeenCalled(); expect(core.loadPreferredTerminology).toHaveBeenCalledWith(config, '/test'); - const lines = logSpy.mock.calls.map((call) => String(call[0])); + const lines = stderrSpy.mock.calls.map((call) => String(call[0])); expect(lines).toContain('⚠️ Preferred terminology: consider "Investment" instead of "Expenditure"'); 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 () => { @@ -427,7 +317,7 @@ describe('addResourceCommand', () => { await add('Investment summary'); - expect(logSpy.mock.calls.some((call) => String(call[0]).includes('Preferred terminology'))).toBe(false); + expect(stderrSpy.mock.calls.some((call) => String(call[0]).includes('Preferred terminology'))).toBe(false); }); it('prints one config warning and skips the check when the rule file is broken', async () => { @@ -435,7 +325,7 @@ describe('addResourceCommand', () => { await add('Expenditure'); - const lines = logSpy.mock.calls.map((call) => String(call[0])); + const lines = stderrSpy.mock.calls.map((call) => String(call[0])); expect(lines).toContain('⚠️ Preferred terminology checks skipped: not valid JSON'); expect(lines.filter((line) => line.includes('Preferred terminology'))).toHaveLength(1); }); @@ -449,7 +339,7 @@ describe('addResourceCommand', () => { await add('Expenditure'); - expect(logSpy).toHaveBeenCalledWith( + expect(stderrSpy).toHaveBeenCalledWith( '⚠️ Preferred terminology file not found: /test/terms.json. Treating as an empty list.', ); }); @@ -460,6 +350,39 @@ describe('addResourceCommand', () => { await add('Expenditure'); expect(core.loadPreferredTerminology).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith('❌ boom'); + expect(process.exitCode).toBe(1); }); }); + + it('reports a cancelled prompt once and exits 0 without adding the resource', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + ...configDefaults, + collections: { TestCollection: { translationsFolder: 'translations', baseLocale: 'en' } }, + baseLocale: 'en', + }); + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + 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 {}; + }); + + try { + await expect(addResourceCommand({})).resolves.toBeUndefined(); + + 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(); + } + }); }); diff --git a/apps/cli/src/add-resource/add-resource.ts b/apps/cli/src/add-resource/add-resource.ts index 8c70578c..c7aa497a 100644 --- a/apps/cli/src/add-resource/add-resource.ts +++ b/apps/cli/src/add-resource/add-resource.ts @@ -1,18 +1,15 @@ -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 prompts from 'prompts'; -import { - ConsoleFormatter, - ErrorMessages, - loadConfiguration, - parseCommaSeparatedList, - promptForCollection, - resolveWritableCollection, - warnAboutPreferredTerminology, -} from '../utils'; +import type { Collection } from '@simoncodes-ca/core'; +import { addResource, openResourceFolder, resolveResourcePaths } from '@simoncodes-ca/core'; +import { type TranslationStatus, translocoToICU } from '@simoncodes-ca/domain'; +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; @@ -21,83 +18,84 @@ 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; - - const answers = await promptForMissing(options, config, collectionName); - - try { - // Check if resource already exists - const resolvedKey = resolveResourceKey(answers.key, answers.targetFolder || undefined); - const { folderPath, entryKey } = splitResolvedKey(resolvedKey); - - const fullFolderPath = folderPath.length - ? join(collection.config.translationsFolder, ...folderPath) - : collection.config.translationsFolder; - - const entryResourcePath = resolve(cwd, fullFolderPath, 'resource_entries.json'); - const resourceExists = existsSync(entryResourcePath) && hasEntryKey(entryResourcePath, 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, - }); +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, + }); + } + 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; + + const { resolvedKey, folderPath, entryKey } = resolveResourcePaths({ + key, + translationsFolder: collection.translationsFolder, + targetFolder, + }); - 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 baseLocale = collection.config.baseLocale || config.baseLocale; - const locales = collection.config.locales || config.locales || []; - const translationConfig = collection.config.translation ?? config.translation; - - // Build translations: use provided translations or create entries for all non-base locales with base value. - // When auto-translation is configured, skip building default translations here — addResource will handle it. - const translations = - answers.translations && answers.translations.length > 0 - ? answers.translations - : translationConfig?.enabled - ? undefined - : createDefaultTranslations(locales, baseLocale, answers.value); + const tagsArray = parseCommaSeparatedList(answers.tags) ?? []; - const result = await addResource( - collection.translationsFolderPath, - { - 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, + baseValue: value, + comment: answers.comment || undefined, + tags: tagsArray.length > 0 ? tagsArray : undefined, + targetFolder, + translations, + }); ConsoleFormatter.success(`Resource added: ${result.resolvedKey}`); if (result.created) { @@ -106,170 +104,97 @@ 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[] = []; +function isTranslationList(value: unknown): value is TranslationInput[] { + return Array.isArray(value) && value.every(isTranslationInput); +} - 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 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.value) { - questions.push({ - type: 'text', - name: 'value', - message: 'Base value (source text)', - validate: (val: string) => (val && val.trim().length > 0 ? true : 'Required'), - }); +/** 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.comment) { - questions.push({ - type: 'text', - name: 'comment', - message: 'Comment (optional, press enter to skip)', - }); + const shouldAddTranslations = await ask({ + type: 'confirm', + name: 'value', + message: 'Add translations for other locales?', + initial: false, + }); + if (shouldAddTranslations.value !== true) { + return undefined; } - if (!options.tags) { - questions.push({ + const translations: TranslationInput[] = []; + for (const locale of collection.targetLocales) { + const translationPrompt = await ask({ type: 'text', - name: 'tags', - message: 'Tags (optional, comma-separated)', + name: 'value', + message: `Translation for ${locale} (press enter to use base value)`, }); - } - if (!options.targetFolder) { - questions.push({ - type: 'text', - name: 'targetFolder', - message: 'Target folder (optional, dot-delimited override)', + 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' }); - } - if (questions.length > 0 && process.stdout.isTTY) { - const result = await prompts(questions, { - onCancel: () => { - throw new Error('Add resource cancelled'); - }, + translations.push({ + locale, + value: + typeof translationPrompt.value === 'string' && translationPrompt.value ? translationPrompt.value : baseValue, + status: statusPrompt.value as TranslationStatus, }); - 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 collectionConfig = config.collections?.[collectionName]; - const baseLocale = collectionConfig?.baseLocale || config.baseLocale; - const locales = collectionConfig?.locales || config.locales || []; - - const nonBaseLocales = locales.filter((locale) => locale !== baseLocale); - - 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, - }); - } - } - } } - - 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; } /** - * Checks if a resource entry already exists in a file. + * Checks if a resource entry already exists in a folder. Unreadable files count as "not found". */ -function hasEntryKey(filePath: string, entryKey: string): boolean { - if (!existsSync(filePath)) { - return false; - } - +function hasEntryKey(folderPath: string, entryKey: string): boolean { try { - const content = readFileSync(filePath, 'utf8'); - const data = JSON.parse(content) as Record; - return entryKey in data; + return openResourceFolder(folderPath).has(entryKey); } catch { return false; } diff --git a/apps/cli/src/commands/add-locale.spec.ts b/apps/cli/src/commands/add-locale.spec.ts index 3ad6d9ab..3218ea29 100644 --- a/apps/cli/src/commands/add-locale.spec.ts +++ b/apps/cli/src/commands/add-locale.spec.ts @@ -1,123 +1,152 @@ -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 { 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 = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', 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 = { - name: 'main', - config: { translationsFolder: 'src/i18n' }, - translationsFolderPath: '/project/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); - - await addLocaleCommand({ locale: 'de' }); + it('exits 1 without calling core when the collection does not exist', async () => { + await addLocaleCommand({ collection: 'nope', locale: 'de' }); - expect(addLocaleToCollection).not.toHaveBeenCalled(); + expect(mockCore).not.toHaveBeenCalled(); + expect(console.error).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.error).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, + mutations: [], }); 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.error).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.error).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.error).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, + mutations: [], + }); + 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.error).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 c01a3517..24f4f468 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(console.error).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(console.error).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({ @@ -194,8 +155,17 @@ describe('bundleCommand', () => { it('should show error for non-existent bundle', async () => { await bundleCommand({ name: 'nonexistent' }); - expect(console.log).toHaveBeenCalledWith('❌ Bundle "nonexistent" not found.'); + expect(console.error).toHaveBeenCalledWith('❌ Bundle "nonexistent" not found.'); expect(mockGenerateBundle).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + }); + + 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' })); }); }); @@ -273,9 +243,9 @@ describe('bundleCommand', () => { await bundleCommand({ name: 'core', quiet: true }); - expect(console.log).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenCalledWith(' ⚠️ Warnings: 1'); - expect(console.log).not.toHaveBeenCalledWith(' - Warning 1'); + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith('⚠️ Warnings: 1'); }); it('should display errors in quiet mode', async () => { @@ -285,8 +255,9 @@ describe('bundleCommand', () => { await bundleCommand({ name: 'core', quiet: true }); - expect(console.log).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenCalledWith(' ❌ Bundle generation failed'); + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith('❌ Bundle generation failed'); }); it('should display type generation errors in quiet mode', async () => { @@ -306,8 +277,9 @@ describe('bundleCommand', () => { await bundleCommand({ name: 'core', quiet: true }); - expect(console.log).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenCalledWith(' └─ Types: Error (Unable to write type file)'); + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith('❌ Type generation failed: Unable to write type file'); }); it('should display warnings count when warnings exist', async () => { @@ -320,7 +292,8 @@ describe('bundleCommand', () => { await bundleCommand({ name: 'core' }); - expect(console.log).toHaveBeenCalledWith(' ⚠️ Warnings: 2'); + expect(console.error).toHaveBeenCalledWith('⚠️ Warnings: 2'); + expect(console.error).not.toHaveBeenCalledWith(' - Warning 1'); }); it('should display warning details in verbose mode', async () => { @@ -333,8 +306,7 @@ describe('bundleCommand', () => { await bundleCommand({ name: 'core', verbose: true }); - expect(console.log).toHaveBeenCalledWith(' - Warning 1'); - expect(console.log).toHaveBeenCalledWith(' - Warning 2'); + expect(vi.mocked(console.error).mock.calls).toEqual([['⚠️ Warnings: 2'], [' - Warning 1'], [' - Warning 2']]); }); it('should display locale filter in verbose mode', async () => { @@ -387,8 +359,8 @@ describe('bundleCommand', () => { expect(console.log).not.toHaveBeenCalledWith(' Total files generated: 5'); expect(console.log).toHaveBeenCalledWith(' Total warnings: 1'); expect(console.log).toHaveBeenCalledWith(' Run with --verbose to see warning details'); - expect(console.log).toHaveBeenCalledWith(' ⚠️ Warnings: 1'); - expect(console.log).not.toHaveBeenCalledWith(' - Warning 1'); + expect(console.error).toHaveBeenCalledWith('⚠️ Warnings: 1'); + expect(console.error).not.toHaveBeenCalledWith(' - Warning 1'); }); it('should display type generation success', async () => { @@ -480,36 +452,22 @@ describe('bundleCommand', () => { it('should error when --token-constant-name is used with multiple bundles via --name', async () => { await bundleCommand({ name: 'core,admin', tokenConstantName: 'MY_CUSTOM_TOKENS' }); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining('Cannot use --token-constant-name with multiple bundles'), ); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Please target a single bundle')); + expect(console.error).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 () => { // In non-TTY mode with no --name, all bundles are processed await bundleCommand({ tokenConstantName: 'MY_CUSTOM_TOKENS' }); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining('Cannot use --token-constant-name with multiple bundles'), ); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Please target a single bundle')); - 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(console.error).toHaveBeenCalledWith(expect.stringContaining('Please target a single bundle')); expect(mockGenerateBundle).not.toHaveBeenCalled(); }); @@ -571,9 +529,10 @@ describe('bundleCommand', () => { await bundleCommand({ name: 'core,admin' }); - expect(console.log).toHaveBeenCalledWith(' ❌ Bundle generation failed'); + expect(console.error).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 () => { @@ -590,13 +549,13 @@ describe('bundleCommand', () => { await bundleCommand({ name: 'core,admin' }); - expect(console.log).toHaveBeenCalledWith('⚠️ 1 bundle(s) failed to generate'); + expect(console.error).toHaveBeenCalledWith('⚠️ 1 bundle(s) failed to generate'); }); }); 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 () => { @@ -649,18 +608,28 @@ describe('bundleCommand', () => { await bundleCommand({ tokenConstantName: 'MY_CUSTOM_TOKENS' }); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining('Cannot use --token-constant-name with multiple bundles'), ); 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'); + // 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(bundleCommand({})).rejects.toThrow('Bundle generation cancelled'); + await expect(bundleCommand({})).resolves.toBeUndefined(); + + expect(console.error).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 ee20f6e5..34862077 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 type { LingoTrackerConfig } 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'; export interface BundleOptions { name?: string; @@ -36,42 +37,56 @@ 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 } = 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; + ConsoleFormatter.error('No bundles configured in .lingo-tracker.json', [ + 'Add a "bundles" section to your configuration file.', + ]); + return { exitCode: 1 }; } - const answers = await promptForMissing(options, config); - - // 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; @@ -111,6 +126,7 @@ export async function bundleCommand(options: BundleOptions): Promise { tokenConstantName: options.tokenConstantName, transformICUToTransloco: options.transformICUToTransloco, debugKeysLocale, + cwd, }); bundleResults.push({ @@ -133,7 +149,7 @@ export async function bundleCommand(options: BundleOptions): Promise { ); } } else if (result.typeGenerationResult.errorReason) { - ConsoleFormatter.indent(`└─ Types: Error (${result.typeGenerationResult.errorReason})`); + ConsoleFormatter.error(`Type generation failed: ${result.typeGenerationResult.errorReason}`); } else if (result.typeGenerationResult.skippedReason) { if (!options.quiet) { const skippedReasonMessages: Record = { @@ -148,18 +164,16 @@ export async function bundleCommand(options: BundleOptions): Promise { } } else if (hasTypeDistConfigured(bundleDefinition)) { // Should have result if configured, but just in case - ConsoleFormatter.indent(`└─ Types: Failed (No result returned)`); + ConsoleFormatter.error('Type generation failed: no result returned'); } else if (!options.quiet) { ConsoleFormatter.indent(`└─ Types: Skipped (no typeDistFile configured)`); } if (result.warnings.length > 0) { - ConsoleFormatter.indent(`⚠️ Warnings: ${result.warnings.length}`); - if (options.verbose) { - result.warnings.forEach((warning) => { - ConsoleFormatter.indent(` - ${warning}`, 2); - }); - } + ConsoleFormatter.warning( + `Warnings: ${result.warnings.length}`, + options.verbose ? result.warnings.map((warning) => `- ${warning}`) : [], + ); } } catch (e: unknown) { const errorMessage = e instanceof Error ? e.message : 'Failed to generate bundle'; @@ -171,7 +185,7 @@ export async function bundleCommand(options: BundleOptions): Promise { error: errorMessage, }); - ConsoleFormatter.indent(`❌ ${errorMessage}`); + ConsoleFormatter.error(errorMessage); } } @@ -205,84 +219,9 @@ export async function bundleCommand(options: BundleOptions): Promise { const errors = bundleResults.filter((r) => r.error); if (errors.length > 0) { - if (!options.quiet) { - console.log(''); - } 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 Error('Bundle generation cancelled'); - }, - }); - - 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 70cd4382..88db7108 100644 --- a/apps/cli/src/commands/delete-resource.test.ts +++ b/apps/cli/src/commands/delete-resource.test.ts @@ -1,183 +1,179 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; import { resolve } from 'node:path'; -import { existsSync, readFileSync } from 'node:fs'; +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'; -import { deleteResource } from '@simoncodes-ca/core'; -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 = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + 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, mutations: [] }); }); - 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(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' }], + mutations: [], }); - 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(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.error).toHaveBeenCalledWith('⚠️ Some operations failed:'); + expect(console.error).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' }], + mutations: [], }); - const options = { + await deleteResourceCommand({ collection: 'default', key: 'apps.common.notfound', yes: true }); + + expect(mockDeleteResource).toHaveBeenCalledWith(expectedCollection, { keys: ['apps.common.notfound'] }); + expect(console.error).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(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.error).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.error).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.error).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(resolve('/test/project', 'src/i18n'), { - keys: ['apps.common.buttons.ok', 'apps.common.buttons.cancel'], + expect(mockDeleteResource).toHaveBeenCalledWith(expectedCollection, { keys: ['a.b'] }); + expect(process.exitCode).toBe(0); }); - }); - 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' }], - }); + it('declining the confirmation cancels with exit 0', async () => { + mockPrompts.mockResolvedValueOnce({ confirmed: false }); - const options = { - collection: 'default', - key: 'apps.common.notfound', - yes: true, - }; + await deleteResourceCommand({ collection: 'default', key: 'a.b' }); + + expect(mockDeleteResource).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('❌ Delete resource cancelled.'); + expect(process.exitCode).toBe(0); + }); - await deleteResourceCommand(options); + it('--yes skips the confirmation', async () => { + await deleteResourceCommand({ collection: 'default', key: 'a.b', yes: true }); - expect(mockDeleteResource).toHaveBeenCalledWith(resolve('/test/project', 'src/i18n'), { - keys: ['apps.common.notfound'], + 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 f48e2a52..acc67835 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,41 +8,33 @@ 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.translationsFolderPath, { keys }); + const result = deleteResource(collection, { keys }); if (result.entriesDeleted === 0) { ConsoleFormatter.warning('No resources were deleted.'); @@ -60,42 +43,16 @@ export async function deleteResourceCommand(options: DeleteResourceOptions): Pro } if (result.errors && result.errors.length > 0) { - console.log(''); - ConsoleFormatter.warning('Some operations failed:'); - for (const error of result.errors) { - ConsoleFormatter.indent(`- ${error.key}: ${error.error}`); - } + ConsoleFormatter.warning( + 'Some operations failed:', + result.errors.map((error) => `- ${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) { @@ -107,9 +64,9 @@ async function confirmDeletion(keys: string[]): Promise { } } - console.log('\n⚠️ This will remove translations for all locales.'); + ConsoleFormatter.warning('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..6b51411c 100644 --- a/apps/cli/src/commands/edit-collection.test.ts +++ b/apps/cli/src/commands/edit-collection.test.ts @@ -1,36 +1,22 @@ -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', () => { const mockConfig = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', baseLocale: 'en', locales: ['en', 'fr'], collections: { @@ -44,9 +30,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 +97,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.error).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.error).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.error).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.error).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 057da44d..868bf68b 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,11 +45,11 @@ 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, + mutations: [], }); const options = { @@ -68,22 +61,25 @@ 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', + }), ); }); 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, message: 'No changes detected', + mutations: [], }); const options = { @@ -98,11 +94,11 @@ 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, + mutations: [], }); const options = { @@ -115,7 +111,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'], @@ -124,11 +121,11 @@ 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, + mutations: [], }); const options = { @@ -141,9 +138,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" }, }, }), @@ -151,10 +149,9 @@ 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'); + const stderrSpy = vi.spyOn(console, 'error'); const options = { collection: 'default', @@ -165,20 +162,19 @@ describe('editResourceCommand', () => { await editResourceCommand(options); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Both --locale and --localeValue must be provided'), - ); + expect(stderrSpy).toHaveBeenCalledWith(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(), }), ); }); 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 = { @@ -189,11 +185,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', @@ -203,14 +199,15 @@ 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, + mutations: [], }); // Mock prompts to return baseValue @@ -224,30 +221,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([ @@ -260,39 +235,76 @@ 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 () => { + vi.mocked(loadConfig).mockReturnValue(mockConfig); + mockEditResource.mockResolvedValue({ resolvedKey: 'shared.ok', updated: true, mutations: [] }); + + 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' }), + ); + }); + + 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.error).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.error).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' }); }); const logged = (spy: ReturnType) => spy.mock.calls.map((call) => String(call[0])); it('warns about the new base value after a successful edit', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: true }); + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: true, mutations: [] }); await editResourceCommand({ collection: 'default', key: 'budget.title', baseValue: 'Capital expenditure' }); - expect(logged(logSpy)).toContain('⚠️ Preferred terminology: consider "Investment" instead of "Expenditure"'); - expect(logged(logSpy)).toContain(' Finance style guide'); - expect(process.exitCode ?? 0).toBe(0); - logSpy.mockRestore(); + expect(logged(stderrSpy)).toContain('⚠️ Preferred terminology: consider "Investment" instead of "Expenditure"'); + expect(logged(stderrSpy)).toContain(' Finance style guide'); + expect(process.exitCode).toBe(0); + stderrSpy.mockRestore(); }); it('does not check when the base value was not part of the edit', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: true }); + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: true, mutations: [] }); await editResourceCommand({ collection: 'default', @@ -303,39 +315,40 @@ describe('editResourceCommand', () => { }); expect(loadPreferredTerminology).not.toHaveBeenCalled(); - expect(logged(logSpy).some((line) => line.includes('Preferred terminology'))).toBe(false); - logSpy.mockRestore(); + expect(logged(stderrSpy).some((line) => line.includes('Preferred terminology'))).toBe(false); + stderrSpy.mockRestore(); }); it('does not check when nothing changed', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: false, message: 'No changes detected', + mutations: [], }); await editResourceCommand({ collection: 'default', key: 'budget.title', baseValue: 'Capital expenditure' }); expect(loadPreferredTerminology).not.toHaveBeenCalled(); - logSpy.mockRestore(); + stderrSpy.mockRestore(); }); it('prints one config warning and skips the check when the rule file is broken', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); vi.mocked(loadPreferredTerminology).mockReturnValue({ rules: [], filePath: '/test/project/terms.json', error: 'not valid JSON', }); - mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: true }); + mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: true, mutations: [] }); await editResourceCommand({ collection: 'default', key: 'budget.title', baseValue: 'Capital expenditure' }); - const lines = logged(logSpy); + const lines = logged(stderrSpy); expect(lines).toContain('⚠️ Preferred terminology checks skipped: not valid JSON'); expect(lines.filter((line) => line.includes('Preferred terminology'))).toHaveLength(1); - logSpy.mockRestore(); + stderrSpy.mockRestore(); }); }); }); diff --git a/apps/cli/src/commands/edit-resource.ts b/apps/cli/src/commands/edit-resource.ts index 055874e8..6182efc9 100644 --- a/apps/cli/src/commands/edit-resource.ts +++ b/apps/cli/src/commands/edit-resource.ts @@ -1,17 +1,7 @@ -import { resolve } from 'node:path'; -import type { LingoTrackerConfig } from '@simoncodes-ca/core'; -import { editResource } 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; @@ -24,120 +14,52 @@ 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); - - // 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.config.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); - } +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.'); + } - if (options.locale && options.localeValue) { - editOptions.locales = { - [options.locale]: { value: options.localeValue }, + 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, }; - } else if (options.locale || options.localeValue) { - 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, { - ...editOptions, - translationConfig, - allLocales, - }); + 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'); } - } 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 1d804934..c5e167e6 100644 --- a/apps/cli/src/commands/export-cmd.test.ts +++ b/apps/cli/src/commands/export-cmd.test.ts @@ -1,8 +1,9 @@ -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 { isInteractiveTerminal } from '../runner/terminal'; +import { exportCommand } from './export-cmd'; const fsMocks = vi.hoisted(() => ({ existsSync: vi.fn(), @@ -15,7 +16,7 @@ vi.mock('node:fs', async (importOriginal) => { return { ...actual, ...fsMocks, - default: { ...actual.default, ...fsMocks }, + default: { ...actual, ...fsMocks }, }; }); vi.mock('fs', async (importOriginal) => { @@ -23,36 +24,67 @@ vi.mock('fs', async (importOriginal) => { return { ...actual, ...fsMocks, - default: { ...actual.default, ...fsMocks }, + default: { ...actual, ...fsMocks }, }; }); vi.mock('prompts'); +vi.mock('../runner/terminal', () => ({ isInteractiveTerminal: vi.fn(() => false) })); -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 { + // Collection resolution runs for real against the mocked config. + loadConfig: vi.fn(), + openCollection: actual.openCollection, + exportTargetLocales: actual.exportTargetLocales, + ConfigNotFoundError: actual.ConfigNotFoundError, + ConfigParseError: actual.ConfigParseError, + CollectionNotFoundError: actual.CollectionNotFoundError, + ReadOnlyCollectionError: actual.ReadOnlyCollectionError, + CONFIG_FILENAME: '.lingo-tracker.json', + runExport: vi.fn(), + validateOutputDirectory: vi.fn(), + validateBasePropertyName: 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 = { exportFolder: 'dist/export', + importFolder: 'dist/import', baseLocale: 'en', locales: ['en', 'fr', 'es'], collections: { @@ -65,127 +97,81 @@ 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; - - // Set to non-TTY by default to avoid prompts - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - configurable: true, - }); + process.env.INIT_CWD = '/project'; + process.exitCode = undefined; + + // 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); - 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(() => { - 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.error).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'); + expect(console.error).toHaveBeenCalledWith('❌ Invalid output directory'); }); }); 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', @@ -193,31 +179,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', @@ -225,211 +200,122 @@ 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.objectContaining({ augmentProtectedTerms: false }), - ); - expect(mockExportToJson).toHaveBeenCalledWith( - expect.anything(), + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), 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 () => { + 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.error).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.error).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 () => { @@ -438,27 +324,14 @@ describe('exportCommand', () => { locale: 'en', // base locale is filtered out }); - expect(console.log).toHaveBeenCalledWith('⚠️ No target locales selected.'); + expect(console.error).toHaveBeenCalledWith('⚠️ No target locales selected.'); + expect(mockRunExport).not.toHaveBeenCalled(); }); }); describe('interactive mode', () => { beforeEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, - }); - mockFilterResources.mockReturnValue([ - { - key: 'test', - locale: 'fr', - value: 'test-fr', - baseValue: '', - status: 'translated', - collection: '', - }, - ]); + vi.mocked(isInteractiveTerminal).mockReturnValue(true); }); it('should prompt for format when not provided', async () => { @@ -489,14 +362,18 @@ 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. + vi.mocked(prompts).mockImplementation(async (questions, options) => { + const [question] = Array.isArray(questions) ? questions : [questions]; + options?.onCancel?.(question, {}); + return {}; }); await exportCommand({}); - expect(console.log).toHaveBeenCalledWith('❌ ❌ Export cancelled.'); - expect(process.exit).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('❌ Export cancelled.'); + expect(mockRunExport).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('should prompt for collections when not provided', async () => { @@ -547,12 +424,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 () => { @@ -576,9 +448,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 () => { @@ -615,10 +485,10 @@ describe('exportCommand', () => { // Type functions should return proper types for JSON format if (structureQuestion && typeof structureQuestion.type === 'function') { - expect(structureQuestion.type(null, { format: 'json' })).toBe('select'); + expect(structureQuestion.type(null, { format: 'json' }, structureQuestion)).toBe('select'); } if (richQuestion && typeof richQuestion.type === 'function') { - expect(richQuestion.type(null, { format: 'json' })).toBe('toggle'); + expect(richQuestion.type(null, { format: 'json' }, richQuestion)).toBe('toggle'); } }); @@ -643,13 +513,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 () => { @@ -704,93 +568,39 @@ describe('exportCommand', () => { // These questions should have type functions that return null for XLIFF if (structureQuestion && typeof structureQuestion.type === 'function') { - expect(structureQuestion.type(null, { format: 'xliff' })).toBeNull(); + expect(structureQuestion.type(null, { format: 'xliff' }, structureQuestion)).toBeNull(); } if (richQuestion && typeof richQuestion.type === 'function') { - expect(richQuestion.type(null, { format: 'xliff' })).toBeNull(); + expect(richQuestion.type(null, { format: 'xliff' }, richQuestion)).toBeNull(); } }); }); - 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 () => { - 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 () => { + describe('rendering the run', () => { + it('should write the summary returned by the run', 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', @@ -800,15 +610,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', @@ -819,143 +621,105 @@ 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('Warning 1')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Warning 2')); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Warnings (2)')); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Warning 1')); + expect(console.error).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('Error 1')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Error 2')); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Errors (2)')); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Error 1')); + expect(console.error).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('Conflict at key.path')); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Errors (1)')); + expect(console.error).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.error).toHaveBeenCalledWith('❌ fr: Export failed - Export failed for fr'); + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('fr: Export failed')); + 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.error).toHaveBeenCalledWith('❌ 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', @@ -967,8 +731,8 @@ describe('exportCommand', () => { filename: 'custom-{locale}.json', }); - expect(mockExportToJson).toHaveBeenCalledWith( - expect.anything(), + expect(mockRunExport).toHaveBeenCalledWith( + expect.any(Array), expect.objectContaining({ format: 'json', jsonStructure: 'flat', @@ -979,60 +743,50 @@ 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')); + + await exportCommand({ format: 'json' }); + expect(process.exitCode).toBe(1); + + expect(console.error).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', @@ -1040,43 +794,29 @@ describe('exportCommand', () => { includeBase: false, }); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining('--base-property-name has no effect without --include-base'), ); }); 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')); + expect(console.error).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', @@ -1084,10 +824,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..9763f99f 100644 --- a/apps/cli/src/commands/export-cmd.ts +++ b/apps/cli/src/commands/export-cmd.ts @@ -1,30 +1,27 @@ -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 type prompts from 'prompts'; +import { type Answers, defineCommand, NO_COLLECTIONS_MESSAGE } from '../runner/command-runner'; import { - loadConfiguration, + buildSummaryPath, + ConsoleFormatter, + multiselectResultToString, parseCommaSeparatedList, processMultiselectWithAll, - multiselectResultToString, - ConsoleFormatter, - ErrorMessages, - buildSummaryPath, } from '../utils'; export interface ExportCommandOptions { @@ -48,279 +45,138 @@ export interface ExportCommandOptions { protectNotes?: boolean; } -export async function exportCommand(options: ExportCommandOptions): Promise { - const loaded = loadConfiguration(); - if (!loaded) return; - const { config, cwd } = loaded; - - let answers: Partial; - try { - answers = await promptForMissing(options, config); - } catch (error) { - if ((error as Error).message === 'Export cancelled') { - 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) { - ConsoleFormatter.error((error as Error).message); - process.exit(1); } - } - // 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) { - ConsoleFormatter.error((error as Error).message); - process.exit(1); - } - - // Resolve collections - const collectionNames = parseCommaSeparatedList(options.collection); - const globalProtectedTerms = readGlobalProtectedTerms(config, cwd); + // An unknown name throws CollectionNotFoundError; none configured fails like every other command. + const collections = openCollections(config, cwd, parseCommaSeparatedList(options.collection)); - const allCollections = Object.entries(config.collections || {}).map(([name, col]) => ({ - name, - path: col.translationsFolder, - tags: col.tags, - protectedTerms: readCollectionProtectedTerms(col, cwd), - })); + const targetLocales = exportTargetLocales(collections, parseCommaSeparatedList(options.locale)); + if (targetLocales.length === 0) { + ConsoleFormatter.warning('No target locales selected.'); + return; + } - const collectionsToProcess = allCollections.filter((c) => !collectionNames || collectionNames.includes(c.name)); + 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]'); - if (collectionsToProcess.length === 0) { - ConsoleFormatter.warning('No matching collections found.'); - return; - } + const result = await runExport(collections, { + 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, + protectedTerms: readProtectedTerms(config, collections, cwd), + onProgress: options.verbose ? (msg) => console.log(` ${msg}`) : undefined, + }); - // Resolve locales - const localeNames = parseCommaSeparatedList(options.locale); - const targetLocales = (config.locales || []).filter( - (l: string) => l !== config.baseLocale && (!localeNames || localeNames.includes(l)), - ); + displayResults(result); - if (targetLocales.length === 0) { - ConsoleFormatter.warning('No target locales selected.'); - return; + 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); } + return [...new Set(names ?? configured)].map((name) => openCollection(config, name, { cwd })); +} - // 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(`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, +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)])), }; +} - 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, - augmentProtectedTerms: options.protectNotes !== false, - baseLocale: config.baseLocale, - }); - - if (filtered.length === 0) { - if (options.verbose) console.log(` Skipping ${locale}: No matching resources.`); - continue; - } - - 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}`); +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.error(error ? `${locale}: Export failed - ${error}` : `${locale}: Failed`); } } ConsoleFormatter.section('Export Summary'); - ConsoleFormatter.keyValue('Files Created', totalFiles); - ConsoleFormatter.keyValue('Resources Exported', totalResources); - - if (allWarnings.length > 0) { - console.log(''); - ConsoleFormatter.warning(`Warnings (${allWarnings.length}):`); - allWarnings.forEach((w) => { - ConsoleFormatter.indent(`- ${w}`); - }); + ConsoleFormatter.keyValue('Files Created', result.filesCreated.length); + ConsoleFormatter.keyValue('Resources Exported', result.resourcesExported); + + if (result.warnings.length > 0) { + ConsoleFormatter.warning( + `Warnings (${result.warnings.length}):`, + result.warnings.map((w) => `- ${w}`), + ); } - if (allErrors.length > 0) { - console.log(''); - ConsoleFormatter.error(`Errors (${allErrors.length}):`); - allErrors.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); + const errors = [...result.errors, ...result.hierarchicalConflicts]; + if (errors.length > 0) { + ConsoleFormatter.error( + `Errors (${errors.length}):`, + errors.map((e) => `- ${e}`), + ); } } -async function promptForMissing( +/** The questions for every option the flags left out. */ +function buildQuestions( options: ExportCommandOptions, config: LingoTrackerConfig, -): 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; - }> = {}; - + targetLocales: string[], +): prompts.PromptObject[] { const collectionNames = Object.keys(config.collections || {}); - const targetLocales = (config.locales || []).filter((l: string) => l !== config.baseLocale); const questions: prompts.PromptObject[] = []; @@ -557,52 +413,38 @@ async function promptForMissing( }); } - if (questions.length > 0 && process.stdout.isTTY) { - const result = await prompts(questions, { - onCancel: () => { - throw new Error('Export cancelled'); - }, - }); - - 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.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 ef1eba80..a8e86fdf 100644 --- a/apps/cli/src/commands/find-similar.spec.ts +++ b/apps/cli/src/commands/find-similar.spec.ts @@ -1,13 +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', () => ({ - searchTranslations: vi.fn(), -})); - -vi.mock('../utils', () => ({ - loadConfiguration: vi.fn(), -})); +vi.mock('@simoncodes-ca/core', async (importOriginal) => { + const actual = await importOriginal(); + // 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) })); vi.mock('path', async (importOriginal) => { const actual = await importOriginal(); @@ -21,24 +20,33 @@ 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, 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: [], }; } -const BASE_CONFIG = { +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 = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', baseLocale: 'en', locales: ['en', 'fr'], collections: { @@ -48,64 +56,73 @@ 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; }); // --------------------------------------------------------------------------- - // 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(loadConfiguration).mockReturnValue(LOADED_CONFIG); + 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,43 +130,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(readCollection).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.error).toHaveBeenCalledWith('❌ Missing required options in non-interactive mode: --value'); + expect(readCollection).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.error).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.error).toHaveBeenCalledWith('❌ --value must not be blank'); + 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); + collectionHolds(); + await findSimilarCommand({ value: 'hello' }); + expect(readCollection).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.error).toHaveBeenCalledWith('❌ Missing required option: --collection'); + expect(readCollection).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.error).toHaveBeenCalledWith('❌ Collection "nonexistent" not found'); + expect(process.exitCode).toBe(1); }); }); @@ -159,23 +197,23 @@ 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 () => { - 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')); @@ -183,84 +221,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(loadConfiguration).mockReturnValue(LOADED_CONFIG); + 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); }); }); @@ -270,170 +284,157 @@ 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 () => { // 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(loadConfiguration).mockReturnValue({ - config: { - baseLocale: 'en', - locales: ['en', 'fr'], - collections: { - tracker: { - translationsFolder: 'src/i18n', - baseLocale: 'fr', - }, + vi.mocked(loadConfig).mockReturnValue({ + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + 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([]); + }); + 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 () => { - vi.mocked(loadConfiguration).mockReturnValue({ - config: { - baseLocale: 'de', - locales: ['de', 'en'], - collections: { - tracker: { - translationsFolder: 'src/i18n', - }, + vi.mocked(loadConfig).mockReturnValue({ + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'de', + locales: ['de', 'en'], + collections: { + tracker: { + translationsFolder: 'src/i18n', }, }, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', - } as any); - 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 () => { - vi.mocked(loadConfiguration).mockReturnValue({ - config: { - locales: ['en'], - collections: { - tracker: { - translationsFolder: 'src/i18n', - }, + // This test deliberately omits baseLocale to exercise the command's legacy fallback. + vi.mocked(loadConfig).mockReturnValue({ + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + locales: ['en'], + collections: { + tracker: { + translationsFolder: 'src/i18n', }, }, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', - } as any); - vi.mocked(searchTranslations).mockReturnValue([]); + } as unknown as LingoTrackerConfig); + collectionHolds(); await findSimilarCommand({ collection: 'tracker', value: 'hello' }); - expect(searchTranslations).toHaveBeenCalledWith(expect.objectContaining({ baseLocale: 'en' })); - }); - }); - - // --------------------------------------------------------------------------- - // findSimilarCommand — searchTranslations call arguments - // --------------------------------------------------------------------------- - - describe('findSimilarCommand — searchTranslations arguments', () => { - beforeEach(() => { - vi.mocked(loadConfiguration).mockReturnValue(LOADED_CONFIG); - vi.mocked(searchTranslations).mockReturnValue([]); + expect(readCollection).toHaveBeenCalledWith(expect.objectContaining({ baseLocale: 'en' })); }); - it('calls searchTranslations with translationsFolder resolved from cwd + collectionConfig', async () => { + it('reads the collection with its translationsFolder resolved from cwd + collectionConfig', async () => { + vi.mocked(loadConfig).mockReturnValue(BASE_CONFIG); + 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.error).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 2371b09b..cf5cca65 100644 --- a/apps/cli/src/commands/find-similar.ts +++ b/apps/cli/src/commands/find-similar.ts @@ -1,18 +1,6 @@ -import path from 'path'; -import { loadConfiguration } from '../utils'; -import { searchTranslations } from '@simoncodes-ca/core'; -import { normalizedLevenshtein } from '@simoncodes-ca/domain'; - -/** - * 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 { type Collection, readCollection, searchResources } from '@simoncodes-ca/core'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface FindSimilarOptions { collection?: string; @@ -20,75 +8,38 @@ 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); - } - - const collectionConfig = config.collections?.[options.collection]; - if (!collectionConfig) { - console.error(`Error: Collection "${options.collection}" not found`); - process.exit(1); - } - - const translationsFolder = path.resolve(cwd, collectionConfig.translationsFolder); - const baseLocale = collectionConfig.baseLocale || config.baseLocale || 'en'; - 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({ - 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.`, - ); +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); + }, +}); + +/** 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/glossary-extractor.ts b/apps/cli/src/commands/glossary-extractor.ts index 0663db06..5c6c9673 100644 --- a/apps/cli/src/commands/glossary-extractor.ts +++ b/apps/cli/src/commands/glossary-extractor.ts @@ -198,9 +198,8 @@ export function createNgramExtractor(options: NgramExtractorOptions = {}): Candi }; for (const sentence of splitSentences(block)) { - const tokens = (sentence.toLowerCase().match(WORD_TOKEN) ?? []).filter( - (t) => t.length >= minLength && !stopwords.has(t), - ); + const words: string[] = sentence.toLowerCase().match(WORD_TOKEN) ?? []; + const tokens = words.filter((t) => t.length >= minLength && !stopwords.has(t)); for (let i = 0; i < tokens.length; i++) { add(tokens[i]); diff --git a/apps/cli/src/commands/glossary.spec.ts b/apps/cli/src/commands/glossary.spec.ts index 3cbafa39..24f928dc 100644 --- a/apps/cli/src/commands/glossary.spec.ts +++ b/apps/cli/src/commands/glossary.spec.ts @@ -1,18 +1,14 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { afterEach, describe, it, expect, beforeEach, vi } from 'vitest'; -vi.mock('@simoncodes-ca/core', () => ({ - loadResourcesFromCollections: vi.fn(), -})); - -vi.mock('../utils', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfiguration: vi.fn(), - resolveCollection: vi.fn(), - }; +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(), readCollection: 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(); return { @@ -30,37 +26,52 @@ vi.mock('fs', async (importOriginal) => { }); import * as fs from 'fs'; -import { loadResourcesFromCollections } from '@simoncodes-ca/core'; -import { loadConfiguration, resolveCollection } from '../utils'; +import { + type CollectionRead, + ConfigNotFoundError, + type LingoTrackerConfig, + loadConfig, + readCollection, + type StoredResource, +} from '@simoncodes-ca/core'; +import type { TranslationStatus } from '@simoncodes-ca/domain'; +import { hasPipedStdin } from '../runner/terminal'; 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', - }, -]; - -const LOADED_CONFIG = { - config: { - baseLocale: 'en', - locales: ['en', 'fr'], - collections: { app: { translationsFolder: 'i18n' } }, - }, - configPath: '/project/.lingo-tracker.json', - cwd: '/project', +/** 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 CONFIG: LingoTrackerConfig = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { app: { translationsFolder: 'i18n' } }, }; function writtenContent(): string { @@ -74,23 +85,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); - vi.mocked(loadResourcesFromCollections).mockReturnValue(LOADED 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); + }); + + afterEach(() => { + process.exitCode = undefined; }); - it('returns early when configuration is missing', async () => { - vi.mocked(loadConfiguration).mockReturnValue(null); + it('exits 1 when configuration is missing', async () => { + vi.mocked(loadConfig).mockImplementation(() => { + throw new ConfigNotFoundError('/project/.lingo-tracker.json'); + }); await glossaryCommand({ text: 'Save' }); - expect(loadResourcesFromCollections).not.toHaveBeenCalled(); + 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 () => { @@ -102,6 +121,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 () => { @@ -120,7 +140,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()); @@ -129,9 +149,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 () => { @@ -142,25 +163,41 @@ describe('glossaryCommand', () => { expect(JSON.parse(printed).matchCount).toBe(1); }); + it('keeps stdout to the JSON payload with --stdout: warnings and the status line go to stderr', async () => { + await glossaryCommand({ text: 'Save', stdout: true, locales: 'en' }); + + expect(console.error).toHaveBeenCalledWith( + '⚠️ No target locales to include (only the base locale is configured or requested).', + ); + expect(console.log).not.toHaveBeenCalled(); + expect(process.stdout.write).toHaveBeenCalledTimes(1); + const printed = vi.mocked(process.stdout.write).mock.calls[0][0] as string; + expect(JSON.parse(printed).locales).toEqual([]); + }); + it('narrows output locales with --locales', async () => { await glossaryCommand({ text: 'Save', locales: 'fr' }); const out = JSON.parse(writtenContent()); expect(out.locales).toEqual(['fr']); }); - it('resolves a single collection with --collection', async () => { - vi.mocked(resolveCollection).mockReturnValue({ - name: 'app', - config: { translationsFolder: 'i18n' }, - translationsFolderPath: '/project/i18n', - } as never); + 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.error).toHaveBeenCalledWith('❌ Collection "nope" not found'); + expect(readCollection).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); it('writes an empty glossary when nothing matches', async () => { @@ -171,16 +208,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); @@ -190,25 +218,16 @@ 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(loadResourcesFromCollections).mockReturnValue([ - { - key: 'save', - fullKey: 'save', - source: 'Enregistrer', - translations: { fr: 'Enregistrer', es: 'Guardar' }, - status: { fr: 'verified', es: 'verified' }, - collection: 'app', - }, - ] as never); + vi.mocked(loadConfig).mockReturnValue({ + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + 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' })), + ); await glossaryCommand({ text: 'Enregistrer' }); const out = JSON.parse(writtenContent()); @@ -216,7 +235,37 @@ describe('glossaryCommand', () => { expect(out.terms[0].translations).toEqual({ es: 'Guardar' }); }); - 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('reports an unreadable folder on stderr and keeps the readable entries', async () => { + 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(console.error).toHaveBeenCalledWith( + "⚠️ Collection 'app': skipped unreadable folder: Failed to parse JSON file x", + ); + expect(console.log).not.toHaveBeenCalled(); + 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 1 with a clear error for the unimplemented ai extractor', async () => { + await glossaryCommand({ text: 'Save', extractor: 'ai' }); + expect(console.error).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 94923026..55e4e8ca 100644 --- a/apps/cli/src/commands/glossary.ts +++ b/apps/cli/src/commands/glossary.ts @@ -1,8 +1,10 @@ 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 { ConsoleFormatter, loadConfiguration, parseCommaSeparatedList, resolveCollection } from '../utils'; +import { openCollection, readCollection } from '@simoncodes-ca/core'; +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'; @@ -44,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; @@ -58,37 +60,30 @@ 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`). - * Returns null if a named collection cannot be resolved. + * 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. + * A named collection that does not exist throws CollectionNotFoundError (the runner exits 1). */ -function loadEntries(options: GlossaryCommandOptions, config: LingoTrackerConfig, cwd: string): FlatEntry[] | null { - const globalBase = config.baseLocale; - - let targets: { name: string; collection: LingoTrackerCollection }[]; - if (options.collection) { - const resolved = resolveCollection(options.collection, config, cwd); - if (!resolved) return null; - targets = [{ name: resolved.name, collection: resolved.config }]; - } else { - targets = Object.entries(config.collections ?? {}).map(([name, collection]) => ({ name, collection })); - } +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 { name, collection } of targets) { - const base = collection.baseLocale ?? globalBase; - const loaded = loadResourcesFromCollections([{ name, path: path.resolve(cwd, collection.translationsFolder) }]); - for (const resource of loaded) { - const translations = { ...resource.translations }; - if (base) delete translations[base]; - 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); + // A warning goes to stderr, so --stdout output stays valid JSON. + for (const problem of problems) { + ConsoleFormatter.warning(`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; @@ -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,17 +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) { - ConsoleFormatter.error((error as Error).message); - process.exit(1); - } + 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 a20580a5..b3862678 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,89 +8,69 @@ 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(); - return { ...actual, ...fsMocks, default: { ...actual.default, ...fsMocks } }; + return { ...actual, ...fsMocks, default: { ...actual, ...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 } }; + return { ...actual, ...fsMocks, default: { ...actual, ...fsMocks } }; }); vi.mock('prompts', () => ({ default: vi.fn(), })); // 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 { + // Collection resolution runs for real against the mocked config. + loadConfig: vi.fn(), + openCollection: actual.openCollection, + ConfigNotFoundError: actual.ConfigNotFoundError, + ConfigParseError: actual.ConfigParseError, + CollectionNotFoundError: actual.CollectionNotFoundError, + ReadOnlyCollectionError: actual.ReadOnlyCollectionError, + CONFIG_FILENAME: '.lingo-tracker.json', + parseJsonImport: vi.fn(() => []), + parseXliffImport: vi.fn(async () => []), + importResources: 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', () => ({ - 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 { detectImportFormat, importFromJson, importFromXliff, loadPreferredTerminology } from '@simoncodes-ca/core'; import { - ConsoleFormatter, - isInteractiveTerminal, - loadConfiguration, - promptForCollection, - resolveWritableCollection, -} from '../utils'; + ConfigNotFoundError, + detectImportFormat, + generateImportSummary, + type ImportResult, + importResources, + type LingoTrackerCollection, + type LingoTrackerConfig, + loadConfig, + loadPreferredTerminology, + parseJsonImport, + parseXliffImport, +} from '@simoncodes-ca/core'; +import { isInteractiveTerminal } from '../runner/terminal'; describe('import-cmd', () => { - const baseConfig = { + const baseConfig: LingoTrackerConfig = { exportFolder: 'dist/lingo-export', importFolder: 'dist/lingo-import', baseLocale: 'en', @@ -103,7 +82,15 @@ describe('import-cmd', () => { }, }; - const baseImportResult = { + /** 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: ImportResult = { + strategy: 'update', + locale: 'fr', + collection: 'default', resourcesImported: 10, resourcesCreated: 0, resourcesUpdated: 10, @@ -114,19 +101,14 @@ describe('import-cmd', () => { warnings: [], errors: [], changes: [], + icuAutoFixes: [], + icuAutoFixErrors: [], + dryRun: false, }; 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'); @@ -134,25 +116,20 @@ 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({ - name: 'default', - config: { translationsFolder: 'src/translations' }, - translationsFolderPath: '/test/project/src/translations', - }); + afterEach(() => { + process.exitCode = undefined; }); 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', @@ -162,11 +139,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', @@ -176,14 +156,15 @@ describe('import-cmd', () => { await importCommand(options); - expect(importFromJson).not.toHaveBeenCalled(); + expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); 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, @@ -204,7 +185,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, @@ -235,14 +216,15 @@ describe('import-cmd', () => { await importCommand(options); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Cannot auto-detect format from .txt extension'); - expect(importFromJson).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('❌ Cannot auto-detect format from .txt extension'); + expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); 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 = { @@ -253,18 +235,43 @@ 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 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); const options: ImportCommandOptions = { @@ -275,18 +282,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'); }); @@ -298,13 +303,29 @@ describe('import-cmd', () => { await importCommand(options); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Import failed: Source file not found'); + expect(console.error).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 () => { + 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(console.error).toHaveBeenCalledWith( + '❌ Import failed: Cannot import into base locale "en" with strategy "translation-service".', + ); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); 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'], }); @@ -319,10 +340,11 @@ 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 () => { - vi.mocked(importFromJson).mockReturnValue({ + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, warnings: ['Warning 1', 'Warning 2'], }); @@ -336,21 +358,18 @@ describe('import-cmd', () => { await importCommand(options); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Warnings (2)')); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Warnings (2)')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Import completed with warnings')); }); it('should exit with code 1 for import with errors', async () => { - vi.mocked(importFromJson).mockReturnValue({ + vi.mocked(importResources).mockReturnValue({ ...baseImportResult, resourcesUpdated: 8, resourcesFailed: 2, 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', @@ -358,20 +377,18 @@ describe('import-cmd', () => { format: 'json', }; - await expect(importCommand(options)).rejects.toThrow('Process exit: 1'); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Errors (2)')); + await importCommand(options); + expect(process.exitCode).toBe(1); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Errors (2)')); }); 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'], }); 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', @@ -379,11 +396,12 @@ 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 () => { - vi.mocked(importFromJson).mockReturnValue(baseImportResult); + vi.mocked(importResources).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); const options: ImportCommandOptions = { @@ -402,26 +420,16 @@ 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({ - name: 'admin', - config: { translationsFolder: 'src/admin-translations' }, - translationsFolderPath: '/test/project/src/admin-translations', }); - vi.mocked(importFromJson).mockReturnValue(baseImportResult); + vi.mocked(importResources).mockReturnValue(baseImportResult); vi.spyOn(console, 'log').mockImplementation(() => undefined); const options: ImportCommandOptions = { @@ -433,11 +441,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 = { @@ -448,52 +460,68 @@ describe('import-cmd', () => { await importCommand(options); - expect(importFromJson).toHaveBeenCalledWith('/test/project/src/translations', expect.any(Object)); - }); - - it('should propagate errors thrown by promptForCollection', async () => { - vi.mocked(promptForCollection).mockRejectedValue(new Error('Missing required option: --collection')); - - await expect(importCommand({ source: '/test/import.json', locale: 'es', format: 'json' })).rejects.toThrow( - 'Missing required option: --collection', + expect(importResources).toHaveBeenCalledWith( + expect.objectContaining({ translationsFolder: '/test/project/src/translations' }), + [], + expect.any(Object), ); - expect(importFromJson).not.toHaveBeenCalled(); }); - it('should return early without error when promptForCollection returns null', async () => { - vi.mocked(promptForCollection).mockResolvedValue(null); + 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 importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); - expect(importFromJson).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('❌ Missing required option: --collection'); + expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + 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.error).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(importFromJson).not.toHaveBeenCalled(); + 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(importFromJson).not.toHaveBeenCalled(); + expect(console.error).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.error).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.error).toHaveBeenCalledWith( + '❌ Missing required options in non-interactive mode: --source, --locale', ); - expect(importFromJson).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); @@ -501,35 +529,32 @@ 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: [] }); 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({ - name: 'docs', - config: { translationsFolder: 'src/docs-translations', baseLocale: 'fr', locales: ['fr', 'de'] }, - translationsFolderPath: '/test/project/src/docs-translations', - }); + onlyCollection('docs', { translationsFolder: 'src/docs-translations', baseLocale: 'fr', locales: ['fr', 'de'] }); 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 () => { @@ -540,6 +565,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.error).toHaveBeenCalledWith('❌ Import cancelled.'); + expect(importResources).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); }); describe('Preferred terminology', () => { @@ -548,68 +596,69 @@ 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: [] }); 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: [] }); 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)')); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Warnings (1)')); + expect(console.error).toHaveBeenCalledWith( expect.stringContaining('Preferred terminology checks skipped: not valid JSON'), ); }); 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: [] }); vi.spyOn(console, 'log').mockImplementation(() => undefined); await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); - expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Preferred terminology')); + expect(console.error).not.toHaveBeenCalledWith(expect.stringContaining('Preferred terminology')); }); 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: [] }); 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', () => { 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', - }); + onlyCollection('docs', { translationsFolder: 'src/docs-translations', baseLocale: 'fr' }); vi.spyOn(console, 'log').mockImplementation(() => undefined); }); 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: [] }); await importCommand({ source: '/test/import.json', @@ -619,15 +668,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: [] }); await importCommand({ source: '/test/import.json', @@ -637,22 +687,23 @@ describe('import-cmd', () => { strategy: 'migration', }); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining('Preferred terminology checks skipped: not valid JSON'), ); }); 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: [] }); 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')); + expect(console.error).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 a94b7c3f..3da30c70 100644 --- a/apps/cli/src/commands/import-cmd.ts +++ b/apps/cli/src/commands/import-cmd.ts @@ -2,26 +2,20 @@ import { detectImportFormat, generateImportSummary, type ImportFormat, - type ImportOptions, type ImportResult, - type ImportStrategy, - importFromJson, - importFromXliff, + type ImportRunOptions, + 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'; -import { - buildSummaryPath, - ConsoleFormatter, - ErrorMessages, - isInteractiveTerminal, - loadConfiguration, - promptForCollection, - resolveWritableCollection, -} from '../utils'; +import type prompts from 'prompts'; +import { defineCommand } from '../runner/command-runner'; +import { buildSummaryPath, ConsoleFormatter } from '../utils'; export const LARGE_FILE_SIZE_THRESHOLD = 5; @@ -40,187 +34,155 @@ 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 = collection.config.baseLocale ?? config.baseLocale ?? 'en'; - const locales = collection.config.locales ?? config.locales ?? []; - - let answers: Partial; - try { - answers = await promptForMissing({ ...options, collection: collectionName }, locales, baseLocale); - } catch (error) { - if ((error as Error).message === 'Import cancelled') { - ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Import')); - return; +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`, [ + 'Import may take longer than usual.', + ]); + } + } + } catch (_error) { + // File size check is non-critical, continue with import } - 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); - - if (fileSizeMB > LARGE_FILE_SIZE_THRESHOLD) { - ConsoleFormatter.warning(`Large import file detected: ${fileSizeMB.toFixed(2)} MB`); - ConsoleFormatter.indent('Import may take longer than usual.'); + 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) { - // File size check is non-critical, continue with import - } - const preferredTerminology = loadPreferredTerminology(config, cwd); - - // Update options with answers - const finalOptions: ImportOptions = { - source: answers.source, - locale: answers.locale, - collection: collectionName, - baseLocale, - format: answers.format, - 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, - }; + // 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(''); - // Auto-detect format if not specified - if (!finalOptions.format) { + // Performance logging for verbose mode + const startTime = runOptions.verbose ? Date.now() : 0; + if (runOptions.verbose) { + console.log(`Started at: ${new Date(startTime).toLocaleTimeString()}`); + } + + let result: ImportResult; try { - finalOptions.format = detectImportFormat(finalOptions.source); - if (finalOptions.verbose) { - console.log(`Detected format: ${finalOptions.format}`); - } + const parseOptions = { onProgress: runOptions.onProgress }; + const resources = + format === 'json' + ? parseJsonImport(sourcePath, parseOptions) + : await parseXliffImport(sourcePath, parseOptions); + result = importResources(collection, resources, runOptions); } catch (error) { - ConsoleFormatter.error((error as Error).message); - return; + throw new Error(`Import failed: ${error instanceof Error ? error.message : String(error)}`); } - } - - const translationsFolderPath = collection.translationsFolderPath; - // Display import summary - console.log(''); - ConsoleFormatter.progress('Starting import...'); - ConsoleFormatter.indent(`Format: ${finalOptions.format}`); - ConsoleFormatter.indent(`Source: ${finalOptions.source}`); - ConsoleFormatter.indent(`Locale: ${finalOptions.locale}`); - ConsoleFormatter.indent(`Strategy: ${finalOptions.strategy}`); - ConsoleFormatter.indent(`Collection: ${finalOptions.collection}`); - if (finalOptions.dryRun) { - ConsoleFormatter.indent('Mode: DRY RUN (no changes will be made)'); - } - console.log(''); - - // Performance logging for verbose mode - const startTime = finalOptions.verbose ? Date.now() : 0; - if (finalOptions.verbose) { - console.log(`Started at: ${new Date(startTime).toLocaleTimeString()}`); - } - - let result: ImportResult; - try { - if (finalOptions.format === 'json') { - result = importFromJson(translationsFolderPath, finalOptions); - } else { - result = await importFromXliff(translationsFolderPath, finalOptions); + // 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] }; } - } catch (error) { - ConsoleFormatter.error(`Import failed: ${(error as Error).message}`); - return; - } - - // 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] }; - } - - // Log elapsed time in verbose mode - if (finalOptions.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)`); - } - // Display results - displayResults(result, finalOptions); + // 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 (!finalOptions.dryRun) { - try { - const summary = generateImportSummary(result, finalOptions); - 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:', @@ -228,60 +190,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 Error('Import cancelled'); - } - - 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 Error('Import cancelled'); - } - - 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:', @@ -291,123 +230,62 @@ 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 Error('Import cancelled'); - } - - 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 Error('Import cancelled'); - } - - 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 Error('Import cancelled'); - } - - answers.updateComments = updateCommentsAnswer.updateComments; - } - - if (answers.updateTags === undefined) { - const updateTagsAnswer = await prompts({ - type: 'confirm', - name: 'updateTags', - message: 'Update tags from import data?', - initial: true, - }); - - if (!('updateTags' in updateTagsAnswer)) { - throw new Error('Import cancelled'); - } - - answers.updateTags = updateTagsAnswer.updateTags; - } - - if (answers.createMissing === undefined) { - const createMissingAnswer = await prompts({ - type: 'confirm', - name: 'createMissing', - message: 'Create missing resources?', + 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 (!('createMissing' in createMissingAnswer)) { - throw new Error('Import cancelled'); - } - - answers.createMissing = createMissingAnswer.createMissing; } - } + }; + migrationFlag('updateComments', 'Update comments from import data?'); + migrationFlag('updateTags', 'Update tags from import data?'); + migrationFlag('createMissing', 'Create missing resources?'); - return answers; + return questions; } -function displayResults(result: ImportResult, options: ImportOptions): void { +function displayResults(result: ImportResult, options: ImportRunOptions): void { ConsoleFormatter.section('Import Results'); if (options.dryRun) { @@ -450,26 +328,18 @@ function displayResults(result: ImportResult, options: ImportOptions): void { // Display warnings if (result.warnings.length > 0) { - console.log(''); - ConsoleFormatter.warning(`Warnings (${result.warnings.length}):`); - result.warnings.slice(0, 10).forEach((warning) => { - ConsoleFormatter.indent(warning); - }); - if (result.warnings.length > 10) { - ConsoleFormatter.indent(`... and ${result.warnings.length - 10} more warnings`); - } + ConsoleFormatter.warning(`Warnings (${result.warnings.length}):`, [ + ...result.warnings.slice(0, 10), + ...(result.warnings.length > 10 ? [`... and ${result.warnings.length - 10} more warnings`] : []), + ]); } // Display errors if (result.errors.length > 0) { - console.log(''); - ConsoleFormatter.error(`Errors (${result.errors.length}):`); - result.errors.slice(0, 10).forEach((error) => { - ConsoleFormatter.indent(error); - }); - if (result.errors.length > 10) { - ConsoleFormatter.indent(`... and ${result.errors.length - 10} more errors`); - } + ConsoleFormatter.error(`Errors (${result.errors.length}):`, [ + ...result.errors.slice(0, 10), + ...(result.errors.length > 10 ? [`... and ${result.errors.length - 10} more errors`] : []), + ]); } console.log('─'.repeat(50)); 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/cli/src/commands/install-skill.spec.ts b/apps/cli/src/commands/install-skill.spec.ts index 34780364..9e8a3c9b 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,76 @@ 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.error).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.error).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.error).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..119e74e7 --- /dev/null +++ b/apps/cli/src/commands/move.test.ts @@ -0,0 +1,152 @@ +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('moves into the collection named by --dest-collection', async () => { + await moveResourceCommand({ collection: 'main', source: 'a.ok', dest: 'b.ok', destCollection: 'admin' }); + + expect(moveResource).toHaveBeenCalledWith(collectionNamed('main'), { + source: 'a.ok', + destination: 'b.ok', + override: undefined, + destinationCollection: expect.objectContaining({ + name: 'admin', + translationsFolder: '/project/src/admin', + readOnly: false, + }), + }); + expect(console.log).toHaveBeenCalledWith('✅ Moved 1 resource(s)'); + expect(process.exitCode).toBe(0); + }); + + it('exits 1 for an unknown destination collection', async () => { + await moveResourceCommand({ collection: 'main', source: 'a.ok', dest: 'b.ok', destCollection: 'missing' }); + + expect(moveResource).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('❌ Collection "missing" not found'); + expect(process.exitCode).toBe(1); + }); + + 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.error).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.error).toHaveBeenCalledWith('⚠️ No resources were moved.'); + expect(console.error).toHaveBeenCalledWith('❌ Errors:'); + expect(console.error).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.error).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.error).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.error).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 bb7a22af..9fac9be8 100644 --- a/apps/cli/src/commands/move.ts +++ b/apps/cli/src/commands/move.ts @@ -1,12 +1,6 @@ -import type prompts from 'prompts'; -import { moveResource } from '@simoncodes-ca/core'; -import { - loadConfiguration, - promptForCollection, - resolveWritableCollection, - executePromptsWithFallback, - type ResolvedCollection, -} from '../utils'; +import { type Collection, moveResource, openCollection } from '@simoncodes-ca/core'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface MoveResourceOptions { collection?: string; @@ -14,99 +8,67 @@ export interface MoveResourceOptions { dest?: string; destCollection?: string; override?: boolean; - 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: ResolvedCollection | undefined; - if (answers.destCollection) { - destCollection = resolveWritableCollection(answers.destCollection, config, cwd); - if (!destCollection) return; - } - - try { - const result = await moveResource(sourceCollection.translationsFolderPath, { + const result = await moveResource(collection, { source: answers.source, destination: answers.dest, - override: options.override, - destinationTranslationsFolder: destCollection?.translationsFolderPath, + override: answers.override, + destinationCollection, }); if (result.movedCount > 0) { - console.log(`✅ Moved ${result.movedCount} resource(s)`); + ConsoleFormatter.success(`Moved ${result.movedCount} resource(s)`); } else { - console.log('⚠️ No resources were moved.'); + ConsoleFormatter.warning('No resources were moved.'); } if (result.warnings && result.warnings.length > 0) { - console.log('\n⚠️ Warnings:'); - for (const warning of result.warnings) { - console.log(` - ${warning}`); - } + ConsoleFormatter.warning( + 'Warnings:', + result.warnings.map((warning) => `- ${warning}`), + ); } if (result.errors && result.errors.length > 0) { - console.log('\n❌ Errors:'); - for (const error of result.errors) { - console.log(` - ${error}`); - } + ConsoleFormatter.error( + 'Errors:', + result.errors.map((error) => `- ${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 029dd931..b3b26125 100644 --- a/apps/cli/src/commands/normalize.test.ts +++ b/apps/cli/src/commands/normalize.test.ts @@ -1,94 +1,224 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import prompts from 'prompts'; import { normalizeCommand } from './normalize'; -import { normalize } from '@simoncodes-ca/core'; -import { loadConfiguration, resolveCollection, ConsoleFormatter } from '../utils'; +import { type LingoTrackerConfig, loadConfig, type NormalizeResult, 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', () => ({ - normalize: vi.fn(), -})); +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(), 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 = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + 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(', ')}`, - }, -})); - -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) { - return { - name, - config: { translationsFolder: `path/${name}`, ...(readOnly ? { readOnly: true } : {}) }, - translationsFolderPath: `/p/path/${name}`, - }; -} +const logged = () => vi.mocked(console.log).mock.calls.map(([line]) => String(line)); +const errored = () => vi.mocked(console.error).mock.calls.map(([line]) => String(line)); describe('normalizeCommand', () => { + // Most calls here are real runs; the dry-run test overrides dryRun. + const NORMALIZE_RESULT: NormalizeResult = { + entriesProcessed: 0, + localesAdded: 0, + valuesConverted: 0, + tagsNormalized: 0, + filesCreated: 0, + filesUpdated: 0, + foldersRemoved: 0, + dryRun: false, + }; + 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(normalize).mockResolvedValue({ - entriesProcessed: 0, - localesAdded: 0, - valuesConverted: 0, - filesCreated: 0, - filesUpdated: 0, - foldersRemoved: 0, - }); + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + vi.mocked(loadConfig).mockReturnValue(CONFIG); + vi.mocked(normalize).mockResolvedValue(NORMALIZE_RESULT); }); afterEach(() => { process.exitCode = undefined; }); - it('is defined and callable', () => { - expect(typeof normalizeCommand).toBe('function'); + it('normalizes the named collection with its opened settings', async () => { + vi.mocked(normalize).mockResolvedValueOnce({ ...NORMALIZE_RESULT, dryRun: true }); + 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(errored()).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(errored()).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(errored()).toContain('❌ Failed to normalize collection "App": disk full'); + expect(process.exitCode).toBe(1); + }); + + it('with --json, reports a failed collection on stderr and keeps stdout to the JSON', async () => { + vi.mocked(normalize).mockRejectedValue(new Error('disk full')); + + await normalizeCommand({ collection: 'App', json: true }); + + expect(errored()).toEqual(['❌ Failed to normalize collection "App": disk full']); + const lines = logged(); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toMatchObject({ collections: [], totals: { collectionsProcessed: 0 } }); + expect(process.exitCode).toBe(1); + }); + + it('with --json, reports a read-only collection on stderr and keeps stdout to the JSON', async () => { + await normalizeCommand({ collection: 'Lib', json: true }); + + expect(errored()).toEqual(['❌ Collection "Lib" is read-only. Its resources cannot be modified.']); + const lines = logged(); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toMatchObject({ collections: [] }); + 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(errored()).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'); + }); + }); + + describe('interactive', () => { + beforeEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + }); + + 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); + }); + + 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(errored()).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(errored().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 cb3fac99..6d74906a 100644 --- a/apps/cli/src/commands/normalize.ts +++ b/apps/cli/src/commands/normalize.ts @@ -1,13 +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 { normalize, openCollection, ReadOnlyCollectionError } from '@simoncodes-ca/core'; +import { CommandCancelledError, defineCommand, NO_COLLECTIONS_MESSAGE } from '../runner/command-runner'; +import { ALL_ITEMS_SENTINEL, aggregateNumericFields, ConsoleFormatter } from '../utils'; export interface NormalizeOptions { collection?: string; @@ -41,250 +34,168 @@ interface NormalizeCommandResult { }; } -export async function normalizeCommand(options: NormalizeOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; - - const answers = await promptForMissing(options, config); - - // 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.config.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 = collection.translationsFolderPath; - const baseLocale = collection.config.baseLocale ?? config.baseLocale; - const locales = collection.config.locales ?? config.locales; - - if (!options.json) { - console.log(''); - ConsoleFormatter.progress(`Normalizing collection: ${collectionName}`); - if (options.dryRun) { - ConsoleFormatter.indent('(Dry run - no changes will be made)'); + if (all && interactive) { + 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 { + // stderr, so it is reported with --json too. + ConsoleFormatter.error(new ReadOnlyCollectionError(name).message); + 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; + // stderr, so it is reported with --json too. + ConsoleFormatter.error( + `Failed to normalize collection "${name}": ${e instanceof Error ? e.message : String(e)}`, + ); + } } - 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; - }> = {}; - - const collections = Object.keys(config.collections || {}); +): void { + const totals = () => ({ + ...aggregateNumericFields(collectionResults, [ + 'entriesProcessed', + 'localesAdded', + 'valuesConverted', + 'tagsNormalized', + 'filesCreated', + 'filesUpdated', + 'foldersRemoved', + ]), + collectionsProcessed: collectionResults.length, + }); - 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 Error('Normalize cancelled'); - }, - }); - - 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) { - 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); - } + if (options.dryRun) { + 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 49248d42..1a658926 100644 --- a/apps/cli/src/commands/preferred-terminology.spec.ts +++ b/apps/cli/src/commands/preferred-terminology.spec.ts @@ -1,48 +1,54 @@ 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 { 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`); const readRules = () => JSON.parse(readFileSync(filePath, 'utf8')); const indented = () => vi.mocked(ConsoleFormatter.indent).mock.calls.map((call) => call[0]); + const errorDetails = (message: string) => + vi.mocked(ConsoleFormatter.error).mock.calls.find(([line]) => line === message)?.[1] ?? []; beforeEach(() => { vi.clearAllMocks(); - clearPreferredTerminologyCache(); 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); + config = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'en', + locales: ['en', 'es'], + collections: {}, + }; + 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', () => { @@ -52,14 +58,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); }); @@ -67,7 +73,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); }); @@ -75,7 +81,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); }); }); @@ -85,7 +91,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 () => { @@ -107,7 +113,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 () => { @@ -120,7 +126,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); }); }); @@ -132,7 +138,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 () => { @@ -160,10 +166,11 @@ describe('preferredTerminologyCommand', () => { // Investment → Capital would make "Investment" both preferred and discouraged: a chain. await preferredTerminologyCommand({ add: 'Investment', preferred: 'Capital' }); - expect(ConsoleFormatter.error).toHaveBeenCalledWith('Preferred terminology not saved:'); - expect(indented().length).toBeGreaterThan(0); - expect(indented()[0]).toContain('"Expenditure → Investment":'); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(ConsoleFormatter.error).toHaveBeenCalledWith('Preferred terminology not saved:', expect.any(Array)); + const details = errorDetails('Preferred terminology not saved:'); + expect(details.length).toBeGreaterThan(0); + expect(details[0]).toContain('"Expenditure → Investment":'); + expect(process.exitCode).toBe(1); expect(ConsoleFormatter.success).not.toHaveBeenCalled(); expect(readFileSync(filePath, 'utf8')).toBe(before); }); @@ -177,7 +184,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); }); }); @@ -206,7 +213,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..8a524dd4 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})`; @@ -131,17 +124,17 @@ export async function preferredTerminologyCommand(options: PreferredTerminologyO writePreferredTerminology(result.filePath, next); } catch (error) { if (error instanceof PreferredTerminologyValidationError) { - ConsoleFormatter.error('Preferred terminology not saved:'); - for (const ruleError of error.errors) { - const row = next[ruleError.index]; - const label = row ? `"${row.discouraged} → ${row.preferred}"` : `row ${ruleError.index + 1}`; - ConsoleFormatter.indent(`${label}: ${ruleError.message}`); - } - process.exit(1); - return; + ConsoleFormatter.error( + 'Preferred terminology not saved:', + error.errors.map((ruleError) => { + const row = next[ruleError.index]; + const label = row ? `"${row.discouraged} → ${row.preferred}"` : `row ${ruleError.index + 1}`; + return `${label}: ${ruleError.message}`; + }), + ); + 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 1be7f1f2..c06e148f 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', () => ({ - loadConfiguration: vi.fn(), - ConsoleFormatter: { - section: vi.fn(), - keyValue: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, -})); +import { ConsoleFormatter } from '../utils'; -import { loadConfiguration, ConsoleFormatter } from '../utils'; +// 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, @@ -34,6 +32,8 @@ import { } from '@simoncodes-ca/core'; const BASE_CONFIG = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', baseLocale: 'en', locales: ['en', 'es'], collections: { @@ -41,28 +41,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 +69,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 +111,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 +161,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 +173,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 +187,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 c1879ad8..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,7 +12,8 @@ import { setGlobalProtectedTermsFile, } from '@simoncodes-ca/core'; import { effectiveProtectedTerms, normalizeProtectedTerms } from '@simoncodes-ca/domain'; -import { loadConfiguration, ConsoleFormatter } from '../utils'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface ProtectedTermsOptions { collection?: string; @@ -28,115 +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) { - ConsoleFormatter.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - 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) { - ConsoleFormatter.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - 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 }); @@ -148,9 +125,6 @@ export async function protectedTermsCommand(options: ProtectedTermsOptions): Pro } else { ConsoleFormatter.success(`${scopeLabel} protected terms updated: ${next.join(', ')} ${where}`); } - } catch (error) { - ConsoleFormatter.error(error instanceof Error ? error.message : String(error)); - process.exit(1); } - } -} + }, +}); diff --git a/apps/cli/src/commands/remove-locale.spec.ts b/apps/cli/src/commands/remove-locale.spec.ts index 051e5611..22c13adb 100644 --- a/apps/cli/src/commands/remove-locale.spec.ts +++ b/apps/cli/src/commands/remove-locale.spec.ts @@ -1,123 +1,167 @@ -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 { 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 = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', 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 = { - name: 'main', - config: { translationsFolder: 'src/i18n', locales: ['en', 'fr', 'de'] }, - translationsFolderPath: '/project/src/i18n', -}; +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); + it('exits 1 without calling core when the collection does not exist', async () => { + await removeLocaleCommand({ collection: 'nope', locale: 'fr' }); - await removeLocaleCommand({ locale: 'fr' }); - - expect(removeLocaleFromCollection).not.toHaveBeenCalled(); + expect(mockCore).not.toHaveBeenCalled(); + expect(console.error).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.error).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, + mutations: [], }); 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.error).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.error).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.error).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.error).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, + mutations: [], + }); + 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.error).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 9afbe8bd..fb6ac614 100644 --- a/apps/cli/src/commands/remove-locale.ts +++ b/apps/cli/src/commands/remove-locale.ts @@ -1,62 +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 baseLocale = collection.config.baseLocale ?? config.baseLocale; - const effectiveLocales = (collection.config.locales ?? config.locales ?? []).filter( - (l) => baseLocale === undefined || l !== baseLocale, - ); - - 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..74a5bb56 --- /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.error).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.error).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.error).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.error).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.error).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 820d5b96..58425991 100644 --- a/apps/cli/src/commands/translate-locale.ts +++ b/apps/cli/src/commands/translate-locale.ts @@ -1,6 +1,6 @@ -import prompts from 'prompts'; -import { translateLocale } from '@simoncodes-ca/core'; -import { loadConfiguration, resolveWritableCollection, ConsoleFormatter, ErrorMessages } from '../utils'; +import { type Collection, translateLocale } from '@simoncodes-ca/core'; +import { defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; export interface TranslateLocaleOptions { collection?: string; @@ -12,154 +12,74 @@ 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 translationEnabled = collection.config.translation?.enabled ?? config.translation?.enabled ?? false; - if (!translationEnabled) { - ConsoleFormatter.error( - `Auto-translation is not enabled for collection "${collectionName}". ` + - `Set translation.enabled = true in your configuration.`, - ); - 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; - } - - // ------------------------------------------------------------------------- - // 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; - } - - // ------------------------------------------------------------------------- - // 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; - - console.log(''); - ConsoleFormatter.progress(`Translating locale '${targetLocale}' in collection '${collectionName}'...`); - - try { - const result = await translateLocale({ - translationsFolder, - translationConfig, - targetLocale, - baseLocale, - allLocales, - cwd, - 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); - 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'); @@ -168,12 +88,19 @@ export async function translateLocaleCommand(options: TranslateLocaleOptions): P } } - if (result.failedCount > 0) { - process.exit(1); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ConsoleFormatter.error(`Translation failed: ${message}`); - process.exit(1); + 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 b7bd21d7..6b4e1d43 100644 --- a/apps/cli/src/commands/validate.icu.test.ts +++ b/apps/cli/src/commands/validate.icu.test.ts @@ -1,37 +1,34 @@ -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 { + // Collection resolution runs for real against the mocked config. + loadConfig: vi.fn(), + 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', + })), + }; }); -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', - })), -})); - import * as core from '@simoncodes-ca/core'; const mockValidateResources = vi.mocked(core.validateResources); const mockGenerateValidationSummary = vi.mocked(core.generateValidationSummary); const CONFIG = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', baseLocale: 'en', locales: ['en', 'fr', 'es'], collections: { common: { translationsFolder: 'translations/common' } }, @@ -39,21 +36,20 @@ 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', () => { 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, @@ -70,7 +66,7 @@ describe('validateCommand ICU options', () => { afterEach(() => { console.log = originalLog; - process.exit = originalExit; + process.exitCode = undefined; }); it('checks ICU by default', async () => { @@ -79,10 +75,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 () => { @@ -121,13 +118,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 ac4b4c66..33e49409 100644 --- a/apps/cli/src/commands/validate.test.ts +++ b/apps/cli/src/commands/validate.test.ts @@ -1,32 +1,26 @@ -import * as fs from 'node:fs'; -import { join } from 'node:path'; 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 { + // Collection resolution runs for real against the mocked config. + loadConfig: vi.fn(), + 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', + })), + }; }); -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', - })), -})); - import * as core from '@simoncodes-ca/core'; const mockValidateResources = vi.mocked(core.validateResources); @@ -35,6 +29,8 @@ const mockLoadPreferredTerminology = vi.mocked(core.loadPreferredTerminology); describe('validateCommand', () => { const mockConfig = { + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', baseLocale: 'en', locales: ['en', 'fr', 'es', 'de'], collections: { @@ -50,17 +46,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'); }); @@ -69,29 +64,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')); }); @@ -101,12 +96,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.'); }); @@ -116,15 +109,15 @@ 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('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.", + ); }); }); @@ -190,33 +183,35 @@ 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(); + expect(process.exitCode).toBe(0); }); it('should validate all collections from configuration', async () => { @@ -262,10 +257,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 }); }); @@ -308,18 +305,16 @@ 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, { allowTranslated: false, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }); }); @@ -361,18 +356,16 @@ 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, { allowTranslated: false, skippedLocales: [], - icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, - placeholders: { baseLocale: 'en' }, + icu: { compileValues: true, requirePortablePlurals: false }, + placeholders: true, }); }); @@ -414,17 +407,15 @@ 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), 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, }); }); @@ -502,11 +493,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( @@ -619,11 +608,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( @@ -680,22 +667,22 @@ 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'); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it('should pass validation with warnings when allowTranslated is true', async () => { @@ -758,7 +745,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 () => { @@ -778,11 +765,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, }); }); }); @@ -807,8 +794,7 @@ describe('validateCommand', () => { expect(mockValidateResources).toHaveBeenCalledWith( expect.any(Array), - expect.any(Array), - expect.objectContaining({ placeholders: { baseLocale: 'en' } }), + expect.objectContaining({ placeholders: true }), ); }); @@ -819,8 +805,7 @@ describe('validateCommand', () => { expect(mockValidateResources).toHaveBeenCalledWith( expect.any(Array), - expect.any(Array), - expect.objectContaining({ placeholders: undefined }), + expect.objectContaining({ placeholders: false }), ); }); @@ -833,14 +818,13 @@ describe('validateCommand', () => { expect(mockValidateResources).toHaveBeenCalledWith( expect.any(Array), - expect.any(Array), - expect.objectContaining({ placeholders: { baseLocale: 'en' } }), + expect.objectContaining({ placeholders: true }), ); }); }); 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, @@ -878,18 +862,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, @@ -913,11 +893,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( @@ -1025,11 +1003,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( @@ -1064,11 +1040,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( @@ -1082,11 +1060,9 @@ describe('validateCommand', () => { await validateCommand({ skipLocales: ['xx'] }); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Skipping unknown locale 'xx'")); + expect(console.error).toHaveBeenCalledWith("⚠️ Skipping unknown locale 'xx' — not in configured locales"); - // 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( @@ -1101,21 +1077,36 @@ describe('validateCommand', () => { await validateCommand({ skipLocales: ['en'] }); // No warning logged - expect(console.warn).not.toHaveBeenCalled(); + expect(console.error).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 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}`); + it('should accept a locale from a collection override without an unknown-locale warning', async () => { + vi.mocked(core.loadConfig).mockReturnValue({ + ...mockConfig, + collections: { + ...mockConfig.collections, + admin: { translationsFolder: 'translations/admin', locales: ['en', 'ja'] }, + }, }); + mockValidateResources.mockReturnValue(successResult); + + await validateCommand({ skipLocales: ['ja'] }); - await expect(validateCommand({ skipLocales: ['fr', 'es', 'de'] })).rejects.toThrow( - 'process.exit called with code 1', + expect(console.error).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 () => { + 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(); @@ -1156,16 +1147,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, @@ -1182,7 +1170,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 () => { @@ -1221,7 +1209,7 @@ describe('validateCommand', () => { await validateCommand({ allowTranslated: true }); - expect(process.exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); }); @@ -1241,15 +1229,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); @@ -1260,11 +1246,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 } }), ); }); @@ -1289,7 +1275,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 () => { @@ -1303,13 +1289,12 @@ describe('validateCommand', () => { await validateCommand({}); expect(mockValidateResources).toHaveBeenCalledWith( - expect.any(Array), expect.any(Array), expect.objectContaining({ 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 () => { @@ -1322,11 +1307,11 @@ describe('validateCommand', () => { await validateCommand({}); - expect(console.warn).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( '⚠️ Preferred terminology file not found: /project/terms.json. Treating as an empty list.', ); - expect(mockValidateResources.mock.calls[0]?.[2].terminology).toBeUndefined(); - expect(process.exit).not.toHaveBeenCalled(); + expect(mockValidateResources.mock.calls[0]?.[1].terminology).toBeUndefined(); + expect(process.exitCode).toBe(0); }); it('omits the check entirely when there are no rules', async () => { @@ -1334,7 +1319,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 ec1b7634..35099b80 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -1,6 +1,13 @@ -import { generateValidationSummary, loadPreferredTerminology, validateResources } from '@simoncodes-ca/core'; -import * as path from 'path'; -import { loadConfiguration } from '../utils'; +import { + generateValidationSummary, + loadPreferredTerminology, + openCollection, + type LingoTrackerConfig, + type ValidationOptions, + validateResources, +} from '@simoncodes-ca/core'; +import { type CommandResult, defineCommand } from '../runner/command-runner'; +import { ConsoleFormatter } from '../utils'; /** * Options for the validate command. @@ -16,9 +23,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[]; @@ -64,8 +71,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 @@ -76,6 +83,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) @@ -105,8 +113,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 @@ -141,105 +148,85 @@ 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; - - const allCollections = Object.entries(config.collections || {}).map(([name, collectionConfig]) => ({ - name, - path: path.resolve(cwd, collectionConfig.translationsFolder), - })); - - if (allCollections.length === 0) { - console.error('❌ No collections found in configuration.'); - process.exit(1); +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) { + ConsoleFormatter.error('No collections found in configuration.'); + return { exitCode: 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.'); - process.exit(1); + ConsoleFormatter.error('No target locales found in configuration.', [ + "Target locales are each collection's locales except its base locale.", + ]); + return { exitCode: 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); + ConsoleFormatter.warning(`Skipping unknown locale '${locale}' — not in configured locales`); } - const localesToValidate = targetLocales.filter((l: string) => !effectiveSkipped.includes(l)); - - if (localesToValidate.length === 0) { - console.error('❌ All target locales were skipped; nothing to validate.'); - process.exit(1); + if (targetLocales.every((locale) => effectiveSkipped.includes(locale))) { + ConsoleFormatter.error('All target locales were skipped; nothing to validate.'); + return { exitCode: 1 }; } // Terminology findings are advisory, but a broken rule file is a failure: // otherwise a typo in the file would silently switch the check off in CI. const preferredTerminology = loadPreferredTerminology(config, cwd); if (preferredTerminology.warning) { - console.warn(`⚠️ ${preferredTerminology.warning}`); + ConsoleFormatter.warning(preferredTerminology.warning); } - const baseLocaleByCollection = Object.fromEntries( - Object.entries(config.collections || {}).map(([name, collectionConfig]) => [ - name, - collectionConfig.baseLocale ?? config.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); 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..6fd95438 100644 --- a/apps/cli/src/delete-collection/delete-collection.test.ts +++ b/apps/cli/src/delete-collection/delete-collection.test.ts @@ -1,26 +1,31 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { deleteCollectionCommand } from './delete-collection'; +import type { LingoTrackerConfig } from '@simoncodes-ca/core'; 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 +45,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 +59,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 +72,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 +86,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 +101,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 +116,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 +125,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 +134,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 +145,8 @@ 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); + // This test deliberately supplies malformed persisted config with no collections field. + vi.mocked(core.loadConfig).mockReturnValue(noCollectionsConfig as unknown as LingoTrackerConfig); const options = { collectionName: 'Collection1', @@ -160,15 +155,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 +167,9 @@ describe('deleteCollectionCommand', () => { await deleteCollectionCommand(options); + expect(console.error).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 +182,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 +194,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 +204,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 +218,105 @@ 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.error).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.error).toHaveBeenCalledWith('❌ Missing required option: --collection-name'); + expect(process.exitCode).toBe(1); + }); + + it('prompts for one of several collections when interactive, then confirms', async () => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + vi.mocked(core.loadConfig).mockReturnValue(mockConfig); + vi.mocked(prompts).mockResolvedValueOnce({ collection: 'Collection2' }).mockResolvedValueOnce({ confirmed: true }); + vi.mocked(core.deleteCollectionByName).mockReturnValue({ message: 'deleted' }); + + await deleteCollectionCommand({}); + + expect(core.deleteCollectionByName).toHaveBeenCalledWith('Collection2', { cwd: '/test/project' }); + }); + + describe('confirmation', () => { + const singleCollectionConfig = { + ...mockConfig, + collections: { OnlyCollection: { translationsFolder: 'src/i18n' } }, + }; + + beforeEach(() => { + vi.mocked(core.loadConfig).mockReturnValue(singleCollectionConfig); + vi.mocked(core.deleteCollectionByName).mockReturnValue({ + message: 'Collection "OnlyCollection" deleted successfully', + }); + }); + + it('asks before deleting an auto-selected collection, naming it and its folder', async () => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + vi.mocked(prompts).mockResolvedValueOnce({ confirmed: true }); + + await deleteCollectionCommand({}); + + expect(prompts).toHaveBeenCalledTimes(1); + expect(prompts).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'confirm', + name: 'confirmed', + initial: false, + message: + 'Delete collection "OnlyCollection" (translations folder: src/i18n)? It is removed from .lingo-tracker.json; its files are kept.', + }), + expect.anything(), + ); + expect(core.deleteCollectionByName).toHaveBeenCalledWith('OnlyCollection', { cwd: '/test/project' }); + expect(console.log).toHaveBeenCalledWith('Collection "OnlyCollection" deleted successfully'); + expect(process.exitCode).toBe(0); + }); + + it('a declined confirmation deletes nothing and cancels with exit 0', async () => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + vi.mocked(prompts).mockResolvedValueOnce({ confirmed: false }); + + await deleteCollectionCommand({ collectionName: 'OnlyCollection' }); + + expect(core.deleteCollectionByName).not.toHaveBeenCalled(); + const cancelLines = vi.mocked(console.error).mock.calls.filter(([line]) => String(line).includes('cancelled')); + expect(cancelLines).toEqual([['❌ Delete collection cancelled.']]); + expect(process.exitCode).toBe(0); + }); + + it('--yes skips the confirmation when interactive', async () => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + + await deleteCollectionCommand({ collectionName: 'OnlyCollection', yes: true }); + + expect(prompts).not.toHaveBeenCalled(); + expect(core.deleteCollectionByName).toHaveBeenCalledWith('OnlyCollection', { cwd: '/test/project' }); + expect(process.exitCode).toBe(0); + }); + + it('does not ask when non-interactive: the flags are the consent', async () => { + await deleteCollectionCommand({}); + + expect(prompts).not.toHaveBeenCalled(); + expect(core.deleteCollectionByName).toHaveBeenCalledWith('OnlyCollection', { cwd: '/test/project' }); + expect(process.exitCode).toBe(0); + }); }); }); diff --git a/apps/cli/src/delete-collection/delete-collection.ts b/apps/cli/src/delete-collection/delete-collection.ts index ed3ea454..b0e321c6 100644 --- a/apps/cli/src/delete-collection/delete-collection.ts +++ b/apps/cli/src/delete-collection/delete-collection.ts @@ -1,22 +1,37 @@ -import { deleteCollectionByName } from '@simoncodes-ca/core'; -import { loadConfiguration, promptForCollection, ConsoleFormatter } from '../utils'; +import * as path from 'path'; +import { CONFIG_FILENAME, deleteCollectionByName } from '@simoncodes-ca/core'; +import { CommandCancelledError, defineCommand } from '../runner/command-runner'; export interface DeleteCollectionOptions { collectionName?: string; + /** Skip the confirmation prompt. */ + yes?: boolean; } -export async function deleteCollectionCommand(options: DeleteCollectionOptions): Promise { - const loaded = loadConfiguration({ exitOnError: false }); - if (!loaded) return; - const { config, cwd } = loaded; +/** + * Removes a collection's registration, so a read-only collection may be deleted too. + * Interactive, it asks first unless `--yes`: the runner may have auto-selected the only + * collection. Non-interactive, the flags are the consent. + */ +export const deleteCollectionCommand = defineCommand()({ + name: 'Delete collection', + collection: 'read', + collectionOption: 'collectionName', + run: async ({ collection, cwd, answers, interactive, ask }) => { + if (!answers.yes && interactive) { + const folder = path.relative(cwd, collection.translationsFolder) || '.'; + const { confirmed } = await ask({ + type: 'confirm', + name: 'confirmed', + message: `Delete collection "${collection.name}" (translations folder: ${folder})? It is removed from ${CONFIG_FILENAME}; its files are kept.`, + initial: false, + }); + if (confirmed !== true) { + throw new CommandCancelledError(); + } + } - const collectionName = await promptForCollection(config, options.collectionName); - if (!collectionName) return; - - try { - const result = deleteCollectionByName(collectionName, { 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..968a59f9 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(() => ({ @@ -13,32 +15,26 @@ vi.mock('node:fs', async (importOriginal) => { return { ...actual, ...fsMocks, - default: { ...actual.default, ...fsMocks }, + default: { ...actual, ...fsMocks }, }; }); 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.error).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 9441cecf..e7599769 100644 --- a/apps/cli/src/init/init.ts +++ b/apps/cli/src/init/init.ts @@ -8,17 +8,27 @@ import { type LingoTrackerConfig, type LingoTrackerCollection, type TranslationConfig, - type TokenCasing, - type BundleDefinition, } from '@simoncodes-ca/core'; -import { getCwd, ConsoleFormatter, executePromptsWithFallback } from '../utils'; +import type { BundleDefinition, TokenCasing } from '@simoncodes-ca/domain'; +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 +36,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 +100,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 +253,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 +272,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..157c477c --- /dev/null +++ b/apps/cli/src/main.spec.ts @@ -0,0 +1,72 @@ +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() })); +vi.mock('./delete-collection/delete-collection', () => ({ deleteCollectionCommand: vi.fn() })); +vi.mock('./commands/move', () => ({ moveResourceCommand: vi.fn() })); + +import { addResourceCommand } from './add-resource/add-resource'; +import { moveResourceCommand } from './commands/move'; +import { validateCommand } from './commands/validate'; +import { deleteCollectionCommand } from './delete-collection/delete-collection'; + +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(() => { + const calls = [validateCommand, addResourceCommand, deleteCollectionCommand, moveResourceCommand].map( + (command) => vi.mocked(command).mock.calls.length, + ); + if (calls.every((count) => count === 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":' })); + }); + + it('passes --yes to delete-collection', async () => { + await runCli('delete-collection', '--collection-name', 'app', '--yes'); + + expect(deleteCollectionCommand).toHaveBeenCalledWith({ collectionName: 'app', yes: true }); + }); + + it('passes --dest-collection to move', async () => { + await runCli('move', '--collection', 'main', '--source', 'a.ok', '--dest', 'b.ok', '--dest-collection', 'admin'); + + expect(moveResourceCommand).toHaveBeenCalledWith({ + collection: 'main', + source: 'a.ok', + dest: 'b.ok', + destCollection: 'admin', + }); + }); +}); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 7100666d..1d5f8f67 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -60,6 +60,7 @@ program .command('delete-collection') .description('Delete a translation collection from the project') .option('--collection-name ', 'Name of the collection to delete') + .option('--yes', 'Skip confirmation prompt') .action(async (options) => { const { deleteCollectionCommand } = await import('./delete-collection/delete-collection'); await deleteCollectionCommand(options); @@ -100,11 +101,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 @@ -115,7 +112,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) => { @@ -140,8 +137,11 @@ program .option('--collection ', 'Name of the collection') .option('--source ', 'Source key or pattern (e.g., common.buttons.ok or common.buttons.*)') .option('--dest ', 'Destination key (e.g., common.actions.ok)') + .option( + '--dest-collection ', + 'Move into another collection; the destination key is relative to that collection', + ) .option('--override', 'Override destination if it exists') - .option('--verbose', 'Show detailed output') .action(async (options) => { const { moveResourceCommand } = await import('./commands/move'); await moveResourceCommand(options); @@ -482,6 +482,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..c97b0149 --- /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.error).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.error).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.error).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.error).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.error).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.error).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.error).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.error).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.error).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.error).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.error).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.error).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.error).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.error).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.error).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/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/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 4fe69a3b..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?.translationsFolderPath).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?.translationsFolderPath).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')); - }); - - 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?.translationsFolderPath).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?.translationsFolderPath).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')); - }); - - it('should handle empty base directory', () => { - const result = resolveCollection('main', mockConfig, ''); - - expect(result?.translationsFolderPath).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?.translationsFolderPath).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?.translationsFolderPath).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 dca8ac5d..00000000 --- a/apps/cli/src/utils/collection-resolver.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { resolve } from 'node:path'; -import type { LingoTrackerConfig, LingoTrackerCollection } 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. - * - * @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 - * - * @example - * const collection = resolveCollection('main', config, cwd); - * if (!collection) return; - * // Use: collection.translationsFolderPath - */ -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), - }; -} - -/** - * 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, -): ResolvedCollection | null { - const resolved = resolveCollection(collectionName, config, baseDirectory); - if (!resolved) return null; - - if (resolved.config.readOnly) { - console.log(ErrorMessages.COLLECTION_READ_ONLY(resolved.name)); - process.exitCode = 1; - return null; - } - - return resolved; -} 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 bac5b606..00000000 --- a/apps/cli/src/utils/config-loader.spec.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -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 = { - exportFolder: 'dist/lingo-export', - importFolder: 'dist/lingo-import', - baseLocale: 'en', - locales: ['en', 'es', 'fr'], - collections: { - default: { - translationsFolder: 'src/translations', - }, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - - // Mock path.join to simply concatenate with '/' - vi.mocked(path.join) - .mockReset() - .mockImplementation((...segments) => segments.join('/')); - - // 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(); - }); - - 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)); - - 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'); - }); - - it('should parse complex configuration with bundles', () => { - const complexConfig = { - ...mockConfig, - bundles: { - 'admin-bundle': { - collections: ['admin', 'shared'], - outputFormat: 'single-file', - }, - }, - }; - - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(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)', () => { - 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}`); - }); - - 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)', () => { - 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}`); - }); - - 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)', () => { - 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}`); - }); - - 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)', () => { - 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}`); - }); - - 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', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{ 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)', () => { - 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'); - }); - - 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)); - - const result = loadConfiguration(); - - expect(result).not.toBeNull(); - expect(result?.cwd).toBe('/standard/project'); - expect(result?.configPath).toBe('/standard/project/.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.'); - 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', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{ 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', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{}'); - - 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: {}, - }; - - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue(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', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockImplementation(() => { - throw new Error('Permission denied'); - }); - - const result = loadConfiguration({ exitOnError: false }); - - expect(result).toBeNull(); - expect(console.error).toHaveBeenCalledWith('❌ Failed to parse configuration file: Permission denied'); - }); - }); -}); diff --git a/apps/cli/src/utils/config-loader.ts b/apps/cli/src/utils/config-loader.ts deleted file mode 100644 index 08a3c182..00000000 --- a/apps/cli/src/utils/config-loader.ts +++ /dev/null @@ -1,127 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { CONFIG_FILENAME, type LingoTrackerConfig } 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 and parses the LingoTracker configuration file (.lingo-tracker.json). - * - * This utility centralizes configuration loading logic used across CLI commands, - * eliminating duplication and ensuring consistent error handling and messaging. - * - * **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 errors: Shows specific JSON parsing 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(); - 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 { - 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; - } - } 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/apps/cli/src/utils/console-formatter.spec.ts b/apps/cli/src/utils/console-formatter.spec.ts index 4e7b89e1..a4431379 100644 --- a/apps/cli/src/utils/console-formatter.spec.ts +++ b/apps/cli/src/utils/console-formatter.spec.ts @@ -3,20 +3,31 @@ import { ConsoleFormatter } from './console-formatter'; describe('ConsoleFormatter', () => { let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; beforeEach(() => { // eslint-disable-next-line @typescript-eslint/no-empty-function consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-empty-function + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); }); describe('error', () => { - it('should format error message with ❌ prefix', () => { + it('should format error message with ❌ prefix on stderr', () => { ConsoleFormatter.error('Something went wrong'); - expect(consoleLogSpy).toHaveBeenCalledWith('❌ Something went wrong'); + expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Something went wrong'); + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it('prints its detail lines under it, indented, on stderr', () => { + ConsoleFormatter.error('Errors (2):', ['- first', 'second\ncontinued']); + expect(consoleErrorSpy.mock.calls).toEqual([['❌ Errors (2):'], [' - first'], [' second\n continued']]); + expect(consoleLogSpy).not.toHaveBeenCalled(); }); }); @@ -28,10 +39,28 @@ describe('ConsoleFormatter', () => { }); describe('warning', () => { - it('should format warning message with ⚠️ prefix', () => { + it('should format warning message with ⚠️ prefix on stderr', () => { ConsoleFormatter.warning('Be careful'); - expect(consoleLogSpy).toHaveBeenCalledWith('⚠️ Be careful'); + expect(consoleErrorSpy).toHaveBeenCalledWith('⚠️ Be careful'); + expect(consoleLogSpy).not.toHaveBeenCalled(); }); + + it('prints its detail lines under it, indented, on stderr', () => { + ConsoleFormatter.warning('Warnings (1):', ['- one']); + expect(consoleErrorSpy.mock.calls).toEqual([['⚠️ Warnings (1):'], [' - one']]); + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + }); + + it('writes success, info, progress, section, indent and keyValue to stdout only', () => { + ConsoleFormatter.success('s'); + ConsoleFormatter.info('i'); + ConsoleFormatter.progress('p'); + ConsoleFormatter.section('t'); + ConsoleFormatter.indent('x'); + ConsoleFormatter.keyValue('k', 1); + expect(consoleLogSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); }); describe('info', () => { diff --git a/apps/cli/src/utils/console-formatter.ts b/apps/cli/src/utils/console-formatter.ts index 81d8f784..dd4ea552 100644 --- a/apps/cli/src/utils/console-formatter.ts +++ b/apps/cli/src/utils/console-formatter.ts @@ -2,7 +2,11 @@ * Standardized console output formatting utilities for consistent CLI UX. * * Provides a centralized way to format success, error, warning, info, and progress - * messages across all CLI commands. This ensures: + * messages across all CLI commands. + * + * Stdout is the payload, stderr is diagnostics: `error` and `warning` (with their detail + * lines) write to stderr, so a command whose stdout is piped (`glossary --stdout`, + * `normalize --json`) keeps it clean. Every other method writes to stdout. This ensures: * - Consistent emoji usage and message formatting * - Easier to test output format * - Single place to change output style globally @@ -18,13 +22,31 @@ * ``` */ +/** Indents every line of `message` by `level` × 2 spaces. */ +function indentLines(message: string, level: number): string { + const spaces = ' '.repeat(level); + return message + .split('\n') + .map((line) => `${spaces}${line}`) + .join('\n'); +} + +/** Writes a diagnostic and its detail lines (indented one level) to stderr. */ +function diagnostic(line: string, details: readonly string[]): void { + console.error(line); + for (const detail of details) { + console.error(indentLines(detail, 1)); + } +} + export const ConsoleFormatter = { /** - * Displays an error message with ❌ prefix + * Displays an error message with ❌ prefix, on stderr * @param message - Error message to display + * @param details - Lines printed under it, indented one level (also on stderr) */ - error(message: string): void { - console.log(`❌ ${message}`); + error(message: string, details: readonly string[] = []): void { + diagnostic(`❌ ${message}`, details); }, /** @@ -36,11 +58,12 @@ export const ConsoleFormatter = { }, /** - * Displays a warning message with ⚠️ prefix + * Displays a warning message with ⚠️ prefix, on stderr * @param message - Warning message to display + * @param details - Lines printed under it, indented one level (also on stderr) */ - warning(message: string): void { - console.log(`⚠️ ${message}`); + warning(message: string, details: readonly string[] = []): void { + diagnostic(`⚠️ ${message}`, details); }, /** @@ -78,13 +101,7 @@ export const ConsoleFormatter = { * @param level - Indentation level (default: 1, each level = 2 spaces) */ indent(message: string, level = 1): void { - const spaces = ' '.repeat(level); - console.log( - message - .split('\n') - .map((line) => `${spaces}${line}`) - .join('\n'), - ); + console.log(indentLines(message, level)); }, /** diff --git a/apps/cli/src/utils/error-messages.spec.ts b/apps/cli/src/utils/error-messages.spec.ts deleted file mode 100644 index de5f4906..00000000 --- a/apps/cli/src/utils/error-messages.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { ErrorMessages } from './error-messages'; - -describe('ErrorMessages', () => { - describe('Configuration Errors', () => { - it('should provide config not found message', () => { - expect(ErrorMessages.CONFIG_NOT_FOUND).toBe( - '❌ No Lingo Tracker configuration found. Run `lingo-tracker init` first.', - ); - }); - - it('should provide config invalid message', () => { - expect(ErrorMessages.CONFIG_INVALID).toBe('❌ Invalid configuration file format.'); - }); - - it('should provide config parse failed message', () => { - const error = 'Unexpected token }'; - expect(ErrorMessages.CONFIG_PARSE_FAILED(error)).toBe(`❌ Failed to parse configuration file: ${error}`); - }); - }); - - 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.'); - }); - - it('should provide operation failed message with reason', () => { - expect(ErrorMessages.OPERATION_FAILED('Build', 'TypeScript errors')).toBe('❌ Build failed: TypeScript errors'); - }); - }); - - describe('Resource Errors', () => { - it('should provide resource not found message', () => { - expect(ErrorMessages.RESOURCE_NOT_FOUND('app.button.ok')).toBe('❌ Resource key "app.button.ok" not found.'); - }); - - it('should provide resource exists message', () => { - expect(ErrorMessages.RESOURCE_EXISTS('app.button.ok')).toBe('❌ Resource key "app.button.ok" already exists.'); - }); - - it('should provide invalid resource key message', () => { - expect(ErrorMessages.INVALID_RESOURCE_KEY).toBe('❌ Invalid resource key format.'); - }); - }); - - describe('Locale Errors', () => { - it('should provide locale not found message', () => { - expect(ErrorMessages.LOCALE_NOT_FOUND('fr')).toBe('❌ Locale "fr" not found in configuration.'); - }); - - it('should provide no locales configured message', () => { - expect(ErrorMessages.NO_LOCALES_CONFIGURED).toBe('❌ No locales configured.'); - }); - }); - - describe('File System Errors', () => { - it('should provide file not found message', () => { - expect(ErrorMessages.FILE_NOT_FOUND('/path/to/file.json')).toBe('❌ File not found: /path/to/file.json'); - }); - - it('should provide directory not found message', () => { - expect(ErrorMessages.DIRECTORY_NOT_FOUND('/path/to/dir')).toBe('❌ Directory not found: /path/to/dir'); - }); - - it('should provide file read failed message without reason', () => { - expect(ErrorMessages.FILE_READ_FAILED('/path/to/file.json')).toBe('❌ Failed to read file: /path/to/file.json'); - }); - - it('should provide file read failed message with reason', () => { - expect(ErrorMessages.FILE_READ_FAILED('/path/to/file.json', 'Permission denied')).toBe( - '❌ Failed to read file /path/to/file.json: Permission denied', - ); - }); - - it('should provide file write failed message without reason', () => { - expect(ErrorMessages.FILE_WRITE_FAILED('/path/to/file.json')).toBe('❌ Failed to write file: /path/to/file.json'); - }); - - it('should provide file write failed message with reason', () => { - expect(ErrorMessages.FILE_WRITE_FAILED('/path/to/file.json', 'Disk full')).toBe( - '❌ Failed to write file /path/to/file.json: Disk full', - ); - }); - }); -}); diff --git a/apps/cli/src/utils/error-messages.ts b/apps/cli/src/utils/error-messages.ts deleted file mode 100644 index 8d48e2db..00000000 --- a/apps/cli/src/utils/error-messages.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Standardized error messages for consistent CLI user experience. - * - * Centralizes all error message definitions to ensure: - * - Consistent wording and formatting across commands - * - Uniform use of backticks vs quotes - * - Consistent suggestions for resolving issues - * - Single place to update messages - * - Easier to maintain documentation - * - * @example - * ```typescript - * 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'])); - * ``` - */ - -export const ErrorMessages = { - // Configuration Errors - /** - * Error when .lingo-tracker.json file is not found - */ - CONFIG_NOT_FOUND: '❌ No Lingo Tracker configuration found. Run `lingo-tracker init` first.', - - /** - * Error when configuration file has invalid format - */ - CONFIG_INVALID: '❌ Invalid configuration file format.', - - /** - * Error when configuration file cannot be parsed - * @param error - Detailed error message from parser - */ - 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 - */ - NO_COLLECTIONS_AVAILABLE: '❌ No collections available.', - - /** - * Error when attempting to modify resources in a read-only collection - * @param name - Name of the read-only collection - */ - 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 - * @param operation - Name of the operation that failed - * @param reason - Optional reason for failure - */ - OPERATION_FAILED: (operation: string, reason?: string) => - reason ? `❌ ${operation} failed: ${reason}` : `❌ ${operation} failed.`, - - // Resource Errors - /** - * Error when specified resource key is not found - * @param key - Resource key that was not found - */ - RESOURCE_NOT_FOUND: (key: string) => `❌ Resource key "${key}" not found.`, - - /** - * Error when resource key already exists - * @param key - Resource key that already exists - */ - RESOURCE_EXISTS: (key: string) => `❌ Resource key "${key}" already exists.`, - - /** - * Error when resource key format is invalid - */ - INVALID_RESOURCE_KEY: '❌ Invalid resource key format.', - - // Locale Errors - /** - * Error when specified locale is not found in configuration - * @param locale - Locale code that was not found - */ - LOCALE_NOT_FOUND: (locale: string) => `❌ Locale "${locale}" not found in configuration.`, - - /** - * Error when no locales are configured - */ - NO_LOCALES_CONFIGURED: '❌ No locales configured.', - - // File System Errors - /** - * Error when a required file is not found - * @param filePath - Path to the missing file - */ - FILE_NOT_FOUND: (filePath: string) => `❌ File not found: ${filePath}`, - - /** - * Error when a required directory is not found - * @param dirPath - Path to the missing directory - */ - DIRECTORY_NOT_FOUND: (dirPath: string) => `❌ Directory not found: ${dirPath}`, - - /** - * Error when file read operation fails - * @param filePath - Path to the file that couldn't be read - * @param reason - Optional reason for failure - */ - FILE_READ_FAILED: (filePath: string, reason?: string) => - reason ? `❌ Failed to read file ${filePath}: ${reason}` : `❌ Failed to read file: ${filePath}`, - - /** - * Error when file write operation fails - * @param filePath - Path to the file that couldn't be written - * @param reason - Optional reason for failure - */ - FILE_WRITE_FAILED: (filePath: string, reason?: string) => - reason ? `❌ Failed to write file ${filePath}: ${reason}` : `❌ Failed to write file: ${filePath}`, -} as const; diff --git a/apps/cli/src/utils/index.ts b/apps/cli/src/utils/index.ts index f91edb77..1be8489b 100644 --- a/apps/cli/src/utils/index.ts +++ b/apps/cli/src/utils/index.ts @@ -1,8 +1,4 @@ -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 './result-aggregator'; diff --git a/apps/cli/src/utils/preferred-terminology-warnings.ts b/apps/cli/src/utils/preferred-terminology-warnings.ts index a4dc57ee..157cc5b3 100644 --- a/apps/cli/src/utils/preferred-terminology-warnings.ts +++ b/apps/cli/src/utils/preferred-terminology-warnings.ts @@ -29,9 +29,9 @@ export function warnAboutPreferredTerminology( } for (const { rule } of findPreferredTermFindings(baseValue, loaded.rules)) { - ConsoleFormatter.warning(`Preferred terminology: ${describePreferredTermRule(rule)}`); - if (rule.reason) { - ConsoleFormatter.indent(rule.reason); - } + ConsoleFormatter.warning( + `Preferred terminology: ${describePreferredTermRule(rule)}`, + rule.reason ? [rule.reason] : [], + ); } } 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 2cf5768e..42fe41bb 100644 --- a/apps/cli/src/utils/prompt-utils.ts +++ b/apps/cli/src/utils/prompt-utils.ts @@ -1,5 +1,3 @@ -import prompts from 'prompts'; - /** * Sentinel value used to represent "all items" in multiselect prompts */ @@ -9,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; } @@ -57,86 +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 Error if operation is cancelled or 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: () => { - const opName = operationName || 'Operation'; - throw new Error(`${opName} cancelled`); - }, - }); - - 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/tsconfig.spec.json b/apps/cli/tsconfig.spec.json index ba1ad41a..0b6e0719 100644 --- a/apps/cli/tsconfig.spec.json +++ b/apps/cli/tsconfig.spec.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", + "typeRoots": ["../../node_modules/@types", "../../node_modules", "../../types"], "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"] }, "include": [ @@ -18,5 +19,6 @@ "src/**/*.test.jsx", "src/**/*.spec.jsx", "src/**/*.d.ts" - ] + ], + "files": ["src/test-setup.ts"] } diff --git a/apps/tracker/PRODUCT.md b/apps/tracker/PRODUCT.md index a159cd9f..d5768f25 100644 --- a/apps/tracker/PRODUCT.md +++ b/apps/tracker/PRODUCT.md @@ -71,7 +71,7 @@ Constraints: Every user-visible string goes through the resource workflow — no hardcoded copy. Shipped UI locales: `en`, `de`, `es`, `fr-ca`, `ja`, `ru`. Layouts must survive long German and Russian strings and Japanese line-breaking. -- Angular 20 standalone components, Angular Material, NgRx Signals, OnPush change detection. +- Angular 21 standalone components, Angular Material, NgRx Signals, OnPush change detection. - Terminology is domain vocabulary and is not up for renaming: collection, resource, key, locale, base locale, bundle, glossary, protected term, and the status lifecycle `new` → `translated` → `stale` → `verified`. diff --git a/apps/tracker/project.json b/apps/tracker/project.json index 09b51ab1..43213dc1 100644 --- a/apps/tracker/project.json +++ b/apps/tracker/project.json @@ -97,9 +97,28 @@ "lint": { "executor": "@nx/eslint:lint" }, + "typecheck": { + "dependsOn": ["^typecheck", "typecheck-spec"] + }, + "typecheck-spec": { + "executor": "nx:run-commands", + "cache": true, + "dependsOn": ["generate-tokens"], + "inputs": [ + "default", + "^default", + "{workspaceRoot}/tsconfig.base.json", + { "dependentTasksOutputFiles": "**/*.ts" } + ], + "options": { + "cwd": "apps/tracker", + "command": "tsc --noEmit -p tsconfig.spec.json" + } + }, "test": { "executor": "@nx/vitest:test", "dependsOn": ["generate-tokens"], + "inputs": ["default", "^default", "translationFixtures", { "dependentTasksOutputFiles": "**/*.ts" }], "options": { "reportsDirectory": "../../coverage/apps/tracker" } 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..d7a0ed2c --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.spec.ts @@ -0,0 +1,488 @@ +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 ResourceEntryDraft, + removeTag, + toCreateDto, + toUpdateDto, +} from './resource-entry-draft'; + +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, + 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('common.ok'), entry('common.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('common.fromTree')], children: [] })], + browserFolderPath: 'common', + browserEntries: [entry('common.fromList')], + }), + ); + expect(keys && [...keys]).toEqual(['fromTree']); + }); +}); + +describe('collisionFor', () => { + const tree = [ + folder('common', { + path: 'common', + resources: [], + 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((key) => entry(path ? `${path}.${key}` : key)) }); + 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('common.buttons.ok'), entry('common.buttons.confirm.dialog.title')], + }), + }), + moreLabel, + ); + + expect(entryNames(nodes)).toEqual(['ok']); + }); + }); + + describe('marks', () => { + 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'], + ['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(`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')}`); + + 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'], + }), + ); + + expect(dto).toEqual({ + key: 'common.buttons.ok', + baseValue: 'OK', + comment: 'The affirmative button', + tags: ['browser'], + 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))).toMatchObject(expected); + }); +}); + +describe('toUpdateDto and editedLocales', () => { + 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[] => [ + { 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).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('moveTo' in dto).toBe(false); + }); + + it('should keep a root-level entry on its bare key', () => { + expect(toUpdateDto(draft({ folderPath: '' }), entry('ok')).key).toBe('ok'); + }); + + 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 send a move out of the collection root', () => { + const dto = toUpdateDto(draft({ folderPath: 'common' }), entry('ok')); + + expect(dto.key).toBe('ok'); + expect(dto.moveTo).toBe('common'); + }); +}); + +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..e35d813d --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/resource-entry-draft.ts @@ -0,0 +1,363 @@ +import type { + CreateResourceDto, + FolderNodeDto, + ResourceSummaryDto, + TranslationStatus, + UpdateResourceDto, +} from '@simoncodes-ca/data-transfer'; +import { isValidSegment, normalizeTag, resolveResourceKey, summaryTarget } 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[]; +} + +// ── 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. + * + * 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?.filter((resource) => resource.folderPath === folderPath).map((resource) => resource.entryKey) ?? + known.fetched.get(folderPath); + return keys ? new Set(keys) : 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. 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): 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, + 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 !== (summaryTarget(original, translation.locale)?.status ?? 'new'); + return hasValue || statusChanged; + }); +} + +/** + * 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: ResourceSummaryDto): UpdateResourceDto { + const dto: UpdateResourceDto = { + key: original.fullKey, + baseValue: draft.baseValue, + comment: draft.comment.trim() || undefined, + tags: [...draft.tags], + }; + + if (draft.folderPath !== original.folderPath) { + dto.moveTo = draft.folderPath; + } + + const locales = editedLocales(draft, original); + 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/similar-translations.html b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.html index 06a04d4b..8715596f 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) { diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss index dd6f0aba..a677f4b9 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss @@ -93,6 +93,45 @@ } } +// The key, with the similarity annotation pinned to the right of its first line. +.result-head { + display: flex; + align-items: baseline; + gap: var(--spacing-2); + width: 100%; + + .result-key { + flex: 1; + min-width: 0; + } +} + +// A quiet annotation, not a badge: it helps rank the rows, the value is what the user reads. +.result-similarity { + // The positioned ancestor of its absolutely positioned `.sr-only` label. + position: relative; + flex-shrink: 0; + padding: 0 var(--spacing-1); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + font-size: var(--font-size-xs); + font-variant-numeric: tabular-nums; + line-height: 1.4; + color: var(--color-text-secondary); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + .result-key { font-size: var(--font-size-sm); font-family: var(--font-family-mono); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.spec.ts new file mode 100644 index 00000000..117c80ae --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.spec.ts @@ -0,0 +1,69 @@ +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import type { SearchResultDto } from '@simoncodes-ca/data-transfer'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; +import { SimilarTranslations } from './similar-translations'; + +describe('SimilarTranslations', () => { + let spectator: Spectator; + + const hit = (fullKey: string, value: string, similarity?: number): SearchResultDto => { + const segments = fullKey.split('.'); + const entryKey = segments.pop() ?? ''; + return { + fullKey, + folderPath: segments.join('.'), + entryKey, + base: { locale: 'en', value }, + targets: [], + tags: [], + inheritedTags: [], + matchType: 'similar-value', + ...(similarity === undefined ? {} : { similarity }), + }; + }; + + const createComponent = createComponentFactory({ + component: SimilarTranslations, + imports: [getTranslocoTestingModule()], + detectChanges: false, + }); + + const chips = () => spectator.queryAll('[data-testid="similar-similarity"]'); + + beforeEach(() => { + spectator = createComponent({ props: { results: [], hasSearchQuery: true } }); + }); + + it('should show each similarity as a rounded percentage with a screen-reader label', () => { + spectator.setInput('results', [hit('common.save', 'Save', 1), hit('common.saveDraft', 'Save draft', 0.4049)]); + spectator.detectChanges(); + + const rendered = chips(); + expect(rendered).toHaveLength(2); + expect(rendered[0].querySelector('[aria-hidden="true"]')?.textContent?.trim()).toBe('100%'); + expect(rendered[1].querySelector('[aria-hidden="true"]')?.textContent?.trim()).toBe('40%'); + expect(rendered[1].querySelector('.sr-only')?.textContent?.trim()).toBe('40% similar'); + }); + + it('should show 0% for a similarity of 0, not hide it', () => { + spectator.setInput('results', [hit('common.save', 'Save', 0)]); + spectator.detectChanges(); + + const rendered = chips(); + expect(rendered).toHaveLength(1); + expect(rendered[0].querySelector('[aria-hidden="true"]')?.textContent?.trim()).toBe('0%'); + expect(rendered[0].querySelector('.sr-only')?.textContent?.trim()).toBe('0% similar'); + }); + + it('should show no percentage for a hit without similarity', () => { + spectator.setInput('results', [hit('common.save', 'Save'), hit('common.saveDraft', 'Save draft', 0.8)]); + spectator.detectChanges(); + + const rows = spectator.queryAll('.result-item'); + expect(rows).toHaveLength(2); + expect(rows[0].querySelector('[data-testid="similar-similarity"]')).toBeNull(); + expect(rows[1].querySelector('[data-testid="similar-similarity"]')?.textContent).toContain('80%'); + expect(rows[0].textContent).not.toContain('%'); + }); +}); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.ts index f88f2ff5..73c07774 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.ts @@ -21,6 +21,8 @@ interface SimilarRow { readonly leafSegment: string; readonly value: string; readonly isExact: boolean; + /** `similarity` (0..1) as a whole percentage; absent when the hit carries no similarity. */ + readonly similarityPercent: number | undefined; } @Component({ @@ -38,7 +40,6 @@ export class SimilarTranslations { results = input.required(); isLoading = input(false); hasSearchQuery = input(false); - baseLocale = input.required(); /** * The key of the hit whose base value is the typed value verbatim, if any. * That row is not just similar — it is the same string already spoken for. @@ -68,15 +69,13 @@ export class SimilarTranslations { readonly displayedRows = computed(() => this.displayedResults().map((result) => { - const segments = result.key.split('.').filter((segment) => segment.length > 0); - const leafSegment = segments.length > 0 ? segments[segments.length - 1] : result.key; - return { result, - parentSegments: segments.slice(0, -1), - leafSegment, + parentSegments: result.folderPath.split('.').filter((segment) => segment.length > 0), + leafSegment: result.entryKey, value: this.getTranslationValue(result), isExact: this.isExactMatch(result), + similarityPercent: result.similarity === undefined ? undefined : Math.round(result.similarity * 100), }; }), ); @@ -98,11 +97,11 @@ export class SimilarTranslations { } isExactMatch(result: SearchResultDto): boolean { - return this.exactKey() !== '' && result.key === this.exactKey(); + return this.exactKey() !== '' && result.fullKey === this.exactKey(); } + /** The base value, or the first target value when the base value is empty. */ getTranslationValue(result: SearchResultDto): string { - const locale = this.baseLocale(); - return result.translations[locale] || Object.values(result.translations)[0] || ''; + return result.base.value || result.targets.find((target) => target.value)?.value || ''; } } 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 21999d70..00000000 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-value-filter.ts +++ /dev/null @@ -1,46 +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, - baseLocale: string, - limit: number = SIMILAR_DISPLAY_LIMIT, -): SearchResultDto[] { - const typed = typedValue.trim().toLowerCase(); - if (!typed) { - return []; - } - - const kept = results.filter((result) => { - const baseValue = (result.translations[baseLocale] ?? '').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.html b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html index 0f4614a8..0dd12b42 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html @@ -391,13 +391,13 @@

[disabled]="isReadOnly()" [attr.aria-label]="TOKENS.BROWSER.TRANSLATIONEDITOR.STATUS | transloco" > - {{ statusLabels[localeStatus] | transloco }} + {{ statusLabelToken(localeStatus) | transloco }} @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 918a4f4f..8abf014b 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,13 @@ 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 { unprotected } from '@ngrx/signals/testing'; +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 +34,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 +51,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, @@ -56,11 +94,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 }, @@ -126,11 +168,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); @@ -152,39 +190,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 +217,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 +230,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 })); @@ -301,11 +251,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'); @@ -397,25 +343,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,93 +437,14 @@ 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 = { - 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); @@ -607,11 +455,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); @@ -620,11 +464,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); @@ -633,11 +473,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); @@ -650,18 +486,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); @@ -678,29 +506,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'); @@ -812,12 +617,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)) }); @@ -956,33 +756,12 @@ 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); - 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'); @@ -995,33 +774,12 @@ 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); - 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 +920,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(); @@ -1184,7 +930,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'); @@ -1193,10 +939,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(); @@ -1208,11 +950,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']); @@ -1224,11 +968,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); @@ -1238,12 +984,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(unprotected(store), { + currentFolderPath: folderPath, + translations: keys.map((key) => entry(folderPath ? `${folderPath}.${key}` : key)), + }); }; it('should detect a collision against the entries the browser already holds', () => { @@ -1256,25 +1005,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 +1050,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']); @@ -1413,7 +1134,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: 'similar-value', + }); const searchReturns = (results: ReturnType[]): void => { mockBrowserApi.searchTranslations.mockReturnValue( @@ -1483,9 +1207,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')]); @@ -1527,38 +1249,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.key)).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); - expect(component.similarResources().map((result) => result.key)).toEqual([ + 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); }); }); @@ -1633,33 +1348,15 @@ 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); - 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'); @@ -1679,32 +1376,12 @@ 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); - 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; @@ -1724,41 +1401,19 @@ 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); - 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(); @@ -1768,33 +1423,15 @@ 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); - 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'); @@ -1847,9 +1484,9 @@ describe('TranslationEditorDialog', () => { it('should keep the collision colour on the copy button', () => { const store = spectator.inject(BrowserStore); - patchState(store, { + patchState(unprotected(store), { currentFolderPath: 'common.buttons', - translations: [{ key: 'ok', translations: { en: 'OK' }, status: {} }], + translations: [summary('common.buttons.ok', 'OK')], }); spectator.detectChanges(); @@ -1879,7 +1516,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 => { @@ -1979,6 +1616,7 @@ describe('TranslationEditorDialog', () => { 'test-collection', 'Total Investment for the year', expect.any(Number), + 'similar', ); }); @@ -2068,7 +1706,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 1c285b76..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 @@ -25,21 +25,19 @@ 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, + summaryTarget, } from '@simoncodes-ca/domain'; import { of, Subject } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil, tap } from 'rxjs/operators'; @@ -48,12 +46,30 @@ 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'; +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, + removeTag, + type ResourceEntryDraft, + toCreateDto, + 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 @@ -68,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; @@ -79,38 +98,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 +137,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 +165,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 +251,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 +312,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?.entryKey : 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. */ @@ -362,13 +349,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(() => @@ -387,10 +369,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 +383,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 +392,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'), ); @@ -449,13 +428,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(() => { @@ -474,77 +451,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()); @@ -559,7 +482,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); } } @@ -578,24 +501,21 @@ 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.#originalBaseValue = baseValue; + this.tagsList.set([...this.data.resource.tags]); this.#populateOtherLocaleTranslations(); } - this.#originalTags = [...this.tagsList()]; - this.#originalFolderPath = this.selectedFolderPath(); + this.#initialDraft = this.#draft(); this.#setupSimilarResourcesSearch(); this.#setupPreferredTermCheck(); @@ -646,20 +566,21 @@ 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(): ResourceSummaryDto | undefined { + return this.isEditMode() ? this.data.resource : undefined; } ngOnDestroy(): void { @@ -708,8 +629,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 }); }); @@ -747,33 +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 - const withoutSelf = - this.isEditMode() && this.data.resource - ? searchResults.results.filter((r) => r.key !== this.#buildOriginalFullKey()) - : 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), - ); + // 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; + + this.similarResources.set(withoutSelf.slice(0, SIMILAR_DISPLAY_LIMIT)); }); } @@ -811,49 +731,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 +786,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit } if (this.isEditMode()) { - return currentValue !== this.#originalBaseValue; + return currentValue !== this.#initialDraft?.baseValue; } return true; @@ -918,7 +817,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; } @@ -929,10 +832,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. @@ -949,124 +850,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 +1025,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 +1035,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 +1048,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; @@ -1286,8 +1065,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)); } /** @@ -1344,14 +1122,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 +1134,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 +1162,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 +1172,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(), - }; - - if (hasFolderChanged) { - updateDto.targetFolder = newFolderPath || undefined; - } - - if (Object.keys(locales).length > 0) { - updateDto.locales = locales; - } + const edited = editedLocales(draft, original); - 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 +1196,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.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/browser-api.service.spec.ts b/apps/tracker/src/app/browser/services/browser-api.service.spec.ts index abd27d6e..c63f73ad 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', }, ], @@ -123,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'; @@ -198,11 +281,10 @@ describe('BrowserApiService', () => { const createDto: CreateResourceDto = { key: 'common.buttons.save', baseValue: 'Save', - baseLocale: 'en', }; const mockResponse: CreateResourceResponseDto = { - resolvedKey: 'common.buttons.save', + entriesCreated: 1, created: true, }; @@ -224,11 +306,10 @@ describe('BrowserApiService', () => { const createDto: CreateResourceDto = { key: 'test.key', baseValue: 'Test', - baseLocale: 'en', }; const mockResponse: CreateResourceResponseDto = { - resolvedKey: 'test.key', + entriesCreated: 1, created: true, }; @@ -276,8 +357,8 @@ describe('BrowserApiService', () => { key: 'common.greeting', baseValue: 'Hello', locales: { - fr: { value: 'Bonjour' }, - es: { value: 'Hola' }, + fr: { value: 'Bonjour', status: 'translated' }, + es: { value: 'Hola', status: 'translated' }, }, }; @@ -297,12 +378,12 @@ describe('BrowserApiService', () => { await firstValueFrom(result$); }); - it('should include targetFolder when moving resource', async () => { + it('should include moveTo when moving resource', async () => { const collectionName = 'my-collection'; const updateDto: UpdateResourceDto = { key: 'old.path.button', baseValue: 'Click Me', - targetFolder: 'new.path', + moveTo: 'new.path', }; const mockResponse: UpdateResourceResponseDto = { @@ -395,7 +476,7 @@ describe('BrowserApiService', () => { const mockResponse: DeleteResourceResponseDto = { entriesDeleted: 0, - errors: ['Resource not found: nonexistent.key'], + errors: [{ key: 'nonexistent.key', error: 'Resource not found: nonexistent.key' }], }; const result$ = service.deleteResource(collectionName, resourceKeys); 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..d899c878 100644 --- a/apps/tracker/src/app/browser/services/browser-api.service.ts +++ b/apps/tracker/src/app/browser/services/browser-api.service.ts @@ -1,10 +1,11 @@ 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, SearchResultsDto, + SearchTranslationsDto, CacheStatusDto, CreateResourceDto, CreateResourceResponseDto, @@ -24,6 +25,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 +64,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), + }), + ); } /** @@ -74,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/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..bd33f622 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 @@ -3,6 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { patchState } from '@ngrx/signals'; +import { unprotected } from '@ngrx/signals/testing'; import { of, throwError } from 'rxjs'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../testing/transloco-testing.module'; @@ -11,6 +12,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 +21,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({ @@ -39,7 +51,7 @@ describe('TranslationEditorLauncher', () => { launcher = TestBed.inject(TranslationEditorLauncher); store = TestBed.inject(BrowserStore); - patchState(store, { availableLocales: ['en', 'fr'], baseLocale: 'en' }); + patchState(unprotected(store), { availableLocales: ['en', 'fr'], baseLocale: 'en' }); }); describe('openByFullKey', () => { @@ -54,7 +66,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); @@ -67,7 +79,7 @@ describe('TranslationEditorLauncher', () => { }); it('should leave search mode before navigating, so the list matches the dialog', () => { - patchState(store, { isSearchMode: true, searchQuery: 'back' }); + patchState(unprotected(store), { isSearchMode: true, searchQuery: 'back' }); launcher.openByFullKey('browser.header.backButton', 'test-collection'); @@ -75,6 +87,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 +98,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'); @@ -101,54 +117,46 @@ 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, 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 full key and confirm the save', () => { + patchState(unprotected(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, collectionName: 'test-collection', - folderPath: 'browser.header', - storeKey: 'backButton', onUpdated, }); - expect(store.translations()[0].translations['en']).toBe('Go back'); - expect(onUpdated).toHaveBeenCalledWith('backButton'); + expect(onUpdated).toHaveBeenCalledWith('browser.header.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..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,34 +12,22 @@ 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 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. - */ - 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; + /** Called with the resource's full key after an in-place update, for the row's flash. */ + onUpdated?: (fullKey: string) => void; } /** * 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 +42,8 @@ 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, onUpdated } = params; + const { folderPath } = resource; const dialogData: TranslationEditorDialogData = { mode: 'edit', @@ -80,18 +69,15 @@ 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); + onUpdated?.(resource.fullKey); this.#notifications.success(this.#transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.TRANSLATIONUPDATED)); if (result.skippedLocales?.length) { @@ -112,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; @@ -130,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/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..4056213d 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,78 @@ 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 keep a loaded tree with root resources and no folders when the index goes not ready', async () => { + const rootOnlyTree: ResourceTreeDto = { + path: '', + resources: [summary('welcome', 'Welcome', { es: ['Bienvenido', 'translated'] })], + children: [], + }; + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + const getTree = vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(rootOnlyTree)); + const notifyError = vi.spyOn(spectator.inject(NotificationService), 'error').mockImplementation(() => undefined); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: ['en', 'es'] }); + await waitForSignals(); + expect(store.rootFolders()).toEqual([]); + expect(store.folderTreeLoaded()).toBe(true); + + getTree.mockReturnValue(throwError(() => new CollectionIndexNotReadyError('Collection is being indexed.'))); + store.loadRootFolders(); + await waitForSignals(); + + expect(store.translations()).toEqual(rootOnlyTree.resources); + expect(store.isFolderTreeLoading()).toBe(false); + expect(store.error()).toBeNull(); + expect(notifyError).toHaveBeenCalledWith('Collection is being indexed.'); + }); + + it('should show the error state when the first root load finds the index not ready', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue( + throwError(() => new CollectionIndexNotReadyError('Collection is being indexed.')), + ); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: ['en', 'es'] }); + await waitForSignals(); + + expect(store.folderTreeLoaded()).toBe(false); + expect(store.error()).toBe('Collection is being indexed.'); + }); + + it('should forget the loaded tree when the collection changes', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + const getTree = vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeRoot)); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: ['en', 'es'] }); + await waitForSignals(); + expect(store.folderTreeLoaded()).toBe(true); + + getTree.mockReturnValue(NEVER); + store.setSelectedCollection({ collectionName: 'website-translations', locales: ['en', 'fr'] }); + + expect(store.folderTreeLoaded()).toBe(false); + }); + 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 +439,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 +638,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 +1063,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 +1314,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 +1342,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 +1415,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: [], }; @@ -1318,6 +1446,25 @@ describe('BrowserStore', () => { store.clearAllLocales(); }); + // `delta`'s only unfinished locale has no metadata (no stored status, `needsWork`). + async function loadWithMetadataLessRow(): Promise { + vi.spyOn(apiService, 'getResourceTree').mockReturnValue( + of({ + ...mockStatusTree, + resources: [ + ...mockStatusTree.resources, + summary('delta', 'Delta', { + es: ['Delta', 'verified'], + fr: [undefined, undefined], + de: ['Delta', 'verified'], + }), + ], + }), + ); + store.loadRootFolders(); + await waitForSignals(); + } + it('should count resources, not status cells', () => { // `gamma` is verified in all three target locales but is one resource. expect(store.statusCounts().verified).toBe(2); @@ -1340,8 +1487,11 @@ describe('BrowserStore', () => { } }); - it('should agree with the list the needs-work shortcut produces', () => { + it('should agree with the list the needs-work shortcut produces', async () => { + await loadWithMetadataLessRow(); + store.selectNeedsWorkStatuses(); + expect(store.sortedTranslations().map((item) => item.fullKey)).toEqual(['alpha', 'beta', 'delta']); expect(store.sortedTranslations().length).toBe(store.needsWorkCount()); }); @@ -1356,140 +1506,48 @@ describe('BrowserStore', () => { expect(store.needsWorkCount()).toBe(0); }); - it('should report zero for every status when the folder is empty', () => { - store.reset(); - expect(store.statusCounts()).toEqual({ new: 0, stale: 0, translated: 0, verified: 0 }); - 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'], - }); + it('should count and filter a locale with no metadata as new', async () => { + await loadWithMetadataLessRow(); - await waitForSignals(); - - expect(store.translations().length).toBe(1); - expect(store.translations()[0].key).toBe('welcome'); - - store.removeResourceFromCache('welcome'); - - expect(store.translations().length).toBe(0); + expect(store.needsWorkCount()).toBe(3); + expect(store.statusCounts()).toEqual({ new: 3, stale: 1, translated: 1, verified: 3 }); + store.setSelectedStatuses(['new']); + expect(store.sortedTranslations().map((item) => item.fullKey)).toContain('delta'); + expect(store.sortedTranslations().length).toBe(store.statusCounts().new); }); - 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)); + it('should sort a locale with no metadata as new', async () => { + await loadWithMetadataLessRow(); - 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, - }, - ]; + store.setSelectedLocales(['fr']); + store.setSortField('status'); + expect(store.sortedTranslations().map((item) => item.fullKey)).toEqual(['alpha', 'delta', 'beta', 'gamma']); + }); - vi.spyOn(apiService, 'searchTranslations').mockReturnValue( + it('should sort by status over every locale when no locale is selected', async () => { + // Key order puts `able` first; only `zulu` needs work, in one locale. + vi.spyOn(apiService, 'getResourceTree').mockReturnValue( of({ - query: 'common', - results: mockSearchResults, - totalFound: 2, - limited: false, + path: '', + resources: [ + summary('able', 'Able', { es: ['Able', 'verified'], fr: ['Able', 'verified'], de: ['Able', 'verified'] }), + summary('zulu', 'Zulu', { es: ['Zulu', 'verified'], fr: ['Zulu', 'new'], de: ['Zulu', 'verified'] }), + ], + children: [], }), ); - - 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'], - }); - + store.loadRootFolders(); 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'); + expect(store.selectedLocales()).toEqual([]); + store.setSortField('status'); + expect(store.sortedTranslations().map((item) => item.fullKey)).toEqual(['zulu', 'able']); }); - 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); + it('should report zero for every status when the folder is empty', () => { + store.reset(); + expect(store.statusCounts()).toEqual({ new: 0, stale: 0, translated: 0, verified: 0 }); + expect(store.needsWorkCount()).toBe(0); }); }); }); diff --git a/apps/tracker/src/app/browser/store/browser.store.ts b/apps/tracker/src/app/browser/store/browser.store.ts index c03e0c17..9e03e9dd 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(), @@ -113,6 +115,7 @@ export const BrowserStore = signalStore( isTranslationsLoading: false, showNestedResources: true, rootFolders: [], + folderTreeLoaded: false, expandedFolders: new Set(), folderTreeFilter: '', isFolderTreeLoading: false, @@ -161,6 +164,7 @@ export const BrowserStore = signalStore( sortDirection: loaded?.sortDirection ?? 'asc', selectedStatuses: loaded?.selectedStatuses ?? ([] as TranslationStatus[]), rootFolders: [], + folderTreeLoaded: false, folderTreeFilter: '', translations: [], error: null, @@ -208,7 +212,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-cache-status.feature.ts b/apps/tracker/src/app/browser/store/features/with-cache-status.feature.ts index bc625698..89ef132e 100644 --- a/apps/tracker/src/app/browser/store/features/with-cache-status.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-cache-status.feature.ts @@ -4,7 +4,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { pipe, switchMap, interval, startWith, takeWhile, catchError, of, tap } from 'rxjs'; import { TranslocoService } from '@jsverse/transloco'; import { BrowserApiService } from '../../services/browser-api.service'; -import type { CacheStatusType, FolderNodeDto } from '@simoncodes-ca/data-transfer'; +import type { CacheStatusType } from '@simoncodes-ca/data-transfer'; import { toErrorMessage } from '../async-error.utils'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; @@ -23,7 +23,7 @@ const initialCacheStatusState: CacheStatusState = { export function withCacheStatusFeature<_>() { return signalStoreFeature( { - state: type<{ selectedCollection: string | null; rootFolders: FolderNodeDto[] }>(), + state: type<{ selectedCollection: string | null; folderTreeLoaded: boolean }>(), methods: type<{ loadRootFolders(): void }>(), }, withState(initialCacheStatusState), @@ -67,7 +67,7 @@ export function withCacheStatusFeature<_>() { : null, }); - if (statusDto.status === 'ready' && store.rootFolders().length === 0) { + if (statusDto.status === 'ready' && !store.folderTreeLoaded()) { store.loadRootFolders(); } }), 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..affabd65 --- /dev/null +++ b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.spec.ts @@ -0,0 +1,302 @@ +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 { unprotected } from '@ngrx/signals/testing'; +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'; + +const RESOURCES_URL = '/api/collections/my-collection/resources'; + +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', () => { + let store: InstanceType; + let http: HttpTestingController; + + /** The folder list as the browser shows `common` with nested resources folded in. */ + const folderMode = (): void => { + patchState(unprotected(store), { + selectedCollection: 'my-collection', + currentFolderPath: 'common', + translations: [entry('common.save', 'Save'), entry('common.dialog.title', 'Title')], + }); + }; + + /** A search over the same collection, with the `common` folder list still behind it. */ + const searchMode = (): void => { + folderMode(); + patchState(unprotected(store), { + isSearchMode: true, + searchQuery: 'sa', + searchResults: [hit('common.save', 'Save'), hit('errors.save', 'Save')], + }); + }; + + const englishOf = (items: readonly ResourceSummaryDto[], fullKey: string): string | undefined => + items.find((item) => item.fullKey === fullKey)?.base.value; + + 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('common.ok'), entry('common.save')], children: [] }); + + expect(next).toHaveBeenCalledWith({ entriesCreated: 1, created: true }); + 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', () => { + 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('common.ok'), entry('common.save')], children: [] }); + + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.ok', 'common.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, moveTo?: string): void => { + store + .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); + patch.flush(response); + }; + + it('should patch the folder list under its full key', () => { + folderMode(); + + update('common.save', { resolvedKey: 'common.save', updated: true, resource: entry('common.save', 'Save now') }); + + expect(englishOf(store.translations(), 'common.save')).toBe('Save now'); + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.save', 'common.dialog.title']); + }); + + it('should patch a nested entry by its full key', () => { + folderMode(); + + update('common.dialog.title', { + resolvedKey: 'common.dialog.title', + updated: true, + resource: entry('common.dialog.title', 'New'), + }); + + expect(englishOf(store.translations(), 'common.dialog.title')).toBe('New'); + }); + + it('should patch both caches under the same full key', () => { + searchMode(); + + update('common.save', { resolvedKey: 'common.save', updated: true, resource: entry('common.save', 'Save now') }); + + 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(), '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('errors.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.save', updated: true, resource: entry('other.save') }, 'other'); + + 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', () => { + folderMode(); + + 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('common.save', '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)', () => { + folderMode(); + + update('common.save', { resolvedKey: 'save', updated: true, resource: entry('save') }, ''); + + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.dialog.title']); + }); + + 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 full key', () => { + folderMode(); + + remove('common.dialog.title', 1); + + 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', () => { + searchMode(); + + remove('common.save', 1); + + 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', () => { + folderMode(); + + remove('common.save', 0); + + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.save', 'common.dialog.title']); + }); + + it('should ignore a key that is not cached', () => { + folderMode(); + + remove('common.missing', 1); + + expect(store.translations().map((item) => item.fullKey)).toEqual(['common.save', 'common.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 a nested folder row by its full key', () => { + folderMode(); + + translate('common.dialog.title', entry('common.dialog.title', 'Title', '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', 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 new file mode 100644 index 00000000..30acf946 --- /dev/null +++ b/apps/tracker/src/app/browser/store/features/with-entry-writes.feature.ts @@ -0,0 +1,106 @@ +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'; + +/** + * 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. + * + * 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; + 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. A cache without it is left as is. */ + function patchEntry(fullKey: string, resource: ResourceSummaryDto): void { + 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 { + patchState(store, { + translations: store.translations().filter((entry) => entry.fullKey !== fullKey), + searchResults: store.searchResults().filter((result) => result.fullKey !== 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 `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.moveTo !== 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..53b93194 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,16 +1,17 @@ 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, removeFolderFromTree, findFolderInTree, + filterFolderTree, rebaseFolderPaths, collectExpandablePaths, collectAncestorPaths, @@ -24,6 +25,11 @@ import type { Observable } from 'rxjs'; interface FolderTreeState { rootFolders: FolderNodeDto[]; + /** + * A root tree load has succeeded for the selected collection. Not the same as + * `rootFolders().length > 0`: a collection with only root resources has no folders. + */ + folderTreeLoaded: boolean; expandedFolders: Set; /** Expansion as it stood before a filter took over; restored when the filter clears. */ preFilterExpandedFolders: Set | null; @@ -39,6 +45,7 @@ interface FolderTreeState { const initialFolderTreeState: FolderTreeState = { rootFolders: [], + folderTreeLoaded: false, expandedFolders: new Set(), preFilterExpandedFolders: null, isRootExpanded: true, @@ -70,30 +77,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(); @@ -132,6 +116,20 @@ 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 root tree has already loaded (`folderTreeLoaded`, which also + * covers a collection with root resources and no folders), 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.folderTreeLoaded()) return false; + patchState(store, { isFolderTreeLoading: false }); + notifications.error(message); + return true; + } function scheduleNewFolderClear(folderFullPath: string): void { setTimeout(() => { @@ -220,24 +218,24 @@ 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, + folderTreeLoaded: true, + 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); }), ); @@ -258,7 +256,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) { @@ -280,13 +277,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); }), ); @@ -512,17 +509,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 98a48715..5d2506fa 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,11 +1,14 @@ 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, TranslationStatus } from '@simoncodes-ca/data-transfer'; +import type { ResourceSummaryDto, SearchResultDto } from '@simoncodes-ca/data-transfer'; +import { countByStatus, STATUS_PRECEDENCE, summaryTarget, type TranslationStatus } from '@simoncodes-ca/domain'; +import { displayStatus } from '../../../shared/translation-status/display-status'; import { toErrorMessage } from '../async-error.utils'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; @@ -21,25 +24,23 @@ const initialTranslationsState: TranslationsState = { showNestedResources: true, }; -const ALL_STATUSES: readonly TranslationStatus[] = ['new', 'stale', 'translated', 'verified']; const NEEDS_WORK_STATUSES: readonly TranslationStatus[] = ['new', 'stale']; /** * A resource is in scope for a status filter when any of the locales being * filtered on carries one of those statuses. * - * Both the list and the counts beside the filter toggles run through here, so - * the two can never drift into disagreeing about what a status means. + * Both the list and the per-status counts beside the filter toggles run through + * here, so the two can never drift into disagreeing about what a status means. + * It reads the `displayStatus`, so a locale with no metadata matches `new`. */ function matchesAnyStatus( - item: { status?: Record }, + item: ResourceSummaryDto, 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) => displayStatus(summaryTarget(item, locale)))); + return statuses.some((status) => counts[status] > 0); } export function withTranslationsFeature<_>() { @@ -59,17 +60,16 @@ export function withTranslationsFeature<_>() { }>(), }, withState(initialTranslationsState), + withComputed(({ selectedLocales, availableLocales }) => ({ + /** + * The locales a status is read over: the selected ones, or every locale when + * none is selected (the UI's "All locales"). The status filter, its counts, + * the needs-work count and sort by status all read this one list. + */ + _statusLocales: computed(() => (selectedLocales().length > 0 ? selectedLocales() : availableLocales())), + })), withComputed( - ({ - translations, - isSearchMode, - searchResults, - selectedStatuses, - selectedLocales, - availableLocales, - sortField, - sortDirection, - }) => ({ + ({ translations, isSearchMode, searchResults, selectedStatuses, _statusLocales, sortField, sortDirection }) => ({ isEmpty: computed(() => translations().length === 0), translationCount: computed(() => translations().length), @@ -82,13 +82,12 @@ export function withTranslationsFeature<_>() { const items = isSearchMode() ? searchResults() : translations(); const statuses = selectedStatuses(); - let filteredItems = items; - if (statuses.length > 0) { - const localesForFiltering = selectedLocales().length > 0 ? selectedLocales() : availableLocales(); - filteredItems = items.filter((item) => matchesAnyStatus(item, localesForFiltering, statuses)); - } + const locales = _statusLocales(); + + const filteredItems = + statuses.length > 0 ? items.filter((item) => matchesAnyStatus(item, locales, statuses)) : items; - return sortTranslations(filteredItems, sortField(), sortDirection(), selectedLocales()); + return sortTranslations(filteredItems, sortField(), sortDirection(), locales); }), /** @@ -109,11 +108,11 @@ export function withTranslationsFeature<_>() { */ statusCounts: computed(() => { const items = isSearchMode() ? searchResults() : translations(); - const locales = selectedLocales().length > 0 ? selectedLocales() : availableLocales(); + const locales = _statusLocales(); 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]++; } } @@ -121,13 +120,14 @@ export function withTranslationsFeature<_>() { }), /** - * Resources with anything unfinished in the filtered locales. Counted as a - * union rather than `new + stale`, because a resource that is new in one - * locale and stale in another is one row, not two. + * Resources with anything unfinished in the filtered locales: the rows the + * needs-work shortcut (`new` + `stale`) shows, a locale with no metadata + * included. Counted as a union rather than `new + stale`, because a resource + * that is new in one locale and stale in another is one row, not two. */ needsWorkCount: computed(() => { const items = isSearchMode() ? searchResults() : translations(); - const locales = selectedLocales().length > 0 ? selectedLocales() : availableLocales(); + const locales = _statusLocales(); return items.filter((item) => matchesAnyStatus(item, locales, NEEDS_WORK_STATUSES)).length; }), }), @@ -135,18 +135,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) { @@ -155,25 +158,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); }), ); @@ -190,39 +195,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/locale-filter/locale-filter.spec.ts b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.spec.ts index 23d87591..fe62ecb4 100644 --- a/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.spec.ts +++ b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.spec.ts @@ -11,7 +11,21 @@ describe('LocaleFilter', () => { let component: LocaleFilter; let fixture: ComponentFixture; let spectator: Spectator; - let mockStore: any; + let mockStore: ReturnType; + + /** The slice of `BrowserStore` the filter reads. */ + const createMockStore = () => ({ + availableLocales: signal(['en', 'es', 'fr', 'de']), + filterableLocales: signal(['es', 'fr', 'de']), // Excludes base locale 'en' + selectedLocales: signal([]), + compactDisplayLocale: signal('en'), + localeFilterText: signal('All locales'), + isShowingAllLocales: signal(true), + toggleLocale: vi.fn(), + selectAllLocales: vi.fn(), + clearAllLocales: vi.fn(), + setSelectedLocales: vi.fn(), + }); const createComponent = createComponentFactory({ component: LocaleFilter, @@ -20,18 +34,7 @@ describe('LocaleFilter', () => { }); beforeEach(() => { - mockStore = { - availableLocales: signal(['en', 'es', 'fr', 'de']), - filterableLocales: signal(['es', 'fr', 'de']), // Excludes base locale 'en' - selectedLocales: signal([]), - compactDisplayLocale: signal('en'), - localeFilterText: signal('All locales'), - isShowingAllLocales: signal(true), - toggleLocale: vi.fn(), - selectAllLocales: vi.fn(), - clearAllLocales: vi.fn(), - setSelectedLocales: vi.fn(), - }; + mockStore = createMockStore(); spectator = createComponent(); fixture = spectator.fixture; diff --git a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.spec.ts b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.spec.ts index 78dc8cd1..70e56c9e 100644 --- a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.spec.ts +++ b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.spec.ts @@ -13,7 +13,18 @@ describe('StatusFilter', () => { let fixture: ComponentFixture; let spectator: Spectator; let selectedStatuses: ReturnType>; - let mockStore: any; + let mockStore: ReturnType; + + /** The slice of `BrowserStore` the filter reads. */ + const createMockStore = () => ({ + selectedStatuses, + statusCounts: signal({ new: 6, stale: 4, translated: 7, verified: 8 }), + needsWorkCount: signal(9), + hasStatusFilter: computed(() => selectedStatuses().length > 0), + toggleStatus: vi.fn(), + selectNeedsWorkStatuses: vi.fn(), + clearAllStatuses: vi.fn(), + }); const toggleFor = (id: string): HTMLButtonElement | null => fixture.nativeElement.querySelector(`[data-testid="status-toggle-${id}"]`); @@ -27,15 +38,7 @@ describe('StatusFilter', () => { beforeEach(() => { selectedStatuses = signal([]); - mockStore = { - selectedStatuses, - statusCounts: signal({ new: 6, stale: 4, translated: 7, verified: 8 }), - needsWorkCount: signal(9), - hasStatusFilter: computed(() => selectedStatuses().length > 0), - toggleStatus: vi.fn(), - selectNeedsWorkStatuses: vi.fn(), - clearAllStatuses: vi.fn(), - }; + mockStore = createMockStore(); spectator = createComponent(); fixture = spectator.fixture; 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/header/translation-main-header.spec.ts b/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts index 14c7eeac..c0b5f4cb 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 @@ -4,6 +4,7 @@ import { MatDialog } from '@angular/material/dialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; import { patchState } from '@ngrx/signals'; +import { unprotected } from '@ngrx/signals/testing'; import { of } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; @@ -100,7 +101,7 @@ describe('TranslationMainHeader', () => { describe('handleAddTranslation — "Open existing"', () => { it('should open the editor on the existing key the create dialog handed back', () => { const store = spectator.inject(BrowserStore); - patchState(store, { selectedCollection: 'test-collection' }); + patchState(unprotected(store), { selectedCollection: 'test-collection' }); const result: TranslationEditorResult = { key: 'backButton', baseValue: 'Back', @@ -198,7 +199,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 +217,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/header/translation-search/translation-search.spec.ts b/apps/tracker/src/app/browser/translations/header/translation-search/translation-search.spec.ts index e4c65c62..d43cdf16 100644 --- a/apps/tracker/src/app/browser/translations/header/translation-search/translation-search.spec.ts +++ b/apps/tracker/src/app/browser/translations/header/translation-search/translation-search.spec.ts @@ -13,7 +13,17 @@ describe('TranslationSearch', () => { let component: TranslationSearch; let fixture: ComponentFixture; let spectator: Spectator; - let mockStore: any; + let mockStore: ReturnType; + + /** The slice of `BrowserStore` the search box reads. */ + const createMockStore = () => ({ + searchQuery: signal(''), + isSearchMode: signal(false), + isSearchLoading: signal(false), + setSearchQuery: vi.fn(), + clearSearch: vi.fn(), + searchTranslations: vi.fn(), + }); const createComponent = createComponentFactory({ component: TranslationSearch, @@ -22,14 +32,7 @@ describe('TranslationSearch', () => { }); beforeEach(() => { - mockStore = { - searchQuery: signal(''), - isSearchMode: signal(false), - isSearchLoading: signal(false), - setSearchQuery: vi.fn(), - clearSearch: vi.fn(), - searchTranslations: vi.fn(), - }; + mockStore = createMockStore(); spectator = createComponent(); fixture = spectator.fixture; 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 da65457c..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 @@ -4,14 +4,12 @@ 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'; 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( @@ -24,7 +22,6 @@ export function withItemActions() { }>(), }, withMethods((store) => { - const api = inject(BrowserApiService); const browserStore = inject(BrowserStore); const dialog = inject(MatDialog); const launcher = inject(TranslationEditorLauncher); @@ -45,24 +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, - originalKey: browserStore.isSearchMode() ? translation.key : undefined, - onUpdated: (key) => store.flashRecentlyUpdated(key), + onUpdated: (fullKey) => store.flashRecentlyUpdated(fullKey), }); }, @@ -82,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), @@ -107,14 +86,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)); @@ -132,23 +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); - 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); + store.removeTranslatingKey(fullKey); + store.flashRecentlyUpdated(fullKey); const { translatedCount, skippedLocales } = response; if (translatedCount > 0) { @@ -171,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 38c606bf..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,8 +9,9 @@ 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 type { ResourceSummaryDto, TranslationStatus } from '@simoncodes-ca/data-transfer'; +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,21 +87,8 @@ export class TranslationItemHeader { readonly TOKENS = TRACKER_TOKENS; - /** Locale states for the rollup component — 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, - })); - }); - - /** 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; @@ -150,25 +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(() => { - const statusMap = this.translation().status || {}; - const base = this.#browserStore.baseLocale(); - return Object.entries(statusMap) - .filter(([locale]) => locale !== base) - .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 b2cd955e..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,75 +3,15 @@ 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, 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; - /** - * 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; -}; - -/** 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 ''; - } -} +import { type BaseRow, type LocaleRow, sharedStatus } from './row-view'; /** * Displays locale translations in a grid layout. @@ -89,14 +29,19 @@ export function statusLabelTokenFor(status: string | undefined): string { }, }) 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'); @@ -110,36 +55,15 @@ 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); - /** - * 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 rows = this.localeTranslations(); - if (rows.length < 2) return undefined; - - const first = asTranslationStatus(rows[0].status); - if (!first) return undefined; - - return rows.every((lt) => lt.status === first) ? first : undefined; - }); + /** 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(() => { @@ -147,11 +71,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/row-view.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-item/row-view.spec.ts new file mode 100644 index 00000000..20d32c4a --- /dev/null +++ b/apps/tracker/src/app/browser/translations/list/translation-item/row-view.spec.ts @@ -0,0 +1,214 @@ +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 locale with no metadata ranks with new', () => { + 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', 'it', 'de']); + expect(rows.find((row) => row.locale === 'it')?.status).toBe('new'); + }); + + 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('asks for attention for a locale with no metadata, shown as new', () => { + const row = summary('Save', { es: [undefined, undefined] }); + + expect(rowView(row, selection({ compactLocale: 'es' })).compact).toMatchObject({ + value: '', + status: 'new', + needsAttention: true, + isSameAsBase: false, + }); + }); + + it('shows one marker for a copied value with no metadata: the chip', () => { + const row = summary('Save', { es: ['Save', undefined] }); + + expect(rowView(row, selection({ compactLocale: 'es' })).compact).toMatchObject({ + needsAttention: true, + 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, whatever the filter shows; one with no metadata as new', () => { + 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' }, + { code: 'it', status: 'new' }, + ]); + expect(view.statusCounts).toEqual({ stale: 1, new: 1, 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..fc1807ed --- /dev/null +++ b/apps/tracker/src/app/browser/translations/list/translation-item/row-view.ts @@ -0,0 +1,154 @@ +import type { ResourceSummaryDto, TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { countByStatus, STATUS_PRECEDENCE, type StatusCounts, summaryTarget } from '@simoncodes-ca/domain'; +import { displayStatus } from '../../../../shared/translation-status/display-status'; + +/* + * 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. + * Every status here is the `displayStatus`: a target with no metadata shows as `new`. + */ + +/** 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; + /** The display status: `new` for a target with no metadata. */ + 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; + /** The display status the chip names: `new` for a target with no metadata; `undefined` for the base locale. */ + readonly status?: TranslationStatus; + /** + * Show the status chip: the locale's summary target says `needsWork` (`new`, + * `stale`, or no metadata). `translated` and `verified` are the quiet states the + * rollup already reports. + */ + 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 display 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: displayStatus(target), + isSameAsBase: target?.sameAsBase ?? false, + }; + }) + .sort((a, b) => statusRank(a.status) - statusRank(b.status) || a.locale.localeCompare(b.locale)); + + const rollupLocales = summary.targets.flatMap((target) => { + const status = displayStatus(target); + return status ? [{ code: target.locale, 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?.needsWork === true; + return { + locale, + value: target?.value ?? '', + isBase: false, + status: displayStatus(target), + 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 027aaa43..5616136a 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'); @@ -224,22 +222,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'); @@ -268,57 +250,28 @@ 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(); 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', - 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'); @@ -356,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(); @@ -376,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(); @@ -398,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(); @@ -427,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); @@ -470,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'); @@ -596,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()); @@ -712,7 +633,7 @@ describe('TranslationItem - compact key chip', () => { trigger?.click(); fixture.detectChanges(); - return [...document.querySelectorAll('.mat-mdc-menu-item')].map((item) => item.textContent?.trim() ?? ''); + return Array.from(document.querySelectorAll('.mat-mdc-menu-item'), (item) => item.textContent?.trim() ?? ''); } it('drops Edit from the compact overflow menu now that the rail shows it', () => { @@ -742,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', () => { @@ -756,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 a9537030..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 @@ -6,13 +6,16 @@ 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 } 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'; import { injectStatusBreakdown } from '../../../../shared/i18n/status-breakdown'; - -const EXPAND_THRESHOLD = 200; +import { + statusIconFor, + statusLabelTokenFor, +} from '../../../../shared/translation-status/translation-status-presentation'; /** * Number of locale rows rendered while a full-density item is collapsed. @@ -21,28 +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(); -} - -const STATUS_SORT_PRIORITY: Record = { - stale: 0, - new: 1, - translated: 2, - verified: 3, - missing: 4, -}; - /** * Displays a single translation entry with key, base value, locale translations, * and action menu. @@ -90,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; @@ -105,103 +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. + * 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 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. - */ - 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 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; - 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)); @@ -237,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?" — @@ -269,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(), }); } @@ -352,31 +258,8 @@ export class TranslationItem { } } - /** - * Computes counts of each status and total known statuses. - * Used by rollupStatus and statusBreakdown to avoid duplicated logic. - */ - 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; - }); + /** Status counts across every target locale of the entry, whatever the locale filter shows. */ + readonly #statusCounts = computed(() => this.view().statusCounts); constructor() { effect(() => { @@ -386,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, })); /** @@ -410,26 +290,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..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 @@ -16,25 +16,22 @@ 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 type { RollupLocale } from './row-view'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { injectActiveLang, injectStatusBreakdown } from '../../../../shared/i18n/status-breakdown'; - -/** Locale state for rollup display */ -export interface LocaleState { - code: string; - status: TranslationStatus; -} - -/** Per-status display configuration. Color lives in CSS so both themes can move it. */ -interface StatusConfig { - labelToken: string; - icon: string; -} +import { + rollupCenter, + STATUS_DISPLAY_ORDER, + STATUS_PRESENTATION, +} from '../../../../shared/translation-status/translation-status-presentation'; /** 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. @@ -322,11 +319,8 @@ const CLOSE_DELAY = 120; }, }) 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); @@ -348,83 +342,30 @@ 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(); } - /** Effective locales (excluding base locale) */ - private readonly effectiveLocales = computed(() => { - const base = this.baseLocale().toLowerCase(); - 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.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); - /** 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 +392,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() + return this.locales() .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/list/translation-list.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts index e5e5cc06..3013f11a 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,9 +1,12 @@ 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'; import { createComponentFactory } from '@ngneat/spectator/vitest'; +import { patchState } from '@ngrx/signals'; +import { unprotected } from '@ngrx/signals/testing'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; import { of, throwError } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -24,7 +27,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; @@ -42,10 +62,6 @@ describe('TranslationList', () => { expect(component).toBeTruthy(); }); - it('should inject translation browser store', () => { - expect(component.store).toBeTruthy(); - }); - it('should accept collectionName input', () => { fixture.componentRef.setInput('collectionName', 'test-collection'); fixture.detectChanges(); @@ -267,10 +283,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: [], }); @@ -282,16 +295,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'); @@ -309,11 +318,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() }; @@ -360,7 +365,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 = { @@ -392,10 +397,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,11 +411,11 @@ 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.fullKey); }); }); -describe('TranslationList - handleEdit key rewrite', () => { +describe('TranslationList - handleEdit full key', () => { let fixture: ComponentFixture; let notificationsSpy: { success: ReturnType; @@ -437,27 +439,15 @@ 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 its full 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"). 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', @@ -474,9 +464,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.fullKey); }); }); @@ -529,11 +517,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() }; @@ -556,14 +540,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(unprotected(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 +554,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(unprotected(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'); }); @@ -604,20 +586,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") that the store uses. The store must rewrite it before - // passing the resource to updateTranslationInCache. - 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() }; @@ -642,7 +613,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 +624,7 @@ describe('TranslationList - handleTranslate', () => { }), ); - const updateCacheSpy = vi.spyOn(store, 'updateTranslationInCache'); + patchState(unprotected(store), { translations: [mockResource] }); const listStore = fixture.debugElement.injector.get(TranslationListStore); listStore.translateResource(mockResource, 'my-collection'); @@ -661,9 +632,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(updateCacheSpy).toHaveBeenCalledWith({ ...mockUpdatedResource, key: mockResource.key }); + expect(store.translations()).toEqual([mockUpdatedResource]); // Success notification shown expect(notificationsSpy.success).toHaveBeenCalledWith('1 locale translated successfully'); @@ -738,10 +707,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 30ab4a51..6fde8fcb 100644 --- a/apps/tracker/src/app/browser/translations/utils/sort-translations.ts +++ b/apps/tracker/src/app/browser/translations/utils/sort-translations.ts @@ -1,60 +1,54 @@ -import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; +import { countByStatus, summaryTarget } from '@simoncodes-ca/domain'; +import { displayStatus } from '../../../shared/translation-status/display-status'; +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, -}; - -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); +const VERIFIED_RANK = STATUS_DISPLAY_ORDER.indexOf('verified'); + +/** + * Sort rank of an item: the position, in the display order (new first), of the + * earliest display status its locales carry (a locale with no metadata is `new`). + * Locales with no status rank as verified. + */ +function statusRank(item: ResourceSummaryDto, locales: string[]): number { + const counts = countByStatus(locales.map((locale) => displayStatus(summaryTarget(item, locale)))); + 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 over `locales` (then full key). Full keys order a + * folder list the same as its entry keys. `locales` are the locales in effect: the + * store passes every available locale when none is selected. + */ +export function sortTranslations( + items: T[], + field: SortField, + direction: SortDirection, + locales: 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 = getWorstStatus(itemA.status ?? {}, selectedLocales); - const statusB = getWorstStatus(itemB.status ?? {}, selectedLocales); + const statusA = statusRank(itemA, locales); + const statusB = statusRank(itemB, locales); 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/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.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) { + + } +