diff --git a/.lingo-tracker.json b/.lingo-tracker.json index 235b94cc..37bce03a 100644 --- a/.lingo-tracker.json +++ b/.lingo-tracker.json @@ -23,9 +23,8 @@ } }, "translation": { - "enabled": true, - "provider": "google-translate", - "apiKeyEnv": "GOOGLE_TRANSLATE_API_KEY" + "enabled": false, + "provider": "google-translate" }, "bundles": { "main": { diff --git a/apps/api/src/app/cache/collection-cache.service.spec.ts b/apps/api/src/app/cache/collection-cache.service.spec.ts index e11a7013..0693dd03 100644 --- a/apps/api/src/app/cache/collection-cache.service.spec.ts +++ b/apps/api/src/app/cache/collection-cache.service.spec.ts @@ -159,14 +159,15 @@ describe('CollectionCacheService', () => { expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); }); - it('should clear previous collection cache when setting new collection', () => { + 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.NOT_STARTED); + expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); + expect(service.getCache('Main')).toEqual(mainTree); expect(service.getCacheStatus('Admin')).toBe(CacheStatus.INDEXING); }); @@ -262,13 +263,13 @@ describe('CollectionCacheService', () => { service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); - service.clearCache(); + 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()).not.toThrow(); + expect(() => service.clearCache('Main')).not.toThrow(); expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); }); @@ -276,10 +277,73 @@ describe('CollectionCacheService', () => { const mockTree = createMockTree(); mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources); service.setCacheStatus('Main', CacheStatus.READY, mockTree, undefined, 3); - service.clearCache(); + 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', () => { @@ -364,14 +428,14 @@ describe('CollectionCacheService', () => { expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); expect(service.getCache('Main')).toEqual(mainTree); - // Index Admin (this should clear Main's cache) + // 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 should have been cleared when Admin started - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); + // Main is untouched by Admin's indexing + expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY); }); it('should store error message when indexing fails', async () => { @@ -397,7 +461,7 @@ describe('CollectionCacheService', () => { expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); }); - it('should clear previous collection cache when indexing new collection', async () => { + it('should keep both collections cached when indexing a second one', async () => { const mainTree = createMockTree(['main']); const adminTree = createMockTree(['admin']); @@ -411,12 +475,13 @@ describe('CollectionCacheService', () => { expect(service.getCache('Main')).toEqual(mainTree); await service.indexCollection('Admin', 'src/admin/i18n', 2); - expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED); + 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 maintain only the latest indexed collection', async () => { + it('should keep the stats of each cached collection separate', async () => { const mainTree = createMockTree(['main']); const adminTree = createMockTree(['admin']); @@ -425,18 +490,11 @@ describe('CollectionCacheService', () => { .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 - should clear Main 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.NOT_STARTED); - expect(service.getCache('Main')).toBeNull(); + 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 () => { @@ -459,11 +517,11 @@ describe('CollectionCacheService', () => { } expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); - // Index Admin - should clear Main's error state and succeed + // 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.NOT_STARTED); + expect(service.getCacheStatus('Main')).toBe(CacheStatus.ERROR); }); }); describe('revalidate', () => { @@ -544,7 +602,7 @@ describe('CollectionCacheService', () => { writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' } }); service.addResourceToCache('Main', createMockTree().resources[0], ''); - service.refreshFingerprint(); + service.refreshFingerprint('Main'); writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' }, later: { source: 'Later' } }); diff --git a/apps/api/src/app/cache/collection-cache.service.ts b/apps/api/src/app/cache/collection-cache.service.ts index 5d4c3e38..f61b02bd 100644 --- a/apps/api/src/app/cache/collection-cache.service.ts +++ b/apps/api/src/app/cache/collection-cache.service.ts @@ -11,6 +11,16 @@ import { computeTreeFingerprint, extractResourcesRecursively, treeFingerprintsMa */ 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', @@ -30,62 +40,89 @@ export interface CachedCollection { 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); - #cachedCollection: CachedCollection | null = null; - #lastRevalidationAt = 0; - #pendingFingerprintRefresh: NodeJS.Timeout | null = null; + 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 { - if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) { + const cached = this.#collections.get(collectionName); + + if (!cached) { return CacheStatus.NOT_STARTED; } - return this.#cachedCollection.status; + cached.accessSequence = ++this.#accessSequence; + + return cached.status; } getCache(collectionName: string): ResourceTreeNode | null { - if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) { + const cached = this.#collections.get(collectionName); + + if (!cached) { return null; } - if (this.#cachedCollection.status !== CacheStatus.READY) { + if (cached.status !== CacheStatus.READY) { return null; } - return this.#cachedCollection.tree; + cached.accessSequence = ++this.#accessSequence; + + return cached.tree; } getCacheMetadata(collectionName: string): { indexedAt: Date | null; error?: string } | null { - if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) { + const cached = this.#collections.get(collectionName); + + if (!cached) { return null; } return { - indexedAt: this.#cachedCollection.indexedAt, - error: this.#cachedCollection.error, + indexedAt: cached.indexedAt, + error: cached.error, }; } getCacheStats(collectionName: string): { totalKeys: number; localeCount: number } | null { - if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) { + const cached = this.#collections.get(collectionName); + + if (!cached) { return null; } - if (this.#cachedCollection.status !== CacheStatus.READY) { + if (cached.status !== CacheStatus.READY) { return null; } return { - totalKeys: this.#cachedCollection.totalKeys, - localeCount: this.#cachedCollection.localeCount, + totalKeys: cached.totalKeys, + localeCount: cached.localeCount, }; } @@ -96,13 +133,12 @@ export class CollectionCacheService { error?: string, localeCount?: number, ): void { - if (this.#cachedCollection && this.#cachedCollection.collectionName !== collectionName) { - this.#logger.log(`Clearing cache for previous collection: ${this.#cachedCollection.collectionName}`); - this.#cachedCollection = null; - } + const existing = this.#collections.get(collectionName); + + if (!existing) { + this.#evictLeastRecentlyUsed(collectionName); - if (!this.#cachedCollection) { - this.#cachedCollection = { + this.#collections.set(collectionName, { collectionName, status, tree: tree ?? null, @@ -112,18 +148,22 @@ export class CollectionCacheService { localeCount: status === CacheStatus.READY ? (localeCount ?? 0) : 0, translationsFolder: null, fingerprint: null, - }; + accessSequence: ++this.#accessSequence, + lastRevalidationAt: 0, + pendingFingerprintRefresh: null, + }); } else { - this.#cachedCollection.status = status; - this.#cachedCollection.tree = tree ?? this.#cachedCollection.tree; - this.#cachedCollection.error = error; + existing.status = status; + existing.tree = tree ?? existing.tree; + existing.error = error; + existing.accessSequence = ++this.#accessSequence; if (status === CacheStatus.READY) { - this.#cachedCollection.indexedAt = new Date(); + existing.indexedAt = new Date(); if (tree) { - this.#cachedCollection.totalKeys = extractResourcesRecursively(tree).length; - this.#cachedCollection.localeCount = localeCount ?? 0; + existing.totalKeys = extractResourcesRecursively(tree).length; + existing.localeCount = localeCount ?? 0; } } } @@ -131,13 +171,88 @@ export class CollectionCacheService { this.#logger.log(`Cache status set to ${status} for collection: ${collectionName}`); } - clearCache(): void { - this.#cancelPendingFingerprintRefresh(); + /** + * 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 (this.#cachedCollection) { - this.#logger.log(`Clearing cache for collection: ${this.#cachedCollection.collectionName}`); - this.#cachedCollection = null; + 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); } /** @@ -148,17 +263,21 @@ export class CollectionCacheService { * @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 { - if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) { + const cached = this.#collections.get(collectionName); + + if (!cached) { this.#logger.warn(`Cannot add folder to cache: no cache for collection ${collectionName}`); return false; } - if (this.#cachedCollection.status !== CacheStatus.READY || !this.#cachedCollection.tree) { + if (cached.status !== CacheStatus.READY || !cached.tree) { this.#logger.warn(`Cannot add folder to cache: cache not ready for collection ${collectionName}`); return false; } - const tree = this.#cachedCollection.tree; + cached.accessSequence = ++this.#accessSequence; + + const tree = cached.tree; const parentSegments = parentPath ? parentPath.split('.') : []; const fullPathSegments = [...parentSegments, folderName]; @@ -177,7 +296,7 @@ export class CollectionCacheService { 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(); + this.#scheduleFingerprintRefresh(cached); return true; } @@ -198,7 +317,7 @@ export class CollectionCacheService { parentNode.children.sort((a, b) => a.name.localeCompare(b.name)); this.#logger.log(`Added folder "${folderName}" to cache at path "${parentPath || 'root'}"`); - this.#scheduleFingerprintRefresh(); + this.#scheduleFingerprintRefresh(cached); return true; } @@ -210,17 +329,21 @@ export class CollectionCacheService { * @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 { - if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) { + const cached = this.#collections.get(collectionName); + + if (!cached) { this.#logger.warn(`Cannot add resource to cache: no cache for collection ${collectionName}`); return false; } - if (this.#cachedCollection.status !== CacheStatus.READY || !this.#cachedCollection.tree) { + if (cached.status !== CacheStatus.READY || !cached.tree) { this.#logger.warn(`Cannot add resource to cache: cache not ready for collection ${collectionName}`); return false; } - const tree = this.#cachedCollection.tree; + cached.accessSequence = ++this.#accessSequence; + + const tree = cached.tree; // Navigate to the target folder let targetNode: ResourceTreeNode = tree; @@ -247,11 +370,11 @@ export class CollectionCacheService { targetNode.resources.push(resourceEntry); targetNode.resources.sort((a, b) => a.key.localeCompare(b.key)); // Update total keys count - this.#cachedCollection.totalKeys++; + cached.totalKeys++; this.#logger.log(`Added resource "${resourceEntry.key}" to cache at path "${folderPath || 'root'}"`); } - this.#scheduleFingerprintRefresh(); + this.#scheduleFingerprintRefresh(cached); return true; } @@ -262,17 +385,21 @@ export class CollectionCacheService { * @returns true if the folder was removed, false if cache wasn't ready or folder not found */ removeFolderFromCache(collectionName: string, folderPath: string): boolean { - if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) { + const cached = this.#collections.get(collectionName); + + if (!cached) { this.#logger.warn(`Cannot remove folder from cache: no cache for collection ${collectionName}`); return false; } - if (this.#cachedCollection.status !== CacheStatus.READY || !this.#cachedCollection.tree) { + if (cached.status !== CacheStatus.READY || !cached.tree) { this.#logger.warn(`Cannot remove folder from cache: cache not ready for collection ${collectionName}`); return false; } - const tree = this.#cachedCollection.tree; + cached.accessSequence = ++this.#accessSequence; + + const tree = cached.tree; const pathSegments = folderPath.split('.'); if (pathSegments.length === 0) { @@ -304,7 +431,7 @@ export class CollectionCacheService { } this.#logger.log(`Removed folder "${folderPath}" from cache`); - this.#scheduleFingerprintRefresh(); + this.#scheduleFingerprintRefresh(cached); return true; } @@ -317,17 +444,21 @@ export class CollectionCacheService { * @returns true if the resource was found and removed, false otherwise */ removeResourceFromCache(collectionName: string, resourceKey: string, folderPath: string): boolean { - if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) { + const cached = this.#collections.get(collectionName); + + if (!cached) { this.#logger.warn(`Cannot remove resource from cache: no cache for collection ${collectionName}`); return false; } - if (this.#cachedCollection.status !== CacheStatus.READY || !this.#cachedCollection.tree) { + if (cached.status !== CacheStatus.READY || !cached.tree) { this.#logger.warn(`Cannot remove resource from cache: cache not ready for collection ${collectionName}`); return false; } - const tree = this.#cachedCollection.tree; + cached.accessSequence = ++this.#accessSequence; + + const tree = cached.tree; // Navigate to the target folder let targetNode: ResourceTreeNode = tree; @@ -353,9 +484,9 @@ export class CollectionCacheService { return false; } - this.#cachedCollection.totalKeys--; + cached.totalKeys--; this.#logger.log(`Removed resource "${resourceKey}" from cache at path "${folderPath || 'root'}"`); - this.#scheduleFingerprintRefresh(); + this.#scheduleFingerprintRefresh(cached); return true; } @@ -369,17 +500,21 @@ export class CollectionCacheService { * @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 { - if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) { + const cached = this.#collections.get(collectionName); + + if (!cached) { this.#logger.warn(`Cannot move folder in cache: no cache for collection ${collectionName}`); return false; } - if (this.#cachedCollection.status !== CacheStatus.READY || !this.#cachedCollection.tree) { + if (cached.status !== CacheStatus.READY || !cached.tree) { this.#logger.warn(`Cannot move folder in cache: cache not ready for collection ${collectionName}`); return false; } - const tree = this.#cachedCollection.tree; + cached.accessSequence = ++this.#accessSequence; + + const tree = cached.tree; const sourceSegments = sourceFolderPath.split('.'); const folderName = sourceSegments[sourceSegments.length - 1]; const sourceParentSegments = sourceSegments.slice(0, -1); @@ -445,7 +580,7 @@ export class CollectionCacheService { destParent.children.sort((a, b) => a.name.localeCompare(b.name)); this.#logger.log(`Moved folder "${sourceFolderPath}" to "${destinationFolderPath || 'root'}" in cache`); - this.#scheduleFingerprintRefresh(); + this.#scheduleFingerprintRefresh(cached); return true; } @@ -465,24 +600,25 @@ export class CollectionCacheService { * @returns true when the cache was dropped and needs re-indexing */ revalidate(collectionName: string, translationsFolder: string, cwd?: string): boolean { - const cached = this.#cachedCollection; + const cached = this.#collections.get(collectionName); - if (!cached || cached.collectionName !== collectionName || cached.status !== CacheStatus.READY) { + if (!cached || cached.status !== CacheStatus.READY) { return false; } const now = Date.now(); - if (now - this.#lastRevalidationAt < this.#revalidationIntervalMs) { + if (now - cached.lastRevalidationAt < this.#revalidationIntervalMs) { return false; } - this.#lastRevalidationAt = now; + 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 (this.#pendingFingerprintRefresh !== null) { - this.#cancelPendingFingerprintRefresh(); + if (cached.pendingFingerprintRefresh !== null) { + this.#cancelPendingFingerprintRefresh(cached); cached.fingerprint = fingerprint; return false; } @@ -492,7 +628,7 @@ export class CollectionCacheService { } this.#logger.log(`Translations folder changed on disk for collection ${collectionName}, dropping cache`); - this.clearCache(); + this.clearCache(collectionName); return true; } @@ -500,11 +636,15 @@ export class CollectionCacheService { * 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(): void { - this.#cancelPendingFingerprintRefresh(); + refreshFingerprint(collectionName: string): void { + const cached = this.#collections.get(collectionName); + if (!cached) { + return; + } + + this.#cancelPendingFingerprintRefresh(cached); - const cached = this.#cachedCollection; - if (!cached?.translationsFolder) { + if (!cached.translationsFolder) { return; } @@ -517,24 +657,24 @@ export class CollectionCacheService { * Bulk endpoints mutate the cache once per resource in a synchronous loop, so deferring * collapses a whole batch into a single scan. */ - #scheduleFingerprintRefresh(): void { - if (this.#pendingFingerprintRefresh !== null || !this.#cachedCollection?.translationsFolder) { + #scheduleFingerprintRefresh(cached: CachedCollection): void { + if (cached.pendingFingerprintRefresh !== null || !cached.translationsFolder) { return; } - this.#pendingFingerprintRefresh = setTimeout(() => { - this.#pendingFingerprintRefresh = null; - this.refreshFingerprint(); + cached.pendingFingerprintRefresh = setTimeout(() => { + cached.pendingFingerprintRefresh = null; + this.refreshFingerprint(cached.collectionName); }, 0); // A pending refresh must never hold the process open on its own. - this.#pendingFingerprintRefresh.unref?.(); + cached.pendingFingerprintRefresh.unref?.(); } - #cancelPendingFingerprintRefresh(): void { - if (this.#pendingFingerprintRefresh !== null) { - clearTimeout(this.#pendingFingerprintRefresh); - this.#pendingFingerprintRefresh = null; + #cancelPendingFingerprintRefresh(cached: CachedCollection): void { + if (cached.pendingFingerprintRefresh !== null) { + clearTimeout(cached.pendingFingerprintRefresh); + cached.pendingFingerprintRefresh = null; } } @@ -549,6 +689,9 @@ export class CollectionCacheService { 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}`); @@ -567,22 +710,19 @@ export class CollectionCacheService { const duration = Date.now() - startTime; this.#logger.log(`Successfully indexed collection ${indexingCollectionName} in ${duration}ms`); - if (this.#cachedCollection?.collectionName !== indexingCollectionName) { - this.#logger.log( - `Discarding indexing results for ${indexingCollectionName} - collection changed to ${ - this.#cachedCollection?.collectionName ?? 'none' - }`, - ); + 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); - if (this.#cachedCollection) { - this.#cachedCollection.translationsFolder = translationsFolder; - this.#cachedCollection.fingerprint = fingerprint; + const cached = this.#collections.get(indexingCollectionName); + if (cached) { + cached.translationsFolder = translationsFolder; + cached.fingerprint = fingerprint; + cached.lastRevalidationAt = Date.now(); } - this.#lastRevalidationAt = Date.now(); } catch (error) { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; @@ -592,12 +732,8 @@ export class CollectionCacheService { error instanceof Error ? error.stack : undefined, ); - if (this.#cachedCollection?.collectionName !== indexingCollectionName) { - this.#logger.log( - `Discarding error state for ${indexingCollectionName} - collection changed to ${ - this.#cachedCollection?.collectionName ?? 'none' - }`, - ); + if (this.#collections.get(indexingCollectionName) !== indexingEntry) { + this.#logger.log(`Discarding error state for ${indexingCollectionName} - its cache entry was dropped`); return; } diff --git a/apps/api/src/app/collections/folders/folders.controller.ts b/apps/api/src/app/collections/folders/folders.controller.ts index a154a112..4820e6c0 100644 --- a/apps/api/src/app/collections/folders/folders.controller.ts +++ b/apps/api/src/app/collections/folders/folders.controller.ts @@ -182,12 +182,13 @@ export class FoldersController { // Handle cross-collection moves let destinationTranslationsFolder: string | undefined; + let destinationCollectionName: string | undefined; if (moveFolderDto.toCollection) { - const destCollectionName = decodeURIComponent(moveFolderDto.toCollection); - if (!config.collections || !config.collections[destCollectionName]) { - throw new NotFoundException(`Destination collection "${destCollectionName}" not found`); + destinationCollectionName = decodeURIComponent(moveFolderDto.toCollection); + if (!config.collections || !config.collections[destinationCollectionName]) { + throw new NotFoundException(`Destination collection "${destinationCollectionName}" not found`); } - destinationTranslationsFolder = config.collections[destCollectionName].translationsFolder; + destinationTranslationsFolder = config.collections[destinationCollectionName].translationsFolder; } // Perform the move @@ -201,14 +202,21 @@ export class FoldersController { // Update cache incrementally after successful folder move if (result.movedCount > 0) { - const moved = this.cacheService.moveFolderInCache( - decodedCollectionName, - moveFolderDto.sourceFolderPath, - moveFolderDto.destinationFolderPath, - ); - if (!moved) { - // Fallback: clear cache if incremental update failed - this.cacheService.clearCache(); + 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); + } } } diff --git a/apps/api/src/app/collections/locales/locales.controller.ts b/apps/api/src/app/collections/locales/locales.controller.ts index 7f794249..3e013046 100644 --- a/apps/api/src/app/collections/locales/locales.controller.ts +++ b/apps/api/src/app/collections/locales/locales.controller.ts @@ -40,7 +40,7 @@ export class LocalesController { const result = await addLocaleToCollection(collectionName, body.locale); - this.#cacheService.clearCache(); + this.#cacheService.clearCache(collectionName); return result; } catch (error: unknown) { @@ -80,7 +80,7 @@ export class LocalesController { const result = await removeLocaleFromCollection(collectionName, locale); - this.#cacheService.clearCache(); + this.#cacheService.clearCache(collectionName); return result; } catch (error: unknown) { 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 a1a3b168..953bb0fd 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -1056,6 +1056,37 @@ describe('ResourcesController', () => { expect(getCache).toHaveBeenCalledWith('test-collection'); }); + 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; + + getCacheStatus.mockReturnValue(CacheStatus.READY); + getCache.mockReturnValue(mockTreeNode); + extractResourcesRecursively.mockReturnValue([ + ...mockTreeNode.resources, + { + key: 'save', + source: 'Save', + translations: { es: 'Guardar' }, + metadata: { + en: { checksum: 'b' }, + es: { status: 'new', checksum: '', baseChecksum: 'b' }, + }, + }, + ]); + + const result = await resourcesController.getTree('test-collection', '', 'true'); + const tree = result as ResourceTreeDto; + + // The root is a folder like any other: it honours includeNested and never goes + // through extractSubtree, which has no path to extract. + expect(extractSubtree).not.toHaveBeenCalled(); + expect(extractResourcesRecursively).toHaveBeenCalledWith(mockTreeNode); + expect(tree.resources.map((r) => r.key)).toEqual(['title', 'save']); + }); + it('should extract and return subtree when cache is READY and path is provided', async () => { const getCacheStatus = cacheService.getCacheStatus as jest.Mock; const getCache = cacheService.getCache as jest.Mock; diff --git a/apps/api/src/app/collections/resources/resources.controller.ts b/apps/api/src/app/collections/resources/resources.controller.ts index 7eac8622..84034cf6 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -42,7 +42,6 @@ import type { UpdateResourceResponseDto, ResourceTreeDto, ResourceSummaryDto, - TranslationStatus, SearchTranslationsDto, SearchResultsDto, CacheStatusDto, @@ -300,7 +299,7 @@ export class ResourcesController { // Clear cache after successful resource deletion if (result.entriesDeleted > 0) { - this.#cacheService.clearCache(); + this.#cacheService.clearCache(decodedCollectionName); } return { @@ -350,6 +349,10 @@ export class ResourcesController { ); } + // Every collection a move touched, so each one's cache is dropped and no untouched + // collection's cache is. + const affectedCollections = new Set([decodedCollectionName]); + for (const moveOp of dto.moves) { let destinationTranslationsFolder: string | undefined; @@ -363,6 +366,7 @@ export class ResourcesController { continue; } destinationTranslationsFolder = config.collections[destCollectionName].translationsFolder; + affectedCollections.add(destCollectionName); } const moveResult = await moveResource(translationsFolder, { @@ -383,7 +387,9 @@ export class ResourcesController { // Clear cache after successful resource move if (result.movedCount > 0) { - this.#cacheService.clearCache(); + for (const affected of affectedCollections) { + this.#cacheService.clearCache(affected); + } } return result; @@ -555,18 +561,10 @@ export class ResourcesController { throw new HttpException('Cache is marked as ready but tree is not available', HttpStatus.INTERNAL_SERVER_ERROR); } - // If no path specified, return full tree - if (!path || path.trim() === '') { - const treeDto = mapResourceTreeToDto(cachedTree, collection.tags); - if (responseObj) { - responseObj.status(HttpStatus.OK).json(treeDto); - return treeDto; - } - return treeDto; - } - - // Extract subtree at specified path - const subtree = extractSubtree(cachedTree, path); + // 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`); @@ -575,35 +573,9 @@ export class ResourcesController { const treeDto = mapResourceTreeToDto(subtree, collection.tags); if (isIncludeNested) { - const nestedResources = extractResourcesRecursively(subtree); - treeDto.resources = nestedResources.map((res) => { - // Find base locale - let baseLocale: string | undefined; - for (const [locale, meta] of Object.entries(res.metadata)) { - if (meta.status === undefined && meta.baseChecksum === undefined) { - baseLocale = locale; - break; - } - } - - const translations: Record = { ...res.translations }; - if (baseLocale) { - translations[baseLocale] = res.source; - } - - const status: Record = {}; - for (const [locale, meta] of Object.entries(res.metadata)) { - status[locale] = meta.status; - } - - return { - key: res.key, - translations, - status, - comment: res.comment, - tags: res.tags, - }; - }); + treeDto.resources = extractResourcesRecursively(subtree).map((res) => + mapResourceEntryToSummary(res, collection.tags), + ); } if (responseObj) { diff --git a/apps/cli/src/commands/validate.test.ts b/apps/cli/src/commands/validate.test.ts index 80936c9b..023ffb57 100644 --- a/apps/cli/src/commands/validate.test.ts +++ b/apps/cli/src/commands/validate.test.ts @@ -186,6 +186,7 @@ describe('validateCommand', () => { allowTranslated: false, skippedLocales: [], icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, + placeholders: { baseLocale: 'en' }, }, ); @@ -193,6 +194,7 @@ describe('validateCommand', () => { allowTranslated: false, skippedLocales: [], icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, + placeholders: { baseLocale: 'en' }, }); expect(console.log).toHaveBeenCalledWith('Validation summary output'); @@ -299,6 +301,7 @@ describe('validateCommand', () => { allowTranslated: false, skippedLocales: [], icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, + placeholders: { baseLocale: 'en' }, }); }); @@ -351,6 +354,7 @@ describe('validateCommand', () => { allowTranslated: false, skippedLocales: [], icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, + placeholders: { baseLocale: 'en' }, }); }); @@ -402,6 +406,7 @@ describe('validateCommand', () => { allowTranslated: false, skippedLocales: [], icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, + placeholders: { baseLocale: 'en' }, }); }); @@ -661,12 +666,14 @@ describe('validateCommand', () => { allowTranslated: true, skippedLocales: [], icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, + placeholders: { baseLocale: 'en' }, }); expect(mockGenerateValidationSummary).toHaveBeenCalledWith(warningResult, { allowTranslated: true, skippedLocales: [], icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, + placeholders: { baseLocale: 'en' }, }); expect(console.log).toHaveBeenCalledWith('Validation summary output'); @@ -757,10 +764,63 @@ describe('validateCommand', () => { allowTranslated: false, skippedLocales: [], icu: { baseLocale: 'en', compileValues: true, requirePortablePlurals: false }, + placeholders: { baseLocale: 'en' }, }); }); }); + describe('placeholder checking', () => { + const successResult = { + totalResourcesValidated: 6, + totalUniqueKeys: 2, + localesValidated: 3, + collectionsValidated: 2, + statusCounts: { new: 0, translated: 0, stale: 0, verified: 6 }, + failures: [], + warnings: [], + successes: [], + passed: true, + }; + + it('should check placeholders against the base locale by default', async () => { + mockValidateResources.mockReturnValue(successResult); + + await validateCommand({}); + + expect(mockValidateResources).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Array), + expect.objectContaining({ placeholders: { baseLocale: 'en' } }), + ); + }); + + it('should skip the check when --skip-placeholders is given', async () => { + mockValidateResources.mockReturnValue(successResult); + + await validateCommand({ skipPlaceholders: true }); + + expect(mockValidateResources).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Array), + expect.objectContaining({ placeholders: undefined }), + ); + }); + + it('should keep checking placeholders when ICU compilation is skipped', async () => { + // The two passes answer different questions, so opting out of one must + // not quietly opt out of the other. + mockValidateResources.mockReturnValue(successResult); + + await validateCommand({ skipIcu: true }); + + expect(mockValidateResources).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Array), + expect.objectContaining({ placeholders: { baseLocale: 'en' } }), + ); + }); + }); + describe('comprehensive validation behavior', () => { it('should display summary output before exiting', async () => { const failureResult = { diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index 89592c24..1233304a 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -34,6 +34,17 @@ export interface ValidateCommandOptions { */ skipIcu?: boolean; + /** + * When true, translations are not checked against their base value for the + * placeholders they interpolate. + * + * The check is on by default because this defect is silent: a renamed + * placeholder renders as empty text rather than raising, so neither the + * status gate nor the ICU compile pass reports it. Use this to opt out where + * translations deliberately diverge from the base value's arguments. + */ + skipPlaceholders?: boolean; + /** * When true, base-locale values selecting a plural branch by category * (`one`, `few`, …) rather than by exact `=N` match generate warnings. @@ -66,6 +77,7 @@ export interface ValidateCommandOptions { * - 'verified' status → SUCCESS (translation reviewed and approved) * - Missing metadata → treated as 'new' (FAILURE) * - 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) * * **ICU Validation:** * Status validation asks whether a human approved a translation. It says @@ -111,6 +123,9 @@ export interface ValidateCommandOptions { * # Status gate only, without compiling values as ICU * $ lingo-tracker validate --skip-icu * + * # Status and ICU gates only, without comparing placeholders + * $ lingo-tracker validate --skip-placeholders + * * # Also warn about base-locale plurals that will not survive being copied * $ lingo-tracker validate --require-portable-plurals * @@ -182,6 +197,9 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< 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 }, }; const validationResult = validateResources(allCollections, localesToValidate, validationOptions); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index faa0ff19..45e51537 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -312,6 +312,11 @@ program "Warn when a base-locale plural selects by category (one, few, ...) instead of an exact '=N' match", false, ) + .option( + '--skip-placeholders', + 'Do not check that each translation interpolates the same placeholders as its base value', + false, + ) .addHelpText( 'after', ` @@ -332,6 +337,9 @@ Examples: # Also warn about base-locale plurals that break when copied to ja/ko $ lingo-tracker validate --require-portable-plurals + # Skip the placeholder-agreement check + $ lingo-tracker validate --skip-placeholders + # Use in CI pipeline (exits with code 1 on validation failure) $ lingo-tracker validate || exit 1 @@ -445,6 +453,10 @@ Notes: - The base locale is compiled too: its value is copied into every translation slot - Use --skip-icu to run the status gate alone; it does not disable --require-portable-plurals, which parses rather than compiles + - Checks that every translation interpolates the same placeholders as its base + value; a renamed one ('{name}' translated to '{nombre}') renders as empty + text rather than raising, so no other check sees it. Use --skip-placeholders + to turn this off - --skip-locales excludes target locales only; the base locale is always compiled, since its value is copied into every translation slot - Validates ALL collections and ALL target locales (no filtering) by default diff --git a/apps/tracker/.impeccable/critique/2026-09-06T04-11-52Z__gs-translation-editor-translation-editor-dialog-ts.md b/apps/tracker/.impeccable/critique/2026-09-06T04-11-52Z__gs-translation-editor-translation-editor-dialog-ts.md new file mode 100644 index 00000000..a10e9813 --- /dev/null +++ b/apps/tracker/.impeccable/critique/2026-09-06T04-11-52Z__gs-translation-editor-translation-editor-dialog-ts.md @@ -0,0 +1,95 @@ +--- +target: Create/Edit Translation dialog and all tabs +total_score: 19 +max_score: 40 +na_heuristics: +p0_count: 0 +p1_count: 1 +target_identity: "file:/Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts" +target_fingerprint: "sha256:6e9d1725f1b2dab364eaf6bb74c03242d9242907d2c9925a715d79a8db0a1892" +target_path: /Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts +timestamp: 2026-09-06T04-11-52Z +slug: gs-translation-editor-translation-editor-dialog-ts +--- +Method: dual-agent (A: design review · B: detector + evidence). Detector ran DEGRADED (HTML/CSS parser modules unavailable, regex fallback; no contrast/token/DOM rules). Findings are an undercount. + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 3 | Save disabled (html:258) with no indication of what is invalid or where | +| 2 | Match System / Real World | 2 | Raw enum statuses; `otherLocales` mistranslated as geography in de/ru/ja | +| 3 | User Control and Freedom | 1 | No unsaved-changes guard; `onCancel()` bare close (ts:379) | +| 4 | Consistency and Standards | 2 | Statuses are localized pills in list view, bare English here | +| 5 | Error Prevention | 1 | Key renaming rejected after submit (ts:481); duplicates only as server 409 | +| 6 | Recognition Rather Than Recall | 2 | Full dotted key never shown as one string | +| 7 | Flexibility and Efficiency | 2 | Ctrl/Cmd+Enter (ts:364) undocumented; no auto-translate; no save-and-add-another | +| 8 | Aesthetic and Minimalist Design | 3 | Similar Translations holds permanent space in idle state | +| 9 | Error Recovery | 2 | Raw server messages piped to user, never mapped to a field | +| 10 | Help and Documentation | 1 | `keyHint` misinforms; neither disabled tab explains itself | +| **Total** | | **19/40** | **Below average — structurally sound, systemically leaky** | + +## Design Specificity Verdict + +Partly authored; the authored parts are chrome, not flow. SCSS is genuinely LingoTracker (parchment, coral accent rails scss:296, mono location pill, scroll-reveal shadow). Interaction model is stock Material — swap labels and it creates a CRM contact. + +Three tells: +- Dot-delimited key syntax is the product's own, and the key field forbids the dot (ts:151). +- Status is "the spine" and renders as raw English enum (html:193) though `browser.status.*` is localized in all six locales. +- Read-only banner uses `var(--color-warn, #b26a00)` (scss:140-143); `--color-warn` does not exist — tokens.scss:33 defines `--color-warning`. Hardcoded brown fires 100% of the time, both themes. + +Deterministic scan: 5 findings, one rule (`side-tab`) across 4 files; 3 are false positives (tree selection indicator, neutral-token state rails). Browser overlay unavailable — dev server on :4200 but no injection attempted; live contrast unverified, and the degraded detector also skipped computed contrast. + +i18n parity is perfect (49/49 editor keys, 13/13 picker keys, six locales). Every key exists; some hold the wrong words. + +## Overall Impression + +Craft is real, bones are sound; failures are at the seams where the dialog meets the product's own promises. Localized except the status control. Has a confirmation guard — pointed at an optional comment field while Escape vaporizes the form. Has a folder picker — hidden behind a third tab while the field that could accept the path rejects its syntax. Biggest opportunity: collapse the tab strip. + +## What's Working + +- Base-value reference banner on Tab 2 (html:169-178) — solves the working-memory problem the tab split creates; scroll-reveal shadow communicates rather than decorates. +- Similar Translations state machine (similar-translations.ts:33-38) — four states as one computed, debounced 300ms, self-filtering in edit mode. +- Inherited tags as dashed non-removable chips (html:117-121) — communicates inheritance without a word. + +## Priority Issues + +**[P1] Key field forbids the product's own key syntax.** ts:151 `/^[a-zA-Z0-9_-]+$/`. User arrives holding `apps.common.buttons.ok`; error mentions neither dots nor folders while `#buildFullKey()` (ts:578) joins with a dot. Fix: accept dots on paste, split at last `.`, set folder prefix, flash the pill. Command: /impeccable harden + +**[P2] Window-scoped Escape + no dirty guard destroys work.** ts:359 binds Escape to window; no `disableClose` at either call site (with-item-actions.feature.ts:70, translation-main-header.ts:101). Escape over the conflict dialog closes the editor beneath it. Fix: delete the HostListener, set disableClose, route backdropClick/keydownEvents through a form.dirty check. Command: /impeccable harden + +**[P3] Statuses render as untranslated raw enums.** html:191-194. Fix: map through TOKENS.BROWSER.STATUS.* and reuse the list view's status chip. Command: /impeccable clarify + +**[P4] Disabled Save with no diagnosis, no per-tab validity signal.** html:258 + html:69 (touched-gated errors). Fix: enable, validate on click, markAllAsTouched, jump to first invalid tab, add error dot to tab label. Command: /impeccable harden + +**[P5] Read-only styling references a nonexistent token.** scss:140-143. Never adapts to dark theme. PRODUCT.md makes read-only legibility binding. Fix: rename to --color-warning, extend lock state to pill and tab strip, tooltip the disabled Tab 3. Command: /impeccable polish + +**[P6] `otherLocales` mistranslated as geography in de/ru/ja; `readOnlyNotice` untranslated in all five non-English locales.** de "Andere Orte", ru "Другие населенные пункты" (other populated settlements), ja "その他の地域". Related: `stale` as de "Abgestanden", ru "Залежалый". Fix: retranslate as software-locale terms; fill readOnlyNotice. Command: /impeccable clarify + +## Persona Red Flags + +**Alex (power user):** key field rejects his clipboard. Ctrl+Enter is undocumented and rewarded with an unrequested comment nag (ts:681, disableClose:true). No save-and-add-another. Escape is a landmine. + +**Jordan (first-timer/translator):** keyHint never says what a key is or that it composes with the folder path. Location pill styled as static metadata — she'll never find Tab 3. Tab 2 disabled and unexplained. Status vocabulary in raw English without teaching color. Comment is a single-line input (html:103) though TextFieldModule is imported unused (ts:27). + +**i18n owner:** auto-translate absent from the dialog entirely (lives only at translation-item/item-header.ts:161). Create mode hardcodes non-base status to 'new' (ts:547) and hides the select. Key rename rejected only after a round-trip through the comment nag (ts:481). Full dotted key not copyable anywhere. + +## Minor Observations + +- Fixed 570px content height (scss:182) + `overflow: hidden !important` (scss:186) clips on an 800px viewport with no scroll escape. +- Responsive unaddressed: 700px both call sites, one @media in 603 lines. German/Russian tab labels force Material pagination arrows. +- `.location-pill-path` truncates with no tooltip (scss:110). +- Reduced-motion covers 3 of 16 transitions (scss:597 only); three sibling SCSS files have no such query. +- Folder picker add-subfolder button is opacity:0 until hover but stays focusable. +- Tree has no aria-activedescendant and no role="treeitem"; DOM focus never moves. +- Hand-rolled role="dialog" (html:1) nested inside MatDialog's own. +- getLocaleDisplayName() just uppercases — `fr-ca` renders FR-CA. +- Similar-translation cards copy to clipboard but are styled as navigation; the 409 flow already knows how to open the resource. +- 3 `!important` and 14 `::ng-deep` uses fighting Material internals. + +## Questions to Consider + +- Why is there a tab strip at all? One scrolling column kills the disabled-tab, cross-tab-validity, fixed-height, and label-overflow problems at once. +- Why does the dialog nag about a missing comment but not about unsaved work? +- Should the location pill be an input rather than a tab destination? +- `--color-warn` failed silently. What else references a token that doesn't exist? A CI check resolving every var(--color-*) against tokens.scss would catch it. diff --git a/apps/tracker/.impeccable/critique/2026-09-06T05-01-25Z__gs-translation-editor-translation-editor-dialog-ts.md b/apps/tracker/.impeccable/critique/2026-09-06T05-01-25Z__gs-translation-editor-translation-editor-dialog-ts.md new file mode 100644 index 00000000..c0fc6310 --- /dev/null +++ b/apps/tracker/.impeccable/critique/2026-09-06T05-01-25Z__gs-translation-editor-translation-editor-dialog-ts.md @@ -0,0 +1,123 @@ +--- +target: Translation creation/edit dialog and all tabs (re-critique) +total_score: 26 +max_score: 40 +na_heuristics: +p0_count: 1 +p1_count: 2 +target_identity: "file:/Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts" +target_fingerprint: "sha256:28e1378efb682268e63c10874f4ee3ddddff09a4a6a16ae08c4efbd78e086fa3" +target_path: /Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts +timestamp: 2026-09-06T05-01-25Z +slug: gs-translation-editor-translation-editor-dialog-ts +--- +Method: dual-agent (A: design review · B: detector + browser evidence, full parser mode, live browser on :4200). + +Re-critique of the same target assessed at 19/40 on 2026-09-06. **Now 26/40.** 4 of 6 P-issues fixed, 2 partial; 6 of 10 minors fixed. No regressions in the fixed set — but the two survivors are the two that define the product, and one *new* P0 arrived with the auto-translate feature. + +## Regression scorecard — how the changes held up + +| Prev | Issue | Verdict | Proof | +|---|---|---|---| +| P1 | Key field forbids dotted syntax | **PARTIAL** | `ts:159` validator unchanged, no paste handler exists. Only the error copy improved. | +| P2 | Window Escape + no dirty guard | **FIXED** | `ts:328` `disableClose`, `ts:330-345` keydown/backdrop routed, `ts:349-364` 3-source dirty check. | +| P3 | Raw English enum statuses | **FIXED mechanism / broken content** | `ts:227-232` + `html:249` localize it — onto four wrong translations. | +| P4 | Disabled Save, no diagnosis | **FIXED** | `html:319`, `ts:619-631`, tab error dot `html:66-72`. | +| P5 | `--color-warn` doesn't exist | **FIXED here, not repo-wide** | `scss:156-159` + `tokens.scss:36`. Detector found 3 surviving refs in `translation-browser.scss:88,89,91`. | +| P6 | `otherLocales` as geography | **FIXED** | de "Weitere Sprachen", ru "Другие локали", ja "他のロケール". | + +Minors: fixed — location tooltip, reduced-motion (16/18 transitions now covered), focusable hidden add-button, nested `role="dialog"` (B confirms exactly one at runtime), `getLocaleDisplayName` via `Intl.DisplayNames`. Not fixed — responsive (still one breakpoint in 690 lines), 3 `!important` / 14 `::ng-deep`, similar-translation cards styled as navigation, 570px fixed height still in the file. + +## Design Health Score + +| # | Heuristic | Score | Key issue | +|---|---|---|---| +| 1 | Visibility of System Status | 3 | `errorMessage` (`ts:623`) never clears as fields are fixed; no `aria-live` anywhere (B). | +| 2 | Match System / Real World | 2 | `stale` = de "Abgestanden", ru "Залежалый", es "Duro", fr-ca "Vicié". `saving` = es "Ahorro…". | +| 3 | User Control and Freedom | 3 | Dirty guard is excellent — but the discard dialog lies after auto-translate. | +| 4 | Consistency and Standards | 3 | Base value is a 4-row textarea (`html:110`); every locale value is a single-line input (`html:259`). | +| 5 | Error Prevention | 2 | Dots still rejected; duplicates still only a 409 (`ts:833`); no ICU-placeholder parity check. | +| 6 | Recognition Rather Than Recall | 2 | Full dotted key is tooltip-only (`html:28`). Tab 3 shows a tree with no key and no value. | +| 7 | Flexibility and Efficiency | 3 | Ctrl+Enter now documented — but hardcoded "Ctrl" in all six locales while `ts:485` binds `meta.enter`. | +| 8 | Aesthetic and Minimalist Design | 3 | Idle Similar-Translations card always painted; B measured 350px dead space on tab 3 at 1440. | +| 9 | Error Recovery | 2 | Raw `error.error.message` piped to banner (`ts:848`); API's 422 "auto-translation not enabled" collapsed into a generic toast (`ts:654`). | +| 10 | Help and Documentation | 3 | Both disabled tabs now explain themselves. Nothing explains the status lifecycle or what auto-translate writes. | +| **Total** | | **26/40** | **Up 7. Seams closed; the model underneath is unchanged.** | + +Cognitive load: **4 failures of 8** — single focus (Tab 1 holds five concerns), grouping (location pill and its control separated by a tab boundary and named differently), one-thing-at-a-time (`ts:600` comment nag interrupts every save), progressive disclosure (idle similar-translations card). + +## Design Specificity Verdict + +**Meaningfully more authored than last time; still a Material tab dialog wearing a LingoTracker coat.** + +Newly product-specific: `ts:238` `canAutoTranslate` with the `saveFirst` tooltip encodes a real architectural truth. `html:242`/`scss:686` pairs the human locale name with a mono `fr-ca` chip — the dual audience in one row, with a source comment naming the person it serves. `tokens.scss:34-36` fixes `--color-warning` *and* records why two tokens exist (2.1:1 contrast on parchment). + +Still category-interchangeable: the three-tab strip is the whole interaction model, and it costs ~120 lines of compensating machinery (`selectedTabIndex`, `formRevision`, `baseTabHasError`, the error dot, two disabled tooltips, the base-value reference banner, a fixed 570px height the new flex layout works around). The full dotted key — the deliverable — is uncopyable, while other people's suggested keys get a clipboard button (`ts:562`). + +**Deterministic scan:** 5 findings, one rule (`side-tab`), all in SCSS. Run in **full parser mode** (agent B installed the missing parser deps, then removed them) — identical to the degraded run, so unlike last time this is not an undercount. 3 genuine, 2 defensible false positives (`similar-translations.scss:9` and `dialog.scss:469` are state channels, not decoration). Notable pattern: the same 3px left-stripe idiom on four different card types in one dialog. **Zero hardcoded colors in the directory** — fully tokenized. + +**Token sweep:** 13 unresolved `var()` refs across `apps/tracker/src`. `--color-warn` survives at `translation-browser.scss:88,89,91` (with fallback). Worse: 7 `--mat-sys-*` refs in `collection-form-dialog.scss` (`:34,41,50,69,79,88,90`) with **no fallback** — `theme.scss` uses M2 APIs, which never emit `--mat-sys-*`. Same silent-failure class as the bug you just fixed. + +**Browser (live, both modes, light+dark, 1440/900/390, EN+DE):** 0 console errors. Focus trap correct, 7 stops, nothing escapes. The 570px height **does not clip** — `min-height: 0` lets it shrink (420px at 900×700, 529px at 390×844). Two real defects it exposed, below. + +## What's Working + +- **The unsaved-changes guard is architecturally right, not just present.** `ts:327-346` owns `disableClose` in the component rather than trusting call sites, routes Escape through `dialogRef.keydownEvents()` instead of `window`, and `hasUnsavedChanges()` compares baselines for three independent state sources — form, `selectedFolderPath`, `tagsList` — two of which live outside the form. Most implementations check `form.dirty` and ship the bug. +- **`--color-warning-text` is a token fix with a stated reason.** `tokens.scss:34-36` records *why* two tokens are needed and gives dark mode its own value. That comment is what stops the next developer re-collapsing them. +- **`getLocaleDisplayName()` serves both personas in one row.** `ts:955-968` resolves through `Intl.DisplayNames` *in the active UI language*, with an uppercase fallback and try/catch; `html:242` keeps the raw code beside it. Neither audience translates for the other. + +## Priority Issues + +**[P0] Auto-translate writes the repository from inside an unsaved form — and the discard dialog then lies about it.** +`ts:646-648` POSTs to `resources/translate`; `resources.controller.ts:80-105` persists to disk. `ts:673` then marks the form dirty, so Cancel shows "Closing now will lose them" — the locale values are already committed to disk and cannot be lost. No confirmation before the write, no undo after. This breaks PRODUCT.md principle 1 (files are the truth, every action a reviewable diff) *and* principle 4 (never let a destructive state be a surprise), then misreports what happened. +Fix: make the endpoint return candidates and let Save persist them; or keep the write, confirm before it ("writes N locales to `apps.common.buttons.ok` now"), and reset the dirty baselines after. → `/impeccable harden` + +**[P1] `stale` is mistranslated in four of six locales — on the control P3's fix just made prominent.** +de "Abgestanden" (beer), ru "Залежалый" (unsold goods), es "Duro" (hard bread), fr-ca "Vicié" (foul air). Plus `saving` = es "Ahorro…", fr-ca "Économie…" — the *money* sense, on the submit button. PRODUCT.md fixes the status lifecycle as non-renamable domain vocabulary and calls it "the spine". A translation tool shipping a wrong translation of its own core noun is the worst dogfooding failure available. +Fix: de "Veraltet", ru "Устарело", es "Desactualizado", fr-ca "Obsolète"; `saving` → "Guardando…" / "Enregistrement…". → `/impeccable clarify` + +**[P1] The key field still refuses the one string the primary user is holding.** +`ts:159` `/^[a-zA-Z0-9_-]+$/`, unchanged, no paste interception. `#buildFullKey` (`ts:811-817`) joins with `.` — the app writes the syntax it won't read. The copy improved; the rejection didn't. +Fix: intercept paste/input, split at the last `.`, set `selectedFolderPath` to the prefix, put the leaf in the control, flash the location pill. Keep the pattern as backstop, not front door. → `/impeccable harden` + +**[P2] Hint text overflows and paints over content in German at narrow widths (browser-measured).** +`.mat-mdc-form-field-subscript-wrapper` is 22px with `overflow: visible`. At 390px the key hint renders 42px, tags hint 62px; in German the key hint reaches **62px** and the tags hint **82px**, spilling onto `.base-locale-card` below. Cause: no `subscriptSizing="dynamic"` at `html:77, :127, :142`. Also at 390px the tab strip paginates and scrolls the *active* tab out of view with no indicator. PRODUCT.md makes long-DE/RU survival binding. +Fix: `subscriptSizing="dynamic"` on the three fields; add a real breakpoint (there is exactly one `@media` breakpoint in 690 lines, scoped to `.base-value-reference`) and drop the dialog to full-width below ~600px. → `/impeccable adapt` + +**[P2] Other-locale values are single-line inputs while the base value is a four-row textarea.** +`html:110-120` vs `html:259-265`. Exactly backwards: the translator, whose whole job is Tab 2, gets the worse field for the languages that run 30–40% longer. `TextFieldModule` is already imported (`ts:27`) — the fix costs nothing. → `/impeccable polish` + +**[P3] The full dotted key is uncopyable, and the error banner never clears.** +`fullKeyPreview()` (`ts:255-263`) renders only into `[matTooltip]` (`html:28`) — unreachable by keyboard, touch, and selection. Separately `errorMessage` (`ts:623`) persists after the user fixes the fields, so a stale "Fix the highlighted fields" banner sits over a now-valid form, undoing the credibility P4 just earned. → `/impeccable polish` + +## Persona Red Flags + +**Alex (power-user dev):** pasted key still rejected (`ts:159`); the key he came for is uncopyable (`html:28`); `saveShortcutHint` hardcodes "Ctrl + Enter" in all six locales while `ts:485` also binds `meta.enter`; the comment nag fires on every save (`ts:600`); still no save-and-add-another. + +**Jordan (first-time translator):** single-line input for every language she writes (`html:259`); a status dropdown offering "Abgestanden"/"Залежалый"/"Duro"; no status color reaches the select (`html:244-253`) so the list view's chip vocabulary doesn't transfer; auto-translate's tooltip describes the mechanism but never says it writes files. + +**i18n owner:** auto-translate is all-or-nothing on empty locales (`ts:665`) — no per-locale control, no preview, no re-run of one stale locale; the API's 422 "Auto-translation is not enabled for this collection" is swallowed into a generic toast (`ts:654`); create mode hardcodes non-base status to `'new'` (`ts:780`) so a verified import can't be seeded; no ICU-placeholder parity check, though `skippedLocales` (`ts:688-694`) proves the server already reasons about it. + +## Minor Observations + +- **20 dialog-scope i18n entries are `stale`, 2 are `new`** — concentrated in es (10) and fr-ca (9). Translations exist but no longer match the current base value. The tool is not clean against itself. +- `similarTranslations.showMoreX` is genuinely untranslated in fr-ca ("Show {count} more"). +- `otherLocalesToggleX` is referenced by nothing — six locales maintained for a dead string, in a product whose job is finding dead strings. +- `ts:711-720`'s `hasKeyChanged` rejection is now unreachable dead code (`html:85` makes the key readonly). +- `folder-picker.ts` sets no `aria-activedescendant` and never calls `.focus()` — the visual tree highlight moves, the screen reader does not. +- `html:5` uses `class="mat-dialog-title"`, not the directive; the accessible name works only because both call sites pass `ariaLabelledBy`. A third call site ships a nameless dialog. +- `scss:12` hardcodes `max-height: 90vh`, duplicating what both call sites already pass. `scss:8` `:host { max-height: inherit }` has no effect. +- `html:92` renders the *error* string `error.keyRenamingNotSupported` as a steady-state `mat-hint`. +- es/fr-ca `keyHint`/`keyPatternError` name the tab as "pestaña Ubicación"/"onglet Emplacement" but the real labels are "Cambiar ubicación"/"Changer d'emplacement". de/ru/ja got this right. +- fr-ca `error.createFailed` = "Échec de la traduction" (translation failed, not create failed). de `conflict.chooseDifferentKey` uses *Taste* (keyboard key) for *Schlüssel*. +- Reduced motion covers 16/18 transitions; uncovered: `picker-folder-node.scss:93` `.folder-icon`, `:104` `.folder-name`. +- `translation-main-header.ts:95-99` builds the dialog data without `readOnly` — only the button's disabled state keeps a read-only collection out of an editable create dialog. +- App-shell bug found in passing: `header.keysCountX` / `header.localesCountX` render as raw keys in German (2 console warnings). +- The 4 new tests cover only P2. The tab error dot, the readonly key, and auto-translate ship untested. + +## Questions to Consider + +1. **If auto-translate writes to disk, why is it inside a form with a Save button?** The dialog now has two meanings of "saved" and tells the user about only one. +2. **The tab strip survived this re-critique — did it earn that, or just outlast the reviewer?** One scrolling column deletes the disabled-tab, cross-tab-validity, fixed-height, dead-space, and label-pagination problems at once. +3. **What check would have caught `stale` = "Abgestanden"?** Key parity is 100%. A glossary term per lifecycle status, enforced across locales, is the product demonstrating its own glossary feature on itself. +4. **`--color-warn` is fixed here and still live three files away, and 7 `--mat-sys-*` refs resolve to nothing with no fallback.** Twenty lines of CI resolving every `var(--color-*)` against `tokens.scss` makes the fix permanent instead of local. diff --git a/apps/tracker/.impeccable/critique/2026-09-06T22-33-09Z__slations-list-translation-item-translation-item-ts.md b/apps/tracker/.impeccable/critique/2026-09-06T22-33-09Z__slations-list-translation-item-translation-item-ts.md new file mode 100644 index 00000000..f3ecf330 --- /dev/null +++ b/apps/tracker/.impeccable/critique/2026-09-06T22-33-09Z__slations-list-translation-item-translation-item-ts.md @@ -0,0 +1,120 @@ +--- +target: compact and full detail translation resource item in the main content area of the browser view +total_score: 14 +max_score: 36 +na_heuristics: 9 +p0_count: 2 +p1_count: 3 +target_identity: "file:/Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts" +target_fingerprint: "sha256:699cf174e39ea7423816a11af7c44a0c9d325911169d19dc7f1585703a7669ef" +target_path: /Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts +timestamp: 2026-09-06T22-33-09Z +slug: slations-list-translation-item-translation-item-ts +closed: true +--- +Method: dual-agent (A: design review · B: detector + browser evidence). Both ran isolated; no degradation. + +**Target:** `apps/tracker/src/app/browser/translations/list/translation-item/` (compact + full detail) · Mode: **Operate** + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|---|---|---| +| 1 | Visibility of System Status | 1 | Compact shows an unlabeled value and silently falls back to the base locale — an all-`new` key renders confident English (`translation-item.ts:187-199`, `.html:45`) | +| 2 | Match System / Real World | 2 | "base locale" never appears on the item; base value is unlabeled; `Comment: -` renders on every commentless card | +| 3 | User Control and Freedom | 1 | Locale list clipped to 4 rows; expand chevron gated on value *length*, not on content being hidden (`translation-item.ts:211-216` vs `.scss:190-192`) | +| 4 | Consistency and Standards | 2 | `new`/`stale` share a color in rows but differ in the rollup; rollup is a `div[role=button]` with `cursor: default` and no click action | +| 5 | Error Prevention | 2 | Bare `Delete` key deletes a focused row, no modifier (`translation-item.ts:292-296`); read-only produces no visual change to the item | +| 6 | Recognition Rather Than Recall | 1 | Full key can be copied but never read — truncated at 50 chars, tooltip says only "Click to copy" (`item-header.html:9`) | +| 7 | Flexibility and Efficiency | 2 | Double-click-to-edit, `E`, `Delete` all exist and are entirely undiscoverable | +| 8 | Aesthetic and Minimalist Design | 1 | ~200px of `CODE · icon · "Translated" · :` chrome before every value; a constant word rendered ~40x per screen | +| 9 | Error Recovery | n/a | The item renders no error state of its own; errors live at list level | +| 10 | Help and Documentation | 2 | Rollup tooltip is the only explainer, and it repeats "Translated" with the locale code demoted to the right edge | +| **Total** | | **14/36** | **Poor — significant rework needed** | + +## Design Specificity Verdict + +**LLM assessment: mostly generic, with one bespoke component that is off-brand.** A generic key/value inspector could ship this card with a find-and-replace. Nothing in the composition knows it is looking at translations. The one authored element — the segmented status ring — is drawn in raw Tailwind hex (`#f97316`, `#eab308`, `#3b82f6`, `#10b981`, `translation-rollup.ts:277-298`) with a hardcoded `#18181b` tooltip panel. The single component with a point of view is the one that abandons the binding watercolor identity. + +The hierarchy is inverted against the stated primary job: the **base value is the card's title** (18px bold coral) while the **key is a small grey chip**. The developer arrives holding a key, not an English string. + +**Deterministic scan:** `impeccable detect` returned **exit 0, zero findings** on the target directory and `translation-list.html`. Verified as a real clean result — the same binary returns 6 findings on `src/app/browser`, and `--no-config` gives the identical empty set. One adjacent true positive just outside scope: `side-tab` at `translation-list.scss:63` (`border-left: 4px solid var(--color-info)`). + +**In-page detector:** injection succeeded (mutation preflight passed; live server on :8400, since stopped). Console reported **67 anti-patterns**. **No user-visible overlay was confirmed** — the evidence is console output only. + +Convergence between the two assessments: + +| Finding | Design review | Measured evidence | +|---|---|---| +| `--color-primary` on item background fails AA | computed 2.68:1 | measured **2.68 light** (dark passes at 5.50) | +| Locales hidden with no affordance | `needsExpansion()` keyed on wrong signal | wrapper clientHeight **128px** vs scrollHeight **295px**, expand button **never rendered** | +| Compact value is unlabeled | code path at `:187` | live: every row rendered **Arabic RTL text in an LTR box**, no locale indicator | +| Rollup is an affordance lie | `role=button`, `cursor: default` | **40x40 `div`**, below the 44px target, not a ` + } @else { + + } {{ folderIcon() }} {{ folder().name }} @if (isNewlyCreated()) { @@ -57,9 +72,9 @@ - - @if (folder().loaded && folder().tree) { -
+ + @if (isExpanded() || shouldShowInlineInput()) { +
@if (shouldShowInlineInput()) { } - @for (child of folder().tree!.children; track child.fullPath) { - + @if (isExpanded()) { + @for (child of childFolders(); track child.fullPath) { + + } }
} diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.scss b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.scss index f0bda97a..776a571b 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.scss +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.scss @@ -1,3 +1,5 @@ +@use '../../../../shared/toggle-animation' as *; + :host { display: block; } @@ -48,7 +50,8 @@ background-color: var(--color-primary-subtle); color: var(--color-primary); - .folder-icon { + .folder-icon, + .expand-toggle { color: var(--color-primary); } } @@ -59,27 +62,6 @@ } } - &.unloaded { - color: var(--color-text-tertiary); - - .folder-icon { - color: var(--color-text-tertiary); - } - - .folder-name { - color: var(--color-text-tertiary); - } - - &:hover { - color: var(--color-text-secondary); - - .folder-icon, - .folder-name { - color: var(--color-text-secondary); - } - } - } - &:focus { outline: 2px solid var(--color-primary); outline-offset: -2px; @@ -200,9 +182,19 @@ } } +.expand-toggle { + @extend %tree-expand-toggle; +} + +.expand-spacer { + @extend %tree-expand-spacer; +} + .folder-children { - margin-left: var(--spacing-4); - padding-left: var(--spacing-2); + // 12px + 16px = the twisty width plus the row gap, so each child's twisty sits directly + // under its parent's folder icon and the guide rule runs between the two. + margin-left: var(--spacing-3); + padding-left: var(--spacing-4); border-left: 1px solid var(--color-border); } diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts index 0d4f20b0..ca35fa7a 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts @@ -10,6 +10,17 @@ describe('FolderNode', () => { let component: FolderNode; let fixture: ComponentFixture; + const folderWithChildren: FolderNodeDto = { + name: 'common', + fullPath: 'common', + loaded: true, + tree: { + path: 'common', + resources: [], + children: [{ name: 'buttons', fullPath: 'common.buttons', loaded: true }], + }, + }; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [FolderNode, getTranslocoTestingModule()], @@ -52,21 +63,109 @@ describe('FolderNode', () => { expect(emitSpy).toHaveBeenCalledWith(folder); }); - it('should emit loadFolder when unloaded folder is clicked', () => { + it('should request expansion when a collapsed folder with children is clicked', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + + const emitSpy = vi.fn(); + component.expandRequested.subscribe(emitSpy); + + component.onFolderClick(); + + expect(emitSpy).toHaveBeenCalledWith('common'); + }); + + it('should not request expansion when the folder is already expanded', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + fixture.componentRef.setInput('expandedPaths', new Set(['common'])); + + const emitSpy = vi.fn(); + component.expandRequested.subscribe(emitSpy); + + component.onFolderClick(); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should not request expansion for a folder without children', () => { const folder: FolderNodeDto = { name: 'common', fullPath: 'common', - loaded: false, + loaded: true, + tree: { path: 'common', resources: [], children: [] }, }; fixture.componentRef.setInput('folder', folder); const emitSpy = vi.fn(); - component.loadFolder.subscribe(emitSpy); + component.expandRequested.subscribe(emitSpy); component.onFolderClick(); - expect(emitSpy).toHaveBeenCalledWith('common'); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should emit toggleExpanded from the chevron without selecting the folder', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + + const toggleSpy = vi.fn(); + const clickSpy = vi.fn(); + component.toggleExpanded.subscribe(toggleSpy); + component.folderClick.subscribe(clickSpy); + + component.onToggleExpandedClick(new MouseEvent('click')); + + expect(toggleSpy).toHaveBeenCalledWith('common'); + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('should render a chevron only for folders with children', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.expand-toggle')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.expand-spacer')).toBeFalsy(); + + fixture.componentRef.setInput('folder', { + name: 'common', + fullPath: 'common', + loaded: true, + tree: { path: 'common', resources: [], children: [] }, + } satisfies FolderNodeDto); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.expand-toggle')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('.expand-spacer')).toBeTruthy(); + }); + + it('should render child folders only while expanded', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.folder-children')).toBeFalsy(); + + fixture.componentRef.setInput('expandedPaths', new Set(['common'])); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.folder-children')).toBeTruthy(); + }); + + it('should expand on ArrowRight and collapse on ArrowLeft', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + + const expandSpy = vi.fn(); + const toggleSpy = vi.fn(); + component.expandRequested.subscribe(expandSpy); + component.toggleExpanded.subscribe(toggleSpy); + + // Collapsed: ArrowRight opens, ArrowLeft does nothing. + component.onExpandKeydown(new KeyboardEvent('keydown')); + component.onCollapseKeydown(new KeyboardEvent('keydown')); + expect(expandSpy).toHaveBeenCalledWith('common'); + expect(toggleSpy).not.toHaveBeenCalled(); + + // Expanded: ArrowLeft closes, ArrowRight does nothing more. + expandSpy.mockClear(); + fixture.componentRef.setInput('expandedPaths', new Set(['common'])); + component.onExpandKeydown(new KeyboardEvent('keydown')); + component.onCollapseKeydown(new KeyboardEvent('keydown')); + expect(expandSpy).not.toHaveBeenCalled(); + expect(toggleSpy).toHaveBeenCalledWith('common'); }); it('should render folder name', () => { @@ -85,14 +184,8 @@ describe('FolderNode', () => { expect(folderNameElement?.textContent?.trim()).toBe('common'); }); - it('should show closed folder icon for unloaded folders', () => { - const folder: FolderNodeDto = { - name: 'common', - fullPath: 'common', - loaded: false, - }; - - fixture.componentRef.setInput('folder', folder); + it('should show closed folder icon while collapsed', () => { + fixture.componentRef.setInput('folder', folderWithChildren); fixture.detectChanges(); const compiled = fixture.nativeElement; @@ -101,15 +194,9 @@ describe('FolderNode', () => { expect(folderIcon?.textContent?.trim()).toBe('folder'); }); - it('should show open folder icon for loaded folders', () => { - const folder: FolderNodeDto = { - name: 'common', - fullPath: 'common', - loaded: true, - tree: { path: 'common', resources: [], children: [] }, - }; - - fixture.componentRef.setInput('folder', folder); + it('should show open folder icon while expanded', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + fixture.componentRef.setInput('expandedPaths', new Set(['common'])); fixture.detectChanges(); const compiled = fixture.nativeElement; diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.ts index 4a814b1b..ec46699b 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.ts @@ -15,9 +15,9 @@ import type { DragData } from '../../../types/drag-data'; * * Features: * - Displays folder icon and name - * - Shows "click to load" for unloaded folders - * - Recursively renders child folders - * - Emits events for folder clicks and load requests + * - Chevron toggles expansion independently of selection + * - Recursively renders child folders while expanded + * - Emits events for folder clicks and expansion requests */ @Component({ selector: 'app-folder-node', @@ -48,6 +48,9 @@ export class FolderNode { /** Whether nested resources mode is active */ showNestedResources = input(false); + /** Paths of every folder currently expanded, shared by the whole tree */ + expandedPaths = input>(new Set()); + /** Whether the tree is disabled (transient: search/operation in progress). Blocks navigation too. */ disabled = input(false); @@ -57,8 +60,11 @@ export class FolderNode { /** Emitted when a folder is clicked */ folderClick = output(); - /** Emitted when "load" is clicked for an unloaded folder */ - loadFolder = output(); + /** Emitted when the chevron is activated, flipping this folder open or shut */ + toggleExpanded = output(); + + /** Emitted when a folder should open without closing if already open */ + expandRequested = output(); /** Emitted when delete button is clicked or Delete key is pressed */ deleteFolder = output(); @@ -86,6 +92,15 @@ export class FolderNode { /** Mutation affordances (drag, drop, delete) are disabled when the tree is busy OR read-only. */ readonly mutationDisabled = computed(() => this.disabled() || this.readOnly()); + /** Child folders of this node, empty when the folder has none. */ + readonly childFolders = computed(() => this.folder().tree?.children ?? []); + + /** Only a folder with children gets a chevron; the rest get a spacer that keeps names aligned. */ + readonly hasChildren = computed(() => this.childFolders().length > 0); + + /** Whether this folder is currently expanded. */ + readonly isExpanded = computed(() => this.expandedPaths().has(this.folder().fullPath)); + /** Timer for auto-expand on hover */ #expandTimer: ReturnType | null = null; @@ -125,27 +140,48 @@ export class FolderNode { if (this.showNestedResources() && this.isDescendantOfSelected()) { return 'folder_check'; } - return this.folder().loaded ? 'folder_open' : 'folder'; + return this.isExpanded() ? 'folder_open' : 'folder'; }); /** - * Handles folder click. - * Single click both selects the folder AND loads it (if not already loaded). + * Handles folder click: selects the folder and, if it was shut, opens it so a single + * click still drills down. Clicking an already-open folder leaves it open — collapsing + * is the chevron's job. */ onFolderClick(): void { - if (!this.disabled()) { - const currentFolder = this.folder(); + if (this.disabled()) return; - // Always emit folderClick to select the folder - this.folderClick.emit(currentFolder); + this.folderClick.emit(this.folder()); - // If folder is not loaded, also emit loadFolder to trigger loading - if (!currentFolder.loaded) { - this.loadFolder.emit(currentFolder.fullPath); - } + if (this.hasChildren() && !this.isExpanded()) { + this.expandRequested.emit(this.folder().fullPath); } } + /** + * Handles chevron activation. Stops propagation so opening a folder does not also + * select it — the chevron is the one control that touches expansion alone. + */ + onToggleExpandedClick(event: Event): void { + event.stopPropagation(); + if (this.disabled()) return; + this.toggleExpanded.emit(this.folder().fullPath); + } + + /** ArrowRight opens a shut folder; on an open one it does nothing. */ + onExpandKeydown(event: Event): void { + if (this.disabled() || !this.hasChildren() || this.isExpanded()) return; + event.preventDefault(); + this.expandRequested.emit(this.folder().fullPath); + } + + /** ArrowLeft shuts an open folder; on a shut one it does nothing. */ + onCollapseKeydown(event: Event): void { + if (this.disabled() || !this.isExpanded()) return; + event.preventDefault(); + this.toggleExpanded.emit(this.folder().fullPath); + } + /** * Checks if this folder is currently selected. */ @@ -289,10 +325,10 @@ export class FolderNode { onDropListEntered(): void { this.isHoveredDuringDrag.set(true); - // Only auto-expand if folder is not already loaded and is a valid drop target - if (!this.folder().loaded && this.isValidDropTarget()) { + // Hovering a shut folder mid-drag opens it, so a drop can reach nested targets + if (this.hasChildren() && !this.isExpanded() && this.isValidDropTarget()) { this.#expandTimer = setTimeout(() => { - this.loadFolder.emit(this.folder().fullPath); + this.expandRequested.emit(this.folder().fullPath); }, 500); } } diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.html b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.html index a8dc1ee9..86f21f61 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.html +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.html @@ -49,50 +49,106 @@
} - - @if (!store.isFolderTreeLoading() && !store.error() && store.filteredFolders().length === 0 && !isAddingFolder()) { -
- folder_off -

- {{ (store.folderTreeFilter() ? TOKENS.BROWSER.NOFOLDERSMATCHSEARCH : TOKENS.BROWSER.NOFOLDERSFOUND) | transloco }} -

-
- } - - - @if (isAddingFolder() && addFolderParentPath() === null) { - - } - - - @if (store.filteredFolders().length > 0) { + @if (!store.error() && !(store.isFolderTreeLoading() && store.rootFolders().length === 0)) {
- @for (folder of store.filteredFolders(); track folder.fullPath) { - + +
+
+ + {{ store.isReadOnly() ? 'lock' : 'folder_special' }} + {{ collectionName() }} + +
+
+ + @if (store.isRootExpanded()) { +
+ + @if (isAddingFolder() && addFolderParentPath() === null) { + + } + + @if (store.filteredFolders().length === 0 && !isAddingFolder()) { +
+ folder_off +

+ {{ (store.folderTreeFilter() ? TOKENS.BROWSER.NOFOLDERSMATCHSEARCH : TOKENS.BROWSER.NOFOLDERSFOUND) | transloco }} +

+
+ } + + @for (folder of store.filteredFolders(); track folder.fullPath) { + + } +
}
} diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.scss b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.scss index a61fc97c..1ea0ee94 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.scss +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.scss @@ -96,13 +96,12 @@ align-items: center; justify-content: center; gap: var(--spacing-3); - padding: var(--spacing-8); - flex: 1; + padding: var(--spacing-6) var(--spacing-4); .empty-icon { - font-size: 48px; - width: 48px; - height: 48px; + font-size: 32px; + width: 32px; + height: 32px; color: var(--color-text-tertiary); } @@ -137,3 +136,128 @@ } } } + +// The collection root. It stands for the whole collection rather than a folder in it, so it +// stays pinned while a deep tree scrolls beneath, carries a heavier label, and owns the +// expand/collapse-all control whose scope is exactly the tree below it. +.root-drop-zone { + position: sticky; + top: calc(var(--spacing-2) * -1); + z-index: 1; + margin: calc(var(--spacing-2) * -1) calc(var(--spacing-2) * -1) 0; + padding: var(--spacing-2) var(--spacing-2) var(--spacing-1); + background: var(--color-background-subtle); + border-bottom: 1px solid var(--color-border); + border-radius: 0; + transition: background-color var(--transition-normal), border-color var(--transition-normal); + + &.drop-target-valid { + background-color: color-mix(in srgb, var(--color-success) 12%, var(--color-background-subtle)); + border-bottom-color: var(--color-success); + } + + &.drop-target-invalid { + background-color: color-mix(in srgb, var(--color-error) 12%, var(--color-background-subtle)); + border-bottom-color: var(--color-error); + } +} + +.root-row { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-3); + cursor: pointer; + border-radius: var(--border-radius-sm); + transition: background-color var(--transition-fast), color var(--transition-fast); + user-select: none; + + &:hover:not(.selected) { + background-color: var(--color-background-muted); + } + + &.selected { + background-color: var(--color-primary-subtle); + color: var(--color-primary); + + .root-icon, + .expand-toggle, + .expand-all-toggle { + color: var(--color-primary); + } + } + + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; + } + + .root-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--color-text-secondary); + flex-shrink: 0; + } + + .root-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.expand-toggle { + @extend %tree-expand-toggle; +} + +// Always visible, unlike the per-folder delete button: this is the tree's only +// whole-tree control and hiding it behind hover would make it unfindable. +.expand-all-toggle { + align-items: center; + background: none; + border: none; + border-radius: var(--border-radius-sm); + color: var(--color-text-secondary); + cursor: pointer; + display: flex; + flex-shrink: 0; + height: 24px; + justify-content: center; + padding: 0; + transition: background-color var(--transition-fast), color var(--transition-fast); + width: 24px; + + mat-icon { + font-size: 18px; + height: 18px; + line-height: 18px; + width: 18px; + } + + &:hover:not(:disabled) { + background-color: var(--color-background-muted); + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } +} + +// A hair of indent under the root — enough to read as nested without charging every folder +// a full indent level, which would cost real width in a narrow sidebar. +.root-children { + padding-top: var(--spacing-1); + padding-left: var(--spacing-1); +} diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.ts index 70e16f0d..64e4d43f 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.ts @@ -27,6 +27,7 @@ import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { SearchInput } from '../../../shared/components/search-input'; import { MatIconModule } from '@angular/material/icon'; +import { CdkDropList, type CdkDrag, type CdkDragDrop } from '@angular/cdk/drag-drop'; import type { DragData } from '../../types/drag-data'; import { extractFolderNameFromPath } from '../../utils/folder-path.utils'; @@ -39,8 +40,10 @@ const SCROLL_INTERVAL_MS = 50; * FolderTree component for hierarchical folder navigation. * * Features: - * - Search/filter folders - * - Progressive loading (click to load) + * - Search/filter folders, which opens the branches holding matches + * - Collapsible folders, plus expand/collapse-all on the root row + * - An artificial root row standing for the collection itself, so the content area can + * list every resource across every folder * - Folder selection * - Toggle between current folder and nested resources view * - Disabled state during search @@ -59,6 +62,7 @@ const SCROLL_INTERVAL_MS = 50; InlineFolderInput, TranslocoPipe, SearchInput, + CdkDropList, ], templateUrl: './folder-tree.html', styleUrl: './folder-tree.scss', @@ -90,6 +94,20 @@ export class FolderTree { /** Signal exposing nested resources visibility from store */ readonly showNestedResources = this.store.showNestedResources; + /** Whether the root row — the collection itself — is the current selection */ + readonly isRootSelected = computed(() => this.store.currentFolderPath() === ''); + + /** True while a drag hovers the root row, for drop-target styling */ + readonly isRootHoveredDuringDrag = signal(false); + + /** Root accepts folders only: a resource is moved between folders, never onto the collection. */ + readonly isValidRootDropTarget = computed(() => { + const dragData = this.activeDragData(); + if (!dragData || dragData.type !== 'folder' || !dragData.path) return false; + // A folder already sitting at root has nowhere to go. + return dragData.path.includes('.'); + }); + /** Drives the icon flip animation — true for one animation frame when toggled */ readonly isNestedToggleFlipping = signal(false); @@ -153,13 +171,74 @@ export class FolderTree { this.folderSelected.emit(folder.fullPath); } + /** Selects the collection root, whose resource list spans every folder. */ + onRootClick(): void { + if (this.store.isDisabled()) return; + this.store.selectFolder(''); + this.folderSelected.emit(''); + } + + /** Flips one folder open or shut from its chevron. */ + onToggleExpanded(folderPath: string): void { + this.store.toggleFolderExpanded(folderPath); + } + + /** Opens a folder that may already be open — from selection, ArrowRight, or a drag hover. */ + onExpandRequested(folderPath: string): void { + this.store.expandFolder(folderPath); + } + + /** Flips the root row itself, hiding or revealing the whole tree. */ + onToggleRootExpanded(event: Event): void { + event.stopPropagation(); + if (this.store.isDisabled()) return; + this.store.toggleRootExpanded(); + } + + /** ArrowRight on the root row opens it. */ + onRootExpandKeydown(event: Event): void { + if (this.store.isDisabled() || this.store.isRootExpanded()) return; + event.preventDefault(); + this.store.toggleRootExpanded(); + } + + /** ArrowLeft on the root row shuts it. */ + onRootCollapseKeydown(event: Event): void { + if (this.store.isDisabled() || !this.store.isRootExpanded()) return; + event.preventDefault(); + this.store.toggleRootExpanded(); + } + /** - * Handles load folder requests from child nodes. - * Loads the folder's children for expansion. - * Note: Selection is handled separately by onFolderClick. + * Opens or shuts every folder in view. Scoped to the filtered subtree while a filter is + * active, and the root row stays open either way so the top level remains reachable. */ - onLoadFolder(folderPath: string): void { - this.store.loadFolderChildren(folderPath); + onToggleExpandAll(event: Event): void { + event.stopPropagation(); + if (this.store.isDisabled()) return; + + if (this.store.areAllFoldersExpanded()) this.store.collapseAllFolders(); + else this.store.expandAllFolders(); + } + + /** Predicate for the root drop list: folders only, and only ones not already at root. */ + canDropOnRoot = (drag: CdkDrag): boolean => { + if (this.store.isReadOnly()) return false; + + const dragData = drag.data; + if (!dragData || dragData.type !== 'folder' || !dragData.path) return false; + + return dragData.path.includes('.'); + }; + + /** Moves a folder dropped on the root row out to the top level. */ + onRootDrop(event: CdkDragDrop): void { + this.isRootHoveredDuringDrag.set(false); + + const dragData = event.item.data as DragData; + if (dragData.type !== 'folder' || !dragData.path) return; + + this.store.moveFolder({ sourceFolderPath: dragData.path, destinationFolderPath: '' }); } /** 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 3a4ba98f..d952152c 100644 --- a/apps/tracker/src/app/browser/store/browser.store.spec.ts +++ b/apps/tracker/src/app/browser/store/browser.store.spec.ts @@ -46,6 +46,33 @@ describe('BrowserStore', () => { children: [{ name: 'buttons', fullPath: 'common.buttons', loaded: false }], }; + const mockTreeWithNesting: ResourceTreeDto = { + path: '', + resources: [], + children: [ + { + name: 'common', + fullPath: 'common', + loaded: true, + tree: { + path: 'common', + resources: [], + children: [{ name: 'buttons', fullPath: 'common.buttons', loaded: true }], + }, + }, + { + name: 'errors', + fullPath: 'errors', + loaded: true, + tree: { + path: 'errors', + resources: [], + children: [{ name: 'http', fullPath: 'errors.http', loaded: true }], + }, + }, + ], + }; + const mockCacheReady: CacheStatusDto = { status: 'ready', stats: { @@ -391,6 +418,113 @@ describe('BrowserStore', () => { expect(store.expandedFolders().has('common')).toBe(true); expect(store.expandedFolders().has('errors')).toBe(true); }); + + it('should open a folder without closing it when already open', () => { + store.expandFolder('common'); + store.expandFolder('common'); + + expect(store.expandedFolders().has('common')).toBe(true); + }); + + it('should reveal the selected folder by expanding its ancestors only', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeCommon)); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + store.selectFolder('common.buttons.primary'); + await waitForSignals(); + + // Ancestors are revealed; the selection itself is not opened, and the stored set is untouched. + expect(store.visibleExpandedFolders().has('common')).toBe(true); + expect(store.visibleExpandedFolders().has('common.buttons')).toBe(true); + expect(store.visibleExpandedFolders().has('common.buttons.primary')).toBe(false); + expect(store.expandedFolders().size).toBe(0); + }); + + it('should start with the root expanded', () => { + expect(store.isRootExpanded()).toBe(true); + }); + + it('should toggle the root row', () => { + store.toggleRootExpanded(); + expect(store.isRootExpanded()).toBe(false); + + store.toggleRootExpanded(); + expect(store.isRootExpanded()).toBe(true); + }); + + it('should expand every folder in view and leave the root open', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeWithNesting)); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + expect(store.areAllFoldersExpanded()).toBe(false); + + store.expandAllFolders(); + + expect(store.expandedFolders().has('common')).toBe(true); + expect(store.expandedFolders().has('errors')).toBe(true); + // A folder with no child folders has nothing to open, so it never enters the set. + expect(store.expandedFolders().has('common.buttons')).toBe(false); + expect(store.areAllFoldersExpanded()).toBe(true); + expect(store.isRootExpanded()).toBe(true); + }); + + it('should collapse every folder but keep the root open', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeWithNesting)); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + store.expandAllFolders(); + store.collapseAllFolders(); + + expect(store.expandedFolders().size).toBe(0); + expect(store.isRootExpanded()).toBe(true); + }); + + it('should open branches holding matches while filtering and restore expansion on clear', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeWithNesting)); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + store.toggleFolderExpanded('errors'); + + store.setFolderTreeFilter('buttons'); + + // The branch leading to the match is open; the user's unrelated branch is set aside. + expect(store.expandedFolders().has('common')).toBe(true); + expect(store.expandedFolders().has('errors')).toBe(false); + + store.setFolderTreeFilter(''); + + expect(store.expandedFolders().has('errors')).toBe(true); + expect(store.expandedFolders().has('common')).toBe(false); + }); + + 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' })); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + store.expandAllFolders(); + store.deleteFolder('common'); + await waitForSignals(); + + expect(store.expandedFolders().has('common')).toBe(false); + expect(store.expandedFolders().has('common.buttons')).toBe(false); + expect(store.expandedFolders().has('errors')).toBe(true); + }); }); describe('Computed Signals', () => { @@ -1099,6 +1233,89 @@ describe('BrowserStore', () => { }); }); + describe('Status Counts', () => { + // Deliberately overlapping: `alpha` is stale in one locale and new in + // another, so a needs-work count that adds new + stale would report 3 + // resources where only 2 exist. + 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 }, + }, + ], + children: [], + }; + + beforeEach(async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockStatusTree)); + store.setSelectedCollection({ + collectionName: 'test', + locales: ['en', 'es', 'fr', 'de'], + }); + await waitForSignals(); + store.setDensityMode('full'); + store.clearAllLocales(); + }); + + 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); + }); + + it('should count a resource under every status it carries', () => { + expect(store.statusCounts()).toEqual({ new: 2, stale: 1, translated: 1, verified: 2 }); + }); + + it('should count needs work as a union rather than a sum', () => { + const { new: isNew, stale } = store.statusCounts(); + expect(isNew + stale).toBe(3); + expect(store.needsWorkCount()).toBe(2); + }); + + it('should agree with the list a status filter actually produces', () => { + for (const status of ['new', 'stale', 'translated', 'verified'] as const) { + store.setSelectedStatuses([status]); + expect(store.sortedTranslations().length).toBe(store.statusCounts()[status]); + } + }); + + it('should agree with the list the needs-work shortcut produces', () => { + store.selectNeedsWorkStatuses(); + expect(store.sortedTranslations().length).toBe(store.needsWorkCount()); + }); + + it('should not collapse other counts when one status is selected', () => { + store.setSelectedStatuses(['new']); + expect(store.statusCounts()).toEqual({ new: 2, stale: 1, translated: 1, verified: 2 }); + }); + + it('should narrow counts to the selected locales', () => { + store.setSelectedLocales(['de']); + expect(store.statusCounts()).toEqual({ new: 0, stale: 0, translated: 1, verified: 2 }); + 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)); diff --git a/apps/tracker/src/app/browser/store/browser.store.ts b/apps/tracker/src/app/browser/store/browser.store.ts index f13c96a8..37cdb5fc 100644 --- a/apps/tracker/src/app/browser/store/browser.store.ts +++ b/apps/tracker/src/app/browser/store/browser.store.ts @@ -151,6 +151,8 @@ export const BrowserStore = signalStore( collectionStats: null, currentFolderPath: '', expandedFolders: new Set(), + preFilterExpandedFolders: null, + isRootExpanded: true, showNestedResources: loaded?.showNestedResources ?? true, compactLocale: loaded?.compactLocale ?? null, compactLocaleManuallyChanged: loaded?.compactLocaleManuallyChanged ?? false, diff --git a/apps/tracker/src/app/browser/store/features/with-filter.feature.ts b/apps/tracker/src/app/browser/store/features/with-filter.feature.ts index 314abd9a..42fce044 100644 --- a/apps/tracker/src/app/browser/store/features/with-filter.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-filter.feature.ts @@ -64,22 +64,7 @@ export function withFilterFeature<_>() { return available.filter((locale) => locale !== base); }), - statusFilterText: computed(() => { - const selected = selectedStatuses(); - if (selected.length === 0) return 'All statuses'; - if (selected.length === 1) { - const labels: Record = { - new: 'New', - stale: 'Stale', - translated: 'Translated', - verified: 'Verified', - }; - return labels[selected[0]]; - } - return `${selected.length} statuses`; - }), - - isShowingAllStatuses: computed(() => selectedStatuses().length === 0), + hasStatusFilter: computed(() => selectedStatuses().length > 0), })), withMethods((store) => ({ setSelectedLocales(locales: string[]): void { 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 3ff1619e..aaa20573 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 @@ -7,7 +7,16 @@ import { TranslocoService } from '@jsverse/transloco'; import { NotificationService } from '../../../shared/notification'; import { BrowserApiService } from '../../services/browser-api.service'; import { extractFolderNameFromPath, extractParentFolderPath } from '../../utils/folder-path.utils'; -import { insertFolderIntoTree, removeFolderFromTree, findFolderInTree, rebaseFolderPaths } from '../folder-tree.utils'; +import { + insertFolderIntoTree, + removeFolderFromTree, + findFolderInTree, + rebaseFolderPaths, + collectExpandablePaths, + collectAncestorPaths, + prunePathsUnder, + rebaseExpandedPaths, +} from '../folder-tree.utils'; import { toErrorMessage } from '../async-error.utils'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; import type { FolderNodeDto, CreateFolderResponseDto, ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; @@ -16,6 +25,9 @@ import type { Observable } from 'rxjs'; interface FolderTreeState { rootFolders: FolderNodeDto[]; expandedFolders: Set; + /** Expansion as it stood before a filter took over; restored when the filter clears. */ + preFilterExpandedFolders: Set | null; + isRootExpanded: boolean; folderTreeFilter: string; isFolderTreeLoading: boolean; isAddingFolder: boolean; @@ -28,6 +40,8 @@ interface FolderTreeState { const initialFolderTreeState: FolderTreeState = { rootFolders: [], expandedFolders: new Set(), + preFilterExpandedFolders: null, + isRootExpanded: true, folderTreeFilter: '', isFolderTreeLoading: false, isAddingFolder: false, @@ -91,6 +105,28 @@ export function withFolderTreeFeature<_>() { }), ), + // Second computed block: derives from filteredFolders, declared above. + withComputed(({ filteredFolders, expandedFolders, currentFolderPath }) => ({ + /** + * Expansion as the tree renders it: what the user opened, plus the ancestors of the + * selected folder, so the selection can never hide inside a closed parent after a + * move, a delete, or a reload. + */ + visibleExpandedFolders: computed(() => { + const visible = new Set(expandedFolders()); + for (const ancestor of collectAncestorPaths(currentFolderPath())) visible.add(ancestor); + return visible; + }), + + /** Drives the expand/collapse-all toggle, scoped to the filtered subtree when filtering. */ + areAllFoldersExpanded: computed(() => { + const expandable = collectExpandablePaths(filteredFolders()); + if (expandable.length === 0) return false; + const expanded = expandedFolders(); + return expandable.every((path) => expanded.has(path)); + }), + })), + // First methods block: core loading operations (loadRootFolders, loadFolderChildren, etc.) // Kept separate so the second block can reference these methods via the store ref. withMethods((store) => { @@ -106,8 +142,30 @@ export function withFolderTreeFeature<_>() { } return { + /** + * Applies the folder filter and takes expansion with it: a filter that hid its own + * matches inside collapsed parents would be useless, so matching branches open + * automatically. The pre-filter expansion is stashed and restored on clear, which + * keeps chevrons working normally while a filter is active. + */ setFolderTreeFilter(filter: string): void { - patchState(store, { folderTreeFilter: filter }); + const wasFiltering = store.folderTreeFilter().trim().length > 0; + const isFiltering = filter.trim().length > 0; + + if (isFiltering) { + patchState(store, { + folderTreeFilter: filter, + preFilterExpandedFolders: wasFiltering ? store.preFilterExpandedFolders() : store.expandedFolders(), + }); + patchState(store, { expandedFolders: new Set(collectExpandablePaths(store.filteredFolders())) }); + return; + } + + patchState(store, { + folderTreeFilter: filter, + expandedFolders: store.preFilterExpandedFolders() ?? store.expandedFolders(), + preFilterExpandedFolders: null, + }); }, toggleFolderExpanded(path: string): void { @@ -117,6 +175,31 @@ export function withFolderTreeFeature<_>() { patchState(store, { expandedFolders: newExpanded }); }, + /** Opens a folder without closing it if it is already open — used when selecting a row. */ + expandFolder(path: string): void { + if (!path || store.expandedFolders().has(path)) return; + patchState(store, { expandedFolders: new Set(store.expandedFolders()).add(path) }); + }, + + toggleRootExpanded(): void { + patchState(store, { isRootExpanded: !store.isRootExpanded() }); + }, + + /** + * Opens every folder in view. Scoped to the filtered subtree when a filter is active, + * so it never expands branches the user has just filtered away. + */ + expandAllFolders(): void { + const expanded = new Set(store.expandedFolders()); + for (const path of collectExpandablePaths(store.filteredFolders())) expanded.add(path); + patchState(store, { expandedFolders: expanded, isRootExpanded: true }); + }, + + /** Closes every folder but leaves the root open, so the top level stays reachable. */ + collapseAllFolders(): void { + patchState(store, { expandedFolders: new Set(), isRootExpanded: true }); + }, + startAddingFolder(parentPath: string | null): void { patchState(store, { isAddingFolder: true, addFolderParentPath: parentPath }); }, @@ -312,6 +395,7 @@ export function withFolderTreeFeature<_>() { isDeletingFolder: false, deletingFolderPath: null, rootFolders: updatedFolders, + expandedFolders: prunePathsUnder(store.expandedFolders(), folderPath), error: null, }); @@ -409,11 +493,18 @@ export function withFolderTreeFeature<_>() { ? `${destinationFolderPath}.${folderName}` : folderName; + // Carry the moved subtree's own expansion across, then open the + // destination so the folder is visible where it landed. + const expanded = rebaseExpandedPaths( + store.expandedFolders(), + sourceFolderPath, + destinationFolderPath, + ); if (destinationFolderPath) { - const expanded = new Set(store.expandedFolders()); expanded.add(destinationFolderPath); - patchState(store, { expandedFolders: expanded }); + for (const ancestor of collectAncestorPaths(destinationFolderPath)) expanded.add(ancestor); } + patchState(store, { expandedFolders: expanded }); const includeNested = store.showNestedResources(); patchState(store, { currentFolderPath: movedFolderPath }); 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 419b553e..98a48715 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 @@ -21,6 +21,27 @@ 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. + */ +function matchesAnyStatus( + item: { status?: Record }, + locales: readonly string[], + statuses: readonly TranslationStatus[], +): boolean { + return locales.some((locale) => { + const localeStatus = item.status?.[locale]; + return !!localeStatus && statuses.includes(localeStatus); + }); +} + export function withTranslationsFeature<_>() { return signalStoreFeature( { @@ -64,16 +85,51 @@ export function withTranslationsFeature<_>() { let filteredItems = items; if (statuses.length > 0) { const localesForFiltering = selectedLocales().length > 0 ? selectedLocales() : availableLocales(); - filteredItems = items.filter((item) => - localesForFiltering.some((locale) => { - const localeStatus = item.status?.[locale]; - return localeStatus && statuses.includes(localeStatus); - }), - ); + filteredItems = items.filter((item) => matchesAnyStatus(item, localesForFiltering, statuses)); } return sortTranslations(filteredItems, sortField(), sortDirection(), selectedLocales()); }), + + /** + * How many resources each status would leave on screen if it were the only + * status selected — not how many status cells exist. + * + * The status filter shows these beside its toggles, so the number has to + * answer the question the user is actually asking before they click: "how + * much is behind this?". That means counting through `matchesAnyStatus`, + * the same predicate `sortedTranslations` filters with, so a count can + * never promise a row the list then declines to show. + * + * Deliberately computed over the status-unfiltered set, so selecting one + * status does not collapse the other three counts to zero and strand the + * user with no way to judge where to go next. A resource with a `stale` + * German entry and a `new` Japanese one counts once under each, so the + * four counts can legitimately sum past the total. + */ + statusCounts: computed(() => { + const items = isSearchMode() ? searchResults() : translations(); + const locales = selectedLocales().length > 0 ? selectedLocales() : availableLocales(); + + const counts: Record = { new: 0, stale: 0, translated: 0, verified: 0 }; + for (const item of items) { + for (const status of ALL_STATUSES) { + if (matchesAnyStatus(item, locales, [status])) counts[status]++; + } + } + return counts; + }), + + /** + * 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. + */ + needsWorkCount: computed(() => { + const items = isSearchMode() ? searchResults() : translations(); + const locales = selectedLocales().length > 0 ? selectedLocales() : availableLocales(); + return items.filter((item) => matchesAnyStatus(item, locales, NEEDS_WORK_STATUSES)).length; + }), }), ), withMethods((store) => { 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 f71b413c..80a14a01 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 @@ -1,5 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { insertFolderIntoTree, removeFolderFromTree, findFolderInTree, rebaseFolderPaths } from './folder-tree.utils'; +import { + insertFolderIntoTree, + removeFolderFromTree, + findFolderInTree, + rebaseFolderPaths, + collectExpandablePaths, + collectAncestorPaths, + prunePathsUnder, + rebaseExpandedPaths, +} from './folder-tree.utils'; import type { FolderNodeDto } from '@simoncodes-ca/data-transfer'; const leaf = (name: string, fullPath: string): FolderNodeDto => ({ @@ -225,3 +234,76 @@ describe('rebaseFolderPaths', () => { expect(bTree.children[0].fullPath).toBe('root.a.b.deep'); }); }); + +describe('collectExpandablePaths', () => { + it('returns every folder that has children, depth first', () => { + const tree = [ + withChildren(leaf('apps', 'apps'), [ + withChildren(leaf('common', 'apps.common'), [leaf('buttons', 'apps.common.buttons')]), + ]), + leaf('errors', 'errors'), + ]; + + expect(collectExpandablePaths(tree)).toEqual(['apps', 'apps.common']); + }); + + it('skips folders whose children array is empty', () => { + expect(collectExpandablePaths([withChildren(leaf('apps', 'apps'), [])])).toEqual([]); + }); + + it('returns nothing for an empty tree', () => { + expect(collectExpandablePaths([])).toEqual([]); + }); +}); + +describe('collectAncestorPaths', () => { + it('returns strict ancestors outermost first', () => { + expect(collectAncestorPaths('apps.common.buttons')).toEqual(['apps', 'apps.common']); + }); + + it('excludes the path itself, so revealing a folder does not open it', () => { + expect(collectAncestorPaths('apps')).toEqual([]); + }); + + it('returns nothing for the root path', () => { + expect(collectAncestorPaths('')).toEqual([]); + }); +}); + +describe('prunePathsUnder', () => { + it('drops the removed path and everything beneath it', () => { + const paths = new Set(['apps', 'apps.common', 'apps.common.buttons', 'errors']); + + expect([...prunePathsUnder(paths, 'apps.common')]).toEqual(['apps', 'errors']); + }); + + it('does not drop a sibling that merely shares a prefix', () => { + const paths = new Set(['apps', 'appsettings']); + + expect([...prunePathsUnder(paths, 'apps')]).toEqual(['appsettings']); + }); +}); + +describe('rebaseExpandedPaths', () => { + it('moves the subtree paths under the new parent', () => { + const paths = new Set(['apps', 'apps.common', 'apps.common.buttons', 'errors']); + + const result = rebaseExpandedPaths(paths, 'apps.common', 'errors'); + + expect([...result].sort()).toEqual(['apps', 'errors', 'errors.common', 'errors.common.buttons']); + }); + + it('moves a subtree out to the root', () => { + const paths = new Set(['apps', 'apps.common', 'apps.common.buttons']); + + const result = rebaseExpandedPaths(paths, 'apps.common', ''); + + expect([...result].sort()).toEqual(['apps', 'common', 'common.buttons']); + }); + + it('leaves the set alone when the path does not change', () => { + const paths = new Set(['apps', 'apps.common']); + + expect([...rebaseExpandedPaths(paths, 'apps', '')]).toEqual(['apps', 'apps.common']); + }); +}); 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 53381605..e925b943 100644 --- a/apps/tracker/src/app/browser/store/folder-tree.utils.ts +++ b/apps/tracker/src/app/browser/store/folder-tree.utils.ts @@ -99,3 +99,85 @@ export function rebaseFolderPaths(folder: FolderNodeDto, newParentPath: string): : undefined, }; } + +/** + * Collects the full paths of every folder in the tree that has at least one child, + * i.e. every folder that expand-all has something to open. + */ +export function collectExpandablePaths(folders: FolderNodeDto[]): string[] { + const paths: string[] = []; + + const walk = (nodes: FolderNodeDto[]): void => { + for (const node of nodes) { + const children = node.tree?.children ?? []; + if (children.length > 0) { + paths.push(node.fullPath); + walk(children); + } + } + }; + + walk(folders); + return paths; +} + +/** + * Returns the strict ancestor paths of a dot-delimited folder path, outermost first. + * `apps.common.buttons` yields `['apps', 'apps.common']`. The path itself is excluded: + * revealing a folder does not open it. + */ +export function collectAncestorPaths(path: string): string[] { + if (!path) return []; + + const segments = path.split('.'); + const ancestors: string[] = []; + + for (let i = 1; i < segments.length; i++) { + ancestors.push(segments.slice(0, i).join('.')); + } + + return ancestors; +} + +/** + * Removes a path and everything beneath it from a set of expanded paths. + * Used after a folder is deleted so the set cannot accumulate paths that no longer exist. + */ +export function prunePathsUnder(paths: ReadonlySet, removedPath: string): Set { + const prefix = `${removedPath}.`; + const next = new Set(); + + for (const path of paths) { + if (path === removedPath || path.startsWith(prefix)) continue; + next.add(path); + } + + return next; +} + +/** + * Rewrites expanded paths after a folder moves, so a subtree the user had opened + * stays open where it lands instead of snapping shut. + */ +export function rebaseExpandedPaths( + paths: ReadonlySet, + sourcePath: string, + destinationParentPath: string, +): Set { + const segments = sourcePath.split('.'); + const folderName = segments[segments.length - 1]; + const newSourcePath = destinationParentPath ? `${destinationParentPath}.${folderName}` : folderName; + + if (newSourcePath === sourcePath) return new Set(paths); + + const prefix = `${sourcePath}.`; + const next = new Set(); + + for (const path of paths) { + if (path === sourcePath) next.add(newSourcePath); + else if (path.startsWith(prefix)) next.add(`${newSourcePath}.${path.slice(prefix.length)}`); + else next.add(path); + } + + return next; +} diff --git a/apps/tracker/src/app/browser/translation-browser.scss b/apps/tracker/src/app/browser/translation-browser.scss index e8b5a32e..0d791c17 100644 --- a/apps/tracker/src/app/browser/translation-browser.scss +++ b/apps/tracker/src/app/browser/translation-browser.scss @@ -50,6 +50,11 @@ */ .translation-main { flex: 1; + // Without this the pane grows to its content's min-content width — the + // toolbar's row of controls — and pushes the whole browser into a horizontal + // scroll that carries the primary action off screen. With it the pane stays + // inside the viewport and the toolbar reflows against its real width. + min-width: 0; overflow: hidden; background: var(--color-background); display: flex; @@ -58,7 +63,7 @@ /** * Sticky header stays at top during scroll */ - app-translation-main-header { + app-translation-header { flex-shrink: 0; } diff --git a/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.html b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.html index 527d355d..2e4dbf7c 100644 --- a/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.html +++ b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.html @@ -1,12 +1,12 @@ diff --git a/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.scss b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.scss index 61ca2d37..67ca0bdf 100644 --- a/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.scss +++ b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.scss @@ -1,25 +1,24 @@ +@use '../../../../shared/toggle-animation' as *; + .locale-filter-trigger { - display: flex; - align-items: center; - gap: var(--spacing-2); - padding: var(--spacing-2) var(--spacing-4); - font-size: var(--font-size-sm); + @extend %toolbar-filter-trigger; + // `translate` rather than the generic `filter_list` the status trigger used to + // share: two adjacent buttons carrying the same glyph told the user nothing + // about which one they were opening. .filter-icon { + color: var(--color-text-tertiary); + flex-shrink: 0; font-size: 18px; - width: 18px; height: 18px; + width: 18px; } .filter-text { - font-weight: var(--font-weight-medium); - } - - .arrow-icon { - font-size: 20px; - width: 20px; - height: 20px; - margin-left: auto; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } 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 fc8407f8..f40ff8ce 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 @@ -52,7 +52,7 @@ describe('LocaleFilter', () => { it('should show filter icon', () => { const icon = fixture.nativeElement.querySelector('.filter-icon'); - expect(icon?.textContent?.trim()).toBe('filter_list'); + expect(icon?.textContent?.trim()).toBe('translate'); }); it('should display current selection text', () => { diff --git a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.html b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.html index aa046837..bcc63859 100644 --- a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.html +++ b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.html @@ -1,68 +1,22 @@ - - - - - - @for (status of statuses; track status) { - } - + diff --git a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.scss b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.scss index 24123357..483b0a95 100644 --- a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.scss +++ b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.scss @@ -1,74 +1,143 @@ -.status-filter-trigger { +// The status rail. Four statuses plus a "Needs work" shortcut, always visible. +// +// Colour comes entirely from the status chip tokens in tokens.scss — the same +// three-value sets (surface / border / ink) the rows below use — so the filter +// and the thing it filters are painted from one spine. Nothing here invents a +// tone; if a status changes colour, it changes here too. + +.status-rail { + align-items: center; + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: var(--spacing-1); +} + +.status-toggle { align-items: center; + appearance: none; + background: transparent; + border: 1px solid transparent; + border-radius: var(--border-radius-full); + color: var(--color-text-secondary); + cursor: pointer; display: flex; - font-size: var(--font-size-sm); + flex-shrink: 0; + font-family: inherit; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); gap: var(--spacing-1); - padding: var(--spacing-2) var(--spacing-3); + // Sits inside the 40px control row without matching it — a toggle in a set of + // five is not the same weight of object as the search field beside it. + height: var(--spacing-8); + line-height: 1; + min-width: 0; + padding: 0 var(--spacing-2) 0 var(--spacing-1); + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast); - .filter-icon { - font-size: 18px; - height: 18px; - width: 18px; + &:hover { + background-color: var(--color-background-muted); } - .status-icons { - align-items: center; - display: flex; - gap: 2px; + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; } +} + +// The dot is the only mark carrying status colour while a toggle is off, so it +// stays at full strength until something is actually filtered. +.status-dot { + border-radius: 50%; + flex-shrink: 0; + height: 7px; + margin-left: var(--spacing-1); + transition: opacity var(--transition-fast); + width: 7px; +} - .status-indicator { - font-size: 16px; - height: 16px; - transition: opacity var(--transition-fast); - width: 16px; +.status-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - &--inactive { - opacity: 0.25; - } - } +.status-count { + font-variant-numeric: tabular-nums; + opacity: 0.82; +} - .arrow-icon { - font-size: 20px; - height: 20px; - margin-left: var(--spacing-1); - width: 20px; +// Resting dot colours read from the graphic-weight spine; the pressed state +// swaps to the chip sets, whose inks are measured for text on their own fill. +.status-toggle[data-status='needsWork'] .status-dot { + background-color: var(--color-status-stale); +} +.status-toggle[data-status='new'] .status-dot { + background-color: var(--color-status-new); +} +.status-toggle[data-status='stale'] .status-dot { + background-color: var(--color-status-stale); +} +.status-toggle[data-status='translated'] .status-dot { + background-color: var(--color-status-translated); +} +.status-toggle[data-status='verified'] .status-dot { + background-color: var(--color-status-verified); +} + +@mixin pressed($surface, $border, $ink) { + background-color: var($surface); + border-color: var($border); + color: var($ink); + + .status-dot { + // Once the chip is filled, a separate coloured dot is a second mark saying + // the same thing; it drops to the label's ink and reads as a bullet. + background-color: currentcolor; } } -:host ::ng-deep .status-filter-menu { - .menu-header { - display: flex; - justify-content: space-between; - padding: var(--spacing-2) var(--spacing-3); - border-bottom: 1px solid var(--mat-divider-color, var(--color-border)); - gap: var(--spacing-2); - - .action-button { - font-size: var(--font-size-xs); - padding: var(--spacing-1) var(--spacing-2); - min-width: auto; - } +.status-toggle[aria-pressed='true'] { + &[data-status='needsWork'], + &[data-status='stale'] { + @include pressed(--color-status-stale-surface, --color-status-stale-border, --color-status-stale-ink); } - .status-option { - align-items: center; - display: flex; - gap: var(--spacing-2); + &[data-status='new'] { + @include pressed(--color-status-new-surface, --color-status-new-border, --color-status-new-ink); + } - .status-checkbox { - flex-shrink: 0; - } + &[data-status='translated'] { + @include pressed(--color-status-translated-surface, --color-status-translated-border, --color-status-translated-ink); + } - .status-icon { - flex-shrink: 0; - font-size: 20px; - height: 20px; - width: 20px; - } + &[data-status='verified'] { + @include pressed(--color-status-verified-surface, --color-status-verified-border, --color-status-verified-ink); + } +} + +// While a filter is on, the toggles that are off recede rather than disappear — +// the user still has to be able to read what else is available and how much of +// it there is, which is the whole reason the counts are on screen. +.status-rail--filtering .status-toggle[aria-pressed='false'] { + // Not --color-text-tertiary: that tone is a 2.5:1 decorative tint, and this is + // a label with a number in it that still has to be read while it is stepped + // back. --color-text-placeholder is the darkest "quiet" tone that clears 4.5:1. + color: var(--color-text-placeholder); + + .status-dot { + opacity: 0.4; + } + + &:hover { + color: var(--color-text-secondary); - .status-label { - flex: 1; + .status-dot { + opacity: 1; } } } 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 new file mode 100644 index 00000000..5dd023ba --- /dev/null +++ b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.spec.ts @@ -0,0 +1,153 @@ +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { signal, computed } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { StatusFilter } from './status-filter'; +import { BrowserStore } from '../../../store/browser.store'; +import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; + +describe('StatusFilter', () => { + let component: StatusFilter; + let fixture: ComponentFixture; + let selectedStatuses: ReturnType>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockStore: any; + + const toggleFor = (id: string): HTMLButtonElement | null => + fixture.nativeElement.querySelector(`[data-testid="status-toggle-${id}"]`); + + beforeEach(async () => { + 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(), + }; + + await TestBed.configureTestingModule({ + imports: [StatusFilter, NoopAnimationsModule, getTranslocoTestingModule()], + providers: [{ provide: BrowserStore, useValue: mockStore }], + }).compileComponents(); + + fixture = TestBed.createComponent(StatusFilter); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('Component Initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should inject store', () => { + expect(component.store).toBe(mockStore); + }); + }); + + describe('Template Rendering', () => { + it('should render a toggle for needs work plus one per status', () => { + const toggles = fixture.nativeElement.querySelectorAll('.status-toggle'); + expect(toggles.length).toBe(5); + }); + + it('should label the group for assistive technology', () => { + const rail = fixture.nativeElement.querySelector('.status-rail'); + expect(rail?.getAttribute('aria-label')).toBeTruthy(); + }); + + it('should show each status count from the store', () => { + expect(toggleFor('new')?.querySelector('.status-count')?.textContent?.trim()).toBe('6'); + expect(toggleFor('stale')?.querySelector('.status-count')?.textContent?.trim()).toBe('4'); + expect(toggleFor('translated')?.querySelector('.status-count')?.textContent?.trim()).toBe('7'); + expect(toggleFor('verified')?.querySelector('.status-count')?.textContent?.trim()).toBe('8'); + }); + + it('should show the needs-work count as a union, not the sum of new and stale', () => { + // 6 new + 4 stale = 10 cells, but only 9 distinct resources. + expect(toggleFor('needsWork')?.querySelector('.status-count')?.textContent?.trim()).toBe('9'); + }); + + it('should expose selection through aria-pressed', () => { + expect(toggleFor('stale')?.getAttribute('aria-pressed')).toBe('false'); + + selectedStatuses.set(['stale']); + fixture.detectChanges(); + + expect(toggleFor('stale')?.getAttribute('aria-pressed')).toBe('true'); + expect(toggleFor('new')?.getAttribute('aria-pressed')).toBe('false'); + }); + + it('should mark the rail as filtering only while a status is selected', () => { + const rail = fixture.nativeElement.querySelector('.status-rail'); + expect(rail?.classList.contains('status-rail--filtering')).toBe(false); + + selectedStatuses.set(['new']); + fixture.detectChanges(); + + expect(rail?.classList.contains('status-rail--filtering')).toBe(true); + }); + + it('should keep every toggle visible while filtering', () => { + selectedStatuses.set(['new']); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelectorAll('.status-toggle').length).toBe(5); + }); + }); + + describe('Toggling a single status', () => { + it('should toggle the status it names', () => { + toggleFor('verified')?.click(); + expect(mockStore.toggleStatus).toHaveBeenCalledWith('verified'); + }); + + it('should not reach for the needs-work shortcut', () => { + toggleFor('new')?.click(); + expect(mockStore.toggleStatus).toHaveBeenCalledWith('new'); + expect(mockStore.selectNeedsWorkStatuses).not.toHaveBeenCalled(); + }); + }); + + describe('Needs work', () => { + it('should select new and stale in one click', () => { + toggleFor('needsWork')?.click(); + expect(mockStore.selectNeedsWorkStatuses).toHaveBeenCalledOnce(); + }); + + it('should read as selected only when it is exactly what is selected', () => { + selectedStatuses.set(['new', 'stale']); + fixture.detectChanges(); + expect(component.isNeedsWorkSelected()).toBe(true); + expect(toggleFor('needsWork')?.getAttribute('aria-pressed')).toBe('true'); + }); + + it('should not read as selected when only one of its statuses is on', () => { + selectedStatuses.set(['new']); + fixture.detectChanges(); + expect(component.isNeedsWorkSelected()).toBe(false); + expect(toggleFor('needsWork')?.getAttribute('aria-pressed')).toBe('false'); + }); + + it('should not read as selected when a status outside it is also on', () => { + selectedStatuses.set(['new', 'stale', 'verified']); + fixture.detectChanges(); + expect(component.isNeedsWorkSelected()).toBe(false); + }); + + it('should clear back to everything when pressed while selected', () => { + selectedStatuses.set(['new', 'stale']); + fixture.detectChanges(); + + toggleFor('needsWork')?.click(); + + expect(mockStore.clearAllStatuses).toHaveBeenCalledOnce(); + expect(mockStore.selectNeedsWorkStatuses).not.toHaveBeenCalled(); + }); + }); +}); 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 f54a866e..336cce5e 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,24 +1,31 @@ -import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { BrowserStore } from '../../../store/browser.store'; -import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +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 { BrowserStore } from '../../../store/browser.store'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; +/** One toggle in the rail. `statuses` is what it selects, not what it is called. */ +interface StatusToggle { + readonly id: string; + readonly label: string; + readonly statuses: readonly TranslationStatus[]; + readonly count: number; + readonly selected: boolean; +} + /** - * StatusFilter component provides a dropdown for filtering - * which translation statuses are displayed in the translation list. + * StatusFilter renders the toolbar's status rail: one always-visible toggle per + * status, plus a leading "Needs work" toggle that selects `new` and `stale` + * together. + * + * It replaced a dropdown whose trigger reported its state as four 16px glyphs. + * Status is how every user in this product decides what to touch next, so the + * filter for it should not require a click to read — here the control *is* the + * state, in the same chip vocabulary the rows below already use for status. * - * Features: - * - Multi-select checkboxes for each status - * - "Needs Work" quick action selects 'new' and 'stale' statuses - * - "Clear All" action to deselect all statuses - * - Visual indicators (colored dots) for selected statuses in trigger button - * - Status icons with corresponding colors + * Each toggle carries the number of resources it would leave on screen, which is + * the question a user is actually asking before they click it. * * @example * @@ -27,7 +34,7 @@ import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; selector: 'app-status-filter', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, MatButtonModule, MatIconModule, MatMenuModule, MatCheckboxModule, TranslocoPipe], + imports: [MatTooltipModule, TranslocoPipe], templateUrl: './status-filter.html', styleUrl: './status-filter.scss', }) @@ -35,48 +42,60 @@ export class StatusFilter { readonly store = inject(BrowserStore); readonly TOKENS = TRACKER_TOKENS; - readonly statusConfig: Record = { - new: { label: TRACKER_TOKENS.BROWSER.STATUS.NEW, icon: 'add_circle', color: 'var(--color-warning)' }, - stale: { label: TRACKER_TOKENS.BROWSER.STATUS.STALE, icon: 'warning', color: 'var(--color-warning)' }, - translated: { - label: TRACKER_TOKENS.BROWSER.STATUS.TRANSLATED, - icon: 'language', - color: 'var(--color-info)', - }, - verified: { - label: TRACKER_TOKENS.BROWSER.STATUS.VERIFIED, - icon: 'check_circle', - color: 'var(--color-success)', - }, - }; + readonly statuses: readonly TranslationStatus[] = ['new', 'stale', 'translated', 'verified']; - readonly statuses: 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, + }; /** - * Checks if a status is currently selected. + * "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 + * claim a filter that is not applied. */ - isStatusSelected(status: TranslationStatus): boolean { - return this.store.selectedStatuses().includes(status); - } + readonly isNeedsWorkSelected = computed(() => { + const selected = this.store.selectedStatuses(); + return selected.length === 2 && selected.includes('new') && selected.includes('stale'); + }); - /** - * Toggles a status's selection state. - */ - toggleStatus(status: TranslationStatus): void { - this.store.toggleStatus(status); - } + readonly toggles = computed(() => { + const counts = this.store.statusCounts(); + const selected = this.store.selectedStatuses(); - /** - * Selects 'new' and 'stale' statuses (items that need work). - */ - selectNeedsWork(): void { - this.store.selectNeedsWorkStatuses(); - } + return [ + { + id: 'needsWork', + label: TRACKER_TOKENS.BROWSER.STATUSFILTER.NEEDSWORK, + statuses: ['new', 'stale'] as const, + count: this.store.needsWorkCount(), + selected: this.isNeedsWorkSelected(), + }, + ...this.statuses.map((status) => ({ + id: status, + label: this.statusLabels[status], + statuses: [status] as const, + count: counts[status], + selected: selected.includes(status), + })), + ]; + }); /** - * Clears all status selections. + * A toggle turns its own statuses on or off. "Needs work" is an exact set + * rather than an accumulation: pressing it means "show me the work", and + * pressing it again clears back to everything. */ - clearAll(): void { - this.store.clearAllStatuses(); + toggle(item: StatusToggle): void { + if (item.id === 'needsWork') { + if (item.selected) this.store.clearAllStatuses(); + else this.store.selectNeedsWorkStatuses(); + return; + } + + this.store.toggleStatus(item.statuses[0]); } } diff --git a/apps/tracker/src/app/browser/translations/header/translation-main-header.html b/apps/tracker/src/app/browser/translations/header/translation-main-header.html index 92e638b3..82ce25cc 100644 --- a/apps/tracker/src/app/browser/translations/header/translation-main-header.html +++ b/apps/tracker/src/app/browser/translations/header/translation-main-header.html @@ -2,9 +2,13 @@
- +
+ - + +
+ + + + + + -
+ @if (layout() === 'inline') { + + + } @else { +
- @if (tags().length || inheritedTags().length) { -
- -
+ @if (tags().length || inheritedTags().length) { +
+ +
+ } }
+ @if (layout() === 'inline' && hasComment()) { + + + } + @if (localeStates().length) { } - @if (!hideCommentButton()) { + @if (layout() === 'band' && !hideCommentButton()) { + + } } -
+ } @else { -
-
+ + @if (baseRow() || visibleLocaleTranslations().length > 0) { + + } + + -
-

{{ TOKENS.BROWSER.COMMENTPREFIX | transloco }}

+ The prefix that used to be visible is now read-only-to-screen-readers. The + glyph names the block for sighted users; the word still names it for + everyone else, and it stays a translated token rather than becoming + hardcoded English in an aria-label. + --> + @if (translation().comment) { +
+ +

+ {{ TOKENS.BROWSER.COMMENTPREFIX | transloco }} + +

+
+ } - @if (needsExpansion()) { + @if (needsExpansion()) { +
- } -
- - - @if (localeTranslations().length > 0) { -
-
- - - -
- @if (!isExpanded() && localeTranslations().length >= 4) { - - }
} } diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.scss b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.scss index 3b9f241f..30846dee 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.scss +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.scss @@ -1,10 +1,22 @@ -.translation-item { - /** - * Translation item wrapper for virtual scroll: - * - No padding or margin - spacing is managed by .item-container below - * - Virtual scroll handles height calculation via itemSize - */ +/** + * Translation item wrapper for virtual scroll: + * - No padding or margin - spacing is managed by .item-container below + * - Virtual scroll handles height calculation via itemSize + * + * This has to be :host. `.translation-item` is the class this component puts on + * its own host element, and a component stylesheet scopes bare selectors to its + * content attribute — which the host element does not carry. + */ +:host { display: block; + + /* + * The row's width comes from a resizable panel, not the viewport, so what the + * compact line can afford is a question about this element rather than the + * screen. Inline-size only: the block axis stays auto so virtual scroll keeps + * measuring heights normally. + */ + container-type: inline-size; } /* Visually hidden but accessible to screen readers */ @@ -44,9 +56,27 @@ outline-offset: 2px; } + /* Compact rows sit 4px apart, so a 2px ring at a 2px offset lands exactly on + the neighbour's edge. Tightening the offset keeps the whole ring inside the + gap it has. */ + &[data-density='compact']:focus-visible { + outline-offset: 1px; + } + + /* + * The item's width comes from the virtual-scroll content wrapper, which is + * absolutely positioned and imposes none — so a nowrap line resolved to + * max-content and pushed the right-hand rail off-screen. The row is bounded by + * its container, and its children are allowed to shrink below their content. + */ + max-width: 100%; + min-width: 0; + /* Density-specific gap spacing */ &[data-density='compact'] { + gap: 0; margin-bottom: var(--spacing-1); + padding: var(--spacing-1) var(--spacing-2); } &[data-density='full'] { @@ -55,46 +85,194 @@ } -.compact-value { - color: var(--color-primary); +/* + * Compact zones. The row is one line composed by the header in `inline` layout: + * drag handle · key · identity · annotation · rail. Everything here is about what + * gives way first when the line runs out of room. + */ + +/* Identity — the source string. Takes the larger share and truncates last, + because it is the only thing on the row a developer already recognises. */ +.compact-identity { + color: var(--color-text-primary); + // The zone that absorbs the squeeze. Everything in the annotation is a chip or + // a code that clips rather than reflows, so the identity is the only thing that + // can give way gracefully — and an ellipsis on a sentence is readable, where a + // status chip cut down to "Ne" is not. + flex: 1 1 0; + min-width: 4rem; font-size: var(--font-size-base); font-weight: var(--font-weight-medium); line-height: var(--line-height-snug); - margin: 0; - padding: 0 0 0 var(--spacing-1); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - transition: opacity var(--transition-fast); } -.compact-value--empty { +/* No source text at all — a state to read, not content. */ +.compact-identity--empty { + color: var(--color-text-secondary); + font-style: italic; +} + +/* Annotation — the selected locale, measured against the identity beside it. */ +.compact-annotation { + align-items: center; + color: var(--color-text-secondary); + display: flex; + // Sizes to its own content and never shrinks below it: its parts are chips and + // codes with nothing to truncate. What it can no longer afford is dropped + // wholesale by the container queries at the end of this file, in priority + // order, rather than sliced in half. + flex: 0 0 auto; + gap: var(--spacing-2); + max-width: 100%; + min-width: 0; +} + +/* The tagged variants carry no value, so they claim only the width they need + and leave the rest to the identity. */ +.compact-annotation--tagged { + flex: 0 0 auto; +} + +.compact-translation { + flex: 1 1 auto; + font-size: var(--font-size-sm); + line-height: var(--line-height-snug); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-translation--empty { color: var(--color-text-tertiary); font-style: italic; } -.compact-value--comment { - .comment-text { - font-weight: var(--font-weight-normal); - line-height: var(--line-height-snug); - display: inline; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.compact-locale-code { + color: var(--color-text-secondary); + flex-shrink: 0; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; +} + +/* Marks a row that is showing base-locale source text, not a translation. */ +.compact-locale-tag { + background-color: var(--color-background-subtle); + border-radius: var(--border-radius-full); + color: var(--color-text-placeholder); + flex-shrink: 0; + font-size: var(--font-size-xs); + padding: 0 var(--spacing-1); +} + +/* + * The collection has no base locale, so the app chose which locale to show. That + * choice is arbitrary and must never be silent — this names it as a default and + * its tooltip points at the locale filter. A real button so the explanation is + * reachable from the keyboard, not only on hover. + */ +.default-locale-tag { + background-color: var(--color-background-subtle); + border: 1px dashed var(--color-border-strong); + border-radius: var(--border-radius-full); + color: var(--color-text-placeholder); + cursor: help; + flex-shrink: 0; + font-family: inherit; + font-size: var(--font-size-xs); + padding: 0 var(--spacing-2); + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; } } -.base-value { - color: var(--color-primary); - font-size: var(--font-size-lg); - font-weight: var(--font-weight-bold); - line-height: var(--line-height-normal); - margin-top: calc(var(--spacing-1) * -0.5); - padding-left: var(--spacing-1); - white-space: pre-wrap; /* allow multi-line base values */ +/* + * Compact status chip. Mirrors item-locales.scss so a status looks identical in + * both densities — the toggle changes how much you see, never what a colour + * means. + */ +.compact-annotation .status-chip { + align-items: center; + background-color: var(--color-background-subtle); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-full); + color: var(--color-text-primary); + display: inline-flex; + flex-shrink: 0; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + gap: calc(var(--spacing-1) / 2); + letter-spacing: 0.01em; + line-height: var(--line-height-snug); + padding: 0 var(--spacing-2); + + &.status-new { + background-color: var(--color-status-new-surface); + border-color: var(--color-status-new-border); + color: var(--color-status-new-ink); + } + + &.status-stale { + background-color: var(--color-status-stale-surface); + border-color: var(--color-status-stale-border); + color: var(--color-status-stale-ink); + } + + &.status-translated { + background-color: var(--color-status-translated-surface); + border-color: var(--color-status-translated-border); + color: var(--color-status-translated-ink); + } + + &.status-verified { + background-color: var(--color-status-verified-surface); + border-color: var(--color-status-verified-border); + color: var(--color-status-verified-ink); + } +} + +.compact-annotation .status-chip__icon { + color: inherit; + flex-shrink: 0; + font-size: var(--font-size-sm); + height: var(--font-size-sm); + width: var(--font-size-sm); } +/* Same contract as the full-density marker: the status chip reports the stored + metadata, this reports the thing the metadata cannot see. */ +.compact-annotation .same-as-source { + align-items: center; + border: 1px dashed var(--color-status-stale-border); + border-radius: var(--border-radius-full); + color: var(--color-status-stale-ink); + display: inline-flex; + flex-shrink: 0; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + gap: calc(var(--spacing-1) / 2); + padding: 0 var(--spacing-2); +} + +.compact-annotation .same-as-source__icon { + font-size: var(--font-size-sm); + height: var(--font-size-sm); + width: var(--font-size-sm); +} + +/* The base value stays the loudest thing in the item — it is the source text the + rest is measured against — but it earns that through size and weight alone. + The coral used to fire here on every row at full saturation, which is an accent + spent on the one element guaranteed to be present, leaving nothing for the + status chips or the primary action to be louder than. Neutral ink is also the + condition for reading base and translation as the same kind of thing. */ /* Full density styles */ .item-container[data-density='full'] { /* Height is determined by virtual scroll itemSize — no max-height constraint */ @@ -102,19 +280,6 @@ position: relative; } -.item-container[data-density='full'] .base-value.full { - /* Clamp base value to 2 lines when collapsed */ - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; -} - -.item-container[data-density='full'] .base-value.full.expanded { - -webkit-line-clamp: unset; - display: block; -} - /* * Expanded items overlay content below them rather than pushing the virtual * scroll layout. The item itself grows naturally; z-index ensures it paints @@ -125,17 +290,69 @@ box-shadow: var(--shadow-md); } -.item-container[data-density='full'] .comment-expand-row { - border-bottom: 1px solid var(--color-border); +/* + * The note is the only human-written sentence on a card of derived values, and + * it used to be set as the least important thing on it: italic, secondary, and + * running unbroken across the full card width behind a "Comment:" prefix that + * repeated on every row. + * + * Nothing encloses it now — no fill, no rule. Space and the glyph do the whole + * job, which is what the card can afford: a fill quiet enough to sit on this + * surface is a shade off the surface itself, and a rule adds a second vertical + * line to a card whose only other one is the source divider. + * + * The glyph starts on the locale-code column instead of an arbitrary indent, so + * the note hangs off the same left edge as every row above it. + * + * Logical properties throughout — the note carries `dir="auto"`, and a comment + * written in an RTL script has to be able to flip. + */ +.item-note { + align-items: start; + column-gap: var(--spacing-3); + display: grid; + grid-template-columns: auto 1fr; + margin-block: var(--spacing-2); + margin-inline-start: var(--spacing-1); } -/* Comment row: comment left, expand button right, vertically centred */ -.comment-expand-row { - align-items: center; +/* + * Carries what the deleted prefix used to say. It clears the 3:1 control floor + * in both themes, where --color-text-tertiary would not. + * + * The box is the height of the text's first line, not the height of the glyph, + * and the glyph is centred inside it. Sizing the box to the glyph top-aligned it + * to the grid row while the text sat lower in its own taller line box, which + * read as the icon floating above the sentence it labels. + */ +.item-note__icon { + color: var(--color-neutral-500); + display: grid; + font-size: var(--font-size-lg); + height: calc(var(--font-size-sm) * var(--line-height-relaxed)); + place-items: center; + width: var(--font-size-lg); +} + +/* + * A step quieter than the strings above it: this is the instruction, not the + * material being judged. The measure cap is the point of the whole change — the + * old line ran past 130 characters on a wide card, which is unreadable prose at + * any size. + */ +.item-note__text { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: var(--line-height-relaxed); + margin: 0; + max-width: 72ch; + white-space: pre-wrap; +} + +/* The expand control keeps its own row now that the note no longer shares one. */ +.expand-row { display: flex; - gap: var(--spacing-2); - justify-content: space-between; - min-height: var(--font-size-base); + justify-content: flex-end; } .expand-button { @@ -148,21 +365,48 @@ align-items: center; justify-content: center; flex-shrink: 0; - padding: var(--spacing-1); + gap: var(--spacing-1); + padding: var(--spacing-1) var(--spacing-2); +} + +/* Names what expanding reveals ("+6 more locales"), so nothing is withheld silently. */ +.expand-button__label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + white-space: nowrap; +} + +.expand-button:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; } .expand-button:hover { background: var(--color-background-subtle); } -/* Ensure touch targets meet minimum recommended size */ -.item-container button, -.expand-button { +/* Ensure touch targets meet minimum recommended size. + Scoped to full density: compact is a single-line scanning mode whose controls + size to the text they sit beside, and restore the 44px floor under coarse + input via the `pointer: coarse` rules in item-header.scss. Applying the floor + here unconditionally is what made a two-line "compact" row 93px tall. */ +.item-container[data-density='full'] button:not(.expand-button) { /* minimum touch target size — no matching token for 44px */ min-width: 44px; min-height: 44px; } +/* The expand control now always owns its row, so its floor is the row's height: + a 44px button under the grid measured 44px of band for a 32px control. It + sizes to its content on a fine pointer and takes the floor back where a finger + needs it. */ +@media (pointer: coarse) { + .expand-button { + min-width: 44px; + min-height: 44px; + } +} + .expand-button mat-icon { color: var(--color-text-secondary); transition: transform var(--transition-normal); @@ -172,93 +416,6 @@ transform: rotate(180deg); } -/* - * Outer container that establishes the stacking context for the fade overlay. - * It is purely a positioning context — no overflow or max-height constraints - * here so the inner wrapper can scroll independently. - */ -.locale-scroll-container { - position: relative; -} - -/* - * Inner scroll wrapper: the actual overflow-y scroll region. - * In the collapsed state the wrapper is height-constrained so locale rows - * scroll within it. In the expanded state the wrapper grows to full content - * height with no constraint. - */ -.locale-scroll-container.collapsed .locale-scroll-wrapper { - max-height: calc(4 * var(--spacing-8)); /* ~4 locale rows × var(--spacing-8) each */ -} - -.locale-scroll-wrapper { - height: auto; - overflow-y: auto; - - /* Hide scrollbar track visually while keeping scrollability */ - scrollbar-width: thin; - scrollbar-color: var(--color-border) transparent; - - &::-webkit-scrollbar { - width: var(--spacing-1); - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background-color: var(--color-border); - border-radius: var(--border-radius-sm); - } -} - -/* - * Invisible sentinel element at the very end of scrollable content. - * The IntersectionObserver watches this within the scroll wrapper as root; - * when it becomes visible the fade gradient is hidden. - */ -.locale-scroll-sentinel { - height: 1px; - pointer-events: none; -} - -/* - * Fade overlay hinting at additional locale rows below the visible area. - * Absolutely positioned over the bottom of .locale-scroll-container so it - * does NOT scroll with the content — it always sits at the visible bottom edge. - * Transitions opacity smoothly so the disappearance is not jarring. - */ -.locale-scroll-fade { - background: linear-gradient(to bottom, transparent 0%, var(--color-background-muted) 100%); - bottom: 0; - height: var(--spacing-6); - left: 0; - opacity: 1; - pointer-events: none; - position: absolute; - right: 0; - transition: opacity var(--transition-fast); - - &.hidden { - opacity: 0; - } -} - -.comment-text { - color: var(--color-text-primary); - font-size: var(--font-size-sm); - font-style: italic; - line-height: var(--line-height-relaxed); - margin: 0; - padding-left: var(--spacing-1); - white-space: pre-wrap; - - &.full { - color: var(--color-text-secondary); - } -} - @keyframes borderFlash { 0% { border-color: var(--color-border); } 25% { border-color: var(--focus-ring-color); } @@ -270,6 +427,16 @@ animation: borderFlash 1s ease-in-out; } +// The flash reports that this row just changed. Under reduced motion the same +// fact is held as a static border for the flash's duration rather than dropped — +// the row still says it was updated, it just stops pulsing to say it. +@media (prefers-reduced-motion: reduce) { + .item-container.recently-updated { + animation: none; + border-color: var(--focus-ring-color); + } +} + // CDK Drag & Drop Styles .cdk-drag-preview { opacity: 0.7; @@ -289,3 +456,45 @@ .cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } + +/* + * What a narrow row gives up, in order. The annotation is the first to lose + * detail because the identity is what the row is for; nothing dropped here is + * lost outright — each still carries its text in a title or accessible name. + */ +@container (max-width: 52rem) { + /* The marker becomes its glyph. Its meaning stays on the title attribute. */ + .compact-annotation .same-as-source span { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } + + .compact-annotation .same-as-source { + padding: 0 var(--spacing-1); + } +} + +@container (max-width: 40rem) { + /* Below this the translation is a word or two of noise. The status chip and + the marker still say what state the row is in, and the editor shows the + value in full. */ + .compact-annotation .compact-translation { + display: none; + } +} + +@container (max-width: 26rem) { + /* Last to go: the status label, leaving the chip as a coloured glyph. */ + .compact-annotation .status-chip__label { + display: none; + } + + .compact-annotation .status-chip { + padding: 0 var(--spacing-1); + } +} 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 3d151f86..2eb31230 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 @@ -8,6 +8,7 @@ import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; import { BrowserStore } from '../../../store/browser.store'; import { TranslationListStore } from '../store/translation-list.store'; +import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; async function configureTranslationItemTestBed(): Promise<{ fixture: ComponentFixture; @@ -81,20 +82,86 @@ describe('TranslationItem', () => { expect(html).toContain('No translation'); }); - it('should gracefully handle zero locales selected (compact fallback)', () => { - store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); + it('should flag the base locale as a fallback when no non-base locale is available', () => { + store.setSelectedCollection({ collectionName: 'test', locales: ['en'], baseLocale: 'en' }); store.setDensityMode('full'); store.clearAllLocales(); fixture.componentRef.setInput('translation', mockTranslation); fixture.detectChanges(); - // primaryLocale should fallback to baseLocale when filteredLocales is empty - expect(component.primaryLocale()).toBe(store.baseLocale()); + expect(component.compactDisplay().locale).toBe(store.baseLocale()); + expect(component.compactDisplay().isBaseFallback).toBe(true); const html = fixture.nativeElement.innerHTML as string; - // Should show a helpful message or at least not crash; check presence of key expect(html).toContain('common.buttons.save'); }); + describe('compact row composition', () => { + it('leads with the base value and annotates it with the selected locale', () => { + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); + store.setDensityMode('compact'); + store.setSelectedLocales(['es']); + fixture.componentRef.setInput('translation', mockTranslation); + fixture.detectChanges(); + + expect(component.compactLayout()).toBe('paired'); + // The source string the developer wrote identifies the row... + expect(component.identityValue()).toBe('Save'); + // ...and the selected locale sits beside it as the comparison. + expect(component.compactDisplay().locale).toBe('es'); + expect(component.compactDisplay().value).toBe('Guardar'); + }); + + it('shows source text alone when the selection resolves to the base locale', () => { + store.setSelectedCollection({ collectionName: 'test', locales: ['en'], baseLocale: 'en' }); + store.setDensityMode('compact'); + store.clearAllLocales(); + fixture.componentRef.setInput('translation', mockTranslation); + fixture.detectChanges(); + + expect(component.compactLayout()).toBe('source-only'); + expect(component.identityValue()).toBe('Save'); + }); + + it('falls back to the shown 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; + + store.setSelectedCollection({ collectionName: 'ds', locales: ['ar', 'de'], baseLocale: 'en' }); + store.setDensityMode('compact'); + store.setSelectedLocales(['ar']); + fixture.componentRef.setInput('translation', noBase); + fixture.detectChanges(); + + expect(component.compactLayout()).toBe('no-base'); + // There is no source text to lead with, so the shown locale becomes identity. + expect(component.identityValue()).toBe('إضافة'); + // And the arbitrary pick is named rather than silent. + expect(fixture.nativeElement.innerHTML as string).toContain('default'); + }); + + 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; + + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'fr-ca'], baseLocale: 'en' }); + store.setDensityMode('compact'); + store.setSelectedLocales(['fr-ca']); + fixture.componentRef.setInput('translation', untouched); + fixture.detectChanges(); + + // Status says `translated`; a checksum cannot see that nobody touched it. + expect(component.compactDisplay().status).toBe('translated'); + expect(component.compactDisplay().isSameAsBase).toBe(true); + }); + }); + it('rollupStatus should reflect all verified state as verified', () => { const t: ResourceSummaryDto = { key: 'k-all-verified', @@ -139,14 +206,15 @@ describe('TranslationItem - Compact helpers', () => { ({ fixture, component, store } = await configureTranslationItemTestBed()); }); - it('should select primary locale from filteredLocales in store', () => { + it('should display the first non-base locale, not the base locale', () => { store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); store.setDensityMode('full'); store.clearAllLocales(); fixture.componentRef.setInput('translation', mockTranslation); fixture.detectChanges(); - expect(component.primaryLocale()).toBe('en'); + expect(component.compactDisplay().locale).toBe('es'); + expect(component.compactDisplay().isBaseFallback).toBe(false); }); it('should return selected non-base locale value, or base value when only base is selected', () => { @@ -188,32 +256,6 @@ describe('TranslationItem - Compact helpers', () => { expect(roll[1]).toBe(1); }); - it('hasMetadata should be true when tags or comment present, false otherwise', () => { - const tWithMeta: ResourceSummaryDto = { - key: 'k', - translations: { en: 'a' }, - status: {}, - tags: ['ui', 'button'], - comment: 'Needs review', - } as any; - - store.setSelectedCollection({ collectionName: 'test', locales: ['en'], baseLocale: 'en' }); - fixture.componentRef.setInput('translation', tWithMeta); - fixture.detectChanges(); - - expect(component.hasMetadata()).toBe(true); - - const tNoMeta: ResourceSummaryDto = { - key: 'k2', - translations: { en: 'b' }, - status: {}, - } as any; - - fixture.componentRef.setInput('translation', tNoMeta); - fixture.detectChanges(); - expect(component.hasMetadata()).toBe(false); - }); - it('statusBreakdown should return human readable counts in priority order', () => { const t: ResourceSummaryDto = { key: 'k3', @@ -227,8 +269,17 @@ describe('TranslationItem - Compact helpers', () => { fixture.componentRef.setInput('translation', t); fixture.detectChanges(); - // Expect order: stale, new, translated, verified (only present ones included) - expect(component.statusBreakdown()).toBe('2 stale, 1 new, 1 verified'); + // The test harness has no translations loaded, so each part comes back as its + // resource key. Assert the contract — worst status first, zero counts omitted — + // rather than the English wording, which lives in the resource file. + const breakdown = component.statusBreakdown(); + + expect(breakdown).toContain(TRACKER_TOKENS.BROWSER.STATUS.STALECOUNTX); + expect(breakdown).toContain(TRACKER_TOKENS.BROWSER.STATUS.VERIFIEDCOUNTX); + expect(breakdown).not.toContain(TRACKER_TOKENS.BROWSER.STATUS.TRANSLATEDCOUNTX); + expect(breakdown.indexOf(TRACKER_TOKENS.BROWSER.STATUS.STALECOUNTX)).toBeLessThan( + breakdown.indexOf(TRACKER_TOKENS.BROWSER.STATUS.NEWCOUNTX), + ); }); }); @@ -259,6 +310,36 @@ describe('TranslationItem - Full density expansion', () => { expect(component.needsExpansion()).toBe(false); }); + it('needsExpansion should be true when locale rows are withheld by the collapsed state', () => { + store.setSelectedCollection({ + collectionName: 'test', + locales: ['en', 'es', 'fr', 'de', 'ja', 'ru'], + baseLocale: 'en', + }); + store.clearAllLocales(); + + const t: ResourceSummaryDto = { + key: 'k-many-locales', + translations: { en: 'short', es: 'corto', fr: 'court', de: 'kurz', ja: '短い', ru: 'коротко' }, + status: {}, + } as any; + + fixture.componentRef.setInput('translation', t); + fixture.detectChanges(); + + // 5 non-base locales, 4 rows visible while collapsed + expect(component.hiddenLocaleCount()).toBe(1); + expect(component.visibleLocaleTranslations().length).toBe(4); + expect(component.needsExpansion()).toBe(true); + + component.toggleExpansion(); + fixture.detectChanges(); + + expect(component.hiddenLocaleCount()).toBe(0); + expect(component.visibleLocaleTranslations().length).toBe(5); + expect(component.needsExpansion()).toBe(true); + }); + it('needsExpansion should be true when base long', () => { const long = 'a'.repeat(201); const t: ResourceSummaryDto = { @@ -301,4 +382,99 @@ describe('TranslationItem - Full density expansion', () => { component.toggleExpansion(); expect(component.isExpanded()).toBe(false); }); + + describe('full-density source row', () => { + it('should expose the base locale as the source row', () => { + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); + store.setDensityMode('full'); + fixture.componentRef.setInput('translation', mockTranslation); + fixture.detectChanges(); + + expect(component.baseRow()).toEqual({ locale: 'en', value: 'Save' }); + }); + + it('should render the source in the same grid as the translations', () => { + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); + store.setDensityMode('full'); + fixture.componentRef.setInput('translation', mockTranslation); + fixture.detectChanges(); + + const grid = fixture.nativeElement.querySelector('.locale-translations'); + expect(grid).toBeDefined(); + + const baseValue = grid?.querySelector('.locale-value--base'); + expect(baseValue).toBeDefined(); + expect(baseValue?.textContent?.trim()).toBe('Save'); + + // The source must be the grid's first line, so the reader meets it before + // the translations it is the reference for. + expect(grid?.firstElementChild?.classList.contains('locale-line--base')).toBe(true); + }); + + 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; + + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); + store.setDensityMode('full'); + fixture.componentRef.setInput('translation', t); + fixture.detectChanges(); + + expect(component.baseRow()).toBeUndefined(); + expect(fixture.nativeElement.querySelector('.locale-value--base')).toBeNull(); + }); + }); + + describe('read-only collections', () => { + beforeEach(() => { + fixture.componentRef.setInput('translation', mockTranslation); + }); + + it('should refuse the Delete shortcut when the collection is read-only', () => { + store.setSelectedCollection({ + collectionName: 'vendored', + locales: ['en', 'es'], + baseLocale: 'en', + readOnly: true, + }); + fixture.detectChanges(); + + const listStore = TestBed.inject(TranslationListStore); + const deleteSpy = vi.spyOn(listStore, 'deleteTranslation'); + + component.onKeyDown(new KeyboardEvent('keydown', { key: 'Delete' })); + + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + it('should run the Delete shortcut when the collection is writable', () => { + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); + fixture.detectChanges(); + + const listStore = TestBed.inject(TranslationListStore); + const deleteSpy = vi.spyOn(listStore, 'deleteTranslation').mockImplementation(() => undefined); + + component.onKeyDown(new KeyboardEvent('keydown', { key: 'Delete' })); + + expect(deleteSpy).toHaveBeenCalledOnce(); + }); + + it('should show a lock in place of the drag handle when the collection is read-only', () => { + store.setSelectedCollection({ + collectionName: 'vendored', + locales: ['en', 'es'], + baseLocale: 'en', + readOnly: true, + }); + fixture.detectChanges(); + + const handle = fixture.nativeElement.querySelector('.translation-drag-handle'); + expect(handle).toBeDefined(); + expect(handle?.textContent?.trim()).toBe('lock'); + expect(handle?.classList.contains('translation-drag-handle--locked')).toBe(true); + }); + }); }); 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 2de79ace..0e46a6e7 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 @@ -1,32 +1,41 @@ -import { - Component, - ChangeDetectionStrategy, - type ElementRef, - input, - output, - computed, - effect, - inject, - signal, - viewChild, - type AfterViewInit, - type OnDestroy, -} from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, output, computed, effect, inject, signal } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { CdkDrag, CdkDragPlaceholder } from '@angular/cdk/drag-drop'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; import { BrowserStore } from '../../../store/browser.store'; -import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; +import { TranslocoPipe } from '@jsverse/transloco'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { TranslationItemHeader } from './item-header'; -import { TranslationItemLocales } from './item-locales'; +import { TranslationItemLocales, statusIconFor, statusLabelTokenFor, type BaseTranslation } from './item-locales'; import { HighlightPipe } from '../../../../shared/pipes/highlight.pipe'; import type { DragData } from '../../../types/drag-data'; import { TranslationListStore } from '../store/translation-list.store'; +import { injectStatusBreakdown } from '../../../../shared/i18n/status-breakdown'; const EXPAND_THRESHOLD = 200; + +/** + * Number of locale rows rendered while a full-density item is collapsed. + * Kept in sync with the virtual-scroll itemSize estimate in translation-list.ts. + */ +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, @@ -45,6 +54,7 @@ const STATUS_SORT_PRIORITY: Record = { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatIconModule, + MatTooltipModule, TranslationItemHeader, TranslationItemLocales, HighlightPipe, @@ -58,7 +68,7 @@ const STATUS_SORT_PRIORITY: Record = { class: 'translation-item', }, }) -export class TranslationItem implements AfterViewInit, OnDestroy { +export class TranslationItem { /** Translation data */ translation = input.required(); @@ -76,7 +86,6 @@ export class TranslationItem implements AfterViewInit, OnDestroy { readonly #store = inject(BrowserStore); readonly #listStore = inject(TranslationListStore); - readonly #transloco = inject(TranslocoService); readonly TOKENS = TRACKER_TOKENS; /** Active collection name — always set when this component is rendered. */ @@ -85,25 +94,12 @@ export class TranslationItem implements AfterViewInit, OnDestroy { /** Whether this item was recently updated (flash highlight). */ readonly isRecentlyUpdated = computed(() => this.#listStore.isRecentlyUpdated(this.translation().key)); - /** Reference to the scrollable wrapper element (used as IntersectionObserver root). */ - protected readonly scrollWrapper = viewChild>('scrollWrapper'); - - /** Reference to the invisible sentinel element observed to detect scroll-to-bottom. */ - protected readonly scrollSentinel = viewChild>('scrollSentinel'); - - /** - * True when the user has scrolled far enough that the sentinel (placed at the - * bottom of the scrollable content) is visible within the scroll viewport. - * Used to hide the fade gradient when no more content is hidden below. - */ - readonly isScrolledToBottom = signal(false); - - /** Active IntersectionObserver instance — cleaned up on destroy or when conditions change. */ - #scrollObserver: IntersectionObserver | undefined; - /** Current search query from the store */ readonly searchQuery = this.#store.searchQuery; + /** Whether the active collection is read-only (mutating actions are refused). */ + readonly isReadOnly = this.#store.isReadOnly; + // Timestamp when touch started (ms since epoch) #touchStartTs = 0; @@ -127,19 +123,37 @@ export class TranslationItem implements AfterViewInit, OnDestroy { 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; + + return { locale: this.#store.baseLocale(), value }; + }); + /** 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) => ({ - locale, - value: trans.translations[locale] || '', - status: trans.status ? trans.status[locale] : undefined, - })) + .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; @@ -151,52 +165,74 @@ export class TranslationItem implements AfterViewInit, OnDestroy { /** Current density mode (reads from BrowserStore) */ readonly currentDensityMode = computed(() => this.#store.densityMode()); - /** Selected locale for compact mode: the first locale from filteredLocales() */ - readonly primaryLocale = computed(() => { - const ls = this.#store.filteredLocales(); - return (ls && ls.length > 0 && ls[0]) || this.#store.baseLocale(); - }); - /** - * Locale to display in the status chip (compact mode). - * Logic: - * - If a specific locale is selected (other than base locale) → show that locale - * - If nothing is selected or only base locale is selected → show the primary locale + * The locale whose value the compact row annotates, together with its status. + * + * `isBaseFallback` marks the case where the compact locale selection resolved to + * the base locale itself — there is then only one string on the row, and it is + * source text rather than a translation. */ - readonly statusChipLocale = computed(() => { - const activeLocales = this.#store.filteredLocales(); + readonly compactDisplay = computed(() => { const base = this.#store.baseLocale(); + const nonBaseLocales = this.#store.filteredLocales().filter((locale) => locale !== base); + + const locale = nonBaseLocales.length > 0 ? nonBaseLocales[0] : base; + const translation = this.translation(); + const value = translation.translations[locale] || ''; + const isBaseFallback = nonBaseLocales.length === 0; + + return { + locale, + value, + status: translation.status?.[locale], + isBaseFallback, + // A base-locale fallback row is already labelled `source`; saying "same as + // source" next to it would be the same fact twice. + isSameAsBase: !isBaseFallback && isIdenticalToBase(value, translation.translations[base] || ''), + }; + }); - // Filter out base locale from active locales - const nonBaseLocales = activeLocales.filter((locale) => locale !== base); + /** Material icon for the compact row's status. */ + readonly compactStatusIcon = computed(() => statusIconFor(this.compactDisplay().status)); - // If exactly one non-base locale is selected, use it - if (nonBaseLocales.length === 1) { - return nonBaseLocales[0]; - } + /** Transloco token for the compact row's status label ('' when the status is unknown). */ + readonly compactStatusToken = computed(() => statusLabelTokenFor(this.compactDisplay().status)); - // Otherwise, fall back to primary locale (first in the list) - return this.primaryLocale(); + /** + * How the compact row composes itself. Three genuinely different situations, + * named rather than inferred in the template: + * + * - `paired` — the ordinary case. The base value identifies the row and the + * selected locale's value sits beside it, so source and translation can be + * judged against each other in one horizontal glance. + * - `source-only` — the compact locale selection resolved to the base locale + * itself. One string, and it is source text; there is nothing to compare it + * against, so no annotation value is shown. + * - `no-base` — the collection carries no base locale at all (a vendored + * design-system collection may ship `ar`…`sv` and no `en`). There is no source + * text to lead with, so the shown locale becomes the identity and the row says + * the pick was automatic. + */ + readonly compactLayout = computed<'paired' | 'source-only' | 'no-base'>(() => { + if (this.compactDisplay().isBaseFallback) return 'source-only'; + if (!this.baseValue().trim()) return 'no-base'; + return 'paired'; }); /** - * Value for the selected primary locale in compact mode. - * In compact mode, shows the translation value for the selected non-base locale. - * Falls back to base locale value if no non-base locale is selected. + * The string that identifies a compact row. + * + * The base value wherever one exists, because it is the only text on the row the + * developer already knows — they wrote it, and they arrived looking for it. + * Compact used to omit it entirely and lead with a translation, which handed a + * developer scanning an eleven-locale collection a script they could not read. */ - readonly primaryLocaleValue = computed(() => { - const activeLocales = this.#store.filteredLocales(); - const base = this.#store.baseLocale(); - const translations = this.translation().translations; - - // Filter out base locale to find selected non-base locales - const nonBaseLocales = activeLocales.filter((locale) => locale !== base); + readonly identityValue = computed(() => + this.compactLayout() === 'no-base' ? this.compactDisplay().value : this.baseValue(), + ); - // If a non-base locale is selected, use the first one - const localeToDisplay = nonBaseLocales.length > 0 ? nonBaseLocales[0] : base; - - return translations[localeToDisplay] || ''; - }); + /** Value rendered in the compact row's annotation zone. */ + readonly primaryLocaleValue = computed(() => this.compactDisplay().value); /** Signal controlling whether the full-mode content is expanded */ readonly isExpanded = signal(false); @@ -205,16 +241,49 @@ export class TranslationItem implements AfterViewInit, OnDestroy { readonly showComment = signal(false); /** - * Returns true when the base value or any active locale value exceeds the - * visual threshold and therefore can be expanded. + * Locale rows rendered in full density. While collapsed the list is sliced to + * MAX_VISIBLE_LOCALE_ROWS; the rows left out are counted by hiddenLocaleCount + * and named on the expand button, so nothing is ever hidden silently. */ - readonly needsExpansion = computed(() => { - const base = this.baseValue() || ''; - if (base.length > EXPAND_THRESHOLD) return true; + readonly visibleLocaleTranslations = computed(() => { + const all = this.localeTranslations(); + return this.isExpanded() ? all : all.slice(0, MAX_VISIBLE_LOCALE_ROWS); + }); + + /** Number of locale rows withheld by the collapsed state. */ + readonly hiddenLocaleCount = computed(() => + this.isExpanded() ? 0 : Math.max(0, this.localeTranslations().length - MAX_VISIBLE_LOCALE_ROWS), + ); + + /** 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); }); + /** + * Whether the expand control is offered. It answers "is anything hidden?" — + * either locale rows the collapsed list dropped, or values the line clamp cut. + */ + readonly needsExpansion = computed( + () => this.isExpanded() || this.hiddenLocaleCount() > 0 || this.hasClippedValues(), + ); + + /** + * Token for the expand control's label. Resolved through the transloco pipe in + * the template rather than TranslocoService, so the label re-renders when the + * UI language changes. + */ + readonly expandLabelToken = computed(() => { + if (this.isExpanded()) return TRACKER_TOKENS.BROWSER.TRANSLATIONITEM.SHOWLESS; + if (this.hiddenLocaleCount() > 0) return TRACKER_TOKENS.BROWSER.TRANSLATIONITEM.MORELOCALESX; + return TRACKER_TOKENS.BROWSER.TRANSLATIONITEM.SHOWMORE; + }); + + /** Interpolation params for expandLabelToken. */ + readonly expandLabelParams = computed(() => ({ count: this.hiddenLocaleCount() })); + /** Toggles the expanded state for full density mode */ toggleExpansion(): void { this.isExpanded.update((v) => !v); @@ -222,17 +291,6 @@ export class TranslationItem implements AfterViewInit, OnDestroy { key: this.translation().key, expanded: this.isExpanded(), }); - - // The fade and sentinel are only rendered when collapsed, so synchronise the - // observer with the new expansion state after Angular has updated the DOM. - this.#teardownScrollObserver(); - this.isScrolledToBottom.set(false); - - if (!this.isExpanded()) { - // Re-enter collapsed state: set up observer on the next microtask so the - // sentinel element has been rendered by Angular's change detection. - Promise.resolve().then(() => this.#setupScrollObserver()); - } } /** Toggles the comment display for compact and medium density modes */ @@ -291,6 +349,10 @@ export class TranslationItem implements AfterViewInit, OnDestroy { case 'delete': case 'del': + // The menu item is disabled in a read-only collection, so the keyboard + // must refuse the same way. Without this the shortcut opens a delete + // confirmation the API will reject after the user commits to it. + if (this.isReadOnly()) return; action = () => this.#listStore.deleteTranslation(this.translation(), this.#collectionName()); break; } @@ -335,59 +397,9 @@ export class TranslationItem implements AfterViewInit, OnDestroy { }); } - ngAfterViewInit(): void { - this.#setupScrollObserver(); - } - - ngOnDestroy(): void { - this.#teardownScrollObserver(); - } - - /** - * Creates the IntersectionObserver that watches the sentinel element inside - * the scroll wrapper. The observer is only created when there are more than - * 4 locale translations and the item is collapsed — conditions where the fade - * gradient is actually rendered. - * - * The scroll wrapper element is used as the root so intersection is measured - * against the scrollable viewport rather than the document viewport. - */ - #setupScrollObserver(): void { - const shouldObserve = this.localeTranslations().length >= 4 && !this.isExpanded(); - if (!shouldObserve) return; - - const sentinel = this.scrollSentinel()?.nativeElement; - const wrapper = this.scrollWrapper()?.nativeElement; - if (!sentinel || !wrapper) return; - - this.#scrollObserver = new IntersectionObserver(([entry]) => this.isScrolledToBottom.set(entry.isIntersecting), { - root: wrapper, - threshold: 0.1, - }); - this.#scrollObserver.observe(sentinel); - } - - /** Disconnects and discards the active IntersectionObserver. */ - #teardownScrollObserver(): void { - this.#scrollObserver?.disconnect(); - this.#scrollObserver = undefined; - } - /** Returns a stable id for the rollup status element. */ readonly statusId = computed(() => `rollup-${this.translation().key}`); - /** Returns true if this translation has tags or a comment. */ - readonly hasMetadata = computed(() => { - const t = this.translation(); - return Boolean((t.tags && t.tags.length > 0) || t.comment); - }); - - /** Returns first 3 tags for preview display in medium density mode. */ - readonly previewTags = computed(() => { - const tags = this.translation().tags; - return tags?.slice(0, 3) ?? []; - }); - /** * Drag data for this translation item. * Contains resource key, folder path, and type identifier. @@ -407,24 +419,10 @@ export class TranslationItem implements AfterViewInit, OnDestroy { }); /** - * Returns a comma-separated breakdown of statuses across all locales for use in tooltips. - * Example: "2 stale, 3 verified, 1 new" + * Localized breakdown of statuses across all locales, announced to screen + * readers. Example: "2 stale, 3 verified, 1 new". */ - readonly statusBreakdown = computed(() => { - const { counts, total } = this.#statusCounts(); - - if (total === 0) return this.#transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONITEM.NOSTATUSES); - - const order: Array = ['stale', 'new', 'translated', 'verified']; - const parts: string[] = []; - - for (const k of order) { - const c = counts[k]; - if (c && c > 0) parts.push(`${c} ${k}`); - } - - return parts.join(', '); - }); + readonly statusBreakdown = injectStatusBreakdown(computed(() => this.#statusCounts().counts)); /** * Roll-up status across ALL locales. Priority (worst first): stale > new > translated > verified 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 d51e9f05..78ec156f 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 @@ -15,7 +15,10 @@ import { MatIconModule } from '@angular/material/icon'; 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 { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; +import { injectActiveLang, injectStatusBreakdown } from '../../../../shared/i18n/status-breakdown'; /** Locale state for rollup display */ export interface LocaleState { @@ -23,12 +26,10 @@ export interface LocaleState { status: TranslationStatus; } -/** Status configuration for display */ +/** Per-status display configuration. Color lives in CSS so both themes can move it. */ interface StatusConfig { - label: string; + labelToken: string; icon: string; - color: string; - colorVar: string; } /** Close delay in ms */ @@ -41,20 +42,23 @@ const CLOSE_DELAY = 120; @Component({ selector: 'app-translation-rollup', standalone: true, - imports: [CommonModule, OverlayModule, PortalModule, MatIconModule], + imports: [CommonModule, OverlayModule, PortalModule, MatIconModule, TranslocoPipe], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+ @@ -107,11 +106,11 @@ const CLOSE_DELAY = 120;
- {{ row.label }} + {{ row.labelToken | transloco }} {{ row.code }}
} @@ -131,9 +130,14 @@ const CLOSE_DELAY = 120; position: relative; display: grid; place-items: center; + padding: 0; + border: none; border-radius: var(--border-radius-lg); + background: none; + color: inherit; + font: inherit; outline: none; - cursor: default; + cursor: pointer; user-select: none; } @@ -149,6 +153,22 @@ const CLOSE_DELAY = 120; display: block; } + /* Compact renders this on a single-line row, where 40px would set the row + height by itself. The ring is a graphic and scales cleanly. Declared after + the defaults so it wins on order rather than on a specificity hack. */ + .rollup--compact, + .rollup--compact .ring { + width: 28px; + height: 28px; + } + + @media (pointer: coarse) { + .rollup--compact { + width: 44px; + height: 44px; + } + } + .track { fill: none; stroke: var(--color-border); @@ -174,9 +194,17 @@ const CLOSE_DELAY = 120; box-shadow: var(--shadow-sm); } - .center--issue { + /* The ring of the centre disc repeats the status hue, so the issue kinds + differ twice over: once in the glyph, once in the border. */ + .center--stale, + .center--mixed { border: 2px solid - color-mix(in srgb, var(--color-warning) 45%, transparent); + color-mix(in srgb, var(--color-status-stale) 45%, transparent); + } + + .center--new { + border: 2px solid + color-mix(in srgb, var(--color-status-new) 45%, transparent); } .center-icon { @@ -186,28 +214,74 @@ const CLOSE_DELAY = 120; line-height: 16px; } - .icon--issue { - color: var(--color-warning); + .icon--stale, + .icon--mixed { + color: var(--color-status-stale); + } + + .icon--new { + color: var(--color-status-new); } .icon--translated { - color: var(--color-info); + color: var(--color-status-translated); } .icon--verified { - color: var(--color-success); + color: var(--color-status-verified); + } + + /* One source of truth per status, shared by the ring and the tooltip rows. */ + .seg--new { + stroke: var(--color-status-new); + } + + .seg--stale { + stroke: var(--color-status-stale); + } + + .seg--translated { + stroke: var(--color-status-translated); + } + + .seg--verified { + stroke: var(--color-status-verified); + } + + .row-icon--new { + color: var(--color-status-new); + } + + .row-icon--stale { + color: var(--color-status-stale); + } + + .row-icon--translated { + color: var(--color-status-translated); + } + + .row-icon--verified { + color: var(--color-status-verified); } /* Tooltip panel - always dark for contrast in both themes */ .tooltip { + /* This surface is dark under either theme, so the status hues here always + take their dark-surface values — the light-theme set is tuned for a + parchment card and would sink into this panel. */ + --color-status-new: var(--status-new-on-dark); + --color-status-stale: var(--status-stale-on-dark); + --color-status-translated: var(--status-translated-on-dark); + --color-status-verified: var(--status-verified-on-dark); + min-width: 200px; max-width: 360px; padding: var(--spacing-3); border-radius: var(--border-radius-xl); - background: #18181b; - color: #fafafa; - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35); - border: 1px solid rgba(255, 255, 255, 0.1); + background: var(--color-tooltip-surface); + color: var(--color-tooltip-text); + box-shadow: var(--shadow-lg); + border: 1px solid var(--color-border-strong); } .grid { @@ -254,8 +328,13 @@ export class TranslationRollup implements OnDestroy { /** Base locale code (excluded from display) */ baseLocale = input('en'); + /** Renders at the smaller size compact's single-line row can afford. */ + compact = input(false); + private readonly overlay = inject(Overlay); private readonly vcr = inject(ViewContainerRef); + private readonly transloco = inject(TranslocoService); + private readonly activeLang = injectActiveLang(); readonly trigger = viewChild.required>('trigger'); readonly tooltipTpl = viewChild.required>('tooltipTpl'); @@ -272,28 +351,20 @@ export class TranslationRollup implements OnDestroy { /** Status configuration */ private readonly statusConfig: Record = { new: { - label: 'New', + labelToken: TRACKER_TOKENS.BROWSER.STATUS.NEW, icon: 'add_circle', - color: '#f97316', - colorVar: '--color-warning', }, stale: { - label: 'Stale', + labelToken: TRACKER_TOKENS.BROWSER.STATUS.STALE, icon: 'warning', - color: '#eab308', - colorVar: '--color-warning', }, translated: { - label: 'Translated', + labelToken: TRACKER_TOKENS.BROWSER.STATUS.TRANSLATED, icon: 'language', - color: '#3b82f6', - colorVar: '--color-info', }, verified: { - label: 'Verified', + labelToken: TRACKER_TOKENS.BROWSER.STATUS.VERIFIED, icon: 'check_circle', - color: '#10b981', - colorVar: '--color-success', }, }; @@ -326,41 +397,52 @@ export class TranslationRollup implements OnDestroy { /** Total non-base locales */ readonly total = computed(() => this.effectiveLocales().length); - /** Count of translated + verified */ - private readonly translatedLike = computed(() => this.counts().translated + this.counts().verified); - - /** Whether there are issues (new or stale) */ - readonly hasIssues = computed(() => this.counts().new > 0 || this.counts().stale > 0); - /** Whether all locales are verified */ readonly isAllVerified = computed(() => this.total() > 0 && this.counts().verified === this.total()); - /** Whether all locales are translated (but not all verified) */ - readonly isAllTranslated = computed( - () => this.total() > 0 && this.translatedLike() === this.total() && !this.isAllVerified(), - ); + /** + * 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. + */ + 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'; + }); - /** Center icon based on 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. + */ readonly centerIcon = computed(() => { - if (this.hasIssues()) return 'priority_high'; - if (this.isAllVerified()) return 'check'; - return 'language'; + 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; }); - /** Aria label for accessibility */ - readonly ariaLabel = computed(() => { - const t = this.total(); - if (t === 0) return 'No locales'; + /** Localized status breakdown, e.g. "2 stale, 1 new". */ + readonly breakdown = injectStatusBreakdown(this.counts); - const parts: string[] = []; - parts.push(`${this.translatedLike()} of ${t} translated`); + /** + * Accessible name. Reads the breakdown rather than a "x of y" summary, because + * the ring already carries the proportion and the counts are what a listener + * cannot see. Depends on the active language so it survives a language switch. + */ + readonly ariaLabel = computed(() => { + const breakdown = this.breakdown(); + this.activeLang(); - const c = this.counts(); - if (c.new) parts.push(`${c.new} new`); - if (c.stale) parts.push(`${c.stale} stale`); - if (c.verified) parts.push(`${c.verified} verified`); + if (this.total() === 0) return breakdown; - return `Translation rollup: ${parts.join(', ')}`; + return this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONITEM.ROLLUPARIALABELX, { + breakdown, + }); }); /** Ring segments for SVG */ @@ -380,7 +462,6 @@ export class TranslationRollup implements OnDestroy { const gap = circ - len; const seg = { status: st, - color: this.statusConfig[st].color, dashArray: `${len} ${gap}`, dashOffset: -acc, }; @@ -403,9 +484,8 @@ export class TranslationRollup implements OnDestroy { .map((l) => ({ code: l.code, status: l.status, - label: this.statusConfig[l.status].label, + labelToken: this.statusConfig[l.status].labelToken, icon: this.statusConfig[l.status].icon, - color: this.statusConfig[l.status].color, })) .sort((a, b) => { const orderDiff = orderIndex(a.status) - orderIndex(b.status); @@ -486,20 +566,20 @@ export class TranslationRollup implements OnDestroy { } } - /** Handle keyboard events */ + /** Toggle the tooltip. Enter and Space reach this through the button's click. */ + toggle(): void { + if (this.isOpen()) { + this.close(); + } else { + this.open(); + } + } + + /** Escape closes; Enter and Space are handled natively by the button. */ onKeydown(ev: KeyboardEvent): void { if (ev.key === 'Escape') { ev.preventDefault(); this.close(); - return; - } - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - if (this.isOpen()) { - this.close(); - } else { - this.open(); - } } } diff --git a/apps/tracker/src/app/browser/translations/list/translation-list.html b/apps/tracker/src/app/browser/translations/list/translation-list.html index fa92ac62..129f14d4 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.html +++ b/apps/tracker/src/app/browser/translations/list/translation-list.html @@ -28,16 +28,7 @@ } @if (!store.isLoading() && !store.error()) { - @if (!store.currentFolderPath()) { -
- - - folder_open -

{{ TOKENS.BROWSER.SELECTFOLDERFIRST | transloco }}

-
-
-
- } @else if (store.sortedTranslations().length === 0) { + @if (store.sortedTranslations().length === 0) {
diff --git a/apps/tracker/src/app/browser/translations/list/translation-list.scss b/apps/tracker/src/app/browser/translations/list/translation-list.scss index a1d622cf..4f929b01 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.scss +++ b/apps/tracker/src/app/browser/translations/list/translation-list.scss @@ -58,9 +58,12 @@ .search-info { + // The tinted fill and the icon already say "this is an advisory". A 4px accent + // bar on top of both is a third statement of the same thing, and it is the one + // that reads as generic template chrome — the border stays a hairline in the + // info hue instead. background-color: var(--color-info-subtle); - border: 1px solid var(--color-border); - border-left: 4px solid var(--color-info); + border: 1px solid var(--color-info); border-radius: var(--border-radius-sm); display: flex; align-items: center; @@ -103,4 +106,11 @@ :host ::ng-deep .viewport .cdk-virtual-scroll-content-wrapper { padding: var(--spacing-4) var(--spacing-6); box-sizing: border-box; + /* + * CDK positions this wrapper absolutely, which leaves it unconstrained: a row + * with a nowrap line resolved to max-content and grew past the viewport, + * carrying the rollup and the actions menu off-screen with no per-item control + * reachable. Bounding the wrapper is what lets the rows' own ellipses fire. + */ + max-width: 100%; } 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 8587b17f..c846dbe4 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 @@ -196,17 +196,17 @@ describe('TranslationList - Loading and Error States', () => { expect(errorContainer).toBeTruthy(); }); - it('should display "select a folder" empty state when no folder is selected', () => { + it('should show the empty-folder state rather than a select-a-folder prompt at the root', () => { + // The collection root is a real selection now — the sidebar's root row lands on it — + // so there is no "nothing selected" state left to prompt for. fixture.componentRef.setInput('collectionName', 'test'); fixture.detectChanges(); - const emptyState = fixture.nativeElement.querySelector('.empty-state'); const icon = fixture.nativeElement.querySelector('.empty-state__icon'); const text = fixture.nativeElement.querySelector('.empty-state__text'); - expect(emptyState).toBeTruthy(); - expect(icon?.textContent).toContain('folder_open'); - expect(text?.textContent).toContain('Select a folder first'); + expect(icon?.textContent).toContain('translate'); + expect(text?.textContent).toContain('No translations found'); }); it('should display "no translations found" empty state when folder is selected but empty', async () => { 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 c83cd2ed..cab5b3a2 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-list.ts @@ -56,16 +56,24 @@ export class TranslationList { protected readonly scrollViewport = viewChild(CdkVirtualScrollViewport); /** - * Fixed layout constants for full-mode item height calculation. - * - BASE_HEIGHT: header + base-value + comment row + padding + margin (~80px) - * - TOUCH_EXTRA: additional height on touch devices for larger tap targets (~16px) - * - LOCALE_ROW_HEIGHT: height of each locale translation row (~32px) - * - MAX_VISIBLE_LOCALE_ROWS: locale rows visible before scroll kicks in (4) + * Fixed layout constants for full-mode item height calculation, measured from a + * rendered item at desktop width (header 45 + base value 27 + comment/expand row + * 45 + padding 8 + three 4px gaps = 137, and 28px per locale row). + * + * These are an estimate for the common case, not a guarantee: a two-line base + * value, a wrapped comment, or the stacked locale layout a narrow item switches + * to all make the real height larger. CDK uses this to size the scrollbar and + * decide how many items to render, so being close matters more than being exact. + * + * - BASE_HEIGHT: header + base-value + comment row + padding + gaps + * - TOUCH_EXTRA: additional height on touch devices for larger tap targets + * - LOCALE_ROW_HEIGHT: height of each locale translation row + * - MAX_VISIBLE_LOCALE_ROWS: locale rows rendered while collapsed (4) * - MARGIN_BOTTOM: item-container margin-bottom = --spacing-3 (12px) */ - readonly #FULL_BASE_HEIGHT = 80; + readonly #FULL_BASE_HEIGHT = 137; readonly #FULL_TOUCH_EXTRA = 16; - readonly #FULL_LOCALE_ROW_HEIGHT = 32; + readonly #FULL_LOCALE_ROW_HEIGHT = 28; readonly #FULL_MAX_VISIBLE_LOCALE_ROWS = 4; readonly #FULL_MARGIN_BOTTOM = 12; // --spacing-3 @@ -95,7 +103,10 @@ export class TranslationList { switch (mode) { case 'compact': - return (isTouch ? 100 : 96) + this.#COMPACT_MARGIN_BOTTOM; + // One line, measured in the browser rather than derived: 4px padding, a + // 28px control row, 4px padding, plus the container border. The touch + // variant restores the 44px control floor and grows to match. + return (isTouch ? 54 : 38) + this.#COMPACT_MARGIN_BOTTOM; case 'full': { const filteredLocales = this.store.filteredLocales(); diff --git a/apps/tracker/src/app/header/app-header.html b/apps/tracker/src/app/header/app-header.html index e55e16e0..1e4c4adf 100644 --- a/apps/tracker/src/app/header/app-header.html +++ b/apps/tracker/src/app/header/app-header.html @@ -33,8 +33,8 @@

LingoTracker

} @else if (totalKeys() !== null) { - {{ keysText() }} - {{ localesText() }} + {{ TOKENS.HEADER.KEYSCOUNTX | transloco: { count: totalKeys() } }} + {{ TOKENS.HEADER.LOCALESCOUNTX | transloco: { count: localeCount() } }} }
} diff --git a/apps/tracker/src/app/header/app-header.ts b/apps/tracker/src/app/header/app-header.ts index 64c1c395..6f64dafb 100644 --- a/apps/tracker/src/app/header/app-header.ts +++ b/apps/tracker/src/app/header/app-header.ts @@ -6,10 +6,9 @@ import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; +import { TranslocoPipe } from '@jsverse/transloco'; import { ThemeService, type ThemeMode } from '../shared/services/theme.service'; import { HeaderContextService } from '../shared/services/header-context.service'; -import { LocaleService } from '../shared/services/locale.service'; import { LocalePickerComponent } from './locale-picker/locale-picker'; import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; @@ -35,8 +34,6 @@ import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; export class AppHeader { readonly #themeService = inject(ThemeService); readonly #headerContext = inject(HeaderContextService); - readonly #transloco = inject(TranslocoService); - readonly #localeService = inject(LocaleService); readonly TOKENS = TRACKER_TOKENS; @@ -47,19 +44,14 @@ export class AppHeader { readonly statsLoading = this.#headerContext.statsLoading; readonly hasCollectionContext = this.#headerContext.hasCollectionContext; - readonly keysText = computed(() => { - const _locale = this.#localeService.currentLocale(); - const k = this.totalKeys(); - if (k === null) return ''; - return this.#transloco.translate(TRACKER_TOKENS.HEADER.KEYSCOUNTX, { count: k }); - }); - - readonly localesText = computed(() => { - const _locale = this.#localeService.currentLocale(); - const l = this.localeCount(); - if (l === null) return ''; - return this.#transloco.translate(TRACKER_TOKENS.HEADER.LOCALESCOUNTX, { count: l }); - }); + /** + * The stat chips resolve through the `transloco` pipe in the template, not + * through `TranslocoService.translate()` in a `computed`. A computed caches + * whatever `translate()` returned on its first read, and that read happens + * before the active language's translation file has finished loading, so it + * would latch the raw key ("header.keysCountX") forever. See `themeModeToken` + * below for the same rule. + */ /** * The switcher wears the *mode* that is selected, not the theme it resolves diff --git a/apps/tracker/src/app/shared/_toggle-animation.scss b/apps/tracker/src/app/shared/_toggle-animation.scss index 1a32213e..26181848 100644 --- a/apps/tracker/src/app/shared/_toggle-animation.scss +++ b/apps/tracker/src/app/shared/_toggle-animation.scss @@ -14,6 +14,15 @@ animation: icon-flip-animation 250ms ease-in-out; } +// The flip only dresses a content swap that happens either way, so removing it +// costs nothing. Both call sites — the density toggle and the folder-tree nested +// toggle — inherit this guard from the pattern that owns the animation. +@media (prefers-reduced-motion: reduce) { + .icon-flip { + animation: none; + } +} + // Shared placeholder for toolbar icon buttons (40×40 bordered icon buttons). // // Usage: @extend %toolbar-icon-button in any component that renders a small @@ -51,8 +60,108 @@ background-color: var(--color-background-subtle); } + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + } + &:disabled { opacity: 0.5; cursor: not-allowed; } } + +// Shared shell for the toolbar's dropdown triggers (locale filter, status filter). +// +// Material's stroked button ships its own outline (rgba black 12%), text colour +// (rgba black 87%) and 32px height, none of which come from the token set — put +// side by side with %toolbar-icon-button that reads as two different control +// families in one row. This re-bases the trigger on the same 40px box, the same +// warm border, and the same hover/focus treatment as the icon buttons. +%toolbar-filter-trigger { + align-items: center; + appearance: none; + background-color: var(--color-background); + cursor: pointer; + font-family: inherit; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + color: var(--color-text-secondary); + display: flex; + flex-shrink: 0; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + gap: var(--spacing-2); + height: var(--spacing-10); + line-height: 1; + min-width: 0; + padding: 0 var(--spacing-3); + transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); + + &:hover:not(:disabled) { + background-color: var(--color-background-muted); + border-color: var(--color-border-strong); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + } + + // The chevron is the trigger's own affordance, not part of the label, so it + // sits a step back from the text it follows. + .arrow-icon { + color: var(--color-text-tertiary); + flex-shrink: 0; + font-size: 20px; + height: 20px; + width: 20px; + } +} + +// Shared twisty for the folder tree: one chevron glyph that rotates rather than swapping, +// so opening and shutting a folder reads as the same object moving. +// +// Deliberately not a tab stop — in a deep tree that would double the number of stops. The +// row carries aria-expanded and ArrowRight/ArrowLeft instead, which is the ARIA tree pattern. +%tree-expand-toggle { + align-items: center; + background: none; + border: none; + border-radius: var(--border-radius-sm); + color: var(--color-text-tertiary); + cursor: pointer; + display: flex; + flex-shrink: 0; + height: 20px; + justify-content: center; + padding: 0; + transition: color var(--transition-fast), background-color var(--transition-fast); + width: 20px; + + mat-icon { + font-size: 18px; + height: 18px; + line-height: 18px; + width: 18px; + transition: transform var(--transition-fast); + } + + &.expanded mat-icon { + transform: rotate(90deg); + } + + &:hover:not(:disabled) { + background-color: var(--color-background-muted); + color: var(--color-text-primary); + } +} + +// Occupies exactly the twisty's footprint so names stay on one vertical rule whether or +// not a folder has children. +%tree-expand-spacer { + display: block; + flex-shrink: 0; + height: 20px; + width: 20px; +} diff --git a/apps/tracker/src/app/shared/components/search-input/search-input.scss b/apps/tracker/src/app/shared/components/search-input/search-input.scss index 42073a23..eadf8c33 100644 --- a/apps/tracker/src/app/shared/components/search-input/search-input.scss +++ b/apps/tracker/src/app/shared/components/search-input/search-input.scss @@ -6,6 +6,15 @@ width: 100%; margin: 0; + // Material's outlined field draws its own greyscale outline; re-base it on the + // warm border tokens so a search box sits in the same control family as the + // buttons beside it in a toolbar. + --mdc-outlined-text-field-outline-color: var(--color-border); + --mdc-outlined-text-field-hover-outline-color: var(--color-border-strong); + --mdc-outlined-text-field-focus-outline-color: var(--color-primary); + --mdc-outlined-text-field-input-text-color: var(--color-text-primary); + --mdc-outlined-text-field-container-shape: var(--border-radius-sm); + ::ng-deep { .mat-mdc-form-field-flex { height: 40px; @@ -13,6 +22,7 @@ } .mat-mdc-text-field-wrapper { + background-color: var(--color-background); padding: 0; } @@ -27,6 +37,13 @@ input { font-size: var(--font-size-sm); margin-top: 0; + + // Placeholders are read, so they clear 4.5:1. Material's default + // rgba(0, 0, 0, 0.54) lands at 4.2:1 on this background. + &::placeholder { + color: var(--color-text-placeholder); + opacity: 1; + } } .mat-mdc-form-field-icon-prefix { @@ -35,7 +52,7 @@ align-items: center; mat-icon { - color: var(--color-text-tertiary); + color: var(--color-text-placeholder); } } diff --git a/apps/tracker/src/app/shared/i18n/status-breakdown.ts b/apps/tracker/src/app/shared/i18n/status-breakdown.ts new file mode 100644 index 00000000..09428f2b --- /dev/null +++ b/apps/tracker/src/app/shared/i18n/status-breakdown.ts @@ -0,0 +1,66 @@ +import { computed, inject, type Signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { TranslocoService } from '@jsverse/transloco'; +import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; +import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; + +/** Number of locales in each translation status. */ +export type StatusCounts = Record; + +/** Pluralized "{n} " resource for each status. */ +const COUNT_TOKENS: Record = { + stale: TRACKER_TOKENS.BROWSER.STATUS.STALECOUNTX, + new: TRACKER_TOKENS.BROWSER.STATUS.NEWCOUNTX, + translated: TRACKER_TOKENS.BROWSER.STATUS.TRANSLATEDCOUNTX, + verified: TRACKER_TOKENS.BROWSER.STATUS.VERIFIEDCOUNTX, +}; + +/** Worst status first, so the part that needs attention is read first. */ +const BREAKDOWN_ORDER: readonly TranslationStatus[] = ['stale', 'new', 'translated', 'verified']; + +/** + * Signal of the active UI language. Read it inside a `computed` that calls + * `TranslocoService.translate()` imperatively — that call is not reactive on its + * own, so without this dependency the derived text survives a language switch + * unchanged. Must be called from an injection context. + */ +export function injectActiveLang(): Signal { + const transloco = inject(TranslocoService); + return toSignal(transloco.langChanges$, { initialValue: transloco.getActiveLang() }); +} + +/** + * Builds a localized status breakdown such as "2 stale, 1 new" from a signal of + * counts, omitting statuses with no locales and falling back to the "no statuses" + * message when every count is zero. The list is joined with `Intl.ListFormat` so + * the separator follows the reader's language rather than a hardcoded comma. + * Must be called from an injection context. + */ +export function injectStatusBreakdown(counts: Signal): Signal { + const transloco = inject(TranslocoService); + const activeLang = injectActiveLang(); + + return computed(() => { + const lang = activeLang(); + const current = counts(); + + const parts = BREAKDOWN_ORDER.filter((status) => current[status] > 0).map((status) => + transloco.translate(COUNT_TOKENS[status], { count: current[status] }), + ); + + if (parts.length === 0) { + return transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONITEM.NOSTATUSES); + } + + return formatList(parts, lang); + }); +} + +/** Joins list parts for a language, falling back when the tag is not usable. */ +function formatList(parts: string[], lang: string): string { + try { + return new Intl.ListFormat(lang, { style: 'short', type: 'unit' }).format(parts); + } catch { + return parts.join(', '); + } +} diff --git a/apps/tracker/src/app/shared/pipes/key-markup.pipe.spec.ts b/apps/tracker/src/app/shared/pipes/key-markup.pipe.spec.ts new file mode 100644 index 00000000..14c01979 --- /dev/null +++ b/apps/tracker/src/app/shared/pipes/key-markup.pipe.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { DomSanitizer } from '@angular/platform-browser'; +import { KeyMarkupPipe } from './key-markup.pipe'; + +describe('KeyMarkupPipe', () => { + let pipe: KeyMarkupPipe; + let sanitizer: DomSanitizer; + + const render = (key: string | null | undefined, searchTerm?: string): string => { + const result = pipe.transform(key, searchTerm); + return typeof result === 'string' ? result : (sanitizer.sanitize(1, result) ?? ''); + }; + + beforeEach(() => { + TestBed.configureTestingModule({}); + pipe = TestBed.runInInjectionContext(() => new KeyMarkupPipe()); + sanitizer = TestBed.inject(DomSanitizer); + }); + + it('should add a break opportunity after every key separator', () => { + expect(render('apps.common.buttons.ok')).toBe('apps.common.buttons.ok'); + }); + + it('should add a break opportunity at each camelCase hump', () => { + expect(render('grid.addCurrentSelection')).toBe('grid.addCurrentSelection'); + }); + + it('should return an empty string for a missing key', () => { + expect(render('')).toBe(''); + expect(render(null)).toBe(''); + }); + + it('should keep the truncation ellipsis intact', () => { + expect(render('a.b')).toBe('a.b'); + expect(render(`first.${'segment.'.repeat(8)}last`)).not.toContain('..'); + }); + + it('should truncate a long key before marking it up', () => { + const key = `first.${'segment.'.repeat(8)}last`; + const output = render(key); + + expect(output).toContain('first...'); + expect(output).toContain('last'); + expect(output).not.toContain('segment.segment'); + }); + + it('should highlight a search match without losing break opportunities', () => { + const output = render('apps.common.buttons.ok', 'common'); + + expect(output).toContain('common'); + expect(output).toBe('apps.common.buttons.ok'); + }); + + it('should keep escaped entities intact across break points', () => { + expect(render('a&b.cD')).toBe('a&b.cD'); + }); + + it('should keep a break opportunity that lands where a match begins', () => { + expect(render('apps.common', 'common')).toBe('apps.common'); + }); + + it('should ignore a search term shorter than three characters', () => { + expect(render('apps.common.ok', 'ok')).not.toContain(' { + const output = render('apps..common', 'common'); + + expect(output).not.toContain(''); + expect(output).toContain('<img>'); + }); +}); diff --git a/apps/tracker/src/app/shared/pipes/key-markup.pipe.ts b/apps/tracker/src/app/shared/pipes/key-markup.pipe.ts new file mode 100644 index 00000000..b069c32f --- /dev/null +++ b/apps/tracker/src/app/shared/pipes/key-markup.pipe.ts @@ -0,0 +1,74 @@ +import { Pipe, type PipeTransform, inject } from '@angular/core'; +import { DomSanitizer, type SafeHtml } from '@angular/platform-browser'; +import { escapeRegExp } from '@simoncodes-ca/domain'; +import { truncateKey } from '../utils/truncate-key'; + +const MINIMUM_SEARCH_LENGTH = 3; + +const HTML_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +/** + * Break opportunities inside a resource key: after a run of separators (matched as + * a run so the truncation ellipsis is never split) and at each camelCase hump, + * which is where a key's own word boundaries are. + */ +const BREAK_POINTS = /(?<=\.)(?!\.)|(?<=[a-z0-9])(?=[A-Z])/g; + +/** + * Renders a dot-delimited resource key for display: shortens it, marks search + * matches, and adds `` break opportunities so a key too wide for its chip + * wraps at its own word boundaries instead of being sliced mid-token. + * + * Truncation, highlighting and break opportunities are resolved together because + * they overlap: the breaks are markup a downstream highlight pipe would escape, + * and a break can fall on the same index where a search match starts or ends. + */ +@Pipe({ + name: 'keyMarkup', + standalone: true, + pure: true, +}) +export class KeyMarkupPipe implements PipeTransform { + readonly #sanitizer = inject(DomSanitizer); + + transform(key: string | null | undefined, searchTerm?: string | null): SafeHtml { + if (!key) { + return ''; + } + + const text = truncateKey(key); + const term = searchTerm ?? ''; + const matches = term.length < MINIMUM_SEARCH_LENGTH ? [] : this.#matchRanges(text, term); + const breaks = new Set([...text.matchAll(BREAK_POINTS)].map((match) => match.index ?? 0)); + + let html = ''; + + for (let index = 0; index < text.length; index += 1) { + if (breaks.has(index)) html += ''; + if (matches.some(([start]) => start === index)) html += ''; + + const char = text[index]; + html += HTML_ESCAPES[char] ?? char; + + if (matches.some(([, end]) => end === index + 1)) html += ''; + } + + return this.#sanitizer.bypassSecurityTrustHtml(html); + } + + /** Start/end index pairs for every occurrence of the search term. */ + #matchRanges(text: string, term: string): Array<[number, number]> { + const pattern = new RegExp(escapeRegExp(term), 'gi'); + + return [...text.matchAll(pattern)].map((match) => { + const start = match.index ?? 0; + return [start, start + match[0].length]; + }); + } +} diff --git a/apps/tracker/src/app/shared/pipes/truncate-key.pipe.ts b/apps/tracker/src/app/shared/pipes/truncate-key.pipe.ts deleted file mode 100644 index ca0d4bb8..00000000 --- a/apps/tracker/src/app/shared/pipes/truncate-key.pipe.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Pipe, type PipeTransform } from '@angular/core'; - -@Pipe({ - name: 'truncateKey', - standalone: true, -}) -export class TruncateKeyPipe implements PipeTransform { - readonly #maximumLength = 50; - - transform(key: string): string { - if (!key || key.length < this.#maximumLength) { - return key; - } - - const segments = key.split('.'); - - if (segments.length === 1) { - return key; - } - - const firstSegment = segments[0]; - const lastSegment = segments[segments.length - 1]; - const basicTruncation = `${firstSegment}...${lastSegment}`; - - if (basicTruncation.length >= this.#maximumLength) { - return basicTruncation; - } - - if (segments.length >= 3) { - const secondToLastSegment = segments[segments.length - 2]; - const extendedTruncation = `${firstSegment}...${secondToLastSegment}.${lastSegment}`; - - if (extendedTruncation.length < this.#maximumLength) { - return extendedTruncation; - } - } - - return basicTruncation; - } -} diff --git a/apps/tracker/src/app/shared/tag-list/tag-list.component.html b/apps/tracker/src/app/shared/tag-list/tag-list.component.html index aa34c715..ec261b6f 100644 --- a/apps/tracker/src/app/shared/tag-list/tag-list.component.html +++ b/apps/tracker/src/app/shared/tag-list/tag-list.component.html @@ -1,6 +1,6 @@
@for (tag of inheritedTags(); track tag) { - {{ tag }} + {{ tag }} } @for (tag of tags(); track tag) { {{ tag }} diff --git a/apps/tracker/src/app/shared/tag-list/tag-list.component.ts b/apps/tracker/src/app/shared/tag-list/tag-list.component.ts index c41732ff..dbe332b7 100644 --- a/apps/tracker/src/app/shared/tag-list/tag-list.component.ts +++ b/apps/tracker/src/app/shared/tag-list/tag-list.component.ts @@ -1,6 +1,8 @@ import { Component, ChangeDetectionStrategy, input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; /** * Displays a horizontal list of tags as styled badges/chips. @@ -10,7 +12,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; selector: 'app-tag-list', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, MatTooltipModule], + imports: [CommonModule, MatTooltipModule, TranslocoPipe], templateUrl: './tag-list.component.html', styleUrl: './tag-list.component.scss', host: { @@ -18,6 +20,8 @@ import { MatTooltipModule } from '@angular/material/tooltip'; }, }) export class TagList { + readonly TOKENS = TRACKER_TOKENS; + /** Array of explicit (per-resource) tag strings to display */ tags = input.required(); diff --git a/apps/tracker/src/app/shared/pipes/truncate-key.pipe.spec.ts b/apps/tracker/src/app/shared/utils/truncate-key.spec.ts similarity index 83% rename from apps/tracker/src/app/shared/pipes/truncate-key.pipe.spec.ts rename to apps/tracker/src/app/shared/utils/truncate-key.spec.ts index c2e3224b..13cf5e71 100644 --- a/apps/tracker/src/app/shared/pipes/truncate-key.pipe.spec.ts +++ b/apps/tracker/src/app/shared/utils/truncate-key.spec.ts @@ -1,36 +1,34 @@ import { describe, it, expect } from 'vitest'; -import { TruncateKeyPipe } from './truncate-key.pipe'; - -describe('TruncateKeyPipe', () => { - const pipe = new TruncateKeyPipe(); +import { truncateKey } from './truncate-key'; +describe('truncateKey', () => { describe('short keys (< 50 characters)', () => { it('should return key as-is when length is less than 50', () => { const shortKey = 'apps.common.buttons.ok'; - expect(pipe.transform(shortKey)).toBe(shortKey); + expect(truncateKey(shortKey)).toBe(shortKey); }); it('should return key as-is when length is exactly 49', () => { const key = 'a'.repeat(49); - expect(pipe.transform(key)).toBe(key); + expect(truncateKey(key)).toBe(key); }); it('should return empty string as-is', () => { - expect(pipe.transform('')).toBe(''); + expect(truncateKey('')).toBe(''); }); }); describe('long keys without dots', () => { it('should return single-segment key as-is even if >= 50 chars', () => { const longSingleSegment = 'a'.repeat(60); - expect(pipe.transform(longSingleSegment)).toBe(longSingleSegment); + expect(truncateKey(longSingleSegment)).toBe(longSingleSegment); }); }); describe('basic truncation (firstPart...lastPart)', () => { it('should create basic truncation for long key with two segments', () => { const key = `${'verylongsegmentname'.repeat(2)}.${'anotherlongsegment'.repeat(2)}`; - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe(`${'verylongsegmentname'.repeat(2)}...${'anotherlongsegment'.repeat(2)}`); }); @@ -38,7 +36,7 @@ describe('TruncateKeyPipe', () => { const firstSegment = 'a'.repeat(30); const lastSegment = 'z'.repeat(30); const key = `${firstSegment}.middle.${lastSegment}`; - const result = pipe.transform(key); + const result = truncateKey(key); const expected = `${firstSegment}...${lastSegment}`; expect(result).toBe(expected); @@ -49,7 +47,7 @@ describe('TruncateKeyPipe', () => { describe('extended truncation (firstPart...secondToLastPart.lastPart)', () => { it('should use extended truncation when it stays under 50 chars', () => { const key = 'apps.feature.component.with.very.long.nested.structure.property.value'; - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe('apps...property.value'); expect(result.length).toBeLessThan(50); @@ -60,7 +58,7 @@ describe('TruncateKeyPipe', () => { const key = 'firstsegmt.middlesegmentxx.lastsegmentxxxxxxxxxxxxxxxxx'; expect(key.length).toBe(55); - const result = pipe.transform(key); + const result = truncateKey(key); // Extended: firstsegmt...middlesegmentxx.lastsegmentxxxxxxxxxxxxxxxxx = 56 chars (>= 50) // So it should fall back to basic: firstsegmt...lastsegmentxxxxxxxxxxxxxxxxx = 40 chars expect(result).toBe('firstsegmt...lastsegmentxxxxxxxxxxxxxxxxx'); @@ -71,7 +69,7 @@ describe('TruncateKeyPipe', () => { const secondToLastSegment = 'a'.repeat(30); const lastSegment = 'z'.repeat(30); const key = `${firstSegment}.middle.${secondToLastSegment}.${lastSegment}`; - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe(`${firstSegment}...${lastSegment}`); }); @@ -80,7 +78,7 @@ describe('TruncateKeyPipe', () => { describe('edge cases', () => { it('should handle key with many short segments', () => { const key = Array(20).fill('abc').join('.'); - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe('abc...abc.abc'); expect(result.length).toBeLessThan(50); @@ -88,21 +86,21 @@ describe('TruncateKeyPipe', () => { it('should handle key where first and last segments are identical', () => { const key = `segment.${'x'.repeat(50)}.segment`; - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe('segment...segment'); }); it('should handle key with only dots', () => { const key = '...'; - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe('...'); }); it('should handle key with empty segments', () => { const key = `${'a'.repeat(30)}..${'z'.repeat(30)}`; - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toContain('...'); }); @@ -114,7 +112,7 @@ describe('TruncateKeyPipe', () => { const key = `${'a'.repeat(20)}.${'b'.repeat(20)}.${'c'.repeat(7)}`; expect(key.length).toBe(49); - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe(key); }); @@ -123,7 +121,7 @@ describe('TruncateKeyPipe', () => { const key = `${'a'.repeat(20)}.${'b'.repeat(20)}.${'c'.repeat(8)}`; expect(key.length).toBe(50); - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).not.toBe(key); expect(result.length).toBeLessThan(key.length); }); @@ -132,7 +130,7 @@ describe('TruncateKeyPipe', () => { describe('real-world examples', () => { it('should handle typical nested translation key', () => { const key = 'apps.admin.users.permissions.roles.management.actions.delete'; - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe('apps...actions.delete'); }); @@ -141,7 +139,7 @@ describe('TruncateKeyPipe', () => { const key = 'shared.components.data-table.pagination'; expect(key.length).toBeLessThan(50); - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe(key); // Under 50 chars, returned as-is }); @@ -149,13 +147,13 @@ describe('TruncateKeyPipe', () => { const key = 'shared.components.data-table.pagination.items-per-page'; expect(key.length).toBeGreaterThanOrEqual(50); - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe('shared...pagination.items-per-page'); }); it('should handle deeply nested feature key', () => { const key = 'applications.customer-portal.dashboard.widgets.recent-activity.empty-state.message'; - const result = pipe.transform(key); + const result = truncateKey(key); expect(result).toBe('applications...empty-state.message'); expect(result.length).toBeLessThan(50); diff --git a/apps/tracker/src/app/shared/utils/truncate-key.ts b/apps/tracker/src/app/shared/utils/truncate-key.ts new file mode 100644 index 00000000..ffa28869 --- /dev/null +++ b/apps/tracker/src/app/shared/utils/truncate-key.ts @@ -0,0 +1,38 @@ +const MAXIMUM_LENGTH = 50; + +/** + * Shortens a dot-delimited resource key by dropping its middle segments, keeping + * the first segment and the last one or two so both ends stay recognizable. + * The full key is never discarded by callers — it is surfaced on hover and to + * assistive technology — so this is a display-only shortening. + */ +export function truncateKey(key: string): string { + if (!key || key.length < MAXIMUM_LENGTH) { + return key; + } + + const segments = key.split('.'); + + if (segments.length === 1) { + return key; + } + + const firstSegment = segments[0]; + const lastSegment = segments[segments.length - 1]; + const basicTruncation = `${firstSegment}...${lastSegment}`; + + if (basicTruncation.length >= MAXIMUM_LENGTH) { + return basicTruncation; + } + + if (segments.length >= 3) { + const secondToLastSegment = segments[segments.length - 2]; + const extendedTruncation = `${firstSegment}...${secondToLastSegment}.${lastSegment}`; + + if (extendedTruncation.length < MAXIMUM_LENGTH) { + return extendedTruncation; + } + } + + return basicTruncation; +} diff --git a/apps/tracker/src/i18n/browser/folderNode/resource_entries.json b/apps/tracker/src/i18n/browser/folderNode/resource_entries.json index d024e431..0e73f9dc 100644 --- a/apps/tracker/src/i18n/browser/folderNode/resource_entries.json +++ b/apps/tracker/src/i18n/browser/folderNode/resource_entries.json @@ -13,11 +13,11 @@ "source": "Folder { name }", "comment": "Aria-label for a folder node in the tree. { name } is the folder name", "tags": ["browser"], - "es": "Carpeta { nombre }", - "fr-ca": "Dossier { nom }", - "ru": "Папка { имя }", - "ja": "フォルダ {名前}", - "de": "Ordner { Name }" + "es": "Carpeta {name}", + "fr-ca": "Dossier {name}", + "ru": "Папка {name}", + "ja": "フォルダ {name}", + "de": "Ordner {name}" }, "dragToMoveFolderAriaLabel": { "source": "Drag to move folder", @@ -33,10 +33,10 @@ "source": "Delete folder { name }", "comment": "Aria-label for the delete button on a folder node. { name } is the folder name", "tags": ["browser"], - "es": "Eliminar carpeta { nombre }", - "fr-ca": "Supprimer le dossier { nom }", - "ru": "Удалить папку {имя}", - "ja": "フォルダ {名前} を削除します", - "de": "Ordner { Name } löschen" + "es": "Eliminar carpeta {name}", + "fr-ca": "Supprimer le dossier {name}", + "ru": "Удалить папку {name}", + "ja": "フォルダ {name} を削除します", + "de": "Ordner {name} löschen" } } diff --git a/apps/tracker/src/i18n/browser/folderNode/tracker_meta.json b/apps/tracker/src/i18n/browser/folderNode/tracker_meta.json index 8bd9b1eb..c6013979 100644 --- a/apps/tracker/src/i18n/browser/folderNode/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/folderNode/tracker_meta.json @@ -34,27 +34,27 @@ "checksum": "2807ee06f9cee829069ff722e0076f63" }, "es": { - "checksum": "c3fa7316902bf055a1e503dc6ef598ff", + "checksum": "919d3629485e94ca4c13bf8ca40183e6", "baseChecksum": "2807ee06f9cee829069ff722e0076f63", "status": "translated" }, "fr-ca": { - "checksum": "aaf5400b2f5e2d034201e563be05efd9", + "checksum": "579125b969a55e9f1429775d20e2faa5", "baseChecksum": "2807ee06f9cee829069ff722e0076f63", "status": "translated" }, "ru": { - "checksum": "6c01295f55133a6381deaa9b19f298ed", + "checksum": "91946bcd8bb4cbac5db6c724fee19b1f", "baseChecksum": "2807ee06f9cee829069ff722e0076f63", "status": "translated" }, "ja": { - "checksum": "b279d2cfed0ba56b787c597461de81bb", + "checksum": "eac7a0a1e12765c214b1085965f45fb7", "baseChecksum": "2807ee06f9cee829069ff722e0076f63", "status": "translated" }, "de": { - "checksum": "9a4eda48758fb87e7899ab93439e897c", + "checksum": "ea4bf7e72c09675cd73a2b718e4c4d45", "baseChecksum": "2807ee06f9cee829069ff722e0076f63", "status": "translated" } @@ -94,27 +94,27 @@ "checksum": "fa3c80ac936464dfdfec9ddb30c5278b" }, "es": { - "checksum": "7ef23897809944b3a62659679103a9f8", + "checksum": "2ebbe550760e93b2e5c3aa8d85ca3d5f", "baseChecksum": "fa3c80ac936464dfdfec9ddb30c5278b", "status": "translated" }, "fr-ca": { - "checksum": "1b45ccc834ef6f20800df6bb073ab938", + "checksum": "a4414fe89c6b56821b0511440ccd57fe", "baseChecksum": "fa3c80ac936464dfdfec9ddb30c5278b", "status": "translated" }, "ru": { - "checksum": "4ece16ddf1acf1166eda7b86121240de", + "checksum": "f7aa0801eacf0a45bafe1203badf27b2", "baseChecksum": "fa3c80ac936464dfdfec9ddb30c5278b", "status": "translated" }, "ja": { - "checksum": "5f068424ca259e11241a275b3e4887b1", + "checksum": "634d25d294c93e70c4ba06faafa0d980", "baseChecksum": "fa3c80ac936464dfdfec9ddb30c5278b", "status": "translated" }, "de": { - "checksum": "0bf5b9676022deb80311c34c42e009e3", + "checksum": "d09bcbeebe5dbed0842e399607d15dd2", "baseChecksum": "fa3c80ac936464dfdfec9ddb30c5278b", "status": "translated" } diff --git a/apps/tracker/src/i18n/browser/folderPicker/resource_entries.json b/apps/tracker/src/i18n/browser/folderPicker/resource_entries.json index 6be31fb5..0b4dbcd8 100644 --- a/apps/tracker/src/i18n/browser/folderPicker/resource_entries.json +++ b/apps/tracker/src/i18n/browser/folderPicker/resource_entries.json @@ -113,11 +113,11 @@ "source": "Folder selector, currently: { path }", "comment": "Aria-label for the folder picker header button. { path } is the currently selected folder path", "tags": ["browser"], - "es": "Selector de carpeta, actualmente: { ruta }", - "fr-ca": "Sélecteur de dossier, présentement : { chemin }", - "ru": "Селектор папки, в данный момент: { путь }", - "ja": "フォルダセレクター、現在の値: { パス }", - "de": "Ordnerauswahl, aktuell: { Pfad }" + "es": "Selector de carpeta, actualmente: {path}", + "fr-ca": "Sélecteur de dossier, présentement : {path}", + "ru": "Селектор папки, в данный момент: {path}", + "ja": "フォルダセレクター、現在の値: {path}", + "de": "Ordnerauswahl, aktuell: {path}" }, "treeAriaLabel": { "source": "Folder tree", diff --git a/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json b/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json index b16c0f03..a3057cce 100644 --- a/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json @@ -6,12 +6,12 @@ "es": { "checksum": "919d3629485e94ca4c13bf8ca40183e6", "baseChecksum": "16708b0cacde01471507891eb88bcf2b", - "status": "stale" + "status": "translated" }, "fr-ca": { "checksum": "579125b969a55e9f1429775d20e2faa5", "baseChecksum": "16708b0cacde01471507891eb88bcf2b", - "status": "stale" + "status": "translated" }, "ru": { "checksum": "91946bcd8bb4cbac5db6c724fee19b1f", @@ -96,12 +96,12 @@ "es": { "checksum": "25077cf7679a114b0de5e0a0a9f3a64e", "baseChecksum": "aa85d08e5c2011ca6a07c0db14c82de2", - "status": "stale" + "status": "translated" }, "fr-ca": { "checksum": "7a3c0707dbd14091260a5aca97d25f02", "baseChecksum": "aa85d08e5c2011ca6a07c0db14c82de2", - "status": "stale" + "status": "translated" }, "ru": { "checksum": "da6ae24598b43f425bbeb36a05a35bed", @@ -334,27 +334,27 @@ "checksum": "b25b6c575d57b6fd62e0b80e20fe9c16" }, "es": { - "checksum": "b11af281684aee0925985fc7d9cae889", + "checksum": "68cbff643c16a87e911880062606a88f", "baseChecksum": "b25b6c575d57b6fd62e0b80e20fe9c16", "status": "translated" }, "fr-ca": { - "checksum": "944c1bba8a4b6cdb6d462fa4cd6cab58", + "checksum": "ffa7a136a656efe5d00faaf3eed884d0", "baseChecksum": "b25b6c575d57b6fd62e0b80e20fe9c16", "status": "translated" }, "ru": { - "checksum": "d622603fd981f67772ec7f1bc7845ff3", + "checksum": "57d52358235bf90216858aeca6cdbbf9", "baseChecksum": "b25b6c575d57b6fd62e0b80e20fe9c16", "status": "translated" }, "ja": { - "checksum": "08175456a2012dc021d7a17f35b7f4e1", + "checksum": "0c11c653b85f4afc2fb5824e72da9254", "baseChecksum": "b25b6c575d57b6fd62e0b80e20fe9c16", "status": "translated" }, "de": { - "checksum": "ed06c442f616d33752f32266cbe3b87c", + "checksum": "f8d0c997ba33f59eb4f8fb38d87dcca5", "baseChecksum": "b25b6c575d57b6fd62e0b80e20fe9c16", "status": "translated" } diff --git a/apps/tracker/src/i18n/browser/folderTree/resource_entries.json b/apps/tracker/src/i18n/browser/folderTree/resource_entries.json index 94e405b4..8d5acb3c 100644 --- a/apps/tracker/src/i18n/browser/folderTree/resource_entries.json +++ b/apps/tracker/src/i18n/browser/folderTree/resource_entries.json @@ -48,5 +48,35 @@ "ru": "Добавить папку (Ctrl+Shift+N)", "ja": "フォルダを追加する(Ctrl+Shift+N)", "de": "Ordner hinzufügen (Strg+Umschalt+N)" + }, + "expandAllTooltip": { + "source": "Expand all folders", + "comment": "Tooltip and aria-label for the expand-all control on the collection root row in the folder tree", + "tags": ["browser"], + "es": "Expandir todas las carpetas", + "fr-ca": "Développer tous les dossiers", + "ru": "Развернуть все папки", + "ja": "すべてのフォルダーを展開する", + "de": "Alle Ordner erweitern" + }, + "collapseAllTooltip": { + "source": "Collapse all folders", + "comment": "Tooltip and aria-label for the collapse-all control on the collection root row in the folder tree", + "tags": ["browser"], + "es": "Contraer todas las carpetas", + "fr-ca": "Réduire tous les dossiers", + "ru": "Свернуть все папки", + "ja": "すべてのフォルダーを折りたたむ", + "de": "Alle Ordner ausblenden" + }, + "rootFolderAriaLabelX": { + "source": "{name}, all resources in this collection", + "comment": "Aria-label for the collection root row in the folder tree; name is the collection name", + "tags": ["browser"], + "es": "{name}, todos los recursos de esta colección", + "fr-ca": "{name}, toutes les ressources de cette collection", + "ru": "{name}, все ресурсы в этой коллекции", + "ja": "{name}、このコレクションのすべてのリソース", + "de": "{name}, alle Ressourcen in dieser Sammlung" } } diff --git a/apps/tracker/src/i18n/browser/folderTree/tracker_meta.json b/apps/tracker/src/i18n/browser/folderTree/tracker_meta.json index 288e0ab9..100a9c5f 100644 --- a/apps/tracker/src/i18n/browser/folderTree/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/folderTree/tracker_meta.json @@ -148,5 +148,95 @@ "baseChecksum": "79e6232f92f3cc7bd3d268f4ec3c9e95", "status": "translated" } + }, + "expandAllTooltip": { + "en": { + "checksum": "66a3e59ee3756fc97481ef4132a912cb" + }, + "es": { + "checksum": "cbdae8b669d9194183b88bb698832de1", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + }, + "fr-ca": { + "checksum": "39773825cbfb6136d98255bb954f6e98", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + }, + "ru": { + "checksum": "46f15ab6a3944df683d74384859b60eb", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + }, + "ja": { + "checksum": "a703dd5a34d4ae944157b99e1b905021", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + }, + "de": { + "checksum": "ef9e58f3b95c6befeb06d8496d9c1962", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + } + }, + "collapseAllTooltip": { + "en": { + "checksum": "937e2c347f4b6a8e5875d505ad776945" + }, + "es": { + "checksum": "711baf14fa10d7bf7e5332477fe3f8ee", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + }, + "fr-ca": { + "checksum": "3de691daaa5038e2a9c0b072906c95b5", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + }, + "ru": { + "checksum": "f23db3f7ac83bbe39ab7392101d1b3d8", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + }, + "ja": { + "checksum": "7391e6bc3da1b47eed7752b2fc8ea998", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + }, + "de": { + "checksum": "38337ada64ba08916c864a4205c0b8e8", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + } + }, + "rootFolderAriaLabelX": { + "en": { + "checksum": "9fc22865b4fc2e63b472368b9a1055ff" + }, + "es": { + "checksum": "57013206ab9a8c138ac478d2af62d898", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + }, + "fr-ca": { + "checksum": "b2eca591d179255f01734a4e21b0a6d7", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + }, + "ru": { + "checksum": "0aaf6609a9a2be2776d8482cdc95a685", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + }, + "ja": { + "checksum": "993646cfe7ddd836cd7c9aa2d9c42eef", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + }, + "de": { + "checksum": "cad47b2e0974446b892143a5d9552da2", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/browser/localeFilter/resource_entries.json b/apps/tracker/src/i18n/browser/localeFilter/resource_entries.json index 1cd240af..01be29b2 100644 --- a/apps/tracker/src/i18n/browser/localeFilter/resource_entries.json +++ b/apps/tracker/src/i18n/browser/localeFilter/resource_entries.json @@ -3,10 +3,10 @@ "source": "Filter locales. Currently showing: { text }", "comment": "Aria-label for the locale filter button. { text } is the current filter display text (e.g. 'All' or a locale code)", "tags": ["browser"], - "es": "Filtrar regiones. Actualmente se muestra: {texto}", - "fr-ca": "Filtrer les paramètres régionaux. Affichage actuel : { texte }", - "ru": "Фильтр по языкам. В данный момент отображается: { текст }", - "ja": "地域をフィルタリングします。現在表示中: { テキスト }", + "es": "Filtrar regiones. Actualmente se muestra: {text}", + "fr-ca": "Filtrer les paramètres régionaux. Affichage actuel : {text}", + "ru": "Фильтр по языкам. В данный момент отображается: {text}", + "ja": "地域をフィルタリングします。現在表示中: {text}", "de": "Gebietsschemas filtern. Aktuell angezeigt: { text }" } } diff --git a/apps/tracker/src/i18n/browser/localeFilter/tracker_meta.json b/apps/tracker/src/i18n/browser/localeFilter/tracker_meta.json index fb7dc4fd..4a5fef90 100644 --- a/apps/tracker/src/i18n/browser/localeFilter/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/localeFilter/tracker_meta.json @@ -4,22 +4,22 @@ "checksum": "8a99d337e7d73673d6afcfa72e7b82fd" }, "es": { - "checksum": "f4168a0d52bc8582a0614fe8d4f26dbd", + "checksum": "077a07a6ddb0dbdc41cb363f5ace2f4f", "baseChecksum": "8a99d337e7d73673d6afcfa72e7b82fd", "status": "translated" }, "fr-ca": { - "checksum": "ac4c82f234ba7f6fb0ac78e8d8dc395b", + "checksum": "1390f9ccbd185622ac251b2201cfe58e", "baseChecksum": "8a99d337e7d73673d6afcfa72e7b82fd", "status": "translated" }, "ru": { - "checksum": "d44d30a0dbadd7dbcaebfe9f596e7a6a", + "checksum": "e3d8f380cfbc0a9bd3cd9448e06276cd", "baseChecksum": "8a99d337e7d73673d6afcfa72e7b82fd", "status": "translated" }, "ja": { - "checksum": "a9125be4e99103ed768fb2f81ed360ed", + "checksum": "da3a8c1057273b8ab1e097387737c341", "baseChecksum": "8a99d337e7d73673d6afcfa72e7b82fd", "status": "translated" }, diff --git a/apps/tracker/src/i18n/browser/resource_entries.json b/apps/tracker/src/i18n/browser/resource_entries.json index 428cd258..b871f2d9 100644 --- a/apps/tracker/src/i18n/browser/resource_entries.json +++ b/apps/tracker/src/i18n/browser/resource_entries.json @@ -158,16 +158,6 @@ "ja": "降順でソート(Z→A)", "de": "Sortieren absteigend (Z→A)" }, - "selectFolderFirst": { - "source": "Select a folder first", - "comment": "Tooltip shown on Add Translation button when no folder is selected", - "tags": ["browser"], - "es": "Seleccione una carpeta primero", - "fr-ca": "Sélectionnez d'abord un dossier", - "ru": "Сначала выберите папку.", - "ja": "まずフォルダを選択してください。", - "de": "Wählen Sie zuerst einen Ordner aus." - }, "addNewTranslationTooltip": { "source": "Add a new translation", "comment": "Tooltip shown on Add Translation button when a folder is selected", @@ -198,16 +188,6 @@ "ja": "コメント:", "de": "Kommentar:" }, - "toggleExpansion": { - "source": "Toggle expansion", - "comment": "Aria-label for the expand/collapse button on a translation item", - "tags": ["browser"], - "es": "Activar o desactivar la expansión", - "fr-ca": "Basculer l'extension", - "ru": "Переключить расширение", - "ja": "展開を切り替える", - "de": "Erweiterung umschalten" - }, "readOnlyNotice": { "source": "This collection is read-only. Its resources can't be modified.", "comment": "Banner shown above the translation list when the active collection is read-only.", diff --git a/apps/tracker/src/i18n/browser/similarTranslations/resource_entries.json b/apps/tracker/src/i18n/browser/similarTranslations/resource_entries.json index dab1b7c5..20c47ca0 100644 --- a/apps/tracker/src/i18n/browser/similarTranslations/resource_entries.json +++ b/apps/tracker/src/i18n/browser/similarTranslations/resource_entries.json @@ -44,9 +44,9 @@ "comment": "Button to show remaining similar translation results, count is the number of hidden results", "tags": ["browser"], "es": "Mostrar {count} más", - "fr-ca": "Show {count} more", - "ru": "Показать {count} больше", - "ja": "{count} を表示", - "de": "Mehr anzeigen {count}" + "fr-ca": "Afficher {count} de plus", + "ru": "Показать ещё {count}", + "ja": "他 {count} 件を表示", + "de": "{count} weitere anzeigen" } } diff --git a/apps/tracker/src/i18n/browser/similarTranslations/tracker_meta.json b/apps/tracker/src/i18n/browser/similarTranslations/tracker_meta.json index 15460e94..fb51589f 100644 --- a/apps/tracker/src/i18n/browser/similarTranslations/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/similarTranslations/tracker_meta.json @@ -126,25 +126,25 @@ "es": { "checksum": "551047ecf45228306f8abf35ab6abd06", "baseChecksum": "b39ae93853f9b177650ca742f758312d", - "status": "stale" + "status": "translated" }, "fr-ca": { - "checksum": "b39ae93853f9b177650ca742f758312d", + "checksum": "08c944bcbe3ad180d238254e94399e00", "baseChecksum": "b39ae93853f9b177650ca742f758312d", - "status": "new" + "status": "translated" }, "ru": { - "checksum": "ed667cad5e2af69efc1eca00c60429b8", + "checksum": "895542cd61c4b73d87235bb92cfa02cc", "baseChecksum": "b39ae93853f9b177650ca742f758312d", "status": "translated" }, "ja": { - "checksum": "1a3b363e63a99737336b9f0bcca01221", + "checksum": "7eb4bbd9d4906a48976b73ceebe4f5c0", "baseChecksum": "b39ae93853f9b177650ca742f758312d", "status": "translated" }, "de": { - "checksum": "590a948074ca10fae48086e64291db05", + "checksum": "041d2297b5c24122c4e86aa0ee079abe", "baseChecksum": "b39ae93853f9b177650ca742f758312d", "status": "translated" } diff --git a/apps/tracker/src/i18n/browser/status/resource_entries.json b/apps/tracker/src/i18n/browser/status/resource_entries.json index 66a9803d..11627d12 100644 --- a/apps/tracker/src/i18n/browser/status/resource_entries.json +++ b/apps/tracker/src/i18n/browser/status/resource_entries.json @@ -6,18 +6,18 @@ "es": "Nuevo", "fr-ca": "Nouveau", "ru": "Новый", - "ja": "新しい", + "ja": "新規", "de": "Neu" }, "stale": { "source": "Stale", "comment": "Translation status label: source has changed and translation is outdated", "tags": ["browser"], - "es": "Duro", - "fr-ca": "Vicié", - "ru": "Залежалый", + "es": "Desactualizado", + "fr-ca": "Obsolète", + "ru": "Устарело", "ja": "古くなった", - "de": "Abgestanden" + "de": "Veraltet" }, "translated": { "source": "Translated", @@ -38,5 +38,45 @@ "ru": "Подтверждено", "ja": "検証済み", "de": "Bestätigt" + }, + "newCountX": { + "source": "{count, plural, =1 {1 new} other {{count} new}}", + "comment": "Count of locales with the \"new\" translation status, used in status breakdowns and the rollup accessible name", + "tags": ["browser"], + "es": "{count, plural, =1 {1 nueva} other {{count} nuevas}}", + "fr-ca": "{count, plural, =1 {1 nouvelle} other {{count} nouvelles}}", + "de": "{count, plural, =1 {1 neu} other {{count} neu}}", + "ja": "{count, plural, other {{count} 件が新規}}", + "ru": "{count, plural, one {{count} новый} few {{count} новых} many {{count} новых} other {{count} новых}}" + }, + "staleCountX": { + "source": "{count, plural, =1 {1 stale} other {{count} stale}}", + "comment": "Count of locales with the \"stale\" translation status, used in status breakdowns and the rollup accessible name", + "tags": ["browser"], + "es": "{count, plural, =1 {1 desactualizada} other {{count} desactualizadas}}", + "fr-ca": "{count, plural, =1 {1 obsolète} other {{count} obsolètes}}", + "de": "{count, plural, =1 {1 veraltet} other {{count} veraltet}}", + "ja": "{count, plural, other {{count} 件が要更新}}", + "ru": "{count, plural, one {{count} устаревший} few {{count} устаревших} many {{count} устаревших} other {{count} устаревших}}" + }, + "translatedCountX": { + "source": "{count, plural, =1 {1 translated} other {{count} translated}}", + "comment": "Count of locales with the \"translated\" translation status, used in status breakdowns and the rollup accessible name", + "tags": ["browser"], + "es": "{count, plural, =1 {1 traducida} other {{count} traducidas}}", + "fr-ca": "{count, plural, =1 {1 traduite} other {{count} traduites}}", + "de": "{count, plural, =1 {1 übersetzt} other {{count} übersetzt}}", + "ja": "{count, plural, other {{count} 件が翻訳済み}}", + "ru": "{count, plural, one {{count} переведённый} few {{count} переведённых} many {{count} переведённых} other {{count} переведённых}}" + }, + "verifiedCountX": { + "source": "{count, plural, =1 {1 verified} other {{count} verified}}", + "comment": "Count of locales with the \"verified\" translation status, used in status breakdowns and the rollup accessible name", + "tags": ["browser"], + "es": "{count, plural, =1 {1 verificada} other {{count} verificadas}}", + "fr-ca": "{count, plural, =1 {1 vérifiée} other {{count} vérifiées}}", + "de": "{count, plural, =1 {1 geprüft} other {{count} geprüft}}", + "ja": "{count, plural, other {{count} 件が承認済み}}", + "ru": "{count, plural, one {{count} проверенный} few {{count} проверенных} many {{count} проверенных} other {{count} проверенных}}" } } diff --git a/apps/tracker/src/i18n/browser/status/tracker_meta.json b/apps/tracker/src/i18n/browser/status/tracker_meta.json index a08a35a4..524ff08d 100644 --- a/apps/tracker/src/i18n/browser/status/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/status/tracker_meta.json @@ -19,7 +19,7 @@ "status": "translated" }, "ja": { - "checksum": "ad4b0bd55882d4f7164630d4158d3c39", + "checksum": "ef5f9cf6987e3eef306718aa3c48c2f5", "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", "status": "translated" }, @@ -34,17 +34,17 @@ "checksum": "f1ac273d3c6d8e6489bf332819a2f650" }, "es": { - "checksum": "605821ebcf6a2e53f03ec6e8e463d1bd", + "checksum": "ef928042b96e611faac34137124515a1", "baseChecksum": "f1ac273d3c6d8e6489bf332819a2f650", "status": "translated" }, "fr-ca": { - "checksum": "3ae88690725fe012389643e0056634b6", + "checksum": "bbf6a331bc2fc35ecc328e2da8c3f7fc", "baseChecksum": "f1ac273d3c6d8e6489bf332819a2f650", "status": "translated" }, "ru": { - "checksum": "0fa45772724dbd2cf9e99b15e04d7efa", + "checksum": "45286731379a1065ab7e4a468985873d", "baseChecksum": "f1ac273d3c6d8e6489bf332819a2f650", "status": "translated" }, @@ -54,7 +54,7 @@ "status": "translated" }, "de": { - "checksum": "e47ee5c96bf1921b48a43365b499d6dc", + "checksum": "2d698d066986540c9b0f8bb98fcb1f45", "baseChecksum": "f1ac273d3c6d8e6489bf332819a2f650", "status": "translated" } @@ -118,5 +118,125 @@ "baseChecksum": "3f68e67dc6c397aaa9d1c24c356f754f", "status": "translated" } + }, + "newCountX": { + "en": { + "checksum": "c2dc9a603f7c8edd5ebde5739be24ebd" + }, + "es": { + "checksum": "31891b77f72b121eb12b1e2f95ee77f0", + "baseChecksum": "c2dc9a603f7c8edd5ebde5739be24ebd", + "status": "translated" + }, + "fr-ca": { + "checksum": "e2b5fc96203e19b215b624f1a8038fc6", + "baseChecksum": "c2dc9a603f7c8edd5ebde5739be24ebd", + "status": "translated" + }, + "de": { + "checksum": "0ee0723a9da6dc0a42056014e0b15de5", + "baseChecksum": "c2dc9a603f7c8edd5ebde5739be24ebd", + "status": "translated" + }, + "ja": { + "checksum": "6784c0ddc2bc30adef3ccd259df8d5fa", + "baseChecksum": "c2dc9a603f7c8edd5ebde5739be24ebd", + "status": "translated" + }, + "ru": { + "checksum": "19333e998e5c46ef258b8a37c39eaf6c", + "baseChecksum": "c2dc9a603f7c8edd5ebde5739be24ebd", + "status": "translated" + } + }, + "staleCountX": { + "en": { + "checksum": "8b3032d0559f9be1cb49045ea6c99b22" + }, + "es": { + "checksum": "ea323f6819c5f1b2ffea5df65f9e5c22", + "baseChecksum": "8b3032d0559f9be1cb49045ea6c99b22", + "status": "translated" + }, + "fr-ca": { + "checksum": "f22ef91c6e7cd25e4612bcedccda12bf", + "baseChecksum": "8b3032d0559f9be1cb49045ea6c99b22", + "status": "translated" + }, + "de": { + "checksum": "7165145c3f04e29235e3efb965c1f270", + "baseChecksum": "8b3032d0559f9be1cb49045ea6c99b22", + "status": "translated" + }, + "ja": { + "checksum": "f7e6a5d7a3d6c5d0bf79ddc2f6c71037", + "baseChecksum": "8b3032d0559f9be1cb49045ea6c99b22", + "status": "translated" + }, + "ru": { + "checksum": "cc61d6a12da82cec7c0855b49fdf2d1b", + "baseChecksum": "8b3032d0559f9be1cb49045ea6c99b22", + "status": "translated" + } + }, + "translatedCountX": { + "en": { + "checksum": "7fc5f3cffda6e47133aac2da6de0ad82" + }, + "es": { + "checksum": "6498869411ad81c4c1fdcfae1c03e931", + "baseChecksum": "7fc5f3cffda6e47133aac2da6de0ad82", + "status": "translated" + }, + "fr-ca": { + "checksum": "ed31779f8cfcf330912f71a27be0aadf", + "baseChecksum": "7fc5f3cffda6e47133aac2da6de0ad82", + "status": "translated" + }, + "de": { + "checksum": "34bd4dfe61430e481007f293b4d38d7a", + "baseChecksum": "7fc5f3cffda6e47133aac2da6de0ad82", + "status": "translated" + }, + "ja": { + "checksum": "02816bb40500aa08f549e2f2ebe9bf82", + "baseChecksum": "7fc5f3cffda6e47133aac2da6de0ad82", + "status": "translated" + }, + "ru": { + "checksum": "75cb0ceb6df093bf4e8cfea8a358b0cb", + "baseChecksum": "7fc5f3cffda6e47133aac2da6de0ad82", + "status": "translated" + } + }, + "verifiedCountX": { + "en": { + "checksum": "9cdc7ca638f0bc445ef2a6e694765a2f" + }, + "es": { + "checksum": "3c06242e72870dac700be83bc815db02", + "baseChecksum": "9cdc7ca638f0bc445ef2a6e694765a2f", + "status": "translated" + }, + "fr-ca": { + "checksum": "0f73c047197763bf25261089e91df781", + "baseChecksum": "9cdc7ca638f0bc445ef2a6e694765a2f", + "status": "translated" + }, + "de": { + "checksum": "9b65bc3c0e08a25eba6162133806b7bd", + "baseChecksum": "9cdc7ca638f0bc445ef2a6e694765a2f", + "status": "translated" + }, + "ja": { + "checksum": "c8c962638f429cea13e3f6cfcd94a79a", + "baseChecksum": "9cdc7ca638f0bc445ef2a6e694765a2f", + "status": "translated" + }, + "ru": { + "checksum": "1114c3f6d1f30227661e10592cc544ba", + "baseChecksum": "9cdc7ca638f0bc445ef2a6e694765a2f", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/browser/statusFilter/resource_entries.json b/apps/tracker/src/i18n/browser/statusFilter/resource_entries.json index 4731b7e8..5c82d055 100644 --- a/apps/tracker/src/i18n/browser/statusFilter/resource_entries.json +++ b/apps/tracker/src/i18n/browser/statusFilter/resource_entries.json @@ -1,22 +1,32 @@ { "needsWork": { - "source": "Needs Work", + "source": "Needs work", "comment": "Quick filter button that selects new and stale statuses", "tags": ["browser"], "es": "Necesita trabajo", - "fr-ca": "Nécessite des travaux", + "fr-ca": "Nécessite des améliorations", "ru": "Требуется доработка", "ja": "改善が必要", "de": "Überarbeitungsbedürftig" }, - "ariaLabelX": { - "source": "Filter by status. Currently showing: { text }", - "comment": "Aria-label for the status filter button. { text } is the current filter display text (e.g. 'All' or status name)", + "groupAriaLabel": { + "source": "Filter by status", + "comment": "Accessible group name for the row of status filter toggles in the browser toolbar.", "tags": ["browser"], - "es": "Filtrar por estado. Actualmente se muestra: {texto}", - "fr-ca": "Filtrer par statut. Affichage actuel : { texte }", - "ru": "Фильтр по статусу. В данный момент отображается: { текст }", - "ja": "ステータスで絞り込み。現在表示中:{テキスト}", - "de": "Nach Status filtern. Aktuell angezeigt: { text }" + "es": "Filtrar por estado", + "fr-ca": "Filtrer par statut", + "ru": "Фильтрация по статусу", + "ja": "ステータスでフィルタリング", + "de": "Nach Status filtern" + }, + "needsWorkTooltip": { + "source": "New and stale entries", + "comment": "Tooltip on the Needs work filter toggle, explaining which two statuses it selects.", + "tags": ["browser"], + "es": "Entradas nuevas y obsoletas", + "fr-ca": "Nouvelles et anciennes entrées", + "ru": "Новые и устаревшие записи", + "ja": "新規エントリと古いエントリ", + "de": "Neue und veraltete Einträge" } } diff --git a/apps/tracker/src/i18n/browser/statusFilter/tracker_meta.json b/apps/tracker/src/i18n/browser/statusFilter/tracker_meta.json index 9be19988..3fcfde45 100644 --- a/apps/tracker/src/i18n/browser/statusFilter/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/statusFilter/tracker_meta.json @@ -1,61 +1,91 @@ { "needsWork": { "en": { - "checksum": "82198ade859bc9f4034a6dd31276d473" + "checksum": "7d0cacf32ad4e3849b9de3804d151373" }, "es": { "checksum": "80220dbff8569e64b7041360ade48d8e", - "baseChecksum": "82198ade859bc9f4034a6dd31276d473", + "baseChecksum": "7d0cacf32ad4e3849b9de3804d151373", "status": "translated" }, "fr-ca": { - "checksum": "3c388b094f2f1d561d99a41462f02279", - "baseChecksum": "82198ade859bc9f4034a6dd31276d473", + "checksum": "3ceebb93cde7758ec3049842528790a3", + "baseChecksum": "7d0cacf32ad4e3849b9de3804d151373", "status": "translated" }, "ru": { "checksum": "58291dae413e4204dd1dc0adb831c6a7", - "baseChecksum": "82198ade859bc9f4034a6dd31276d473", + "baseChecksum": "7d0cacf32ad4e3849b9de3804d151373", "status": "translated" }, "ja": { "checksum": "8b5d48d121784c6c800b9e719ea02926", - "baseChecksum": "82198ade859bc9f4034a6dd31276d473", + "baseChecksum": "7d0cacf32ad4e3849b9de3804d151373", "status": "translated" }, "de": { "checksum": "4820f28cfac23b34516fe45ed518dcf1", - "baseChecksum": "82198ade859bc9f4034a6dd31276d473", + "baseChecksum": "7d0cacf32ad4e3849b9de3804d151373", "status": "translated" } }, - "ariaLabelX": { + "groupAriaLabel": { "en": { - "checksum": "dae9b86c4a9710ce0da801e65751954d" + "checksum": "5dd5cd6670a0fc8e47a47aa94d27b8b2" }, "es": { - "checksum": "ea40ee2ecad70b2859450126c6922cc3", - "baseChecksum": "dae9b86c4a9710ce0da801e65751954d", + "checksum": "b2d2be0f8cb7837bb5071ca1aac2e7dd", + "baseChecksum": "5dd5cd6670a0fc8e47a47aa94d27b8b2", "status": "translated" }, "fr-ca": { - "checksum": "ba99ae2f5ef8f8da0cf7140ab17fc4ee", - "baseChecksum": "dae9b86c4a9710ce0da801e65751954d", + "checksum": "48b9a4856edd97f109ddf552db80a161", + "baseChecksum": "5dd5cd6670a0fc8e47a47aa94d27b8b2", "status": "translated" }, "ru": { - "checksum": "046b8a36af662a632097d5e0ae2611ac", - "baseChecksum": "dae9b86c4a9710ce0da801e65751954d", + "checksum": "a2d8fc788eb8e9a67916b41da283ded2", + "baseChecksum": "5dd5cd6670a0fc8e47a47aa94d27b8b2", "status": "translated" }, "ja": { - "checksum": "ecf1e9344fc9256c2f55bc99d58b833d", - "baseChecksum": "dae9b86c4a9710ce0da801e65751954d", + "checksum": "aba8ffd3714bb6c5b0cf9f9d176612a8", + "baseChecksum": "5dd5cd6670a0fc8e47a47aa94d27b8b2", "status": "translated" }, "de": { - "checksum": "a3e08e86453dc105c7c4d0db9fa0dbc3", - "baseChecksum": "dae9b86c4a9710ce0da801e65751954d", + "checksum": "908ff1c2416a149962b66e508843ca88", + "baseChecksum": "5dd5cd6670a0fc8e47a47aa94d27b8b2", + "status": "translated" + } + }, + "needsWorkTooltip": { + "en": { + "checksum": "be1e348a0e94c1c48cb8eb3a8870da0f" + }, + "es": { + "checksum": "e6f7a8914b494f2dc65129f8e6b4c5ff", + "baseChecksum": "be1e348a0e94c1c48cb8eb3a8870da0f", + "status": "translated" + }, + "fr-ca": { + "checksum": "c3917a2b60ec25271caee4315d750342", + "baseChecksum": "be1e348a0e94c1c48cb8eb3a8870da0f", + "status": "translated" + }, + "ru": { + "checksum": "9ed0837d9e1283d6511cdd458b3ce76c", + "baseChecksum": "be1e348a0e94c1c48cb8eb3a8870da0f", + "status": "translated" + }, + "ja": { + "checksum": "c667548152894789375814c53f53457a", + "baseChecksum": "be1e348a0e94c1c48cb8eb3a8870da0f", + "status": "translated" + }, + "de": { + "checksum": "64fbf8e40421615da74fe5c1ed801514", + "baseChecksum": "be1e348a0e94c1c48cb8eb3a8870da0f", "status": "translated" } } diff --git a/apps/tracker/src/i18n/browser/tracker_meta.json b/apps/tracker/src/i18n/browser/tracker_meta.json index d0f8241f..e622211a 100644 --- a/apps/tracker/src/i18n/browser/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/tracker_meta.json @@ -539,36 +539,6 @@ "status": "translated" } }, - "selectFolderFirst": { - "en": { - "checksum": "addf30815d3754a3170b8d0d9b3f7196" - }, - "es": { - "checksum": "f05d4a9337ae4bbf227281a5375eed29", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - }, - "fr-ca": { - "checksum": "2d975dbf2e3f83915e39ce6f550724cb", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - }, - "ru": { - "checksum": "55a41223d9c642c59c0ff06f085d27a6", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - }, - "ja": { - "checksum": "9586322b0e1c3ac2016a8878266fde6e", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - }, - "de": { - "checksum": "25dd264201a52111b9b20ad7d6e9b37d", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - } - }, "addNewTranslationTooltip": { "en": { "checksum": "e26fd2bf951546994a81c53bc593e520" @@ -659,36 +629,6 @@ "status": "translated" } }, - "toggleExpansion": { - "en": { - "checksum": "a600b0cbba3359d934a659341e5b727b" - }, - "es": { - "checksum": "de65def594f2cf991756b46e3662b5dc", - "baseChecksum": "a600b0cbba3359d934a659341e5b727b", - "status": "translated" - }, - "fr-ca": { - "checksum": "6c0219acbdc6307898118a325d8ff138", - "baseChecksum": "a600b0cbba3359d934a659341e5b727b", - "status": "translated" - }, - "ru": { - "checksum": "73ccd74271a9e6b138f4b699c3bf78d8", - "baseChecksum": "a600b0cbba3359d934a659341e5b727b", - "status": "translated" - }, - "ja": { - "checksum": "40786f814610f633f1b322b0caf01025", - "baseChecksum": "a600b0cbba3359d934a659341e5b727b", - "status": "translated" - }, - "de": { - "checksum": "0481a68c4e2090ddb31725d7542181d9", - "baseChecksum": "a600b0cbba3359d934a659341e5b727b", - "status": "translated" - } - }, "readOnlyNotice": { "en": { "checksum": "ee180bd986e2dab87c25b037339bfd5c" diff --git a/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json index 3602db98..3566377c 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json @@ -14,7 +14,7 @@ "comment": "Conflict dialog message with the conflicting key name", "tags": ["browser"], "es": "La clave de traducción \"{key}\" ya existe en esta colección. ¿Desea editar la traducción existente o elegir una clave diferente?", - "fr-ca": "La clé de traduction « {key} » existe déjà dans cette collection. Souhaitez-vous modifier la traduction existante ou choisir une autre clé ?", + "fr-ca": "La clé de traduction « {key} » existe déjà dans cette collection. Souhaitez-vous modifier la traduction existante ou choisir une autre clé ?", "ru": "Ключ перевода \"{key}\" уже существует в этой коллекции. Хотите отредактировать существующий перевод или выбрать другой ключ?", "ja": "翻訳キー「{key}」は既にこのコレクションに存在します。既存の翻訳を編集しますか、それとも別のキーを選択しますか?", "de": "Der Übersetzungsschlüssel \"{key}\" ist in dieser Sammlung bereits vorhanden. Möchten Sie die bestehende Übersetzung bearbeiten oder einen anderen Schlüssel auswählen?" @@ -37,6 +37,6 @@ "fr-ca": "Choisir une autre clé", "ru": "Выберите другой ключ", "ja": "別のキーを選択してください", - "de": "Wählen Sie eine andere Taste" + "de": "Anderen Schlüssel wählen" } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json index a2197203..aa16ebfd 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json @@ -36,12 +36,12 @@ "es": { "checksum": "da3d1620b019c5340dac24168c4a3721", "baseChecksum": "765a0106f27fce3620418e6a5afece18", - "status": "stale" + "status": "translated" }, "fr-ca": { - "checksum": "1228373b337d25fe96d944ad0103a4bc", + "checksum": "a8487b5a94351a9826d2deeebcf05ec1", "baseChecksum": "765a0106f27fce3620418e6a5afece18", - "status": "stale" + "status": "translated" }, "ru": { "checksum": "5dcee4e9669d158cef1d3bcfbe2d985c", @@ -114,7 +114,7 @@ "status": "translated" }, "de": { - "checksum": "b15777d65f74a76f6e45fe581a06a942", + "checksum": "9db3484f4e345f2c9cfe95a52b04dfd7", "baseChecksum": "5275ecd41038dd5cab36ac1d387e95aa", "status": "translated" } diff --git a/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json index b72cbd4d..feaab824 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json @@ -44,7 +44,7 @@ "comment": "Fallback error when resource creation fails", "tags": ["browser"], "es": "No se pudo crear la traducción", - "fr-ca": "Échec de la traduction", + "fr-ca": "Échec de la création de la traduction", "ru": "Не удалось создать перевод", "ja": "翻訳の作成に失敗しました", "de": "Übersetzung konnte nicht erstellt werden" diff --git a/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json index a9facfc7..2d7c664f 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json @@ -129,7 +129,7 @@ "status": "translated" }, "fr-ca": { - "checksum": "fa7979919d970f5fd2df5d63a36735d9", + "checksum": "156f42edc85551feb65103bc5e5f8cc2", "baseChecksum": "20a64455184899c3fb2e93b4566c8860", "status": "translated" }, diff --git a/apps/tracker/src/i18n/browser/translationEditor/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/resource_entries.json index 8a7a87a2..3ba471ca 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/resource_entries.json @@ -20,14 +20,14 @@ "de": "Übersetzungsschlüssel" }, "keyHint": { - "source": "Use only letters, numbers, underscores, and hyphens", - "comment": "Hint text below translation key input", - "tags": ["browser"], - "es": "Utilice únicamente letras, números, guiones bajos y guiones.", - "fr-ca": "Utilisez seulement des lettres, des chiffres, des traits de soulignement et des tirets.", - "ru": "Используйте только буквы, цифры, подчеркивания и дефисы.", - "ja": "文字、数字、アンダースコア、ハイフンのみを使用してください。", - "de": "Verwenden Sie nur Buchstaben, Zahlen, Unterstriche und Bindestriche." + "source": "Entry name only — paste a full dotted key and its folder part moves to Location", + "comment": "Hint under the translation key field in the editor dialog. Explains that only the last segment lives here, and that pasting a full dot-delimited key moves the prefix into the folder location.", + "tags": ["browser"], + "es": "Solo el nombre de la entrada: pegue una clave completa con puntos y su carpeta pasará a Ubicación", + "fr-ca": "Nom de l'entrée seulement — collez une clé complète avec des points et son dossier ira dans Emplacement", + "ru": "Только имя записи — вставьте полный ключ с точками, и его папка перейдёт в поле «Папка»", + "ja": "エントリ名のみ。ドット区切りの完全なキーを貼り付けると、フォルダー部分は「フォルダー」に移ります", + "de": "Nur der Eintragsname — fügen Sie einen vollständigen Schlüssel mit Punkten ein, sein Ordnerteil wandert zu „Ordner“" }, "baseBadge": { "source": "Base", @@ -37,7 +37,7 @@ "fr-ca": "Base", "ru": "База", "ja": "ベース", - "de": "Base" + "de": "Basis" }, "locationLabel": { "source": "Location:", @@ -45,9 +45,9 @@ "tags": ["browser"], "es": "Ubicación:", "fr-ca": "Emplacement :", - "ru": "Расположение:", - "ja": "位置:", - "de": "Standort:" + "ru": "Папка:", + "ja": "フォルダー:", + "de": "Ordner:" }, "locationTabHint": { "source": "Choose a different folder for this translation resource.", @@ -63,8 +63,8 @@ "source": "{locale} Translation", "comment": "Label for locale translation input field, locale is uppercased locale code", "tags": ["browser"], - "es": "{locale} Traducción", - "fr-ca": "{locale} Traduction", + "es": "Traducción {locale}", + "fr-ca": "Traduction {locale}", "ru": "{locale} Перевод", "ja": "{locale} 翻訳", "de": "{locale} Übersetzung" @@ -119,16 +119,6 @@ "ja": "翻訳者向けに背景情報や指示を提供する", "de": "Geben Sie Kontext oder Anweisungen für die Übersetzer an." }, - "otherLocalesToggleX": { - "source": "Add translations for other locales ({count} available)", - "comment": "Toggle label showing number of additional locales", - "tags": ["browser"], - "es": "Agregar traducciones para otras configuraciones regionales ({count} disponible)", - "fr-ca": "Ajouter des traductions pour d'autres langues ({count} disponibles)", - "ru": "Добавить переводы для других языковых версий ({count} доступны)", - "ja": "他のロケールの翻訳を追加してください({count} が利用可能です)", - "de": "Übersetzungen für weitere Gebietsschemas hinzufügen ({count} verfügbar)" - }, "status": { "source": "Status", "comment": "Label for translation status dropdown", @@ -143,8 +133,8 @@ "source": "Saving...", "comment": "Button text shown while form is submitting", "tags": ["browser"], - "es": "Ahorro...", - "fr-ca": "Économie...", + "es": "Guardando...", + "fr-ca": "Enregistrement...", "ru": "Сохранение...", "ja": "保存中...", "de": "Speichern..." @@ -240,23 +230,93 @@ "de": "Übersetzungsschlüssel erforderlich" }, "keyPatternError": { - "source": "Only letters, numbers, underscores, and hyphens allowed", - "comment": "Validation error for invalid key characters", - "tags": ["browser"], - "es": "Solo se permiten letras, números, guiones bajos y guiones.", - "fr-ca": "Seules les lettres, les chiffres, les traits de soulignement et les tirets sont autorisés.", - "ru": "Разрешаются только буквы, цифры, подчеркивания и дефисы.", - "ja": "使用できる文字は、英字、数字、アンダースコア、ハイフンのみです。", - "de": "Nur Buchstaben, Zahlen, Unterstriche und Bindestriche sind erlaubt." + "source": "Use letters, numbers, hyphens and underscores only", + "comment": "Validation error when the translation key contains a character that is invalid in any position. Dots are accepted and split into a folder path, so they are not named here.", + "tags": ["browser"], + "es": "Use solo letras, números, guiones y guiones bajos", + "fr-ca": "Utilisez uniquement des lettres, des chiffres, des traits d'union et des tirets bas", + "ru": "Используйте только буквы, цифры, дефисы и подчёркивания", + "ja": "英数字、ハイフン、アンダースコアのみ使用できます", + "de": "Nur Buchstaben, Ziffern, Bindestriche und Unterstriche verwenden" }, "readOnlyNotice": { "source": "This collection is read-only — you're viewing this resource in read-only mode.", "comment": "Banner in the resource editor dialog when the collection is read-only.", "tags": ["browser"], - "es": "This collection is read-only — you're viewing this resource in read-only mode.", - "fr-ca": "This collection is read-only — you're viewing this resource in read-only mode.", - "ru": "This collection is read-only — you're viewing this resource in read-only mode.", - "ja": "This collection is read-only — you're viewing this resource in read-only mode.", - "de": "This collection is read-only — you're viewing this resource in read-only mode." + "es": "Esta colección es de solo lectura: está viendo este recurso en modo de solo lectura.", + "fr-ca": "Cette collection est en lecture seule — vous consultez cette ressource en mode lecture seule.", + "ru": "Эта коллекция доступна только для чтения — вы просматриваете этот ресурс в режиме чтения.", + "ja": "このコレクションは読み取り専用です。このリソースは閲覧のみ可能です。", + "de": "Diese Sammlung ist schreibgeschützt — Sie sehen diese Ressource im Nur-Lese-Modus." + }, + "fixErrors": { + "source": "Fix the highlighted fields before saving", + "comment": "Error banner shown when the user tries to save with invalid fields.", + "tags": ["browser"], + "es": "Corrija los campos resaltados antes de guardar", + "fr-ca": "Corrigez les champs surlignés avant d'enregistrer", + "ru": "Исправьте выделенные поля перед сохранением", + "ja": "保存する前に、強調表示されたフィールドを修正してください", + "de": "Beheben Sie die markierten Felder, bevor Sie speichern" + }, + "tabHasErrors": { + "source": "This tab has errors", + "comment": "Accessible label for the error indicator on a dialog tab.", + "tags": ["browser"], + "es": "Esta pestaña tiene errores", + "fr-ca": "Cet onglet contient des erreurs", + "ru": "На этой вкладке есть ошибки", + "ja": "このタブにエラーがあります", + "de": "Dieser Tab enthält Fehler" + }, + "readOnlyTabTooltip": { + "source": "Not available in a read-only collection", + "comment": "Tooltip on the disabled Change Location tab when the collection is read-only.", + "tags": ["browser"], + "es": "No disponible en una colección de solo lectura", + "fr-ca": "Non disponible dans une collection en lecture seule", + "ru": "Недоступно в коллекции только для чтения", + "ja": "読み取り専用のコレクションでは利用できません", + "de": "In einer schreibgeschützten Sammlung nicht verfügbar" + }, + "noOtherLocalesTooltip": { + "source": "This collection has only the base locale", + "comment": "Tooltip on the disabled Other Locales tab when no other locales are configured.", + "tags": ["browser"], + "es": "Esta colección solo tiene el idioma base", + "fr-ca": "Cette collection n'a que la langue de base", + "ru": "В этой коллекции есть только базовая локаль", + "ja": "このコレクションには基準ロケールしかありません", + "de": "Diese Sammlung hat nur die Basissprache" + }, + "saveShortcutHint": { + "source": "Press Ctrl + Enter to save", + "comment": "Tooltip on the save button advertising the keyboard shortcut.", + "tags": ["browser"], + "es": "Pulse Ctrl + Intro para guardar", + "fr-ca": "Appuyez sur Ctrl + Entrée pour enregistrer", + "ru": "Нажмите Ctrl + Enter, чтобы сохранить", + "ja": "Ctrl + Enter で保存します", + "de": "Zum Speichern Strg + Eingabe drücken" + }, + "fullKeyX": { + "source": "Full key: {key}", + "comment": "Tooltip on the location pill showing the complete dot-delimited key.", + "tags": ["browser"], + "es": "Clave completa: {key}", + "fr-ca": "Clé complète : {key}", + "ru": "Полный ключ: {key}", + "ja": "完全なキー: {key}", + "de": "Vollständiger Schlüssel: {key}" + }, + "locationFromKeyX": { + "source": "Location set to {folder} from the key you entered", + "comment": "Screen-reader announcement when a dotted key's prefix is moved into the folder location pill. {folder} is the dot-delimited folder path.", + "tags": ["browser"], + "es": "Ubicación establecida en {folder} a partir de la clave introducida", + "fr-ca": "Emplacement défini à {folder} à partir de la clé saisie", + "ru": "Папка задана как {folder} на основе введённого ключа", + "ja": "入力されたキーからフォルダーを {folder} に設定しました", + "de": "Ordner aus dem eingegebenen Schlüssel auf {folder} gesetzt" } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json index 6a94861f..46586e0b 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json @@ -15,18 +15,18 @@ "tags": ["browser"], "es": "Cambiar ubicación", "fr-ca": "Changer d'emplacement", - "ru": "Изменить местоположение", - "ja": "場所を変更する", - "de": "Standort ändern" + "ru": "Изменить папку", + "ja": "フォルダーを変更", + "de": "Ordner ändern" }, "otherLocales": { "source": "Other Locales", "comment": "Tab label for the other locales section in the editor dialog", "tags": ["browser"], - "es": "Otros lugares", - "fr-ca": "Autres localités", - "ru": "Другие населенные пункты", - "ja": "その他の地域", - "de": "Andere Orte" + "es": "Otros idiomas", + "fr-ca": "Autres langues", + "ru": "Другие локали", + "ja": "他のロケール", + "de": "Weitere Sprachen" } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json index 6f9574b9..cda24e20 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json @@ -44,17 +44,17 @@ "status": "translated" }, "ru": { - "checksum": "86568c4f274d53ddbd5c0ca909838948", + "checksum": "c3460d5f12a50700d1460eaa19a24c41", "baseChecksum": "6dd495f1c2ae29c8e9a6c4405f18e22e", "status": "translated" }, "ja": { - "checksum": "1b8bf802cd4b26490f8d9ea9033c1b10", + "checksum": "c9edd0ef72155d040ebad4056362dc00", "baseChecksum": "6dd495f1c2ae29c8e9a6c4405f18e22e", "status": "translated" }, "de": { - "checksum": "37b9b091bf44c29f032ce905da6fd10f", + "checksum": "a190bb4504ea9e591e957538efe48642", "baseChecksum": "6dd495f1c2ae29c8e9a6c4405f18e22e", "status": "translated" } @@ -64,27 +64,27 @@ "checksum": "aca1284fedfa7431bb8a9dc0b59fe6c6" }, "es": { - "checksum": "d2b581ecac176d7d13962b1b2f1b4da4", + "checksum": "550c7cdfb92362bb233e70dfa2ad509c", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" }, "fr-ca": { - "checksum": "7d4be744a78b006182b35c9d056b566e", + "checksum": "2e2ee859214fac995e01e6dfa885d4dc", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" }, "ru": { - "checksum": "dae4ccfa3a748a5236612c9cb07dafac", + "checksum": "acc46f241a646d68c8632fe59c2a2c00", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" }, "ja": { - "checksum": "5d191996e0ef3a0e2d45658ad517e8c1", + "checksum": "2929efe49206febaad199b1c79eac0a9", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" }, "de": { - "checksum": "01a98b4b9cfdd78d96ce093d75f2efe6", + "checksum": "6313ed09e8f88fde09840484c4ac2b9f", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" } diff --git a/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json index fe5c7e94..d3681d79 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json @@ -26,7 +26,7 @@ "de": { "checksum": "189f63f277cd73395561651753563065", "baseChecksum": "189f63f277cd73395561651753563065", - "status": "new" + "status": "translated" } }, "addPlaceholder": { diff --git a/apps/tracker/src/i18n/browser/translationEditor/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/tracker_meta.json index f22e4d93..2dbeefb7 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tracker_meta.json @@ -61,31 +61,31 @@ }, "keyHint": { "en": { - "checksum": "a150213ebef4acd1123fc4f76b89141d" + "checksum": "7d575a4bbda281c6c59b61a09c632b9f" }, "es": { - "checksum": "14381697bd7f8b105c4bd3a1a9afba79", - "baseChecksum": "a150213ebef4acd1123fc4f76b89141d", + "checksum": "2bbaa6703d4bd47a4d89c1cae7e35776", + "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", "status": "translated" }, "fr-ca": { - "checksum": "b7b58d7abbfe1d258bea836ce70eb4ec", - "baseChecksum": "a150213ebef4acd1123fc4f76b89141d", + "checksum": "b46deba29548de7d3902685aaa62c6ae", + "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", "status": "translated" }, "ru": { - "checksum": "a4309f56e3d6a57faf9ce6ba06d0af93", - "baseChecksum": "a150213ebef4acd1123fc4f76b89141d", + "checksum": "36b133727fa07e130f5d32c970b8e1bf", + "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", "status": "translated" }, "ja": { - "checksum": "838d654d1155ba342ba1f05995265764", - "baseChecksum": "a150213ebef4acd1123fc4f76b89141d", + "checksum": "aaef9b27a6c779323e5d34aeac15558d", + "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", "status": "translated" }, "de": { - "checksum": "9cff025ed420cce9d77d59fbec08f519", - "baseChecksum": "a150213ebef4acd1123fc4f76b89141d", + "checksum": "3f7a97ba0dec4691bd25bbb5ada33e58", + "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", "status": "translated" } }, @@ -114,7 +114,7 @@ "status": "translated" }, "de": { - "checksum": "095a1b43effec73955e31e790438de49", + "checksum": "9bc05d0fdf5b16984c37b23709d0e7b4", "baseChecksum": "095a1b43effec73955e31e790438de49", "status": "translated" } @@ -126,27 +126,27 @@ "es": { "checksum": "8d2e8546ce97da7a71ae80513dc5d55c", "baseChecksum": "be9469719aa6efa2fc3bd5768bc406d7", - "status": "stale" + "status": "translated" }, "fr-ca": { "checksum": "e5dd521e036b4fad8e88b771121d4a7e", "baseChecksum": "be9469719aa6efa2fc3bd5768bc406d7", - "status": "stale" + "status": "translated" }, "ru": { - "checksum": "2be7ebd9f9cac1aa575397e3d0b12b01", + "checksum": "0bde293383ace742393f9d7ab4c6d6f2", "baseChecksum": "be9469719aa6efa2fc3bd5768bc406d7", - "status": "stale" + "status": "translated" }, "ja": { - "checksum": "4c856befb87ffa52b70b527023d634d4", + "checksum": "fa4c0a090e36ab2d2edd635b06de3e7c", "baseChecksum": "be9469719aa6efa2fc3bd5768bc406d7", - "status": "stale" + "status": "translated" }, "de": { - "checksum": "527d181f9e73d18b872ffea9d7af6544", + "checksum": "00328b87c1f4b09e604c0d725ff1919f", "baseChecksum": "be9469719aa6efa2fc3bd5768bc406d7", - "status": "stale" + "status": "translated" } }, "locationTabHint": { @@ -156,27 +156,27 @@ "es": { "checksum": "649dc33186f7fc8a34d3e49ed45ee53a", "baseChecksum": "dadce9b910a3e27df11a37c6dc932b38", - "status": "stale" + "status": "translated" }, "fr-ca": { "checksum": "f577dace518117582063305a3c98c468", "baseChecksum": "dadce9b910a3e27df11a37c6dc932b38", - "status": "stale" + "status": "translated" }, "ru": { "checksum": "8278d26bbaa2462e777e00884338a749", "baseChecksum": "dadce9b910a3e27df11a37c6dc932b38", - "status": "stale" + "status": "translated" }, "ja": { "checksum": "16b48a57e9983e810366f033cd23422c", "baseChecksum": "dadce9b910a3e27df11a37c6dc932b38", - "status": "stale" + "status": "translated" }, "de": { "checksum": "6606f1a308e6297887adc1f1bb89f533", "baseChecksum": "dadce9b910a3e27df11a37c6dc932b38", - "status": "stale" + "status": "translated" } }, "localeTranslationX": { @@ -184,14 +184,14 @@ "checksum": "06c8a0c2ef0476880f67ff4230e593b4" }, "es": { - "checksum": "7176235182e5be91a4467522f42f133f", + "checksum": "fc6fb125525e175024ddf6e830124fa9", "baseChecksum": "06c8a0c2ef0476880f67ff4230e593b4", - "status": "stale" + "status": "translated" }, "fr-ca": { - "checksum": "6d2fd9fc703c835e836365104ce929c6", + "checksum": "a518c31f4bdc777b2075a1b3d3c68185", "baseChecksum": "06c8a0c2ef0476880f67ff4230e593b4", - "status": "stale" + "status": "translated" }, "ru": { "checksum": "820a9f699c16f046f7c80778834ec280", @@ -216,12 +216,12 @@ "es": { "checksum": "540fe84a67c579689be50e6a1e055ad4", "baseChecksum": "831af3e4fbd2ef1d6685bb25b9373509", - "status": "stale" + "status": "translated" }, "fr-ca": { "checksum": "9e1e2e4cd501aeb8196a6b20217bfd1d", "baseChecksum": "831af3e4fbd2ef1d6685bb25b9373509", - "status": "stale" + "status": "translated" }, "ru": { "checksum": "684070f154dc864269f0fc71bf86ca36", @@ -359,36 +359,6 @@ "status": "translated" } }, - "otherLocalesToggleX": { - "en": { - "checksum": "bf32152e1d3fd4d222bf92e179a25a7e" - }, - "es": { - "checksum": "03ad033f462d8ad15108f23415c69f39", - "baseChecksum": "bf32152e1d3fd4d222bf92e179a25a7e", - "status": "stale" - }, - "fr-ca": { - "checksum": "bc079033bb772e8a21525f9064bd47b7", - "baseChecksum": "bf32152e1d3fd4d222bf92e179a25a7e", - "status": "stale" - }, - "ru": { - "checksum": "0bd39f06176ffb76f44bfb101e822487", - "baseChecksum": "bf32152e1d3fd4d222bf92e179a25a7e", - "status": "translated" - }, - "ja": { - "checksum": "d67637567b1cf7744bbb08f2bb17901f", - "baseChecksum": "bf32152e1d3fd4d222bf92e179a25a7e", - "status": "translated" - }, - "de": { - "checksum": "32ea44efe3ca412e0ed666b3007a3057", - "baseChecksum": "bf32152e1d3fd4d222bf92e179a25a7e", - "status": "translated" - } - }, "status": { "en": { "checksum": "ec53a8c4f07baed5d8825072c89799be" @@ -424,12 +394,12 @@ "checksum": "575f5f86d2c7ea113c6710c1037c4465" }, "es": { - "checksum": "918eaaa408213b7470aa8f3af6ef54f6", + "checksum": "333253ab7b2189d075fac0f76cbae630", "baseChecksum": "575f5f86d2c7ea113c6710c1037c4465", "status": "translated" }, "fr-ca": { - "checksum": "c6b16cb467ec9ee9a2f3e4c7f6f7ac30", + "checksum": "8a4254cecbe8c060f1c0d3014a4d5abd", "baseChecksum": "575f5f86d2c7ea113c6710c1037c4465", "status": "translated" }, @@ -721,31 +691,31 @@ }, "keyPatternError": { "en": { - "checksum": "a5fe09c595ead0cd466e781fc4e87dbe" + "checksum": "089b2de6ff01b64fe2db67ee3bdaf3d9" }, "es": { - "checksum": "874a3a886e9eb6507df2bbf44570b7e4", - "baseChecksum": "a5fe09c595ead0cd466e781fc4e87dbe", + "checksum": "d08266a1d76c309187aa3914538ed56b", + "baseChecksum": "089b2de6ff01b64fe2db67ee3bdaf3d9", "status": "translated" }, "fr-ca": { - "checksum": "7552d3fca2f3b4d304ddf128cd1619f6", - "baseChecksum": "a5fe09c595ead0cd466e781fc4e87dbe", + "checksum": "0b24b8ccf82986f74068c89da24e62f3", + "baseChecksum": "089b2de6ff01b64fe2db67ee3bdaf3d9", "status": "translated" }, "ru": { - "checksum": "9f5fa4fee28b782fab639f3433c9d2eb", - "baseChecksum": "a5fe09c595ead0cd466e781fc4e87dbe", + "checksum": "4a305ba86e83cffd3687fd5d0ff40454", + "baseChecksum": "089b2de6ff01b64fe2db67ee3bdaf3d9", "status": "translated" }, "ja": { - "checksum": "1e1b5c2f4f5aed3d88fab601e91f55f7", - "baseChecksum": "a5fe09c595ead0cd466e781fc4e87dbe", + "checksum": "77cc6b6bbfb5e300e74405245b7b1228", + "baseChecksum": "089b2de6ff01b64fe2db67ee3bdaf3d9", "status": "translated" }, "de": { - "checksum": "bcdd26c99eee838f7b84e91bdcf7d1ac", - "baseChecksum": "a5fe09c595ead0cd466e781fc4e87dbe", + "checksum": "c8b79a15fed4b9ce7779240f6dfecc6f", + "baseChecksum": "089b2de6ff01b64fe2db67ee3bdaf3d9", "status": "translated" } }, @@ -754,29 +724,239 @@ "checksum": "1e04e9275a74cc36d1cf84a620de1588" }, "es": { - "checksum": "1e04e9275a74cc36d1cf84a620de1588", + "checksum": "b9e03c2a39dd5f0514582d5d3fad1ee9", "baseChecksum": "1e04e9275a74cc36d1cf84a620de1588", - "status": "new" + "status": "translated" }, "fr-ca": { - "checksum": "1e04e9275a74cc36d1cf84a620de1588", + "checksum": "bee6cd592b0af1f9ef6de10c91b00ced", "baseChecksum": "1e04e9275a74cc36d1cf84a620de1588", - "status": "new" + "status": "translated" }, "ru": { - "checksum": "1e04e9275a74cc36d1cf84a620de1588", + "checksum": "d06a1e3f568ee6f9c13fa7e9c6ec7cf7", "baseChecksum": "1e04e9275a74cc36d1cf84a620de1588", - "status": "new" + "status": "translated" }, "ja": { - "checksum": "1e04e9275a74cc36d1cf84a620de1588", + "checksum": "f85fae8c88724ceb63b9e16aded0f766", "baseChecksum": "1e04e9275a74cc36d1cf84a620de1588", - "status": "new" + "status": "translated" }, "de": { - "checksum": "1e04e9275a74cc36d1cf84a620de1588", + "checksum": "9dea4181431e455e8bf220eaaed09a7f", "baseChecksum": "1e04e9275a74cc36d1cf84a620de1588", - "status": "new" + "status": "translated" + } + }, + "fixErrors": { + "en": { + "checksum": "f11c8dfcc8ea32625c0d66b286ac2e7f" + }, + "es": { + "checksum": "e335ac5e69ea025b99b990cb1cfb0f50", + "baseChecksum": "f11c8dfcc8ea32625c0d66b286ac2e7f", + "status": "translated" + }, + "fr-ca": { + "checksum": "58376e937793eb9e5b611c3e1c23f6eb", + "baseChecksum": "f11c8dfcc8ea32625c0d66b286ac2e7f", + "status": "translated" + }, + "ru": { + "checksum": "a5adcbf5ad6fc463b198c5e6f3458b16", + "baseChecksum": "f11c8dfcc8ea32625c0d66b286ac2e7f", + "status": "translated" + }, + "ja": { + "checksum": "969060b1c12552d258ba588ff531de90", + "baseChecksum": "f11c8dfcc8ea32625c0d66b286ac2e7f", + "status": "translated" + }, + "de": { + "checksum": "3bc47a380d4275dea26b68af236456a5", + "baseChecksum": "f11c8dfcc8ea32625c0d66b286ac2e7f", + "status": "translated" + } + }, + "tabHasErrors": { + "en": { + "checksum": "63004df77cd2b44c44503a52544e4d98" + }, + "es": { + "checksum": "a62006cc7c04625f96dbf8d894b7014b", + "baseChecksum": "63004df77cd2b44c44503a52544e4d98", + "status": "translated" + }, + "fr-ca": { + "checksum": "067f3a456e78be64fcf5e985af6f46c1", + "baseChecksum": "63004df77cd2b44c44503a52544e4d98", + "status": "translated" + }, + "ru": { + "checksum": "732944c19619eee8f15d98ad8c0fc6a4", + "baseChecksum": "63004df77cd2b44c44503a52544e4d98", + "status": "translated" + }, + "ja": { + "checksum": "8efb0a8b638ca82821bffb1b3b6e0177", + "baseChecksum": "63004df77cd2b44c44503a52544e4d98", + "status": "translated" + }, + "de": { + "checksum": "a100fe03d6ec15700d8d19d0b6b34920", + "baseChecksum": "63004df77cd2b44c44503a52544e4d98", + "status": "translated" + } + }, + "readOnlyTabTooltip": { + "en": { + "checksum": "71c10bf8c4363fdd201da4a90c8125a3" + }, + "es": { + "checksum": "ff9b244b0d8c521e516ea9f781db270a", + "baseChecksum": "71c10bf8c4363fdd201da4a90c8125a3", + "status": "translated" + }, + "fr-ca": { + "checksum": "89b38902a9260c8dd508e3e66cd434c9", + "baseChecksum": "71c10bf8c4363fdd201da4a90c8125a3", + "status": "translated" + }, + "ru": { + "checksum": "6434a1b6711353bc081b294295bbc3fc", + "baseChecksum": "71c10bf8c4363fdd201da4a90c8125a3", + "status": "translated" + }, + "ja": { + "checksum": "8ab162ff1f12d2b48800c7ebd34c1fd2", + "baseChecksum": "71c10bf8c4363fdd201da4a90c8125a3", + "status": "translated" + }, + "de": { + "checksum": "5f502c21ed2c722f367f890bdf1f454e", + "baseChecksum": "71c10bf8c4363fdd201da4a90c8125a3", + "status": "translated" + } + }, + "noOtherLocalesTooltip": { + "en": { + "checksum": "162c0970e1c9e8a2e394847f27215149" + }, + "es": { + "checksum": "224ea148495553f85a215ebb68989a80", + "baseChecksum": "162c0970e1c9e8a2e394847f27215149", + "status": "translated" + }, + "fr-ca": { + "checksum": "b91a011c3144b03d0b8085454409a1b8", + "baseChecksum": "162c0970e1c9e8a2e394847f27215149", + "status": "translated" + }, + "ru": { + "checksum": "9b6a9255edb7a6fa59706851c00142f8", + "baseChecksum": "162c0970e1c9e8a2e394847f27215149", + "status": "translated" + }, + "ja": { + "checksum": "20e2a6342fb37a5066b256df14a85a56", + "baseChecksum": "162c0970e1c9e8a2e394847f27215149", + "status": "translated" + }, + "de": { + "checksum": "1450618ba74b538c294c02602a66ea6f", + "baseChecksum": "162c0970e1c9e8a2e394847f27215149", + "status": "translated" + } + }, + "saveShortcutHint": { + "en": { + "checksum": "d7ec10311f92e5ef4372dedc4595da76" + }, + "es": { + "checksum": "6f181792bac6321784ef98f6031bcf99", + "baseChecksum": "d7ec10311f92e5ef4372dedc4595da76", + "status": "translated" + }, + "fr-ca": { + "checksum": "90a7d170fa89824c658ec1ab595da586", + "baseChecksum": "d7ec10311f92e5ef4372dedc4595da76", + "status": "translated" + }, + "ru": { + "checksum": "2fa7a17b6164327f97aa25dcb27ce1a1", + "baseChecksum": "d7ec10311f92e5ef4372dedc4595da76", + "status": "translated" + }, + "ja": { + "checksum": "5ea32b62c1e968ca64925928d8374c44", + "baseChecksum": "d7ec10311f92e5ef4372dedc4595da76", + "status": "translated" + }, + "de": { + "checksum": "cc0fe92b3295aee7cbcbf1cdb8a2cd4f", + "baseChecksum": "d7ec10311f92e5ef4372dedc4595da76", + "status": "translated" + } + }, + "fullKeyX": { + "en": { + "checksum": "454afca1714d6da5dd9212b932cd221f" + }, + "es": { + "checksum": "120ff5111787c3b25fba853a3b55e929", + "baseChecksum": "454afca1714d6da5dd9212b932cd221f", + "status": "translated" + }, + "fr-ca": { + "checksum": "ac642583da894a9c360a229737b237de", + "baseChecksum": "454afca1714d6da5dd9212b932cd221f", + "status": "translated" + }, + "ru": { + "checksum": "10a1df8f03f88ad89a41456570d19b27", + "baseChecksum": "454afca1714d6da5dd9212b932cd221f", + "status": "translated" + }, + "ja": { + "checksum": "d73ddcc103b28c4fa147e4de7c064a0e", + "baseChecksum": "454afca1714d6da5dd9212b932cd221f", + "status": "translated" + }, + "de": { + "checksum": "32c5ea197cc9d8919e476e41c8a28077", + "baseChecksum": "454afca1714d6da5dd9212b932cd221f", + "status": "translated" + } + }, + "locationFromKeyX": { + "en": { + "checksum": "e155c712f48c13481d3bea3a90d5db0b" + }, + "es": { + "checksum": "36c060074c301e2262482ddafaffeb70", + "baseChecksum": "e155c712f48c13481d3bea3a90d5db0b", + "status": "translated" + }, + "fr-ca": { + "checksum": "36ad45f1de9ca0dcafa8e4d595d8d163", + "baseChecksum": "e155c712f48c13481d3bea3a90d5db0b", + "status": "translated" + }, + "ru": { + "checksum": "7dec825d5b181cc8b9c8628dc4ec5ca6", + "baseChecksum": "e155c712f48c13481d3bea3a90d5db0b", + "status": "translated" + }, + "ja": { + "checksum": "38efe9bcd8619fc35063d56dbdd0b165", + "baseChecksum": "e155c712f48c13481d3bea3a90d5db0b", + "status": "translated" + }, + "de": { + "checksum": "29ee2c65fbaac8cf94a1b24c4a466981", + "baseChecksum": "e155c712f48c13481d3bea3a90d5db0b", + "status": "translated" } } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/unsaved/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/unsaved/resource_entries.json new file mode 100644 index 00000000..10135fcb --- /dev/null +++ b/apps/tracker/src/i18n/browser/translationEditor/unsaved/resource_entries.json @@ -0,0 +1,42 @@ +{ + "title": { + "source": "Discard your changes?", + "comment": "Title of the confirmation shown when closing the translation editor with unsaved edits.", + "tags": ["browser"], + "es": "¿Descartar los cambios?", + "fr-ca": "Abandonner vos modifications ?", + "ru": "Отменить изменения?", + "ja": "変更を破棄しますか?", + "de": "Änderungen verwerfen?" + }, + "message": { + "source": "This translation has unsaved changes. Closing now will lose them.", + "comment": "Message of the unsaved-changes confirmation in the translation editor.", + "tags": ["browser"], + "es": "Esta traducción tiene cambios sin guardar. Si la cierra ahora, se perderán.", + "fr-ca": "Cette traduction comporte des modifications non enregistrées. Les fermer maintenant les perdra.", + "ru": "В этом переводе есть несохранённые изменения. При закрытии они будут потеряны.", + "ja": "この翻訳には保存されていない変更があります。閉じると失われます。", + "de": "Diese Übersetzung hat ungespeicherte Änderungen. Beim Schließen gehen sie verloren." + }, + "discard": { + "source": "Discard changes", + "comment": "Confirm button of the unsaved-changes dialog; closes the editor and loses edits.", + "tags": ["browser"], + "es": "Descartar los cambios", + "fr-ca": "Abandonner les modifications", + "ru": "Отменить изменения", + "ja": "変更を破棄", + "de": "Änderungen verwerfen" + }, + "keepEditing": { + "source": "Keep editing", + "comment": "Cancel button of the unsaved-changes dialog; returns to the editor.", + "tags": ["browser"], + "es": "Seguir editando", + "fr-ca": "Continuer la modification", + "ru": "Продолжить редактирование", + "ja": "編集を続ける", + "de": "Weiter bearbeiten" + } +} diff --git a/apps/tracker/src/i18n/browser/translationEditor/unsaved/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/unsaved/tracker_meta.json new file mode 100644 index 00000000..31b1d70d --- /dev/null +++ b/apps/tracker/src/i18n/browser/translationEditor/unsaved/tracker_meta.json @@ -0,0 +1,122 @@ +{ + "title": { + "en": { + "checksum": "945f95f5f5e654f60adcbb13d91c0bfd" + }, + "es": { + "checksum": "82fcdf4e0d26c1c17bd660c5732bc1e2", + "baseChecksum": "945f95f5f5e654f60adcbb13d91c0bfd", + "status": "translated" + }, + "fr-ca": { + "checksum": "8a575b39ce48d8b4e722c3f72108ce43", + "baseChecksum": "945f95f5f5e654f60adcbb13d91c0bfd", + "status": "translated" + }, + "ru": { + "checksum": "486c77269d9d75c46a7475076a12b721", + "baseChecksum": "945f95f5f5e654f60adcbb13d91c0bfd", + "status": "translated" + }, + "ja": { + "checksum": "cbd8c5638c5cedfdbe37bfe0dd9f5533", + "baseChecksum": "945f95f5f5e654f60adcbb13d91c0bfd", + "status": "translated" + }, + "de": { + "checksum": "d85425bda5c9a2d22bc199afe8f6f0ba", + "baseChecksum": "945f95f5f5e654f60adcbb13d91c0bfd", + "status": "translated" + } + }, + "message": { + "en": { + "checksum": "68a4d151d612a4930dade3b5ddd50e19" + }, + "es": { + "checksum": "deae81b419d2fdf7fde9274b87dda558", + "baseChecksum": "68a4d151d612a4930dade3b5ddd50e19", + "status": "translated" + }, + "fr-ca": { + "checksum": "dfbbf7a0aa980a0a922716e36d66d50e", + "baseChecksum": "68a4d151d612a4930dade3b5ddd50e19", + "status": "translated" + }, + "ru": { + "checksum": "d4d283dc3c67e8ad3b2915b7edbeec4e", + "baseChecksum": "68a4d151d612a4930dade3b5ddd50e19", + "status": "translated" + }, + "ja": { + "checksum": "06adaea546ed6cbb050fbed896046751", + "baseChecksum": "68a4d151d612a4930dade3b5ddd50e19", + "status": "translated" + }, + "de": { + "checksum": "3ed06087b028bbe4cca94cceb3629aea", + "baseChecksum": "68a4d151d612a4930dade3b5ddd50e19", + "status": "translated" + } + }, + "discard": { + "en": { + "checksum": "98313f623bb6f464b9a154eca0b99bf3" + }, + "es": { + "checksum": "a188f7c78d59ad8d01d5c03450c000df", + "baseChecksum": "98313f623bb6f464b9a154eca0b99bf3", + "status": "translated" + }, + "fr-ca": { + "checksum": "edc15b68ac07cc6e1ea993cba663e138", + "baseChecksum": "98313f623bb6f464b9a154eca0b99bf3", + "status": "translated" + }, + "ru": { + "checksum": "c9fa6f072e38f738c38a487c0e9c2009", + "baseChecksum": "98313f623bb6f464b9a154eca0b99bf3", + "status": "translated" + }, + "ja": { + "checksum": "88eb290a05e3d142d2290884dfa3a0c5", + "baseChecksum": "98313f623bb6f464b9a154eca0b99bf3", + "status": "translated" + }, + "de": { + "checksum": "46d26e8f0828ddfabe8467d012e5a65a", + "baseChecksum": "98313f623bb6f464b9a154eca0b99bf3", + "status": "translated" + } + }, + "keepEditing": { + "en": { + "checksum": "680f147973741a34858711100fe5ec10" + }, + "es": { + "checksum": "2d7d45f55eda41f9e6a4512c11a347fc", + "baseChecksum": "680f147973741a34858711100fe5ec10", + "status": "translated" + }, + "fr-ca": { + "checksum": "58c2a9a2f9441ab2913ed4d44a6977ab", + "baseChecksum": "680f147973741a34858711100fe5ec10", + "status": "translated" + }, + "ru": { + "checksum": "35e76446c6bd70addd8e3084a189f365", + "baseChecksum": "680f147973741a34858711100fe5ec10", + "status": "translated" + }, + "ja": { + "checksum": "b0aed015f6309aadbf48a0191d4b97d3", + "baseChecksum": "680f147973741a34858711100fe5ec10", + "status": "translated" + }, + "de": { + "checksum": "5d31b5d86cfbf2982b258a790f77ff6e", + "baseChecksum": "680f147973741a34858711100fe5ec10", + "status": "translated" + } + } +} diff --git a/apps/tracker/src/i18n/browser/translationItem/resource_entries.json b/apps/tracker/src/i18n/browser/translationItem/resource_entries.json index b166651f..247c6ed7 100644 --- a/apps/tracker/src/i18n/browser/translationItem/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationItem/resource_entries.json @@ -9,16 +9,6 @@ "ja": "ドラッグして翻訳を移動", "de": "Zum Verschieben der Übersetzung ziehen" }, - "copyKeyAriaLabel": { - "source": "Copy key", - "comment": "Aria-label for the copy key button on a translation item header", - "tags": ["browser"], - "es": "Copiar clave", - "fr-ca": "Copier la clé", - "ru": "Копировать ключ", - "ja": "コピーキー", - "de": "Schlüssel kopieren" - }, "hideCommentAriaLabel": { "source": "Hide comment", "comment": "Aria-label for the comment toggle button when comment is currently shown", @@ -88,5 +78,125 @@ "ru": "Нет статусов", "ja": "ステータスなし", "de": "Keine Statusmeldungen" + }, + "moreLocalesX": { + "source": "{ count, plural, =1 {1 more locale} other {{count} more locales} }", + "comment": "Label on the expand button of a translation item, showing how many locale rows are hidden below the collapsed area", + "tags": ["browser"], + "es": "{ count, plural, =1 {1 idioma más} other {{count} idiomas más} }", + "fr-ca": "{ count, plural, =1 {1 langue de plus} other {{count} langues de plus} }", + "de": "{ count, plural, =1 {1 weitere Region} other {{count} weitere Regionen} }", + "ja": "{ count, plural, other {他 {count} 件のロケール} }", + "ru": "{ count, plural, one {ещё {count} локаль} few {ещё {count} локали} many {ещё {count} локалей} other {ещё {count} локали} }" + }, + "showLess": { + "source": "Show less", + "comment": "Label on the expand button of a translation item when it is expanded and can be collapsed again", + "tags": ["browser"], + "es": "Mostrar menos", + "fr-ca": "Afficher moins", + "ru": "Свернуть", + "ja": "折りたたむ", + "de": "Weniger anzeigen" + }, + "showMore": { + "source": "Show more", + "comment": "Label on the expand button of a translation item when long values are clipped but no locale rows are hidden", + "tags": ["browser"], + "es": "Mostrar más", + "fr-ca": "Afficher plus", + "ru": "Показать больше", + "ja": "もっと見る", + "de": "Mehr anzeigen" + }, + "compactLocaleAriaLabelX": { + "source": "Showing {locale}, status: {status}", + "comment": "Screen-reader label for the locale badge on a compact translation item, naming which locale the displayed value belongs to and its translation status", + "tags": ["browser"], + "es": "Mostrando {locale}, estado: {status}", + "fr-ca": "Affichage de {locale}, statut : {status}", + "ru": "Показано: {locale}, статус: {status}", + "ja": "{locale} を表示中、ステータス: {status}", + "de": "{locale} wird angezeigt, Status: {status}" + }, + "baseLocaleTag": { + "source": "source", + "comment": "Short tag shown beside the locale code when a compact translation item is displaying the base locale source text rather than a translation", + "tags": ["browser"], + "es": "origen", + "fr-ca": "source", + "ru": "источник", + "ja": "原文", + "de": "Quelle" + }, + "keyTooltipX": { + "source": "{key} — click to copy", + "comment": "Tooltip on the translation item key chip: shows the untruncated resource key followed by the copy action. {key} is a dot-delimited resource key and is never translated.", + "tags": ["browser"], + "es": "{key} — clic para copiar", + "fr-ca": "{key} — cliquer pour copier", + "de": "{key} — zum Kopieren klicken", + "ja": "{key} — クリックしてコピー", + "ru": "{key} — нажмите, чтобы скопировать" + }, + "copyKeyAriaLabelX": { + "source": "Copy key {key}", + "comment": "Accessible name for the key chip button on a translation item header; names the full resource key that will be copied. {key} is a dot-delimited resource key and is never translated.", + "tags": ["browser"], + "es": "Copiar la clave {key}", + "fr-ca": "Copier la clé {key}", + "de": "Schlüssel {key} kopieren", + "ja": "キー {key} をコピー", + "ru": "Копировать ключ {key}" + }, + "rollupAriaLabelX": { + "source": "Translation status: {breakdown}", + "comment": "Accessible name for the translation status rollup on a translation item. {breakdown} is an already-localized, locale-joined list of status counts such as \"2 stale, 1 new\".", + "tags": ["browser"], + "es": "Estado de traducción: {breakdown}", + "fr-ca": "État de traduction : {breakdown}", + "de": "Übersetzungsstatus: {breakdown}", + "ja": "翻訳ステータス: {breakdown}", + "ru": "Статус перевода: {breakdown}" + }, + "sameAsSourceTag": { + "source": "same as source", + "comment": "Marker on a locale row whose translated value is byte-identical to the base locale value — it was never actually translated", + "tags": ["browser"], + "es": "igual que la fuente", + "fr-ca": "identique à la source", + "ru": "то же самое, что и источник", + "ja": "ソースと同じ", + "de": "gleich der Quelle" + }, + "sameAsSourceAriaLabelX": { + "source": "{locale} is identical to the source text", + "comment": "Accessible description for a locale row whose value is byte-identical to the base locale value", + "tags": ["browser"], + "es": "{locale} es idéntico al texto fuente", + "fr-ca": "{locale} est identique au texte source", + "ru": "{locale} идентичен исходному тексту", + "ja": "{locale} はソーステキストと同一です", + "de": "{locale} ist identisch mit dem Quelltext" + }, + "defaultLocaleTag": { + "source": "default", + "comment": "Short marker on the compact row when the shown locale was picked automatically rather than chosen by the user", + "tags": ["browser"], + "es": "por defecto", + "fr-ca": "défaut", + "ru": "по умолчанию", + "ja": "デフォルト", + "de": "Standard" + }, + "defaultLocaleTooltipX": { + "source": "This collection has no base locale, so {locale} is shown by default. Pick a different one with the locale filter.", + "comment": "Tooltip explaining why a locale was auto-selected for the compact row, and how to change it", + "tags": ["browser"], + "es": "Esta colección no tiene una configuración regional base, por lo que {locale} se muestra por defecto. Seleccione una diferente con el filtro de configuración regional.", + "fr-ca": "Cette collection n'a pas de langue de base ; donc, {locale} est affiché par défaut. Choisissez-en un autre à l'aide du filtre de langue.", + "ru": "В этой коллекции отсутствует базовая локаль, поэтому по умолчанию отображается {locale}. Выберите другую локаль с помощью фильтра локали.", + "ja": "このコレクションには基本ロケールが設定されていないため、デフォルトで{locale}が表示されます。ロケールフィルターを使用して別のロケールを選択してください。", + "de": "Diese Sammlung hat kein Basisgebietsschema, daher wird standardmäßig {locale} angezeigt. Wählen Sie mit dem Gebietsschemafilter ein anderes Gebietsschema aus." } } diff --git a/apps/tracker/src/i18n/browser/translationItem/tracker_meta.json b/apps/tracker/src/i18n/browser/translationItem/tracker_meta.json index 471dbb68..4eb171b0 100644 --- a/apps/tracker/src/i18n/browser/translationItem/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationItem/tracker_meta.json @@ -29,36 +29,6 @@ "status": "translated" } }, - "copyKeyAriaLabel": { - "en": { - "checksum": "819c7746f5e0a2af0fd512b5ea7ece90" - }, - "es": { - "checksum": "47be1f0eb74f984a19e9e8e0dfc22f52", - "baseChecksum": "819c7746f5e0a2af0fd512b5ea7ece90", - "status": "translated" - }, - "fr-ca": { - "checksum": "b2581a92e7db3a59b61db2e29cd5379b", - "baseChecksum": "819c7746f5e0a2af0fd512b5ea7ece90", - "status": "translated" - }, - "ru": { - "checksum": "0825c26b70c6f32db71b9705d16f7382", - "baseChecksum": "819c7746f5e0a2af0fd512b5ea7ece90", - "status": "translated" - }, - "ja": { - "checksum": "40388375ca056587f52bebf7aa1c6f01", - "baseChecksum": "819c7746f5e0a2af0fd512b5ea7ece90", - "status": "translated" - }, - "de": { - "checksum": "75456b1abc45aa5521e88abf70c79831", - "baseChecksum": "819c7746f5e0a2af0fd512b5ea7ece90", - "status": "translated" - } - }, "hideCommentAriaLabel": { "en": { "checksum": "2204356776748f7de4c06b8d0e77ff37" @@ -268,5 +238,365 @@ "baseChecksum": "7c7917a58dcd74962c36abf81de3903f", "status": "translated" } + }, + "moreLocalesX": { + "en": { + "checksum": "a4b73345e4edb4fa1554a9e7ea3ec966" + }, + "es": { + "checksum": "447b4b30b743b1157ccfea470ee1841b", + "baseChecksum": "a4b73345e4edb4fa1554a9e7ea3ec966", + "status": "translated" + }, + "fr-ca": { + "checksum": "a66cbd705ce3d4491dbe0d48cfbeedbe", + "baseChecksum": "a4b73345e4edb4fa1554a9e7ea3ec966", + "status": "translated" + }, + "de": { + "checksum": "2d2f040432962eade988f04cadc92e91", + "baseChecksum": "a4b73345e4edb4fa1554a9e7ea3ec966", + "status": "translated" + }, + "ja": { + "checksum": "01417b861847a9b6adfd33e3077425f1", + "baseChecksum": "a4b73345e4edb4fa1554a9e7ea3ec966", + "status": "translated" + }, + "ru": { + "checksum": "67194e525f59e91885e7bee6833a6a2e", + "baseChecksum": "a4b73345e4edb4fa1554a9e7ea3ec966", + "status": "translated" + } + }, + "showLess": { + "en": { + "checksum": "c74ea6dbff701bfa23819583c52ebd97" + }, + "es": { + "checksum": "5e0d5ccde0c007975b6ff307ede261db", + "baseChecksum": "c74ea6dbff701bfa23819583c52ebd97", + "status": "translated" + }, + "fr-ca": { + "checksum": "d71be6dc134de758af7a57761ecd0a9c", + "baseChecksum": "c74ea6dbff701bfa23819583c52ebd97", + "status": "translated" + }, + "ru": { + "checksum": "ca9f19d3f56d16d94c7292a20800b1ae", + "baseChecksum": "c74ea6dbff701bfa23819583c52ebd97", + "status": "translated" + }, + "ja": { + "checksum": "8c8dee86e85157e64470945bd2b3a273", + "baseChecksum": "c74ea6dbff701bfa23819583c52ebd97", + "status": "translated" + }, + "de": { + "checksum": "0d9b30a06ba69cf8872590df14cc7dd0", + "baseChecksum": "c74ea6dbff701bfa23819583c52ebd97", + "status": "translated" + } + }, + "showMore": { + "en": { + "checksum": "146ffe2fd9fa5bec3b63b52543793ec7" + }, + "es": { + "checksum": "3b3b2038c9f68d1a059b2f796d0aa616", + "baseChecksum": "146ffe2fd9fa5bec3b63b52543793ec7", + "status": "translated" + }, + "fr-ca": { + "checksum": "44e9a323d789cf6366baee5a38b76989", + "baseChecksum": "146ffe2fd9fa5bec3b63b52543793ec7", + "status": "translated" + }, + "ru": { + "checksum": "b5e755ffc917836f81056954b8846e28", + "baseChecksum": "146ffe2fd9fa5bec3b63b52543793ec7", + "status": "translated" + }, + "ja": { + "checksum": "149a580276be81488b5656c6d6ae29d1", + "baseChecksum": "146ffe2fd9fa5bec3b63b52543793ec7", + "status": "translated" + }, + "de": { + "checksum": "4f02abcd0236148c6fe91ca67cca6b4c", + "baseChecksum": "146ffe2fd9fa5bec3b63b52543793ec7", + "status": "translated" + } + }, + "compactLocaleAriaLabelX": { + "en": { + "checksum": "70447edfe1b8a2ff249ae9b890414e25" + }, + "es": { + "checksum": "0dd7c0c59762cce57fc5aaa6bb25b153", + "baseChecksum": "70447edfe1b8a2ff249ae9b890414e25", + "status": "translated" + }, + "fr-ca": { + "checksum": "a0ae186265afea87450a6ba64dae831f", + "baseChecksum": "70447edfe1b8a2ff249ae9b890414e25", + "status": "translated" + }, + "ru": { + "checksum": "fde9f6d1dcc7f4ba58e8a6778b27c35e", + "baseChecksum": "70447edfe1b8a2ff249ae9b890414e25", + "status": "translated" + }, + "ja": { + "checksum": "4fa854117fec790837729932d96607b4", + "baseChecksum": "70447edfe1b8a2ff249ae9b890414e25", + "status": "translated" + }, + "de": { + "checksum": "dc9057018144e0b8e9fa05fb6efa2bc6", + "baseChecksum": "70447edfe1b8a2ff249ae9b890414e25", + "status": "translated" + } + }, + "baseLocaleTag": { + "en": { + "checksum": "36cd38f49b9afa08222c0dc9ebfe35eb" + }, + "es": { + "checksum": "80e0d8d085a1120a47508a62e93b0f8e", + "baseChecksum": "36cd38f49b9afa08222c0dc9ebfe35eb", + "status": "translated" + }, + "fr-ca": { + "checksum": "36cd38f49b9afa08222c0dc9ebfe35eb", + "baseChecksum": "36cd38f49b9afa08222c0dc9ebfe35eb", + "status": "translated" + }, + "ru": { + "checksum": "a458e1350fcfdde6b3787e4ae6c59d80", + "baseChecksum": "36cd38f49b9afa08222c0dc9ebfe35eb", + "status": "translated" + }, + "ja": { + "checksum": "e5729e9463f94a404b4ddfa8283bcb1b", + "baseChecksum": "36cd38f49b9afa08222c0dc9ebfe35eb", + "status": "translated" + }, + "de": { + "checksum": "d3402ec65ac6ad5241812e5ffeb57219", + "baseChecksum": "36cd38f49b9afa08222c0dc9ebfe35eb", + "status": "translated" + } + }, + "keyTooltipX": { + "en": { + "checksum": "09a2531316d3ebd89a1040ba433cffbc" + }, + "es": { + "checksum": "d31829ba6af163dc492f6e0eabc43c31", + "baseChecksum": "09a2531316d3ebd89a1040ba433cffbc", + "status": "translated" + }, + "fr-ca": { + "checksum": "a889e8daf379c5739c05458e13f26a9d", + "baseChecksum": "09a2531316d3ebd89a1040ba433cffbc", + "status": "translated" + }, + "de": { + "checksum": "72acf7ea2fad2025519301fd61a94f88", + "baseChecksum": "09a2531316d3ebd89a1040ba433cffbc", + "status": "translated" + }, + "ja": { + "checksum": "cd48c6ea684ff40c9c33809517e2b0ed", + "baseChecksum": "09a2531316d3ebd89a1040ba433cffbc", + "status": "translated" + }, + "ru": { + "checksum": "e21af80c9caba9e615152bbab743439a", + "baseChecksum": "09a2531316d3ebd89a1040ba433cffbc", + "status": "translated" + } + }, + "copyKeyAriaLabelX": { + "en": { + "checksum": "455c747b975b00178a7202375105b304" + }, + "es": { + "checksum": "70d477d4119635693838b06279a6ad6a", + "baseChecksum": "455c747b975b00178a7202375105b304", + "status": "translated" + }, + "fr-ca": { + "checksum": "8e2277b04732e91ca4ad7a6b17bbca8f", + "baseChecksum": "455c747b975b00178a7202375105b304", + "status": "translated" + }, + "de": { + "checksum": "1846c82cbfba66c84f3e3e4f67be9822", + "baseChecksum": "455c747b975b00178a7202375105b304", + "status": "translated" + }, + "ja": { + "checksum": "06f5c851cee436d72b966aed7d2eb3f5", + "baseChecksum": "455c747b975b00178a7202375105b304", + "status": "translated" + }, + "ru": { + "checksum": "be7e5891981c0c19e97e074ea43d65d1", + "baseChecksum": "455c747b975b00178a7202375105b304", + "status": "translated" + } + }, + "rollupAriaLabelX": { + "en": { + "checksum": "875cc323b722a4681821e91214dbe7de" + }, + "es": { + "checksum": "5b31af890f84dee3c505361f4f87bd0c", + "baseChecksum": "875cc323b722a4681821e91214dbe7de", + "status": "translated" + }, + "fr-ca": { + "checksum": "c045a798eb301698b5d05c380c739d3c", + "baseChecksum": "875cc323b722a4681821e91214dbe7de", + "status": "translated" + }, + "de": { + "checksum": "cb200206f47980340b50cfcf2f43ad5c", + "baseChecksum": "875cc323b722a4681821e91214dbe7de", + "status": "translated" + }, + "ja": { + "checksum": "fcdb3f5ea693dc76c05e52a495ff1da9", + "baseChecksum": "875cc323b722a4681821e91214dbe7de", + "status": "translated" + }, + "ru": { + "checksum": "1343c05e307f9b77e52a13a89ceb0254", + "baseChecksum": "875cc323b722a4681821e91214dbe7de", + "status": "translated" + } + }, + "sameAsSourceTag": { + "en": { + "checksum": "96ff0a38746e390c3b727f5794e7c844" + }, + "es": { + "checksum": "0dacadd2e73fe46be79c967b7931fe38", + "baseChecksum": "96ff0a38746e390c3b727f5794e7c844", + "status": "translated" + }, + "fr-ca": { + "checksum": "3e9798c100ed8b04c8db8c8546829f5d", + "baseChecksum": "96ff0a38746e390c3b727f5794e7c844", + "status": "translated" + }, + "ru": { + "checksum": "ec11661bce04708f783f7f0b95a53b64", + "baseChecksum": "96ff0a38746e390c3b727f5794e7c844", + "status": "translated" + }, + "ja": { + "checksum": "41f060fb32ee2262d2867285402436c9", + "baseChecksum": "96ff0a38746e390c3b727f5794e7c844", + "status": "translated" + }, + "de": { + "checksum": "053ae33e0589538da6d23e93259caaee", + "baseChecksum": "96ff0a38746e390c3b727f5794e7c844", + "status": "translated" + } + }, + "sameAsSourceAriaLabelX": { + "en": { + "checksum": "c0dc809859930b46f606dc17a108ecd2" + }, + "es": { + "checksum": "35a16690247f102405cc43a5390a2d80", + "baseChecksum": "c0dc809859930b46f606dc17a108ecd2", + "status": "translated" + }, + "fr-ca": { + "checksum": "2d7306610c42a352a604903aa311a539", + "baseChecksum": "c0dc809859930b46f606dc17a108ecd2", + "status": "translated" + }, + "ru": { + "checksum": "3a48efe29a8d29871c9ebb3f6e5e5737", + "baseChecksum": "c0dc809859930b46f606dc17a108ecd2", + "status": "translated" + }, + "ja": { + "checksum": "9e9b73a1257fef89581ec218e7684cca", + "baseChecksum": "c0dc809859930b46f606dc17a108ecd2", + "status": "translated" + }, + "de": { + "checksum": "f596e9cd71c3f82f009159aef2ddb9fc", + "baseChecksum": "c0dc809859930b46f606dc17a108ecd2", + "status": "translated" + } + }, + "defaultLocaleTag": { + "en": { + "checksum": "c21f969b5f03d33d43e04f8f136e7682" + }, + "es": { + "checksum": "e2642a0564b9c5cc80485e23ce0952b3", + "baseChecksum": "c21f969b5f03d33d43e04f8f136e7682", + "status": "translated" + }, + "fr-ca": { + "checksum": "dec6fff71afd6dbe1bfad9a9d49fa88d", + "baseChecksum": "c21f969b5f03d33d43e04f8f136e7682", + "status": "translated" + }, + "ru": { + "checksum": "df5286777a9daed8bceab13c26a98fca", + "baseChecksum": "c21f969b5f03d33d43e04f8f136e7682", + "status": "translated" + }, + "ja": { + "checksum": "646d7148c8576be37d4c8219e0d0fa09", + "baseChecksum": "c21f969b5f03d33d43e04f8f136e7682", + "status": "translated" + }, + "de": { + "checksum": "eb6d8ae6f20283755b339c0dc273988b", + "baseChecksum": "c21f969b5f03d33d43e04f8f136e7682", + "status": "translated" + } + }, + "defaultLocaleTooltipX": { + "en": { + "checksum": "438526f9f6e730c0c85c090770f71ae6" + }, + "es": { + "checksum": "d32ce491d5540f894709c756eb24e7bd", + "baseChecksum": "438526f9f6e730c0c85c090770f71ae6", + "status": "translated" + }, + "fr-ca": { + "checksum": "b9f5200aa369f04397b069efafec132e", + "baseChecksum": "438526f9f6e730c0c85c090770f71ae6", + "status": "translated" + }, + "ru": { + "checksum": "65fee06fcc3216b3fa92a14fa36c7d4c", + "baseChecksum": "438526f9f6e730c0c85c090770f71ae6", + "status": "translated" + }, + "ja": { + "checksum": "3f6d6a3383ae4f4780b46db445218376", + "baseChecksum": "438526f9f6e730c0c85c090770f71ae6", + "status": "translated" + }, + "de": { + "checksum": "54ba17e119798edf39a39a772244e071", + "baseChecksum": "438526f9f6e730c0c85c090770f71ae6", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/header/resource_entries.json b/apps/tracker/src/i18n/header/resource_entries.json index a8885950..b85ae8f5 100644 --- a/apps/tracker/src/i18n/header/resource_entries.json +++ b/apps/tracker/src/i18n/header/resource_entries.json @@ -23,20 +23,20 @@ "source": "{ count, plural, =1 {1 key} other {{count} keys} }", "comment": "Pluralized count of translation keys shown in the app header stats", "tags": ["header"], - "es": "{ count, plural, =1 {1 key} other {{count} keys} }", - "fr-ca": "{ count, plural, =1 {1 key} other {{count} keys} }", - "ru": "{ count, plural, =1 {1 key} other {{count} keys} }", - "ja": "{ count, plural, =1 {1 key} other {{count} keys} }", - "de": "{ count, plural, =1 {1 key} other {{count} keys} }" + "es": "{ count, plural, =1 {1 clave} other {{count} claves} }", + "fr-ca": "{ count, plural, =1 {1 clé} other {{count} clés} }", + "ru": "{ count, plural, one {{count} ключ} few {{count} ключа} many {{count} ключей} other {{count} ключа} }", + "ja": "{ count, plural, other {キー {count} 件} }", + "de": "{ count, plural, =1 {1 Schlüssel} other {{count} Schlüssel} }" }, "localesCountX": { "source": "{ count, plural, =1 {1 locale} other {{count} locales} }", "comment": "Pluralized count of locales shown in the app header stats", "tags": ["header"], - "es": "{ count, plural, =1 {1 locale} other {{count} locales} }", - "fr-ca": "{ count, plural, =1 {1 locale} other {{count} locales} }", - "ru": "{ count, plural, =1 {1 locale} other {{count} locales} }", - "ja": "{ count, plural, =1 {1 locale} other {{count} locales} }", - "de": "{ count, plural, =1 {1 locale} other {{count} locales} }" + "es": "{ count, plural, =1 {1 idioma} other {{count} idiomas} }", + "fr-ca": "{ count, plural, =1 {1 langue} other {{count} langues} }", + "ru": "{ count, plural, one {{count} локаль} few {{count} локали} many {{count} локалей} other {{count} локали} }", + "ja": "{ count, plural, other {ロケール {count} 件} }", + "de": "{ count, plural, =1 {1 Sprache} other {{count} Sprachen} }" } } diff --git a/apps/tracker/src/i18n/header/tracker_meta.json b/apps/tracker/src/i18n/header/tracker_meta.json index 5e3ecdb4..f675186a 100644 --- a/apps/tracker/src/i18n/header/tracker_meta.json +++ b/apps/tracker/src/i18n/header/tracker_meta.json @@ -64,29 +64,29 @@ "checksum": "2ccecfa93e0d0c95b7f229684871db68" }, "es": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "b4add8d9375f64e3ce074505a1ec49ed", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" }, "fr-ca": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "8ad69cbaac79b56e7d979301e33abded", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" }, "ru": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "84cb422a7d82aa5c6173d70f4fefef32", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" }, "ja": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "0684e7169b654b80e4e618011cb2169d", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" }, "de": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "c00cc3e5b357b5b43b42d8b40c65882c", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" } }, "localesCountX": { @@ -94,29 +94,29 @@ "checksum": "ab692b642031b8646162616db0a399cf" }, "es": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "56e43b9de1371eb403e0daceed54a6bc", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" }, "fr-ca": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "34130c5c281c87f76f7a15bf6fe1f767", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" }, "ru": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "74e5281bd2ef7c22a192f8783230ebc0", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" }, "ja": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "25f3eae8130ca224d8b0c54ebd49b889", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" }, "de": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "7fd6a4358024b802a67790227a1f9edc", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" } } } diff --git a/apps/tracker/src/styles.scss b/apps/tracker/src/styles.scss index 2218256d..744396c7 100644 --- a/apps/tracker/src/styles.scss +++ b/apps/tracker/src/styles.scss @@ -77,6 +77,54 @@ mark.search-highlight { box-shadow: var(--shadow-lg); } +// The translation editor's outer box lives here, not in the two TS call sites. +// The CDK puts the panel class on the overlay pane, which is the only element +// that can carry the dialog's width and height; both call sites now pass this +// class and nothing else, so there is one place to change the numbers. +.translation-editor-dialog-panel { + width: 700px; + max-width: min(700px, calc(100vw - 2 * var(--spacing-8))); + max-height: 90vh; + + // Material's dialog container already carries `max-height: inherit`; the + // component host repeats the trick so the flex column inside can distribute + // the height instead of guessing at chrome heights. + app-translation-editor-dialog { + display: block; + max-height: inherit; + } + + // Below the `sm` breakpoint a 700px dialog collapses to whatever 80vw allows + // — 312px at 390px of viewport, a column too narrow to read a translation in. + // Material's standard answer at this size is a full-screen dialog, so take + // the whole screen, drop the floating chrome, and let the editor's own flex + // column own the height. + @media (max-width: 639px) { + width: 100vw; + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + + .mat-mdc-dialog-container:has(app-translation-editor-dialog) { + height: 100%; + border-top-width: 0; + + // The surface is the link in the chain between the container and the + // component; without a height of its own the dialog stops wherever its + // shortest tab ends and leaves the rest of the screen blank behind it. + .mat-mdc-dialog-surface { + height: 100%; + border-radius: 0; + } + } + + app-translation-editor-dialog { + height: 100%; + max-height: 100%; + } + } +} + // Make overlay backdrops slightly darker .cdk-overlay-backdrop, .cdk-overlay-dark-backdrop { @@ -118,3 +166,12 @@ mark.search-highlight { transform: translateY(0); } } + +// The key chip tooltip carries an untruncated resource key, which is longer and +// less breakable than ordinary tooltip copy. +.key-chip-tooltip .mdc-tooltip__surface { + font-family: var(--font-family-mono); + max-width: 32rem; + overflow-wrap: anywhere; + text-align: left; +} diff --git a/apps/tracker/src/styles/tokens.scss b/apps/tracker/src/styles/tokens.scss index 9699325a..40428153 100644 --- a/apps/tracker/src/styles/tokens.scss +++ b/apps/tracker/src/styles/tokens.scss @@ -9,6 +9,9 @@ --color-primary-hover: #c25d55; --color-primary-active: #b04a42; --color-primary-subtle: #fdf2f1; + // Text-weight primary: --color-primary is a fill tone and reaches only 2.68:1 + // on a card, so copy that should read as "primary" uses this instead (5.05:1). + --color-primary-text: #a1443d; // Colors - Secondary (Deep Sky Blue) --color-secondary: #4a7fb5; @@ -31,10 +34,77 @@ // Semantic colors --color-success: #6da67e; --color-warning: #d9a441; + // Text-weight warning: --color-warning is a fill/border tone and only reaches + // ~2.1:1 on the parchment background, so it must never be used for copy. + --color-warning-text: #8a5a00; --color-error: #c95e5e; --color-info: #5a8db8; --color-info-subtle: #e6f0f8; + // Translation status spine. Every surface that reports a status — locale rows, + // the compact badge, the rollup ring and its tooltip — reads these, so a status + // can never mean two different colors in two places. `new` and `stale` are + // deliberately distinct: one is work not started, the other is work gone out of + // date, and conflating them hides the more urgent of the two. + // + // Each status carries two values because the same token is painted on both a + // parchment card and an always-dark tooltip. One mid-tone that cleared 3:1 on + // both would force all four to the same lightness and make them harder to tell + // apart, so the surface picks instead. Ratios below are against the card + // background (--color-background-muted); every value clears 3:1 for non-text. + --status-new-on-light: #a87716; // 3.24:1 + --status-new-on-dark: #e4b454; // 7.52:1 + --status-stale-on-light: #b85c26; // 3.75:1 + --status-stale-on-dark: #e08a4e; // 5.43:1 + --status-translated-on-light: #3f7aa8; // 3.78:1 + --status-translated-on-dark: #7bafde; // 6.19:1 + --status-verified-on-light: #4e8a63; // 3.35:1 + --status-verified-on-dark: #7dbb8f; // 6.43:1 + + --color-status-new: var(--status-new-on-light); + --color-status-stale: var(--status-stale-on-light); + --color-status-translated: var(--status-translated-on-light); + --color-status-verified: var(--status-verified-on-light); + + // Status chips. The tones above are graphic weight — correct for a ring + // segment, too light to set type on and only legible as a 16px glyph. A status + // chip is a filled region that has to read across a list at a glance and carry + // its own label, so it needs a third value the spine did not have: an ink dark + // enough for copy on its own fill. + // + // The four are deliberately NOT equal in force. Most rows in a healthy + // collection are `translated` or `verified`, and giving every status the same + // saturated fill turns a list into a wall of one colour where nothing can be + // picked out — the same mistake as an accent used on everything. So the two + // states that mean "nothing to do here" go quiet: a near-neutral fill with the + // hue carried only by ink and a hairline border. `new` and `stale` — the two + // that mean someone has work — keep a fill saturated enough to find in + // peripheral vision. Scanning a folder, the eye lands on the exceptions. + // + // Every ink is measured against its own surface at ≥4.5:1, so the label reads + // as body copy and the glyph clears the 3:1 control floor with room to spare. + + // Needs work — full fill, findable without reading. + --color-status-new-surface: #e0cfae; + --color-status-new-border: #c7a86b; + --color-status-new-ink: #745415; // 4.54:1 on surface + --color-status-stale-surface: #e6c9b7; + --color-status-stale-border: #d29a79; + --color-status-stale-ink: #884720; // 4.51:1 on surface + + // Done — quiet fill, hue in the ink and the border only. + --color-status-translated-surface: #e8e9e6; + --color-status-translated-border: #bbcbd6; + --color-status-translated-ink: #3a6d95; // 4.53:1 on surface + --color-status-verified-surface: #e9eae1; + --color-status-verified-border: #c0d0bf; + --color-status-verified-ink: #447353; // 4.53:1 on surface + + // Tooltip panel. Stays dark in both themes, so it is built from whichever end + // of the neutral scale is dark in the current theme rather than a fixed hex. + --color-tooltip-surface: var(--color-neutral-800); + --color-tooltip-text: var(--color-neutral-50); + // Watercolor accent palette (for splashes & decorative elements) --wc-coral: #e8847c; --wc-amber: #e8a838; @@ -53,7 +123,15 @@ --color-text-primary: #2e2923; --color-text-secondary: #5a534a; --color-text-tertiary: #a89f93; + // Placeholder / hint text. Darker than tertiary because placeholders are read + // as text and must clear 4.5:1 against --color-background; tertiary is a + // decorative tint at 2.5:1 and is not safe for anything a user has to read. + --color-text-placeholder: #736b61; --color-text-inverse: #faf8f5; + // Ink on a filled primary control. The primary fill is the same light coral + // in both themes, so this ink stays dark in both — swapping it for + // --color-text-primary under dark drops the label to 2:1. + --color-text-on-primary: #2e2923; // Border colors — warm --color-border: #e5dfd7; @@ -84,6 +162,9 @@ --color-primary-hover: #f0a09a; --color-primary-active: #d4726a; --color-primary-subtle: #3d2220; + // The dark-theme primary already clears 4.5:1 on a card (5.5:1), so text-weight + // primary needs no separate tone here. + --color-primary-text: var(--color-primary); // Colors - Secondary (Sky Blue — brightened) --color-secondary: #7bafde; @@ -106,10 +187,46 @@ // Semantic colors — dark mode --color-success: #7dbb8f; --color-warning: #e4b454; + --color-warning-text: #e4b454; --color-error: #d97878; --color-info: #7bafde; --color-info-subtle: #1a2d40; + // Translation status spine — dark. Primitives are declared once on :root; the + // theme only chooses which surface set applies. + --color-status-new: var(--status-new-on-dark); + --color-status-stale: var(--status-stale-on-dark); + --color-status-translated: var(--status-translated-on-dark); + --color-status-verified: var(--status-verified-on-dark); + + // Status chips — dark. Composed against the dark card rather than inverted + // from the light set: the on-dark spine tones are already text-weight, so here + // the surface is the value that moves, sunk toward the page black until the + // ink clears 4.5:1 on it. `stale` sits closest to the warm card and takes the + // narrowest separation of the four; that is the honest cost of keeping it + // distinguishable from `new` rather than the two sharing a hue. + // Needs work — full fill. + --color-status-new-surface: #5b4927; + --color-status-new-border: #997a3c; + --color-status-new-ink: var(--status-new-on-dark); // 4.51:1 on surface + --color-status-stale-surface: #493120; + --color-status-stale-border: #865533; + --color-status-stale-ink: var(--status-stale-on-dark); // 4.54:1 on surface + + // Done — quiet. Sunk toward the page black rather than lifted off the card, so + // the resting states recede and the two above are the only things that glow. + --color-status-translated-surface: #1e2225; + --color-status-translated-border: #374756; + --color-status-translated-ink: var(--status-translated-on-dark); // 6.89:1 on surface + --color-status-verified-surface: #1e231d; + --color-status-verified-border: #384b3b; + --color-status-verified-ink: var(--status-verified-on-dark); // 7.14:1 on surface + + // Tooltip panel — dark. The neutral scale is inverted here, so the dark end is + // the low numbers; this keeps the panel raised above the near-black page. + --color-tooltip-surface: var(--color-neutral-200); + --color-tooltip-text: var(--color-neutral-900); + // Watercolor accents — slightly brighter for dark --wc-coral: #f0a09a; --wc-amber: #f0bc5e; @@ -128,7 +245,9 @@ --color-text-primary: #f3efe9; --color-text-secondary: #d4cdc3; --color-text-tertiary: #7d7569; + --color-text-placeholder: #8a8175; --color-text-inverse: #1c1814; + --color-text-on-primary: #2e2923; // Border colors --color-border: #433d36; diff --git a/apps/tracker/src/testing/transloco-testing.module.ts b/apps/tracker/src/testing/transloco-testing.module.ts index 70ccd4a1..5e8a6fce 100644 --- a/apps/tracker/src/testing/transloco-testing.module.ts +++ b/apps/tracker/src/testing/transloco-testing.module.ts @@ -22,11 +22,9 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'browser.sortDescending': 'Sort descending', 'browser.sortAscendingTooltip': 'Sort ascending (A→Z)', 'browser.sortDescendingTooltip': 'Sort descending (Z→A)', - 'browser.selectFolderFirst': 'Select a folder first', 'browser.addNewTranslationTooltip': 'Add a new translation', 'browser.noTranslation': 'No translation', 'browser.commentPrefix': 'Comment:', - 'browser.toggleExpansion': 'Toggle expansion', 'browser.noResults': 'No results found', 'browser.density.compactTooltip': 'Compact — 2-line view, high density', 'browser.density.fullTooltip': 'Full — detailed view with comments and all locales', @@ -89,7 +87,9 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'browser.toast.moveFolderFailed': 'Failed to move folder', 'browser.translationEditor.closeDialog': 'Close dialog', 'browser.translationEditor.translationKey': 'Translation Key', - 'browser.translationEditor.keyHint': 'Use only letters, numbers, underscores, and hyphens', + 'browser.translationEditor.keyHint': + 'Entry name only — paste a full dotted key and its folder part moves to Location', + 'browser.translationEditor.locationFromKeyX': 'Location set to {{ folder }} from the key you entered', 'browser.translationEditor.baseBadge': 'Base', 'browser.translationEditor.localeTranslationX': '{{ locale }} Translation', 'browser.translationEditor.enterTranslationX': 'Enter {{ locale }} translation...', @@ -97,7 +97,6 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'browser.translationEditor.commentLabel': 'Comment (Optional)', 'browser.translationEditor.commentPlaceholder': 'Add context for translators...', 'browser.translationEditor.commentHint': 'Provide context or instructions for translators', - 'browser.translationEditor.otherLocalesToggleX': 'Add translations for other locales ({{ count }} available)', 'browser.translationEditor.status': 'Status', 'browser.translationEditor.saving': 'Saving...', 'browser.translationEditor.editTitle': 'Edit Translation', @@ -109,7 +108,7 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'browser.translationEditor.keyCopied': 'Similar resource key copied', 'browser.translationEditor.copyFailed': 'Failed to copy', 'browser.translationEditor.keyRequired': 'Translation key is required', - 'browser.translationEditor.keyPatternError': 'Only letters, numbers, underscores, and hyphens allowed', + 'browser.translationEditor.keyPatternError': 'Use letters, numbers, hyphens and underscores only', 'browser.translationEditor.commentConfirm.title': 'No Comment Added', 'browser.translationEditor.commentConfirm.message': 'Comments help other translators understand context. Are you sure you want to save without a comment?', diff --git a/apps/tracker/tsconfig.json b/apps/tracker/tsconfig.json index ede744a8..c3cd71d4 100644 --- a/apps/tracker/tsconfig.json +++ b/apps/tracker/tsconfig.json @@ -7,6 +7,9 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "target": "es2022", + // Match lib to target: the base config still pins lib to es2020, which hides + // ES2021 runtime APIs the browser bundle can rely on (e.g. Intl.ListFormat). + "lib": ["es2022", "dom"], "moduleResolution": "bundler", "isolatedModules": true, "emitDecoratorMetadata": false, diff --git a/architecture-docs/api.md b/architecture-docs/api.md index b017fd9c..9d066040 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -173,22 +173,17 @@ This means a single `node apps/api/main.js` process serves both the UI and the A ## Collection Cache -### Single-Collection Design +### Bounded Multi-Collection Design -`CollectionCacheService` holds at most one `CachedCollection` at a time, stored in a private field `#cachedCollection`. The constraint is intentional: +`CollectionCacheService` holds a `Map` of `CachedCollection` entries keyed by collection name, capped at `LINGO_TRACKER_MAX_CACHED_COLLECTIONS` (default 4) and evicted least-recently-used. -**Memory constraints.** A fully-loaded [resource tree](glossary.md#resource-tree) for a large collection (thousands of keys, multiple locales, full translation values and metadata) can be several megabytes of JavaScript heap. Caching multiple collections simultaneously would multiply this linearly with no benefit in the typical usage pattern. +**Why more than one.** Opening a second collection in another browser tab is a real usage pattern. With a single slot, each tab's 2-second `/cache/status` poll evicted the other tab's cache, so neither ever reached `READY`, both polled forever, and the server re-indexed continuously. Independent entries remove the contention entirely. -**Typical single-collection usage pattern.** The Tracker UI exposes a collection selector, but users nearly always work within one collection at a time. Switching collections triggers a new cache build for the incoming collection — the outgoing collection's cache is discarded immediately when `setCacheStatus()` is called for a different collection name. +**Why bounded.** A fully-loaded [resource tree](glossary.md#resource-tree) for a large collection (thousands of keys, multiple locales, full translation values and metadata) can be tens of megabytes of JavaScript heap, and that cost multiplies per cached collection. The cap is a memory budget; lower it to 1 to restore the old single-slot behaviour. -```typescript -// From CollectionCacheService.setCacheStatus() -if (this.#cachedCollection && this.#cachedCollection.collectionName !== collectionName) { - this.#cachedCollection = null; // evict the previous collection -} -``` +**Eviction.** On inserting a new entry at the cap, the entry with the lowest `accessSequence` is dropped. `accessSequence` is a monotonic counter bumped on every read and write, not a clock — several collections can be touched inside the same millisecond and eviction still needs a strict order. An entry in `INDEXING` state is never chosen: discarding in-flight work would leave the request that started it waiting for nothing, so the map is allowed to overflow briefly when every entry is busy. -There is no LRU strategy or size limit — the single-slot design is the entire eviction policy. +**Per-entry state.** Fingerprint, revalidation throttle stamp and the deferred fingerprint-refresh timer all live on the entry. A read of one collection therefore cannot postpone another collection's staleness check. ### Cache State Machine @@ -196,7 +191,7 @@ There is no LRU strategy or size limit — the single-slot design is the entire ```mermaid stateDiagram-v2 - [*] --> NOT_STARTED : server start\nor collection switch + [*] --> NOT_STARTED : server start\nor cache eviction NOT_STARTED --> INDEXING : indexCollection() called\n(triggered by first /tree or /cache/status request) ERROR --> INDEXING : indexCollection() called\n(auto-retry on next /tree request) @@ -204,8 +199,8 @@ stateDiagram-v2 INDEXING --> READY : core.loadResourceTree() succeeds INDEXING --> ERROR : core.loadResourceTree() throws - READY --> NOT_STARTED : clearCache() called\n(delete/move resource or locale change) - READY --> INDEXING : collection switch\ntriggers new indexCollection() + READY --> NOT_STARTED : clearCache(name) called\n(delete/move resource or locale change) + READY --> NOT_STARTED : evicted as least recently used\n(cache at its collection limit) READY --> READY : incremental update\n(addResourceToCache, addFolderToCache,\nremoveFolderFromCache, removeResourceFromCache,\nmoveFolderInCache) ``` @@ -216,8 +211,8 @@ State values are the string literals from the `CacheStatus` enum in `collection- |-------|-------------|---------| | `NOT_STARTED` | `"not-started"` | No cache exists for this collection. Indexing has not been requested yet. | | `INDEXING` | `"indexing"` | `core.loadResourceTree()` is running asynchronously. Read requests must wait. | -| `READY` | `"ready"` | Tree is in memory. Read requests are served instantly from `#cachedCollection.tree`. | -| `ERROR` | `"error"` | The last indexing attempt threw. The error message is stored in `#cachedCollection.error`. The next `/tree` or `/cache/status` request automatically re-triggers indexing. | +| `READY` | `"ready"` | Tree is in memory. Read requests are served instantly from the entry's `tree`. | +| `ERROR` | `"error"` | The last indexing attempt threw. The error message is stored on the entry. The next `/tree` or `/cache/status` request automatically re-triggers indexing. | ### Incremental Updates vs Full Cache Clear @@ -231,9 +226,9 @@ After a successful write operation the cache is updated by one of two strategies | `removeResourceFromCache()` | `PATCH /resources` (when resource moves folder — removes from old location) | | `addFolderToCache()` | `POST /folders` | | `removeFolderFromCache()` | `DELETE /folders` | -| `moveFolderInCache()` | `POST /folders/move` (with `clearCache()` fallback if structural navigation fails) | +| `moveFolderInCache()` | `POST /folders/move` within one collection (with `clearCache(name)` fallback if structural navigation fails; a cross-collection move clears both collections instead) | -**Full cache clear** — for operations where the breadth of changes cannot be tracked in a single incremental call, or where correctness risk outweighs the cost of a re-index. `clearCache()` sets `#cachedCollection` to `null`, returning state to `NOT_STARTED`. The next request to `/tree` or `/cache/status` triggers a fresh `indexCollection()`. +**Full cache clear** — for operations where the breadth of changes cannot be tracked in a single incremental call, or where correctness risk outweighs the cost of a re-index. `clearCache(collectionName)` drops that one collection's entry, returning it to `NOT_STARTED`; the next request to `/tree` or `/cache/status` triggers a fresh `indexCollection()`. It is always scoped to the collection that was written — a write against one collection must never force a re-index of a collection somebody else is viewing. Cross-collection moves clear the source and every destination collection they touched. `clearAllCaches()` exists for changes that invalidate everything. | Operation | Why full clear | |---|---| diff --git a/docs/api.md b/docs/api.md index d8198050..864d9947 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,6 +12,7 @@ The LingoTracker API provides REST endpoints for managing translation resources, - **Base URL**: `http://localhost:3030/api` - **Default Port**: `3030` (configurable via `LINGO_TRACKER_PORT` environment variable) - **Cache revalidation interval**: `2000` ms (configurable via `LINGO_TRACKER_REVALIDATE_INTERVAL_MS` environment variable) +- **Cached collections**: `4` at most, least-recently-used evicted (configurable via `LINGO_TRACKER_MAX_CACHED_COLLECTIONS` environment variable) - **Content-Type**: `application/json` - **Response Format**: JSON - **CORS**: Enabled with wildcard origin (`*`) in development diff --git a/libs/core/src/lib/validate/generate-validation-summary.ts b/libs/core/src/lib/validate/generate-validation-summary.ts index 035235f8..7d955d3e 100644 --- a/libs/core/src/lib/validate/generate-validation-summary.ts +++ b/libs/core/src/lib/validate/generate-validation-summary.ts @@ -6,6 +6,14 @@ import type { IcuValidationResult, } from './types'; +/** + * The fields every per-value detail carries, whichever pass produced it. + * + * The ICU pass and the placeholder pass both report one explained problem per + * value, so they share a renderer rather than each growing their own. + */ +type ExplainedDetail = Pick; + /** * Maximum number of resources to display in each failure/warning category * before truncating with an overflow message. @@ -55,6 +63,14 @@ export function generateValidationSummary(result: ResourceValidationResult, opti if (result.icu) { sections.push(...buildIcuSections(result.icu)); } + if (result.placeholders && result.placeholders.failures.length > 0) { + sections.push( + buildDetailSection( + `❌ Placeholder Failures (${result.placeholders.failures.length})`, + result.placeholders.failures, + ), + ); + } sections.push(buildFooterSection(result, options)); return sections.join('\n\n'); @@ -102,6 +118,10 @@ function buildStatisticsSection(result: ResourceValidationResult, options: Valid lines.push(` ICU Values Compiled: ${result.icu.valuesChecked}`); } + if (result.placeholders) { + lines.push(` Placeholders Compared: ${result.placeholders.valuesChecked}`); + } + return lines.join('\n'); } @@ -122,27 +142,28 @@ function buildIcuSections(icu: IcuValidationResult): string[] { sections.push(buildUnsupportedLocalesSection(icu.unsupportedLocales)); } if (icu.failures.length > 0) { - sections.push(buildIcuDetailSection(`❌ ICU Failures (${icu.failures.length})`, icu.failures)); + sections.push(buildDetailSection(`❌ ICU Failures (${icu.failures.length})`, icu.failures)); } if (icu.warnings.length > 0) { - sections.push(buildIcuDetailSection(`⚠️ ICU Warnings (${icu.warnings.length})`, icu.warnings)); + sections.push(buildDetailSection(`⚠️ ICU Warnings (${icu.warnings.length})`, icu.warnings)); } return sections; } /** - * Builds a list of ICU details grouped by locale. + * Builds a list of per-value details grouped by locale. * * Each entry carries its own explanation, so unlike the status sections the * message is printed alongside the key rather than a shared emoji legend. + * Shared by the ICU and placeholder passes, which report the same shape. * * @param heading - Section heading, already including its count - * @param details - The ICU details to list + * @param details - The details to list * @returns Formatted section string * @internal */ -function buildIcuDetailSection(heading: string, details: readonly IcuValidationDetail[]): string { +function buildDetailSection(heading: string, details: readonly ExplainedDetail[]): string { const lines = [`${heading}:`, '─'.repeat(50)]; const byLocale = groupByLocale(details); @@ -306,6 +327,9 @@ function buildFooterSection(result: ResourceValidationResult, options: Validatio if (result.icu && result.icu.failures.length > 0) { lines.push(` Total ICU Failures: ${result.icu.failures.length}`); } + if (result.placeholders && result.placeholders.failures.length > 0) { + lines.push(` Total Placeholder Failures: ${result.placeholders.failures.length}`); + } if (options.allowTranslated && result.warnings.length > 0) { lines.push(` Total Warnings: ${result.warnings.length}`); } diff --git a/libs/core/src/lib/validate/index.ts b/libs/core/src/lib/validate/index.ts index 1b20daa5..fb335460 100644 --- a/libs/core/src/lib/validate/index.ts +++ b/libs/core/src/lib/validate/index.ts @@ -2,3 +2,4 @@ export * from './types'; export * from './validate-resources'; export * from './generate-validation-summary'; export * from './validate-icu'; +export * from './validate-placeholders'; diff --git a/libs/core/src/lib/validate/types.ts b/libs/core/src/lib/validate/types.ts index 239f3d26..3aaa8d8a 100644 --- a/libs/core/src/lib/validate/types.ts +++ b/libs/core/src/lib/validate/types.ts @@ -29,6 +29,83 @@ export interface ValidationOptions { * Omit when no ICU check is wanted at all. */ readonly icu?: IcuValidationOptions; + + /** + * When present, every translation is checked against its base value for the + * arguments it interpolates. + * + * A third question again: a translation can be approved by a reviewer and + * compile cleanly while interpolating an argument nobody passes, because a + * machine translator renamed it along with the prose. ICU renders the + * argument as empty text rather than raising, so nothing else catches it. + * + * Omit to skip the check. + */ + readonly placeholders?: PlaceholderValidationOptions; +} + +/** + * Options controlling the placeholder-agreement pass. + */ +export interface PlaceholderValidationOptions { + /** + * The locale whose value defines the arguments a translation must interpolate. + * + * The base value is the contract: it is what the calling code passes + * arguments for, so it is what every translation has to agree with. + */ + readonly baseLocale: string; +} + +/** + * A translation whose interpolated arguments disagree with its base value. + */ +export interface PlaceholderValidationDetail { + /** + * The full dot-delimited key of the resource (e.g., 'common.buttons.ok'). + */ + readonly key: string; + + /** + * The locale the offending value is stored under. + */ + readonly locale: string; + + /** + * The collection this resource belongs to. + */ + readonly collection: string; + + /** + * Arguments the base value interpolates that this translation does not. + */ + readonly missing: readonly string[]; + + /** + * Arguments this translation interpolates that the base value does not. + */ + readonly unexpected: readonly string[]; + + /** + * A single-line explanation suitable for a CI log. + */ + readonly message: string; +} + +/** + * Outcome of the placeholder-agreement pass. + */ +export interface PlaceholderValidationResult { + /** + * Translations whose arguments disagree with their base value. These are hard + * blockers: the placeholder renders as empty text wherever it appears. + */ + readonly failures: readonly PlaceholderValidationDetail[]; + + /** + * How many stored translations were actually compared. + */ + readonly valuesChecked: number; } /** @@ -205,8 +282,15 @@ export interface ResourceValidationResult { readonly icu?: IcuValidationResult; /** - * Whether the validation passed overall (no status failures and no ICU - * compile failures). Note: warnings do not cause validation to fail. + * Outcome of the placeholder-agreement pass, when one was requested. + * Undefined when placeholder checking was disabled. + */ + readonly placeholders?: PlaceholderValidationResult; + + /** + * Whether the validation passed overall (no status failures, no ICU compile + * failures, and no placeholder mismatches). Note: warnings do not cause + * validation to fail. */ readonly passed: boolean; } diff --git a/libs/core/src/lib/validate/validate-placeholders.spec.ts b/libs/core/src/lib/validate/validate-placeholders.spec.ts new file mode 100644 index 00000000..fcd8a09a --- /dev/null +++ b/libs/core/src/lib/validate/validate-placeholders.spec.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest'; +import { validatePlaceholders } from './validate-placeholders'; +import type { LoadedResource } from '../export/export-common'; + +const resource = (overrides: Partial = {}): LoadedResource => ({ + key: 'folderAriaLabelX', + fullKey: 'browser.folderNode.folderAriaLabelX', + source: 'Folder {name}', + translations: {}, + status: {}, + collection: 'trackerResources', + ...overrides, +}); + +describe('validatePlaceholders', () => { + it('passes when every translation interpolates the same argument', () => { + const result = validatePlaceholders( + [resource({ translations: { de: 'Ordner {name}', es: 'Carpeta {name}' } })], + ['de', 'es'], + 'en', + ); + + expect(result.failures).toEqual([]); + expect(result.valuesChecked).toBe(2); + }); + + it('reports a translated argument name', () => { + const result = validatePlaceholders([resource({ translations: { es: 'Carpeta {nombre}' } })], ['es'], 'en'); + + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toMatchObject({ + key: 'browser.folderNode.folderAriaLabelX', + locale: 'es', + collection: 'trackerResources', + missing: ['name'], + unexpected: ['nombre'], + }); + }); + + it('describes a one-for-one swap as a rename', () => { + const result = validatePlaceholders([resource({ translations: { es: 'Carpeta {nombre}' } })], ['es'], 'en'); + + expect(result.failures[0]?.message).toBe( + "Placeholder '{name}' was renamed to '{nombre}'; it renders as empty text", + ); + }); + + it('catches a case-only rename', () => { + // The one that survives human review: German noun capitalisation is also a rename. + const result = validatePlaceholders([resource({ translations: { de: 'Ordner {Name}' } })], ['de'], 'en'); + + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.missing).toEqual(['name']); + expect(result.failures[0]?.unexpected).toEqual(['Name']); + }); + + it('accepts Transloco syntax in a stored value', () => { + const result = validatePlaceholders([resource({ translations: { de: 'Ordner {{ name }}' } })], ['de'], 'en'); + + expect(result.failures).toEqual([]); + }); + + it('reports every offending locale rather than stopping at the first', () => { + const result = validatePlaceholders( + [ + resource({ + translations: { de: 'Ordner {Name}', es: 'Carpeta {nombre}', fr: 'Dossier {nom}', ja: 'フォルダ {name}' }, + }), + ], + ['de', 'es', 'fr', 'ja'], + 'en', + ); + + expect(result.failures.map((f) => f.locale)).toEqual(['de', 'es', 'fr']); + expect(result.valuesChecked).toBe(4); + }); + + it('skips a locale with no stored translation', () => { + // An absent translation is the status gate's failure to report, not this one's. + const result = validatePlaceholders([resource({ translations: {} })], ['de'], 'en'); + + expect(result.failures).toEqual([]); + expect(result.valuesChecked).toBe(0); + }); + + it('never compares the base locale against itself', () => { + const result = validatePlaceholders([resource({ translations: { en: 'Folder {name}' } })], ['en', 'de'], 'en'); + + expect(result.valuesChecked).toBe(0); + }); + + it('prefers an explicit base-locale translation over source', () => { + const result = validatePlaceholders( + [resource({ source: 'Folder {old}', translations: { en: 'Folder {name}', de: 'Ordner {name}' } })], + ['de'], + 'en', + ); + + expect(result.failures).toEqual([]); + }); + + it('reports a dropped placeholder', () => { + const result = validatePlaceholders([resource({ translations: { de: 'Ordner' } })], ['de'], 'en'); + + expect(result.failures[0]).toMatchObject({ missing: ['name'], unexpected: [] }); + expect(result.failures[0]?.message).toBe("Placeholders disagree with the base value: missing '{name}'"); + }); + + it('reports an added placeholder', () => { + const result = validatePlaceholders( + [resource({ source: 'Folder', translations: { de: 'Ordner {name}' } })], + ['de'], + 'en', + ); + + expect(result.failures[0]).toMatchObject({ missing: [], unexpected: ['name'] }); + expect(result.failures[0]?.message).toBe("Placeholders disagree with the base value: unexpected '{name}'"); + }); + + it('tolerates a locale using its own plural categories', () => { + const result = validatePlaceholders( + [ + resource({ + source: '{count, plural, =1 {1 file} other {# files}}', + translations: { ru: '{count, plural, one {# файл} few {# файла} other {# файлов}}' }, + }), + ], + ['ru'], + 'en', + ); + + expect(result.failures).toEqual([]); + }); + + it('checks arguments used only inside a plural branch', () => { + const result = validatePlaceholders( + [ + resource({ + source: '{count, plural, other {# files in {dir}}}', + translations: { de: '{count, plural, other {# Dateien in {Ordner}}}' }, + }), + ], + ['de'], + 'en', + ); + + expect(result.failures[0]).toMatchObject({ missing: ['dir'], unexpected: ['Ordner'] }); + }); + + it('passes a value with no placeholders at all', () => { + const result = validatePlaceholders( + [resource({ source: 'Save', translations: { de: 'Speichern' } })], + ['de'], + 'en', + ); + + expect(result.failures).toEqual([]); + expect(result.valuesChecked).toBe(1); + }); + + it('leaves an unparseable translation to the ICU pass', () => { + const result = validatePlaceholders([resource({ translations: { de: 'Ordner {unbalanced' } })], ['de'], 'en'); + + expect(result.failures).toEqual([]); + }); +}); diff --git a/libs/core/src/lib/validate/validate-placeholders.ts b/libs/core/src/lib/validate/validate-placeholders.ts new file mode 100644 index 00000000..d7de6a53 --- /dev/null +++ b/libs/core/src/lib/validate/validate-placeholders.ts @@ -0,0 +1,99 @@ +import { compareIcuArguments } from '@simoncodes-ca/domain'; +import type { LoadedResource } from '../export/export-common'; +import type { PlaceholderValidationDetail, PlaceholderValidationResult } from './types'; + +/** + * Checks that every translation interpolates the same arguments as its base value. + * + * This asks a third question, distinct from the other two passes. Status + * validation asks whether a human approved the wording. ICU validation asks + * whether the value compiles. Neither notices a translation that compiles + * perfectly, reads perfectly, and interpolates the wrong name. + * + * That defect is silent by construction: ICU renders a missing argument as an + * empty string, so `Folder {name}` translated as `Carpeta {nombre}` renders as + * "Carpeta " and throws nothing. It survives review because the prose is + * correct — only the identifier inside the braces is wrong. Machine translation + * produces it routinely, translating the argument name along with the sentence. + * + * The case that most needs a machine to catch it is the one that looks + * harmless: `Ordner {Name}`, where German capitalisation of a noun is also a + * rename, because ICU argument names are case-sensitive. + * + * Every value is checked; the pass never stops at the first failure. + * + * @param resources - Resources already loaded from the collections under validation. + * @param targetLocales - Target locales to check translations for. + * @param baseLocale - The locale whose value defines the expected arguments. + * @returns One failure per translation whose arguments disagree with the base value. + */ +export function validatePlaceholders( + resources: readonly LoadedResource[], + targetLocales: readonly string[], + baseLocale: string, +): PlaceholderValidationResult { + const failures: PlaceholderValidationDetail[] = []; + let valuesChecked = 0; + + // The base locale defines the contract; comparing it against itself proves nothing. + const localesToCheck = targetLocales.filter((locale) => locale !== baseLocale); + + for (const resource of resources) { + const baseValue = resource.translations[baseLocale] ?? resource.source; + if (baseValue === undefined) continue; + + for (const locale of localesToCheck) { + const translatedValue = resource.translations[locale]; + + // An absent translation is the status pass's business. There is no value + // here to disagree with the base one, and reporting one gap twice helps nobody. + if (translatedValue === undefined) continue; + + valuesChecked++; + + const mismatch = compareIcuArguments(baseValue, translatedValue); + if (!mismatch) continue; + + failures.push({ + key: resource.fullKey, + locale, + collection: resource.collection, + missing: mismatch.missing, + unexpected: mismatch.unexpected, + message: describeMismatch(mismatch.missing, mismatch.unexpected), + }); + } + } + + return { failures, valuesChecked }; +} + +/** + * Renders a mismatch as one line a CI log can be read from. + * + * When each list holds exactly one name, the two together are almost always a + * rename, so the message says so rather than making the reader infer it from + * two lists. + * + * @internal + */ +function describeMismatch(missing: readonly string[], unexpected: readonly string[]): string { + if (missing.length === 1 && unexpected.length === 1) { + return `Placeholder '{${missing[0]}}' was renamed to '{${unexpected[0]}}'; it renders as empty text`; + } + + const parts: string[] = []; + if (missing.length > 0) { + parts.push(`missing ${quoteAll(missing)}`); + } + if (unexpected.length > 0) { + parts.push(`unexpected ${quoteAll(unexpected)}`); + } + + return `Placeholders disagree with the base value: ${parts.join(', ')}`; +} + +/** Renders argument names as they appear in a value, for a log line. @internal */ +function quoteAll(args: readonly string[]): string { + return args.map((arg) => `'{${arg}}'`).join(', '); +} diff --git a/libs/core/src/lib/validate/validate-resources.ts b/libs/core/src/lib/validate/validate-resources.ts index 171c0c3c..d78b6311 100644 --- a/libs/core/src/lib/validate/validate-resources.ts +++ b/libs/core/src/lib/validate/validate-resources.ts @@ -1,6 +1,7 @@ import { loadResourcesFromCollections, type LoadedResource } from '../export/export-common'; import type { TranslationStatus } from '@simoncodes-ca/domain'; import { validateIcuValues } from './validate-icu'; +import { validatePlaceholders } from './validate-placeholders'; import type { ValidationOptions, ResourceValidationResult, ResourceValidationDetail, StatusCounts } from './types'; /** @@ -24,6 +25,11 @@ import type { ValidationOptions, ResourceValidationResult, ResourceValidationDet * failure regardless of its status — plural categories are per-language, so a * value approved by a reviewer can still throw for its own locale. * + * When `options.placeholders` is provided, a third pass checks that every + * translation interpolates the same arguments as its base value. A renamed + * argument renders as empty text rather than raising, so neither of the other + * two passes can see it. + * * The function validates ALL resources comprehensively before returning results. * This ensures teams have complete visibility into translation status across * their entire project. @@ -101,10 +107,18 @@ export function validateResources( // whether the stored value renders at all, not whether anyone approved it. const icu = options.icu ? validateIcuValues(loadedResources, targetLocales, options.icu) : undefined; - const passed = failures.length === 0 && (icu?.failures.length ?? 0) === 0; + // And placeholder agreement is a third: a value can be approved and compile + // cleanly while interpolating an argument the caller never passes. + const placeholders = options.placeholders + ? validatePlaceholders(loadedResources, targetLocales, options.placeholders.baseLocale) + : undefined; + + const passed = + failures.length === 0 && (icu?.failures.length ?? 0) === 0 && (placeholders?.failures.length ?? 0) === 0; return { icu, + placeholders, totalResourcesValidated, totalUniqueKeys: loadedResources.length, localesValidated: targetLocales.length, diff --git a/libs/domain/src/index.ts b/libs/domain/src/index.ts index 4271d7b7..e5f54083 100644 --- a/libs/domain/src/index.ts +++ b/libs/domain/src/index.ts @@ -17,3 +17,4 @@ export * from './lib/protected-terms'; export * from './lib/normalized-levenshtein'; export * from './lib/icu-locale-validation'; export * from './lib/portable-plural-categories'; +export * from './lib/icu-arguments'; diff --git a/libs/domain/src/lib/icu-arguments.spec.ts b/libs/domain/src/lib/icu-arguments.spec.ts new file mode 100644 index 00000000..64184baa --- /dev/null +++ b/libs/domain/src/lib/icu-arguments.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { findIcuArguments, compareIcuArguments } from './icu-arguments'; + +describe('findIcuArguments', () => { + it('returns an empty set for a value with no arguments', () => { + expect(findIcuArguments('No placeholders here')).toEqual(new Set()); + }); + + it('finds a plain argument', () => { + expect(findIcuArguments('Folder {name}')).toEqual(new Set(['name'])); + }); + + it('accepts Transloco syntax', () => { + expect(findIcuArguments('Folder {{ name }}')).toEqual(new Set(['name'])); + }); + + it('gives the same answer in either syntax', () => { + expect(findIcuArguments('Folder {{ name }}')).toEqual(findIcuArguments('Folder {name}')); + }); + + it('finds a formatted argument', () => { + expect(findIcuArguments('{count, number} files')).toEqual(new Set(['count'])); + }); + + it('finds the argument a plural switches on', () => { + expect(findIcuArguments('{count, plural, =1 {1 file} other {# files}}')).toEqual(new Set(['count'])); + }); + + it('finds arguments used only inside a branch', () => { + const value = '{count, plural, =1 {1 file in {dir}} other {# files in {dir}}}'; + expect(findIcuArguments(value)).toEqual(new Set(['count', 'dir'])); + }); + + it('finds arguments nested through a select inside a plural', () => { + const value = '{n, plural, =1 {{kind, select, a {{x}} other {{y}}}} other {#}}'; + expect(findIcuArguments(value)).toEqual(new Set(['n', 'kind', 'x', 'y'])); + }); + + it('excludes branch selectors', () => { + const args = findIcuArguments('{count, plural, =1 {one} one {one} few {few} other {many}}'); + expect(args).toEqual(new Set(['count'])); + }); + + it('excludes the octothorpe', () => { + expect(findIcuArguments('{count, plural, other {# items}}')).toEqual(new Set(['count'])); + }); + + it('collapses a repeated argument', () => { + expect(findIcuArguments('{a} and {a} and {a}')).toEqual(new Set(['a'])); + }); + + it('returns an empty set for an unparseable value', () => { + expect(findIcuArguments('{unbalanced')).toEqual(new Set()); + }); + + it('is case-sensitive', () => { + expect(findIcuArguments('Ordner {Name}')).toEqual(new Set(['Name'])); + }); +}); + +describe('compareIcuArguments', () => { + it('returns null when the arguments match', () => { + expect(compareIcuArguments('Folder {name}', 'Ordner {name}')).toBeNull(); + }); + + it('returns null when neither value interpolates anything', () => { + expect(compareIcuArguments('Save', 'Guardar')).toBeNull(); + }); + + it('reports a translated argument name in both directions', () => { + expect(compareIcuArguments('Folder {name}', 'Carpeta {nombre}')).toEqual({ + missing: ['name'], + unexpected: ['nombre'], + }); + }); + + it('catches a case-only difference', () => { + // The one that survives review: German `{{ Name }}` looks right and is not. + expect(compareIcuArguments('Folder {name}', 'Ordner {Name}')).toEqual({ + missing: ['name'], + unexpected: ['Name'], + }); + }); + + it('reports a dropped argument with nothing unexpected', () => { + expect(compareIcuArguments('Folder {name}', 'Dossier')).toEqual({ missing: ['name'], unexpected: [] }); + }); + + it('reports an added argument with nothing missing', () => { + expect(compareIcuArguments('Folder', 'Dossier {name}')).toEqual({ missing: [], unexpected: ['name'] }); + }); + + it('ignores ordering and repetition', () => { + expect(compareIcuArguments('{a} then {b}', '{b} then {a} then {a}')).toBeNull(); + }); + + it('compares across syntaxes', () => { + expect(compareIcuArguments('Folder {name}', 'Ordner {{ name }}')).toBeNull(); + }); + + it('tolerates a locale using a different plural category', () => { + // Categories are ICU vocabulary, not arguments; they are meant to differ. + const base = '{count, plural, =1 {1 file} other {# files}}'; + const russian = '{count, plural, one {# файл} few {# файла} other {# файлов}}'; + expect(compareIcuArguments(base, russian)).toBeNull(); + }); + + it('sorts both lists so a report is stable', () => { + const mismatch = compareIcuArguments('{b} {a}', '{d} {c}'); + expect(mismatch).toEqual({ missing: ['a', 'b'], unexpected: ['c', 'd'] }); + }); + + it('reports nothing when the translation is unparseable', () => { + // An unparseable value is the ICU compile check's defect to report, not this + // one's — and reporting it here would name the wrong repair, since a value + // that will not parse looks identical to one that dropped every placeholder. + expect(compareIcuArguments('Folder {name}', '{unbalanced')).toBeNull(); + }); + + it('reports nothing when the base value is unparseable', () => { + expect(compareIcuArguments('{unbalanced', 'Ordner {name}')).toBeNull(); + }); +}); diff --git a/libs/domain/src/lib/icu-arguments.ts b/libs/domain/src/lib/icu-arguments.ts new file mode 100644 index 00000000..5171d017 --- /dev/null +++ b/libs/domain/src/lib/icu-arguments.ts @@ -0,0 +1,178 @@ +import { parse, type Token } from '@messageformat/parser'; +import { normalizeTranslocoSyntax } from './normalize-transloco-syntax'; + +/** + * The set of arguments a message interpolates. + * + * A translation has to interpolate the same arguments as the value it was + * translated from. When it does not, nothing throws: ICU renders the message + * with an empty string where the argument should have been, so the defect + * reaches production looking like ordinary text. + * + * The failure this catches in practice is a machine translator translating the + * argument name along with the prose — `Folder {name}` coming back as + * `Carpeta {nombre}`, `フォルダ {名前}`, or, most easily missed of all, + * `Ordner {Name}`. Argument names are case-sensitive identifiers, so the German + * one is as broken as the Japanese one while looking entirely correct. + * + * @module icu-arguments + */ + +/** + * Collects the names of every argument a message interpolates. + * + * Counts plain arguments (`{name}`), formatted ones (`{count, number}`), and + * the argument a `plural`, `selectordinal` or `select` switches on — each of + * those is a name the caller must supply. Recurses into branch bodies, so an + * argument used only inside one branch is still reported. + * + * Branch selectors (`one`, `=1`, `other`) are not arguments and are excluded: + * they are ICU's own vocabulary and are expected to differ between locales, + * which is the entire point of a plural rule. `#` is excluded for the same + * reason — it refers to the enclosing plural's argument, which is already + * counted. + * + * A set rather than a list, because repetition carries no obligation: a + * translation may legitimately mention an argument more times than the base + * value does, or fewer, provided it mentions the same ones. + * + * Values that do not parse yield an empty set. An unparseable value is the ICU + * compile check's business, and reporting one defect as two helps nobody. Use + * `compareIcuArguments` rather than comparing two of these sets by hand: it + * tells an unparseable value apart from one that genuinely has no arguments. + * + * Accepts either syntax: Transloco's `{{ name }}` is normalized to `{name}` + * first, so stored values and bundled values give the same answer. + * + * @param value - The translation string, in ICU or Transloco syntax. + * @returns The distinct argument names the value interpolates. + * + * @example + * ```typescript + * findIcuArguments('Folder {name}'); + * // → Set { 'name' } + * + * findIcuArguments('Folder {{ name }}'); + * // → Set { 'name' } — same answer in either syntax + * + * findIcuArguments('{count, plural, one {1 file in {dir}} other {# files in {dir}}}'); + * // → Set { 'count', 'dir' } + * + * findIcuArguments('No placeholders here'); + * // → Set {} + * ``` + */ +export function findIcuArguments(value: string): ReadonlySet { + return parseArguments(value) ?? new Set(); +} + +/** + * Collects a value's arguments, or reports that it could not be parsed. + * + * The distinction matters to `compareIcuArguments` and nowhere else: a value + * that does not parse has no arguments to speak of, which is not the same as a + * value that parses and interpolates none. Treating the two alike makes every + * malformed translation look as though it had dropped every placeholder. + * + * @internal + */ +function parseArguments(value: string): Set | null { + let tokens: Token[]; + try { + tokens = parse(normalizeTranslocoSyntax(value)); + } catch { + return null; + } + + const found = new Set(); + collectArguments(tokens, found); + return found; +} + +/** + * Walks a parsed token tree, adding the name of every argument it interpolates. + * + * @internal + */ +function collectArguments(tokens: readonly Token[], found: Set): void { + for (const token of tokens) { + switch (token.type) { + case 'content': + case 'octothorpe': + break; + + case 'argument': + case 'function': + found.add(token.arg); + break; + + // The argument a sub-message switches on is supplied by the caller like + // any other, so it counts, and its branches can hold arguments of their own. + default: + found.add(token.arg); + for (const branch of token.cases) { + collectArguments(branch.tokens, found); + } + break; + } + } +} + +/** + * A mismatch between the arguments a translation interpolates and the ones its + * base value does. + */ +export interface ArgumentMismatch { + /** Arguments the base value interpolates that the translation does not. */ + readonly missing: readonly string[]; + + /** Arguments the translation interpolates that the base value does not. */ + readonly unexpected: readonly string[]; +} + +/** + * Compares the arguments of a translation against those of its base value. + * + * Reports both directions, because they fail differently and a reader needs to + * tell them apart. A missing argument renders as nothing — the folder name + * simply never appears. An unexpected one names a value no caller passes, so it + * renders as nothing too, but the fix is different: usually the argument was + * renamed rather than dropped, and the two lists read together name the rename. + * + * Both lists are sorted, so a report is stable between runs. + * + * Returns null when either value fails to parse. A malformed value would + * otherwise read as having dropped every placeholder, which names the wrong + * repair — the ICU compile pass reports the syntax error itself. + * + * @param baseValue - The base-locale value. + * @param translatedValue - The value stored for a target locale. + * @returns The mismatch, or null when both interpolate the same arguments. + * + * @example + * ```typescript + * compareIcuArguments('Folder {name}', 'Carpeta {nombre}'); + * // → { missing: ['name'], unexpected: ['nombre'] } + * + * compareIcuArguments('Folder {name}', 'Ordner {name}'); + * // → null + * + * compareIcuArguments('Folder {name}', 'Ordner {unbalanced'); + * // → null — a parse error, reported by the ICU pass rather than as a mismatch + * ``` + */ +export function compareIcuArguments(baseValue: string, translatedValue: string): ArgumentMismatch | null { + const expected = parseArguments(baseValue); + const actual = parseArguments(translatedValue); + + // A value that will not parse has no arguments to compare, which is not the + // same as having none. Reporting it here would dress a syntax error up as a + // set of dropped placeholders and point the reader at the wrong repair; the + // ICU compile pass names the real defect. + if (expected === null || actual === null) return null; + + const missing = [...expected].filter((arg) => !actual.has(arg)).sort(); + const unexpected = [...actual].filter((arg) => !expected.has(arg)).sort(); + + return missing.length === 0 && unexpected.length === 0 ? null : { missing, unexpected }; +} diff --git a/sample-translations/playground/forms/fileUpload/resource_entries.json b/sample-translations/playground/forms/fileUpload/resource_entries.json index c9b0c014..a8a12ca1 100644 --- a/sample-translations/playground/forms/fileUpload/resource_entries.json +++ b/sample-translations/playground/forms/fileUpload/resource_entries.json @@ -50,14 +50,14 @@ "fr-ca": "File size exceeds {maxSize}" }, "fileWasOverwritten": { - "source": "File '{filename}' was overwritten", + "source": "File ''{filename}'' was overwritten", "comment": "A message shown to the user when they upload a file that overrode a previously uploaded file", "tags": ["ui", "forms", "fileUpload"], - "de": "Datei „{filename}“ wurde überschrieben", - "es": "Se sobrescribió el archivo '{filename}'", + "de": "Die Datei ''{filename}'' wurde überschrieben.", + "es": "El archivo ''{filename}'' fue sobrescrito.", "fr-fr": "Le fichier « {filename} » a été écrasé", - "ja": "ファイル '{filename}' が上書きされました", - "fr-ca": "File '{filename}' was overwritten" + "ja": "ファイル「{filename}」が上書きされました", + "fr-ca": "Le fichier ''{filename}'' a été écrasé" }, "dropYourFilesHere": { "source": "Drag & drop files here or", @@ -70,13 +70,13 @@ "fr-ca": "Drag & drop files here or" }, "duplicateFileNotAllowed": { - "source": "Duplicate file '{filename}' already exists. Delete the existing file to upload.", + "source": "Duplicate file ''{filename}'' already exists. Delete the existing file to upload.", "comment": "Message shown when user try to upload same file again", "tags": ["ui", "forms", "fileUpload"], - "de": "Duplikat-Datei „{filename}“ ist bereits vorhanden. Löschen Sie zum Hochladen die vorhandene Datei.", - "es": "Ya existe el archivo duplicado '{filename}'. Elimine el archivo existente para cargarlo.", + "de": "Die Datei „{filename}“ ist bereits vorhanden. Bitte löschen Sie die vorhandene Datei, um sie hochzuladen.", + "es": "El archivo duplicado ''{filename}'' ya existe. Elimine el archivo existente para subirlo.", "fr-fr": "Un doublon du fichier « {filename} » existe déjà. Pour le téléverser, supprimez le fichier existant.", - "ja": "重複するファイル '{filename}' がすでに存在します。アップロードするには既存のファイルを削除してください。", - "fr-ca": "Duplicate file '{filename}' already exists. Delete the existing file to upload." + "ja": "重複ファイル「{filename}」が既に存在します。アップロードするには、既存のファイルを削除してください。", + "fr-ca": "Le fichier ''{filename}'' existe déjà. Supprimez le fichier existant avant de le télécharger." } } diff --git a/sample-translations/playground/forms/fileUpload/tracker_meta.json b/sample-translations/playground/forms/fileUpload/tracker_meta.json index 0a04e342..6469eba1 100644 --- a/sample-translations/playground/forms/fileUpload/tracker_meta.json +++ b/sample-translations/playground/forms/fileUpload/tracker_meta.json @@ -151,32 +151,32 @@ }, "fileWasOverwritten": { "en": { - "checksum": "f979db3e616de33d329e8f63993d1bfa" + "checksum": "72bf30fe79de145b724c37afde857eae" }, "de": { - "checksum": "ad43bc531b5b3238b08745e06e5137a3", - "baseChecksum": "f979db3e616de33d329e8f63993d1bfa", - "status": "stale" + "checksum": "d88ad642798379daf53a47381feb27cc", + "baseChecksum": "72bf30fe79de145b724c37afde857eae", + "status": "translated" }, "es": { - "checksum": "d4fcea761b0e9b1c4c759e53237ce324", - "baseChecksum": "f979db3e616de33d329e8f63993d1bfa", - "status": "stale" + "checksum": "da89a84cd625dd8e3d698f1b7df7050c", + "baseChecksum": "72bf30fe79de145b724c37afde857eae", + "status": "translated" }, "fr-fr": { "checksum": "45c8db583423241ba6e78af77264b36a", - "baseChecksum": "7affae3854e1343255a6971f1b98cb22", - "status": "translated" + "baseChecksum": "72bf30fe79de145b724c37afde857eae", + "status": "stale" }, "ja": { - "checksum": "421953544a370fe231c02777e8932f23", - "baseChecksum": "f979db3e616de33d329e8f63993d1bfa", - "status": "stale" + "checksum": "e258214b07decd72edf980dff49fefa2", + "baseChecksum": "72bf30fe79de145b724c37afde857eae", + "status": "translated" }, "fr-ca": { - "checksum": "f979db3e616de33d329e8f63993d1bfa", - "baseChecksum": "f979db3e616de33d329e8f63993d1bfa", - "status": "new" + "checksum": "570e2dfea0198c043e6b1a688c4614dc", + "baseChecksum": "72bf30fe79de145b724c37afde857eae", + "status": "translated" } }, "dropYourFilesHere": { @@ -211,32 +211,32 @@ }, "duplicateFileNotAllowed": { "en": { - "checksum": "7b5a36f54ffefab635bad4b69c802933" + "checksum": "d4647ccba7274104590b0de52f658d8f" }, "de": { - "checksum": "ced72c00f1d648b07dff43b0b3f41228", - "baseChecksum": "7b5a36f54ffefab635bad4b69c802933", - "status": "stale" + "checksum": "93f57ac19502b5bbaee89c9b964bbe1b", + "baseChecksum": "d4647ccba7274104590b0de52f658d8f", + "status": "translated" }, "es": { - "checksum": "28397bd7bb7d622831e815b69a25c935", - "baseChecksum": "7b5a36f54ffefab635bad4b69c802933", - "status": "stale" + "checksum": "90cae6734d550feb52424155dbeec885", + "baseChecksum": "d4647ccba7274104590b0de52f658d8f", + "status": "translated" }, "fr-fr": { "checksum": "75a5398351830a332c0022e76aa75fb7", - "baseChecksum": "322361151ea5823f52989afe7857ff32", - "status": "translated" + "baseChecksum": "d4647ccba7274104590b0de52f658d8f", + "status": "stale" }, "ja": { - "checksum": "b832938861f4a9aa183e94007c9a85fb", - "baseChecksum": "7b5a36f54ffefab635bad4b69c802933", - "status": "stale" + "checksum": "7371a78fc49becf7b7165c7efec871f1", + "baseChecksum": "d4647ccba7274104590b0de52f658d8f", + "status": "translated" }, "fr-ca": { - "checksum": "7b5a36f54ffefab635bad4b69c802933", - "baseChecksum": "7b5a36f54ffefab635bad4b69c802933", - "status": "new" + "checksum": "1c128b92590f495bc552315f14d759c1", + "baseChecksum": "d4647ccba7274104590b0de52f658d8f", + "status": "translated" } } }