From 1c13aba193b9ad7180c6384e13455150a26ea382 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Fri, 18 Sep 2026 11:38:39 +0200 Subject: [PATCH 1/2] Count with a plural rather than an apologetic "(s)" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-eight strings refused to choose a plural. Eighteen carried more than one "(s)", and four carried three independent counts in a single sentence, each with its own agreement — a key per form would have been eight variants of those alone. The library for this cannot run here. @jsverse/transloco-messageformat compiles every message with new Function, the CSP is script-src 'self', and the application boots onto an error banner and seeds nothing. The unit suite passed throughout: vitest runs under jsdom, where there is no policy to violate. Only the e2e run said so, with 19 spec files out of 21 red. So the transpiler is ours, sixty lines over Intl.PluralRules, reading the ICU the strings were already written in. It knows plural, exact =N, the categories and #, and nothing else — a parser that guesses at a grammar it does not implement is worse than one that refuses it. Plain interpolation stays {{name}} and stays the default transpiler's, which is what keeps {{app}} resolving against a sibling key. Three more from the same ticket. The trash said "erased in 31 d" under a sentence promising 30: the clock ticks every thirty seconds, so the now a card renders against can be behind an instant Rust has just stamped, and rounding up made the difference a whole day. Thirty-four icon buttons gained a tooltip, from the same key as their label. And the one empty state that was a dead end now offers the clearing that was already sitting there. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 1 + e2e/specs/06-search-and-filters.e2e.ts | 21 ++- src/app/app.config.ts | 12 +- .../error-banner/error-banner.component.html | 1 + .../status-toast/status-toast.component.html | 1 + .../status-toast.component.spec.ts | 2 +- .../services/i18n/plural-transpiler.spec.ts | 78 +++++++++++ .../core/services/i18n/plural-transpiler.ts | 126 ++++++++++++++++++ .../core/services/i18n/translations/en.json | 59 ++++---- .../core/services/i18n/translations/fr.json | 59 ++++---- .../services/i18n/transloco-loader.spec.ts | 29 ++++ .../global-shortcuts.service.spec.ts | 4 +- .../shortcuts/global-shortcuts.service.ts | 8 +- .../board-zone/board-zone.component.html | 3 + .../note-card-menu.component.html | 1 + .../canvas/note-card/note-card.component.html | 2 + .../note-card/note-card.component.spec.ts | 2 +- .../folder-breadcrumb.component.html | 1 + .../folder-editor.component.html | 1 + .../folder-switcher.component.html | 1 + .../new-note-button.component.html | 1 + .../search-box/search-box.component.spec.ts | 4 +- .../selection-bar.component.html | 1 + .../space-switcher.component.html | 1 + src/app/notes/notes-page.component.html | 22 ++- .../image-lightbox.component.html | 1 + .../attachment-strip.component.html | 3 + .../checklist-editor.component.html | 3 + .../checklist-editor.component.spec.ts | 2 +- .../note-editor-overlay.component.html | 3 + .../quick-palette.component.html | 3 + .../tag-manager/tag-manager.component.html | 1 + .../tag-manager/tag-manager.component.spec.ts | 4 +- .../trash-panel/trash-panel.component.html | 1 + .../trash-panel/trash-panel.component.spec.ts | 21 +++ .../trash-panel/trash-panel.component.ts | 10 +- .../overlays/undo-bar/undo-bar.component.html | 1 + .../library-tree/library-tree.component.html | 4 + .../variables-page.component.html | 1 + src/testing/provide-transloco-testing.ts | 35 +++-- 40 files changed, 446 insertions(+), 88 deletions(-) create mode 100644 src/app/core/services/i18n/plural-transpiler.spec.ts create mode 100644 src/app/core/services/i18n/plural-transpiler.ts diff --git a/CLAUDE.md b/CLAUDE.md index 23a2c777..e1d12e3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **Read-only store signals.** Writable signals in a store are private (`_x`) and exposed via `.asReadonly()`; mutation goes through methods. Writes are **not** optimistic: persist, adopt the returned note, reload the view — nothing is applied locally, so there is nothing to roll back. `resource.value()` throws while in error — read it behind a `hasValue()` guard. - **`NotesQueryStore.view` is a `linkedSignal` that only retains what it is read through.** It keeps the previous view during a reload so the canvas doesn't blank on every keystroke. Everything the store exposes reads it, and `isLoading` reads it _first_ — a `&&` that short-circuits past it would drop the freshly loaded view on the floor. - **⚠️ Transloco eats `{{…}}`.** An unknown `{{name}}` in a translation is replaced by the **empty string**, not left alone — so no translated string can carry a snippet's `{{fields}}`. The two sample snippet bodies are hard-coded in `core/state/sample-notes.service.ts` (they are code, so they would not be translated anyway), and a chapter of the guide that names a key receives it as an interpolation parameter instead of spelling it out. The application's name is the exception that costs nothing: a translation writes `{{app}}` and `AppTranslocoLoader` adds `app: APP_INFO.name` to every language, because the transpiler resolves an unknown interpolation against a **sibling key** before giving up on it. One line, no call site passes a parameter, and a string with parameters of its own still resolves both. No translated string spells `DevBox` out; a spec on the shipped files holds that. +- **A count is an ICU plural, and the transpiler that reads it is ours.** `{count, plural, =0 {…} one {# note} other {# notes}}` in `plural-transpiler.ts`, ~60 lines over `Intl.PluralRules`. ⚠️ **Not** `@jsverse/transloco-messageformat`, which was installed, tried and removed: it compiles each message with `new Function`, the CSP is `script-src 'self'`, and the application boots onto an error banner and seeds nothing. The unit suite cannot see that — vitest runs under jsdom, where there is no policy to violate — so only the e2e run caught it. ⚠️ The grammar is deliberately tiny: `plural`, exact `=N`, the categories `Intl.PluralRules` answers, and `#`. No `select`, no `selectordinal`, no nesting. Plain interpolation stays `{{name}}` and is still `DefaultTranspiler`'s, which is what keeps the `{{app}}` sibling-key trick above working. ⚠️ `TranslocoService` asks for the transpiler in its own constructor, so the service is resolved on the first render rather than injected. `=0` is written out wherever zero reads badly: French calls zero `one`, so "0 résultat" would look like a singular. Two specs on the shipped files hold the rest — no string may carry "(s)", and none may carry a brace that is neither an interpolation nor a plural. - **Translation keys, not strings.** Code that produces user-visible text returns a translation reference (`{ key, params }`) consumed by the `transloco` pipe in the template. Adding a string means adding it to **both** `src/app/core/i18n/translations/fr.json` and `en.json`. No user-visible string belongs in the Rust back-end — a new note gets an empty title, and the UI renders a translated placeholder. - **Accessibility is enforced by the linter, except the two things it cannot see.** Decorative emoji need `aria-hidden`, toggles need `aria-pressed`, and information shown only graphically needs a `.visually-hidden` text twin — `npm run lint` catches most of it. What it cannot catch is **contrast** (`scripts/palette.test.mjs`), **target size** — a control takes `@include hit-target`, which is 24×24 of box and never a bigger glyph, since growing the type would be a density change — and **whether a destructive control looks like one**: `@include destructive` is red text at rest, not on `:hover`, because a warning that arrives under the pointer plays no part in choosing. ⚠️ Text and border, never a solid fill (one red button beside the single amber accent reads as a second accent), and the pressed state fills with `--bg-3` rather than a red tint, since red text on a tint of itself falls under AA (#191). ⚠️ On a card that box is not free — the card is a fixed 150px and `.card-items` is `overflow: hidden`, so `MAX_VISIBLE_ITEMS` and the "+N autres" badge have to keep agreeing with what is actually drawn. `07-checklists` measures the rendered boxes and the badge together, because the stylesheet alone cannot say either. - **CSS variables must stay global.** Theme variables live on `:root` in `src/styles/styles.scss`. Angular's emulated encapsulation rewrites a `:root` selector inside a `*.component.scss` into a form that never matches ``, silently invalidating every variable. Shared SCSS patterns are mixins in `src/styles/_mixins.scss`, imported as `@use 'mixins' as *;`. diff --git a/e2e/specs/06-search-and-filters.e2e.ts b/e2e/specs/06-search-and-filters.e2e.ts index bb4b1ec5..30719880 100644 --- a/e2e/specs/06-search-and-filters.e2e.ts +++ b/e2e/specs/06-search-and-filters.e2e.ts @@ -1,7 +1,7 @@ import { $, expect } from '@wdio/globals'; import { canvas } from '../pageobjects/canvas.page.js'; -import { blur, cursorOf, press, reloadCanvas, testid } from '../support/app.js'; +import { blur, cursorOf, press, reloadCanvas, testid, waitForCanvas } from '../support/app.js'; import { bridge, draft, homeSpaceId } from '../support/bridge.js'; /** @@ -80,6 +80,20 @@ describe('Search, filters and facets', () => { expect(await canvas.noResults().isExisting()).toBe(true); }); + /** + * ⚠️ An empty state that only reports is a dead end: the one thing to do from here is the + * thing that emptied it, and `clearFilters()` was already sitting there unoffered. + */ + it('offers the way out of the state that emptied it', async () => { + await canvas.search('nothing matches this'); + + await $(testid('canvas-clear-filters')).click(); + await waitForCanvas(); + + expect(await canvas.searchQuery()).toBe(''); + expect(await canvas.noResults().isExisting()).toBe(false); + }); + /** How big is this result, and why is that card in it. */ describe('what a search says about itself', () => { /** @@ -96,8 +110,11 @@ describe('Search, filters and facets', () => { // Against what is on screen: the corpus is shared with every file that ran before. expect(await canvas.matchedCount()).toContain(String((await canvas.titles()).length)); + // ⚠️ In words, not as a digit. French keeps the singular at zero, so "0 résultat" + // would read as one — the count says "aucun résultat" instead, and this asserts the + // meaning rather than a character. await canvas.search('nothing matches this'); - expect(await canvas.matchedCount()).toContain('0'); + expect(await canvas.matchedCount()).toContain('aucun'); }); it('hides the count again once nothing is being filtered', async () => { diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 43a2c15e..e802e15b 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -7,12 +7,13 @@ import { provideZonelessChangeDetection, } from '@angular/core'; import { provideRouter, withHashLocation } from '@angular/router'; -import { provideTransloco } from '@jsverse/transloco'; +import { TRANSLOCO_TRANSPILER, provideTransloco } from '@jsverse/transloco'; import { routes } from './app.routes'; import { AppErrorHandler } from '@core/services/errors/app-error-handler'; import { APP_LOCALES, DEFAULT_LOCALE } from '@core/services/i18n/locale.model'; import { LocaleService } from '@core/services/i18n/locale.service'; +import { PluralTranspiler } from '@core/services/i18n/plural-transpiler'; import { AppTranslocoLoader } from '@core/services/i18n/transloco-loader'; import { AutostartService } from '@core/services/autostart/autostart.service'; import { PreferencesService } from '@core/services/preferences/preferences.service'; @@ -42,6 +43,15 @@ export const appConfig: ApplicationConfig = { loader: AppTranslocoLoader, }), + // ⚠️ French keeps the singular at zero where English does not, and four of these + // strings carry three independent counts in one sentence, each with its own agreement. + // A key per form would have meant eight variants of those alone. + // + // ⚠️ Ours rather than `@jsverse/transloco-messageformat`, which compiles each message + // with `new Function` — the CSP here is `script-src 'self'`, and the application boots + // onto an error banner with it. See `plural-transpiler.ts`. + { provide: TRANSLOCO_TRANSPILER, useClass: PluralTranspiler }, + // ⚠️ One initialiser for both steps rather than two chained: Angular starts them // together and awaits their promises as a block, so `restore()` would read a // still-empty cache. diff --git a/src/app/banners/error-banner/error-banner.component.html b/src/app/banners/error-banner/error-banner.component.html index 793c6cfd..98212b02 100644 --- a/src/app/banners/error-banner/error-banner.component.html +++ b/src/app/banners/error-banner/error-banner.component.html @@ -12,6 +12,7 @@ type="button" class="error-dismiss" [attr.aria-label]="'errors.dismiss' | transloco" + [attr.title]="'errors.dismiss' | transloco" (click)="notifier.dismiss()" > diff --git a/src/app/banners/status-toast/status-toast.component.html b/src/app/banners/status-toast/status-toast.component.html index 0a953440..4bc825b0 100644 --- a/src/app/banners/status-toast/status-toast.component.html +++ b/src/app/banners/status-toast/status-toast.component.html @@ -6,6 +6,7 @@ type="button" class="status-dismiss" [attr.aria-label]="'errors.dismiss' | transloco" + [attr.title]="'errors.dismiss' | transloco" (click)="notifier.dismiss()" > diff --git a/src/app/banners/status-toast/status-toast.component.spec.ts b/src/app/banners/status-toast/status-toast.component.spec.ts index ecf64f37..60bc8e0b 100644 --- a/src/app/banners/status-toast/status-toast.component.spec.ts +++ b/src/app/banners/status-toast/status-toast.component.spec.ts @@ -29,7 +29,7 @@ describe('StatusToastComponent', () => { await fixture.whenStable(); const text = fixture.nativeElement.querySelector('.status-text').textContent; - expect(text).toContain('3 note(s) exportée(s)'); + expect(text).toContain('3 notes exportées'); expect(text).toContain('devbox.json'); }); diff --git a/src/app/core/services/i18n/plural-transpiler.spec.ts b/src/app/core/services/i18n/plural-transpiler.spec.ts new file mode 100644 index 00000000..7e8f317d --- /dev/null +++ b/src/app/core/services/i18n/plural-transpiler.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { expandPlurals } from './plural-transpiler'; + +/** + * ⚠️ Exhaustive on purpose. This is a parser written here rather than taken from a library, + * because the library compiles with `new Function` and the CSP forbids it — so the grammar + * it accepts is the grammar nobody else is checking. + */ +describe('expandPlurals', () => { + const fr = (source: string, params: Record = {}) => expandPlurals(source, params, 'fr'); + const en = (source: string, params: Record = {}) => expandPlurals(source, params, 'en'); + + it('picks the singular and the plural from the count', () => { + const source = '{count, plural, one {# note} other {# notes}}'; + + expect(fr(source, { count: 1 })).toBe('1 note'); + expect(fr(source, { count: 4 })).toBe('4 notes'); + }); + + /** ⚠️ The whole reason `=0` is written out: French calls zero `one`. */ + it('lets an exact match win over the category', () => { + const source = '{count, plural, =0 {aucune note} one {# note} other {# notes}}'; + + expect(fr(source, { count: 0 })).toBe('aucune note'); + expect(new Intl.PluralRules('fr').select(0)).toBe('one'); + }); + + it('follows the locale rather than a rule of its own', () => { + const source = '{count, plural, one {# note} other {# notes}}'; + + // French keeps the singular at one *and* at zero; English does not. + expect(fr(source, { count: 0 })).toBe('0 note'); + expect(en(source, { count: 0 })).toBe('0 notes'); + }); + + it('expands several independent counts in one sentence', () => { + const source = + '{notes, plural, one {# note} other {# notes}} depuis {{path}}, ' + + '{skipped, plural, one {# ignorée} other {# ignorées}}.'; + + expect(fr(source, { notes: 1, skipped: 3 })).toBe('1 note depuis {{path}}, 3 ignorées.'); + }); + + /** ⚠️ `{{app}}` and the like must pass through untouched: they are the other transpiler's. */ + it('leaves a double-brace interpolation alone', () => { + expect(fr('Bienvenue dans {{app}}')).toBe('Bienvenue dans {{app}}'); + expect(fr('{{notes}} et {{path}}')).toBe('{{notes}} et {{path}}'); + }); + + it('leaves a hash outside a block alone, which is what a tag is written with', () => { + expect(fr('Renommer #{{tag}} sur {count, plural, one {# note} other {# notes}}', { count: 2 })).toBe( + 'Renommer #{{tag}} sur 2 notes', + ); + }); + + it('falls back to other when the category has no branch', () => { + expect(fr('{count, plural, other {# choses}}', { count: 1 })).toBe('1 choses'); + }); + + it('renders nothing rather than "undefined" when the parameter is missing', () => { + expect(fr('{count, plural, one {# note} other {# notes}}')).toBe(' notes'); + }); + + /** A half-written block is left as written: visibly wrong beats silently truncated. */ + it('leaves an unbalanced block exactly as it found it', () => { + const broken = 'avant {count, plural, one {# note} other {# notes}'; + + expect(fr(broken, { count: 2 })).toBe(broken); + }); + + it('keeps the text around a block, on both sides', () => { + expect(fr('a {count, plural, other {#}} b', { count: 7 })).toBe('a 7 b'); + }); + + it('is unchanged by a string with no block at all', () => { + expect(fr('Rien à compter ici.')).toBe('Rien à compter ici.'); + }); +}); diff --git a/src/app/core/services/i18n/plural-transpiler.ts b/src/app/core/services/i18n/plural-transpiler.ts new file mode 100644 index 00000000..b85fd938 --- /dev/null +++ b/src/app/core/services/i18n/plural-transpiler.ts @@ -0,0 +1,126 @@ +import { Injectable, Injector, inject } from '@angular/core'; +import { DefaultTranspiler, TranslocoService } from '@jsverse/transloco'; +import type { Translation } from '@jsverse/transloco'; + +/** + * Counting, in the one ICU shape these translations use: + * `{name, plural, =0 {…} one {# thing} other {# things}}`. + * + * ⚠️ Written here rather than taken from `@jsverse/transloco-messageformat`, which was tried + * and **cannot run in this application**: it compiles each message into a function with + * `new Function`, and `tauri.conf.json` locks the WebView down to `script-src 'self'`. The + * application booted onto a CSP error banner and seeded nothing. A unit test cannot see + * that — vitest runs under jsdom, where there is no policy to violate. + * + * ⚠️ Deliberately tiny. It knows `plural`, exact `=N` matches, the categories + * `Intl.PluralRules` answers, and `#`. It does **not** know `select`, `selectordinal`, or a + * plural inside a plural, because nothing here needs them — and a parser that guesses at a + * grammar it does not implement is worse than one that refuses it. + * + * Plain interpolation stays `{{name}}`, which is the rest of the application's convention + * and what `DefaultTranspiler` already does. This only ever expands the counted blocks and + * then hands the result on. + */ +@Injectable() +export class PluralTranspiler extends DefaultTranspiler { + private readonly injector = inject(Injector); + private service?: TranslocoService; + + override transpile(payload: { + value: unknown; + params?: Translation; + translation: Translation; + key: string; + }): unknown { + const { value, params } = payload; + + return typeof value === 'string' + ? super.transpile({ ...payload, value: expandPlurals(value, params ?? {}, this.locale()) }) + : super.transpile(payload); + } + + /** + * ⚠️ Resolved on the first render rather than injected: `TranslocoService` asks for the + * transpiler in its own constructor, so taking it here would be a cycle. + */ + private locale(): string { + this.service ??= this.injector.get(TranslocoService); + return this.service.getActiveLang(); + } +} + +/** Where a `{name, plural,` block starts. The name is what the parameters are keyed by. */ +const PLURAL_HEAD = /\{\s*([A-Za-z0-9_]+)\s*,\s*plural\s*,/g; + +export function expandPlurals(source: string, params: Translation, locale: string): string { + PLURAL_HEAD.lastIndex = 0; + let out = ''; + let from = 0; + let head: RegExpExecArray | null; + + while ((head = PLURAL_HEAD.exec(source)) !== null) { + const closing = matchingBrace(source, head.index); + // An unbalanced block is left exactly as written: rendering half a sentence would be + // worse than rendering the source of it, which is at least visibly wrong. + if (closing < 0) break; + + const count = Number(params[head[1] ?? '']); + const branches = source.slice(head.index + head[0].length, closing); + + out += source.slice(from, head.index) + choose(branches, count, locale); + from = closing + 1; + PLURAL_HEAD.lastIndex = from; + } + + return out + source.slice(from); +} + +/** The index of the `}` closing the `{` at `start`, or `-1`. */ +function matchingBrace(source: string, start: number): number { + let depth = 0; + + for (let at = start; at < source.length; at += 1) { + if (source[at] === '{') depth += 1; + else if (source[at] === '}') { + depth -= 1; + if (depth === 0) return at; + } + } + + return -1; +} + +/** + * ⚠️ An exact `=N` wins over a category, which is the whole reason `=0` is written out: + * French calls zero `one`, so "0 note" would otherwise read as a singular where the + * sentence wants "aucune note". + */ +function choose(branches: string, count: number, locale: string): string { + const cases = parseBranches(branches); + const category = Number.isFinite(count) ? new Intl.PluralRules(locale).select(count) : 'other'; + const chosen = cases.get(`=${count}`) ?? cases.get(category) ?? cases.get('other') ?? ''; + + return chosen.replaceAll('#', Number.isFinite(count) ? String(count) : ''); +} + +/** `selector {text}` pairs, in order, with the braces inside each text left alone. */ +function parseBranches(branches: string): Map { + const cases = new Map(); + let at = 0; + + while (at < branches.length) { + const open = branches.indexOf('{', at); + if (open < 0) break; + + const selector = branches.slice(at, open).trim(); + const closing = matchingBrace(branches, open); + if (closing < 0) break; + + if (selector.length > 0) { + cases.set(selector, branches.slice(open + 1, closing)); + } + at = closing + 1; + } + + return cases; +} diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index 4f2e5593..ebf5c051 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -32,10 +32,10 @@ "pinnedState": "Pinned note", "loading": "Loading notes…", "noResults": "No note matches this search.", - "searchMatched": "{{count}} result(s)", + "searchMatched": "{count, plural, =0 {no results} one {# result} other {# results}}", "clearFilters": "Show everything", - "attachmentsCount": "{{count}} attachment(s)", - "placeholdersCount": "{{count}} field(s) to fill", + "attachmentsCount": "{count, plural, =0 {no attachments} one {# attachment} other {# attachments}}", + "placeholdersCount": "{count, plural, =0 {no fields to fill} one {# field to fill} other {# fields to fill}}", "selectNote": "Select note {{title}}", "keyboardHint": "↑↓←→ move · Enter open · C copy · X select · Del trash", "trash": "Trash", @@ -45,7 +45,7 @@ "newChecklist": "New to-do list", "checklistProgress": "{{done}}/{{total}}", "checklistToggle": "Tick the task {{text}}", - "checklistMore": "+{{count}} more", + "checklistMore": "+{count, plural, one {# more} other {# more}}", "samples": { "space": "Getting started", "welcome": { @@ -80,7 +80,8 @@ "snippets": "Snippets", "startHere": "Start here" } - } + }, + "clearFiltersFromEmpty": "Clear the filters" }, "filters": { "groupLabel": "Filter notes", @@ -163,7 +164,7 @@ "expiryLabel": "Note deadline (empty = permanent)", "footerStats": "{{lines}} lines · {{bytes}} bytes", "modifiedPrefix": "Updated", - "checklistStats": "{{done}}/{{total}} task(s)", + "checklistStats": "{{done}}/{total, plural, one {# task} other {# tasks}}", "addItem": "Add a task", "itemLabel": "Task {{position}}", "itemPlaceholder": "Describe the task…", @@ -313,7 +314,7 @@ "close": "Close tag management", "empty": "No tags yet.", "loading": "Loading…", - "noteCount": "{{count}} note(s)", + "noteCount": "{count, plural, =0 {no notes} one {# note} other {# notes}}", "selectLabel": "Select tag {{tag}}", "targetLabel": "Target name", "targetPlaceholder": "tag name", @@ -322,15 +323,15 @@ "delete": "Delete", "hint": "Renaming onto an existing tag merges the two.", "selectionHint": "Select the tags to merge, then type their common name.", - "confirmRename": "Rename #{{tag}} to #{{into}} on {{count}} note(s)?", - "confirmMerge": "⚠️ Merge {{tags}} tags into #{{into}} across {{count}} note(s)? A merge cannot be undone: once they are one, nothing knows which note carried which.", - "confirmDelete": "Strip {{tags}} tag(s) from {{count}} note(s)? The notes themselves stay.", + "confirmRename": "Rename #{{tag}} to #{{into}} on {count, plural, one {# note} other {# notes}}?", + "confirmMerge": "⚠️ Merge {{tags}} tags into #{{into}} across {count, plural, one {# note} other {# notes}}? A merge cannot be undone: once they are one, nothing knows which note carried which.", + "confirmDelete": "Strip {tags, plural, one {# tag} other {# tags}} from {count, plural, one {# note} other {# notes}}? The notes themselves stay.", "confirmAction": "Confirm", "confirmCancel": "Cancel" }, "selection": { "label": "Selection actions", - "count": "{{count}} selected", + "count": "{count, plural, one {# selected} other {# selected}}", "clear": "Clear selection", "moveTo": "Move to", "addTagLabel": "Add a tag to the selection", @@ -353,14 +354,14 @@ }, "placeholders": { "title": "Fill the fields", - "hint": "This snippet expects {{count}} value(s).", + "hint": "This snippet expects {count, plural, one {# value} other {# values}}.", "fieldLabel": "Value for {{name}}", "copy": "Copy filled", "copyRaw": "Copy as is", "cancel": "Cancel", "panelTitle": "Fields", "panelHint": "A field left empty keeps the value the text suggests.", - "filledCount": "{{filled}} of {{total}} field(s) filled", + "filledCount": "{{filled}} of {total, plural, one {# field} other {# fields}} filled", "show": "Show", "summaryMore": "+{{count}}", "preview": "Preview", @@ -386,12 +387,12 @@ "closeZoom": "Close the image" }, "undo": { - "deleted": "{{count}} note(s) moved to trash", + "deleted": "{count, plural, one {# note} other {# notes}} moved to the trash", "restore": "Undo", "dismiss": "Dismiss", - "moved": "{{count}} note(s) moved", - "tagged": "{{count}} note(s) tagged", - "filed": "{{count}} note(s) filed" + "moved": "{count, plural, one {# note} other {# notes}} moved", + "tagged": "{count, plural, one {# note} other {# notes}} tagged", + "filed": "{count, plural, one {# note} other {# notes}} filed" }, "file": { "menuLabel": "File", @@ -402,15 +403,15 @@ "copyMarkdown": "Copy selection as Markdown", "quit": "Quit {{app}}", "quitConfirm": "Confirm?", - "imported": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped.", - "importedNothing": "Nothing to import: those {{skipped}} note(s) are already in your library.", - "importedFromNewerVersion": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped. {{degraded}} came from a newer version: their language or kind was brought down to what this version can read.", - "importedWithAttachments": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped, {{attachments}} attachment(s) restored.", - "importedWithoutSomeAttachments": "{{notes}} note(s) imported from {{path}}. ⚠️ {{missing}} attachment(s) the file named but did not carry: their preview will stay empty.", - "exported": "{{notes}} note(s) exported to {{path}}.", - "exportedWithAttachments": "{{notes}} note(s) and {{attachments}} attachment(s) exported to {{path}}.", - "exportedProtected": "{{notes}} note(s) exported to {{path}}, sealed with the passphrase you gave it.", - "exportedProtectedWithAttachments": "{{notes}} note(s) and {{attachments}} attachment(s) exported to {{path}}, sealed with the passphrase you gave it.", + "imported": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}}, {skipped, plural, one {# skipped} other {# skipped}}.", + "importedNothing": "Nothing to import: {skipped, plural, one {that note is} other {those # notes are}} already in your library.", + "importedFromNewerVersion": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}}, {skipped, plural, one {# skipped} other {# skipped}}. {degraded, plural, one {# comes} other {# come}} from a newer version: its language or its kind was brought down to what this one can read.", + "importedWithAttachments": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}}, {skipped, plural, one {# skipped} other {# skipped}}, {attachments, plural, one {# attachment} other {# attachments}} restored.", + "importedWithoutSomeAttachments": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}}. ⚠️ {missing, plural, one {# attachment} other {# attachments}} named by the file but missing: their preview will stay empty.", + "exported": "{notes, plural, one {# note} other {# notes}} exported to {{path}}.", + "exportedWithAttachments": "{notes, plural, one {# note} other {# notes}} and {attachments, plural, one {# attachment} other {# attachments}} exported to {{path}}.", + "exportedProtected": "{notes, plural, one {# note} other {# notes}} exported to {{path}}, sealed with the passphrase you gave it.", + "exportedProtectedWithAttachments": "{notes, plural, one {# note} other {# notes}} and {attachments, plural, one {# attachment} other {# attachments}} exported to {{path}}, sealed with the passphrase you gave it.", "protectTitle": "Protect this export?", "protectLead": "{{path}} is about to leave your library. A passphrase seals it with a key of its own, which travels with the file rather than with this machine.", "protectWarning": "⚠️ Without one, the file carries every note and every attachment in the clear: anyone holding it can read them.", @@ -421,14 +422,14 @@ "unlockAction": "Import", "cancel": "Cancel", "working": "Working…", - "copied": "{{notes}} note(s) copied as Markdown to the clipboard.", + "copied": "{notes, plural, one {# note} other {# notes}} copied as Markdown to the clipboard.", "needsSelection": "Select at least one note.", "emptyLibrary": "Nothing to export: this library has no notes.", "preferences": "Preferences…", - "importedIntoFolders": "{{notes}} note(s) imported from {{path}} into {{folders}} new folder(s), {{skipped}} skipped." + "importedIntoFolders": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}} into {folders, plural, one {# new folder} other {# new folders}}, {skipped, plural, one {# skipped} other {# skipped}}." }, "shortcuts": { - "unavailable": "Global shortcut(s) unavailable: {{list}}. Another application is already using them.", + "unavailable": "{count, plural, one {Global shortcut unavailable} other {Global shortcuts unavailable}}: {{list}}. Another application already holds {count, plural, one {it} other {them}}.", "title": "Keyboard shortcuts", "subtitle": "The keys that are live depending on where you are. Bare letters only act on the canvas, never while you are typing.", "editHint": "The quick-paste shortcut is set in File → Preferences.", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index 2cd83719..a925aa85 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -32,10 +32,10 @@ "pinnedState": "Note épinglée", "loading": "Chargement des notes…", "noResults": "Aucune note ne correspond à cette recherche.", - "searchMatched": "{{count}} résultat(s)", + "searchMatched": "{count, plural, =0 {aucun résultat} one {# résultat} other {# résultats}}", "clearFilters": "Tout afficher", - "attachmentsCount": "{{count}} pièce(s) jointe(s)", - "placeholdersCount": "{{count}} champ(s) à remplir", + "attachmentsCount": "{count, plural, =0 {aucune pièce jointe} one {# pièce jointe} other {# pièces jointes}}", + "placeholdersCount": "{count, plural, =0 {aucun champ à remplir} one {# champ à remplir} other {# champs à remplir}}", "selectNote": "Sélectionner la note {{title}}", "keyboardHint": "↑↓←→ naviguer · Entrée ouvrir · C copier · X sélectionner · Suppr corbeille", "trash": "Corbeille", @@ -45,7 +45,7 @@ "newChecklist": "Nouvelle todolist", "checklistProgress": "{{done}}/{{total}}", "checklistToggle": "Cocher la tâche {{text}}", - "checklistMore": "+{{count}} autre(s)", + "checklistMore": "+{count, plural, one {# autre} other {# autres}}", "samples": { "space": "Découverte", "welcome": { @@ -80,7 +80,8 @@ "snippets": "Snippets", "startHere": "Prise en main" } - } + }, + "clearFiltersFromEmpty": "Effacer les filtres" }, "filters": { "groupLabel": "Filtrer les notes", @@ -163,7 +164,7 @@ "expiryLabel": "Échéance de la note (vide = permanente)", "footerStats": "{{lines}} lignes · {{bytes}} octets", "modifiedPrefix": "Modifiée", - "checklistStats": "{{done}}/{{total}} tâche(s)", + "checklistStats": "{{done}}/{total, plural, one {# tâche} other {# tâches}}", "addItem": "Ajouter une tâche", "itemLabel": "Tâche {{position}}", "itemPlaceholder": "Décrire la tâche…", @@ -313,7 +314,7 @@ "close": "Fermer la gestion des tags", "empty": "Aucun tag pour le moment.", "loading": "Chargement…", - "noteCount": "{{count}} note(s)", + "noteCount": "{count, plural, =0 {aucune note} one {# note} other {# notes}}", "selectLabel": "Sélectionner le tag {{tag}}", "targetLabel": "Nom de destination", "targetPlaceholder": "nom du tag", @@ -322,15 +323,15 @@ "delete": "Supprimer", "hint": "Renommer vers un tag existant fusionne les deux.", "selectionHint": "Sélectionnez les tags à fusionner, puis saisissez leur nom commun.", - "confirmRename": "Renommer #{{tag}} en #{{into}} sur {{count}} note(s) ?", - "confirmMerge": "⚠️ Fusionner {{tags}} tags en #{{into}} sur {{count}} note(s) ? Une fusion ne s’annule pas : une fois réunis, plus rien ne sait quel tag portait quelle note.", - "confirmDelete": "Retirer {{tags}} tag(s) de {{count}} note(s) ? Les notes, elles, restent.", + "confirmRename": "Renommer #{{tag}} en #{{into}} sur {count, plural, one {# note} other {# notes}} ?", + "confirmMerge": "⚠️ Fusionner {{tags}} tags en #{{into}} sur {count, plural, one {# note} other {# notes}} ? Une fusion ne s’annule pas : une fois réunis, plus rien ne sait quel tag portait quelle note.", + "confirmDelete": "Retirer {tags, plural, one {# tag} other {# tags}} de {count, plural, one {# note} other {# notes}} ? Les notes, elles, restent.", "confirmAction": "Confirmer", "confirmCancel": "Annuler" }, "selection": { "label": "Actions sur la sélection", - "count": "{{count}} sélectionnée(s)", + "count": "{count, plural, one {# sélectionnée} other {# sélectionnées}}", "clear": "Tout désélectionner", "moveTo": "Déplacer vers", "addTagLabel": "Ajouter un tag à la sélection", @@ -353,14 +354,14 @@ }, "placeholders": { "title": "Remplir les champs", - "hint": "Ce snippet attend {{count}} valeur(s).", + "hint": "Ce snippet attend {count, plural, one {# valeur} other {# valeurs}}.", "fieldLabel": "Valeur de {{name}}", "copy": "Copier rempli", "copyRaw": "Copier tel quel", "cancel": "Annuler", "panelTitle": "Champs", "panelHint": "Un champ laissé vide garde la valeur proposée par le texte.", - "filledCount": "{{filled}} champ(s) rempli(s) sur {{total}}", + "filledCount": "{filled, plural, one {# champ rempli} other {# champs remplis}} sur {{total}}", "show": "Afficher", "summaryMore": "+{{count}}", "preview": "Aperçu", @@ -386,12 +387,12 @@ "closeZoom": "Fermer l'image" }, "undo": { - "deleted": "{{count}} note(s) mise(s) à la corbeille", + "deleted": "{count, plural, one {# note mise} other {# notes mises}} à la corbeille", "restore": "Annuler", "dismiss": "Masquer", - "moved": "{{count}} note(s) déplacée(s)", - "tagged": "{{count}} note(s) étiquetée(s)", - "filed": "{{count}} note(s) rangée(s)" + "moved": "{count, plural, one {# note déplacée} other {# notes déplacées}}", + "tagged": "{count, plural, one {# note étiquetée} other {# notes étiquetées}}", + "filed": "{count, plural, one {# note rangée} other {# notes rangées}}" }, "file": { "menuLabel": "Fichier", @@ -402,15 +403,15 @@ "copyMarkdown": "Copier la sélection en Markdown", "quit": "Quitter {{app}}", "quitConfirm": "Confirmer ?", - "imported": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s).", - "importedNothing": "Rien à importer : ces {{skipped}} note(s) sont déjà dans votre bibliothèque.", - "importedFromNewerVersion": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s). {{degraded}} vien(nen)t d’une version plus récente : leur langage ou leur type a été ramené à ce que cette version sait lire.", - "importedWithAttachments": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s), {{attachments}} pièce(s) jointe(s) restaurée(s).", - "importedWithoutSomeAttachments": "{{notes}} note(s) importée(s) depuis {{path}}. ⚠️ {{missing}} pièce(s) jointe(s) annoncée(s) par le fichier mais absente(s) : leur aperçu restera vide.", - "exported": "{{notes}} note(s) exportée(s) vers {{path}}.", - "exportedWithAttachments": "{{notes}} note(s) et {{attachments}} pièce(s) jointe(s) exportée(s) vers {{path}}.", - "exportedProtected": "{{notes}} note(s) exportée(s) vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.", - "exportedProtectedWithAttachments": "{{notes}} note(s) et {{attachments}} pièce(s) jointe(s) exportée(s) vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.", + "imported": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}}, {skipped, plural, one {# ignorée} other {# ignorées}}.", + "importedNothing": "Rien à importer : {skipped, plural, one {cette note est déjà} other {ces # notes sont déjà}} dans votre bibliothèque.", + "importedFromNewerVersion": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}}, {skipped, plural, one {# ignorée} other {# ignorées}}. {degraded, plural, one {# vient} other {# viennent}} d’une version plus récente : leur langage ou leur type a été ramené à ce que cette version sait lire.", + "importedWithAttachments": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}}, {skipped, plural, one {# ignorée} other {# ignorées}}, {attachments, plural, one {# pièce jointe restaurée} other {# pièces jointes restaurées}}.", + "importedWithoutSomeAttachments": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}}. ⚠️ {missing, plural, one {# pièce jointe annoncée} other {# pièces jointes annoncées}} par le fichier mais {missing, plural, one {absente} other {absentes}} : leur aperçu restera vide.", + "exported": "{notes, plural, one {# note exportée} other {# notes exportées}} vers {{path}}.", + "exportedWithAttachments": "{notes, plural, one {# note} other {# notes}} et {attachments, plural, one {# pièce jointe exportée} other {# pièces jointes exportées}} vers {{path}}.", + "exportedProtected": "{notes, plural, one {# note exportée} other {# notes exportées}} vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.", + "exportedProtectedWithAttachments": "{notes, plural, one {# note} other {# notes}} et {attachments, plural, one {# pièce jointe exportée} other {# pièces jointes exportées}} vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.", "protectTitle": "Protéger cet export ?", "protectLead": "{{path}} va quitter votre bibliothèque. Une phrase de passe le scelle avec une clé qui lui est propre, et qui voyage avec le fichier plutôt qu’avec cette machine.", "protectWarning": "⚠️ Sans elle, le fichier emporte toutes les notes et toutes les pièces jointes en clair : quiconque le détient peut les lire.", @@ -421,14 +422,14 @@ "unlockAction": "Importer", "cancel": "Annuler", "working": "En cours…", - "copied": "{{notes}} note(s) copiée(s) en Markdown dans le presse-papier.", + "copied": "{notes, plural, one {# note copiée} other {# notes copiées}} en Markdown dans le presse-papier.", "needsSelection": "Sélectionnez au moins une note.", "emptyLibrary": "Rien à exporter : cette bibliothèque n'a aucune note.", "preferences": "Préférences…", - "importedIntoFolders": "{{notes}} note(s) importée(s) depuis {{path}} dans {{folders}} nouveau(x) dossier(s), {{skipped}} ignorée(s)." + "importedIntoFolders": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}} dans {folders, plural, one {# nouveau dossier} other {# nouveaux dossiers}}, {skipped, plural, one {# ignorée} other {# ignorées}}." }, "shortcuts": { - "unavailable": "Raccourci(s) global(aux) indisponible(s) : {{list}}. Une autre application les utilise déjà.", + "unavailable": "{count, plural, one {Raccourci global indisponible} other {Raccourcis globaux indisponibles}} : {{list}}. Une autre application {count, plural, one {l’utilise} other {les utilise}} déjà.", "title": "Raccourcis clavier", "subtitle": "Les touches actives selon l'endroit où vous êtes. Les lettres nues n'agissent que sur le canevas, jamais pendant une saisie.", "editHint": "Le raccourci de collage rapide se règle dans Fichier → Préférences.", diff --git a/src/app/core/services/i18n/transloco-loader.spec.ts b/src/app/core/services/i18n/transloco-loader.spec.ts index 41490b80..51da1122 100644 --- a/src/app/core/services/i18n/transloco-loader.spec.ts +++ b/src/app/core/services/i18n/transloco-loader.spec.ts @@ -51,4 +51,33 @@ describe('the translation files', () => { ])('writes {{app}} instead (%s)', (_locale, translations) => { expect(strings(translations).some((value) => value.includes('{{app}}'))).toBe(true); }); + + /** + * ⚠️ "(s)" is not a plural, it is a refusal to choose one — and French does not agree + * with English about zero, or about where the mark goes on a past participle. Counting + * is the transpiler's job now. + */ + it.each([ + ['fr', fr], + ['en', en], + ])('counts with a plural rather than an apologetic "(s)" (%s)', (_locale, translations) => { + expect(strings(translations).filter((value) => value.includes('(s)'))).toEqual([]); + }); + + /** + * ⚠️ The messageformat transpiler replaces the default one, so `{` is syntax. A literal + * brace in a translated string would have to be escaped as `'{'`, and the failure mode is + * silent — the string renders as something else entirely rather than throwing. + */ + it.each([ + ['fr', fr], + ['en', en], + ])('leaves no brace that is neither an interpolation nor a plural (%s)', (_locale, translations) => { + const suspicious = strings(translations).filter((value) => + // `{{name}}` and `{name…}` are both fine; a lone `{` with nothing after it is not. + /\{(?!\{)\s*(?:\}|$)/.test(value), + ); + + expect(suspicious).toEqual([]); + }); }); diff --git a/src/app/core/services/shortcuts/global-shortcuts.service.spec.ts b/src/app/core/services/shortcuts/global-shortcuts.service.spec.ts index 666423ba..0a8df8ff 100644 --- a/src/app/core/services/shortcuts/global-shortcuts.service.spec.ts +++ b/src/app/core/services/shortcuts/global-shortcuts.service.spec.ts @@ -52,9 +52,11 @@ describe('GlobalShortcutsService', () => { TestBed.tick(); await Promise.resolve(); + // ⚠️ The count travels beside the list: the sentence agrees three times over, and a + // list is not something a translation can count. expect(TestBed.inject(ErrorNotifier).notice()?.ref).toEqual({ key: 'shortcuts.unavailable', - params: { list: 'Ctrl+Alt+P' }, + params: { count: 1, list: 'Ctrl+Alt+P' }, }); }); diff --git a/src/app/core/services/shortcuts/global-shortcuts.service.ts b/src/app/core/services/shortcuts/global-shortcuts.service.ts index 000a86bf..789a3480 100644 --- a/src/app/core/services/shortcuts/global-shortcuts.service.ts +++ b/src/app/core/services/shortcuts/global-shortcuts.service.ts @@ -30,8 +30,14 @@ export class GlobalShortcutsService { // No `Result` on the Rust side: it throws when the bridge is absent. const taken = await commands.setGlobalShortcuts(bindings); if (taken.length > 0) { + // ⚠️ The count travels beside the list: the sentence agrees three times over — + // the noun, the adjective and the participle — and a list cannot be counted by + // the translation. this.notifier.notify({ - ref: { key: 'shortcuts.unavailable', params: { list: taken.join(', ') } }, + ref: { + key: 'shortcuts.unavailable', + params: { count: taken.length, list: taken.join(', ') }, + }, }); } } catch { diff --git a/src/app/notes/canvas/board/board-zone/board-zone.component.html b/src/app/notes/canvas/board/board-zone/board-zone.component.html index 198d3b61..1099e91a 100644 --- a/src/app/notes/canvas/board/board-zone/board-zone.component.html +++ b/src/app/notes/canvas/board/board-zone/board-zone.component.html @@ -23,6 +23,7 @@ class="zone-grip" data-testid="board-zone-grip" [attr.aria-label]="'board.moveZone' | transloco: { name: folder.name }" + [attr.title]="'board.moveZone' | transloco: { name: folder.name }" (pointerdown)="headGrabbed.emit($event)" > @@ -51,6 +52,7 @@ aria-haspopup="menu" [attr.aria-expanded]="menuOpen()" [attr.aria-label]="'folders.optionsLabel' | transloco: { name: folder.name }" + [attr.title]="'folders.optionsLabel' | transloco: { name: folder.name }" (click)="menuOpen.set(!menuOpen())" > @@ -98,6 +100,7 @@ class="zone-resize" data-testid="board-zone-resize" [attr.aria-label]="'board.resizeZone' | transloco: { name: folder.name }" + [attr.title]="'board.resizeZone' | transloco: { name: folder.name }" (pointerdown)="resizeGrabbed.emit($event)" > } diff --git a/src/app/notes/canvas/note-card/note-card-menu/note-card-menu.component.html b/src/app/notes/canvas/note-card/note-card-menu/note-card-menu.component.html index a5effc7e..2ab2fff6 100644 --- a/src/app/notes/canvas/note-card/note-card-menu/note-card-menu.component.html +++ b/src/app/notes/canvas/note-card/note-card-menu/note-card-menu.component.html @@ -6,6 +6,7 @@ aria-haspopup="menu" [attr.aria-expanded]="menu.open()" [attr.aria-label]="'notes.noteOptionsLabel' | transloco: { title: noteTitle() }" + [attr.title]="'notes.noteOptionsLabel' | transloco: { title: noteTitle() }" (click)="toggle($event)" > diff --git a/src/app/notes/canvas/note-card/note-card.component.html b/src/app/notes/canvas/note-card/note-card.component.html index dae098b3..497f470d 100644 --- a/src/app/notes/canvas/note-card/note-card.component.html +++ b/src/app/notes/canvas/note-card/note-card.component.html @@ -148,6 +148,7 @@ [class.checked]="checked()" [attr.aria-pressed]="checked()" [attr.aria-label]="'notes.selectNote' | transloco: { title: menuTitle }" + [attr.title]="'notes.selectNote' | transloco: { title: menuTitle }" (click)="onCheck($event)" > @@ -161,6 +162,7 @@ class="card-action card-fill" data-testid="note-card-fill" [attr.aria-label]="'placeholders.title' | transloco" + [attr.title]="'placeholders.title' | transloco" (click)="onFill($event)" > diff --git a/src/app/notes/canvas/note-card/note-card.component.spec.ts b/src/app/notes/canvas/note-card/note-card.component.spec.ts index f9b81fd1..24bf616f 100644 --- a/src/app/notes/canvas/note-card/note-card.component.spec.ts +++ b/src/app/notes/canvas/note-card/note-card.component.spec.ts @@ -538,7 +538,7 @@ describe('NoteCardComponent', () => { await fixture.whenStable(); expect(fixture.nativeElement.querySelectorAll('.card-item')).toHaveLength(2); - expect(text('.card-items-more')).toBe('+3 autre(s)'); + expect(text('.card-items-more')).toBe('+3 autres'); }); it('shows no language badge, a checklist having no format to announce', () => { diff --git a/src/app/notes/header/folder-breadcrumb/folder-breadcrumb.component.html b/src/app/notes/header/folder-breadcrumb/folder-breadcrumb.component.html index 15398abe..a245e201 100644 --- a/src/app/notes/header/folder-breadcrumb/folder-breadcrumb.component.html +++ b/src/app/notes/header/folder-breadcrumb/folder-breadcrumb.component.html @@ -23,6 +23,7 @@ aria-haspopup="menu" [attr.aria-expanded]="menu.open()" [attr.aria-label]="'folders.optionsLabel' | transloco: { name: folder().name }" + [attr.title]="'folders.optionsLabel' | transloco: { name: folder().name }" (click)="menu.toggle()" > diff --git a/src/app/notes/header/folder-editor/folder-editor.component.html b/src/app/notes/header/folder-editor/folder-editor.component.html index 5150d79a..909e1f9d 100644 --- a/src/app/notes/header/folder-editor/folder-editor.component.html +++ b/src/app/notes/header/folder-editor/folder-editor.component.html @@ -12,6 +12,7 @@ [class.on]="folder().colour === colour" [attr.aria-checked]="folder().colour === colour" [attr.aria-label]="'folders.colour.' + colour | transloco" + [attr.title]="'folders.colour.' + colour | transloco" (click)="pickColour(colour)" > } diff --git a/src/app/notes/header/folder-switcher/folder-switcher.component.html b/src/app/notes/header/folder-switcher/folder-switcher.component.html index 0ab78056..96947963 100644 --- a/src/app/notes/header/folder-switcher/folder-switcher.component.html +++ b/src/app/notes/header/folder-switcher/folder-switcher.component.html @@ -83,6 +83,7 @@ data-testid="folder-edit" [attr.data-folder-id]="folder.id" [attr.aria-label]="'folders.optionsLabel' | transloco: { name: folder.name }" + [attr.title]="'folders.optionsLabel' | transloco: { name: folder.name }" (click)="startEditing(folder)" > diff --git a/src/app/notes/header/new-note-button/new-note-button.component.html b/src/app/notes/header/new-note-button/new-note-button.component.html index cbe513f3..ca4eaf1e 100644 --- a/src/app/notes/header/new-note-button/new-note-button.component.html +++ b/src/app/notes/header/new-note-button/new-note-button.component.html @@ -12,6 +12,7 @@ aria-haspopup="menu" [attr.aria-expanded]="menu.open()" [attr.aria-label]="'notes.newNoteKindLabel' | transloco" + [attr.title]="'notes.newNoteKindLabel' | transloco" (click)="menu.toggle()" > diff --git a/src/app/notes/header/search-box/search-box.component.spec.ts b/src/app/notes/header/search-box/search-box.component.spec.ts index 4cf44dd8..83cc2389 100644 --- a/src/app/notes/header/search-box/search-box.component.spec.ts +++ b/src/app/notes/header/search-box/search-box.component.spec.ts @@ -65,7 +65,7 @@ describe('SearchBoxComponent', () => { fixture.componentRef.setInput('matched', 12); await fixture.whenStable(); - expect(matchedText()).toBe('12 résultat(s) ✕'); + expect(matchedText()).toBe('12 résultats ✕'); expect(fixture.nativeElement.querySelector('.kbd')).toBeNull(); }); @@ -74,7 +74,7 @@ describe('SearchBoxComponent', () => { fixture.componentRef.setInput('matched', 0); await fixture.whenStable(); - expect(matchedText()).toBe('0 résultat(s) ✕'); + expect(matchedText()).toBe('aucun résultat ✕'); }); it('shows the hint again when nothing is being filtered', async () => { diff --git a/src/app/notes/header/selection-bar/selection-bar.component.html b/src/app/notes/header/selection-bar/selection-bar.component.html index 336264dd..09e92b1d 100644 --- a/src/app/notes/header/selection-bar/selection-bar.component.html +++ b/src/app/notes/header/selection-bar/selection-bar.component.html @@ -71,6 +71,7 @@ class="selection-clear" data-testid="selection-clear" [attr.aria-label]="'selection.clear' | transloco" + [attr.title]="'selection.clear' | transloco" (click)="cleared.emit()" > diff --git a/src/app/notes/header/space-switcher/space-switcher.component.html b/src/app/notes/header/space-switcher/space-switcher.component.html index d778f4d5..8c194c0d 100644 --- a/src/app/notes/header/space-switcher/space-switcher.component.html +++ b/src/app/notes/header/space-switcher/space-switcher.component.html @@ -82,6 +82,7 @@ data-testid="space-edit" [attr.data-space-id]="space.id" [attr.aria-label]="'notes.spaceOptionsLabel' | transloco: { name: space.name }" + [attr.title]="'notes.spaceOptionsLabel' | transloco: { name: space.name }" (click)="startEditing(space)" > diff --git a/src/app/notes/notes-page.component.html b/src/app/notes/notes-page.component.html index d78066b6..93eed77c 100644 --- a/src/app/notes/notes-page.component.html +++ b/src/app/notes/notes-page.component.html @@ -28,6 +28,7 @@ data-testid="library-rail-toggle" [attr.aria-pressed]="settings.showLibraryRail()" [attr.aria-label]="'sidebar.toggle' | transloco" + [attr.title]="'sidebar.toggle' | transloco" (click)="toggleLibraryRail()" > @@ -168,9 +169,24 @@ } @else if (canvas.isLoading()) {

{{ 'notes.loading' | transloco }}

} @else if (canvas.hasNoResults()) { -

- {{ 'notes.noResults' | transloco }} -

+ +
+

+ {{ 'notes.noResults' | transloco }} +

+ @if (canvas.hasUserFilters()) { + + } +
} @else { @for (section of canvas.sections(); track section.key) { diff --git a/src/app/notes/overlays/image-lightbox/image-lightbox.component.html b/src/app/notes/overlays/image-lightbox/image-lightbox.component.html index dfeb780b..1eb7a1d2 100644 --- a/src/app/notes/overlays/image-lightbox/image-lightbox.component.html +++ b/src/app/notes/overlays/image-lightbox/image-lightbox.component.html @@ -10,6 +10,7 @@ type="button" class="lightbox-close" [attr.aria-label]="'attachments.closeZoom' | transloco" + [attr.title]="'attachments.closeZoom' | transloco" (click)="closed.emit()" > diff --git a/src/app/notes/overlays/note-editor-overlay/attachment-strip/attachment-strip.component.html b/src/app/notes/overlays/note-editor-overlay/attachment-strip/attachment-strip.component.html index 41888a55..69995e9b 100644 --- a/src/app/notes/overlays/note-editor-overlay/attachment-strip/attachment-strip.component.html +++ b/src/app/notes/overlays/note-editor-overlay/attachment-strip/attachment-strip.component.html @@ -29,6 +29,7 @@ class="strip-icon" [attr.aria-pressed]="previewId() === attachment.id" [attr.aria-label]="'attachments.openPreview' | transloco: { name: attachment.fileName }" + [attr.title]="'attachments.openPreview' | transloco: { name: attachment.fileName }" (click)="previewToggled.emit(attachment.id)" > @@ -39,6 +40,7 @@ type="button" class="strip-icon" [attr.aria-label]="'attachments.save' | transloco: { name: attachment.fileName }" + [attr.title]="'attachments.save' | transloco: { name: attachment.fileName }" (click)="saveRequested.emit(attachment.id)" > @@ -51,6 +53,7 @@ [attr.data-attachment-id]="attachment.id" [class.confirming]="confirmingRemove() === attachment.id" [attr.aria-label]="'attachments.remove' | transloco: { name: attachment.fileName }" + [attr.title]="'attachments.remove' | transloco: { name: attachment.fileName }" (click)="onRemoveClick(attachment.id)" > diff --git a/src/app/notes/overlays/note-editor-overlay/checklist-editor/checklist-editor.component.html b/src/app/notes/overlays/note-editor-overlay/checklist-editor/checklist-editor.component.html index 6bb0b2d7..f0dcdcd6 100644 --- a/src/app/notes/overlays/note-editor-overlay/checklist-editor/checklist-editor.component.html +++ b/src/app/notes/overlays/note-editor-overlay/checklist-editor/checklist-editor.component.html @@ -24,6 +24,7 @@ class="row-grip" data-testid="checklist-grip" [attr.aria-label]="'editor.moveItem' | transloco: { text: item.text }" + [attr.title]="'editor.moveItem' | transloco: { text: item.text }" (pointerdown)="onPointerDown($event, $index)" (pointermove)="onPointerMove($event)" (pointerup)="onPointerUp($event)" @@ -42,6 +43,7 @@ role="checkbox" [attr.aria-checked]="item.done" [attr.aria-label]="'editor.toggleItem' | transloco: { text: item.text }" + [attr.title]="'editor.toggleItem' | transloco: { text: item.text }" (click)="toggle($index)" > @@ -70,6 +72,7 @@ class="row-remove" data-testid="checklist-remove" [attr.aria-label]="'editor.removeItem' | transloco: { text: item.text }" + [attr.title]="'editor.removeItem' | transloco: { text: item.text }" (click)="remove($index)" > diff --git a/src/app/notes/overlays/note-editor-overlay/checklist-editor/checklist-editor.component.spec.ts b/src/app/notes/overlays/note-editor-overlay/checklist-editor/checklist-editor.component.spec.ts index 94f096e5..9030ef42 100644 --- a/src/app/notes/overlays/note-editor-overlay/checklist-editor/checklist-editor.component.spec.ts +++ b/src/app/notes/overlays/note-editor-overlay/checklist-editor/checklist-editor.component.spec.ts @@ -50,7 +50,7 @@ describe('ChecklistEditorComponent', () => { const fill = fixture.nativeElement.querySelector('.progress-fill') as HTMLElement; expect(fill.style.width).toBe('50%'); - expect(fixture.nativeElement.querySelector('.progress-count').textContent.trim()).toBe('1/2 tâche(s)'); + expect(fixture.nativeElement.querySelector('.progress-count').textContent.trim()).toBe('1/2 tâches'); }); it('commits a tick straight away, a discrete gesture having nothing to wait for', async () => { diff --git a/src/app/notes/overlays/note-editor-overlay/note-editor-overlay.component.html b/src/app/notes/overlays/note-editor-overlay/note-editor-overlay.component.html index 9b2ebd94..794d4921 100644 --- a/src/app/notes/overlays/note-editor-overlay/note-editor-overlay.component.html +++ b/src/app/notes/overlays/note-editor-overlay/note-editor-overlay.component.html @@ -80,6 +80,7 @@ data-testid="editor-fullscreen" [attr.aria-pressed]="fullscreen()" [attr.aria-label]="(fullscreen() ? 'editor.exitFullscreen' : 'editor.fullscreen') | transloco" + [attr.title]="(fullscreen() ? 'editor.exitFullscreen' : 'editor.fullscreen') | transloco" (click)="toggleFullscreen()" > @@ -89,6 +90,7 @@ class="icon-btn close-btn" data-testid="editor-close" [attr.aria-label]="'editor.close' | transloco" + [attr.title]="'editor.close' | transloco" (click)="requestClose()" > @@ -118,6 +120,7 @@ data-testid="editor-tag-remove" [attr.data-tag]="tag" [attr.aria-label]="'editor.removeTag' | transloco: { tag: tag }" + [attr.title]="'editor.removeTag' | transloco: { tag: tag }" (click)="removeTag(tag)" > diff --git a/src/app/notes/overlays/quick-palette/quick-palette.component.html b/src/app/notes/overlays/quick-palette/quick-palette.component.html index 915cc1b5..b15d3e61 100644 --- a/src/app/notes/overlays/quick-palette/quick-palette.component.html +++ b/src/app/notes/overlays/quick-palette/quick-palette.component.html @@ -67,6 +67,9 @@

{{ 'palette.title' | transloco }} [attr.aria-label]=" 'palette.copyLabel' | transloco: { title: note.title || ('notes.untitled' | transloco) } " + [attr.title]=" + 'palette.copyLabel' | transloco: { title: note.title || ('notes.untitled' | transloco) } + " (click)="highlightSet.emit(index); chosen.emit()" > diff --git a/src/app/notes/overlays/tag-manager/tag-manager.component.html b/src/app/notes/overlays/tag-manager/tag-manager.component.html index f02e804a..30750665 100644 --- a/src/app/notes/overlays/tag-manager/tag-manager.component.html +++ b/src/app/notes/overlays/tag-manager/tag-manager.component.html @@ -6,6 +6,7 @@

{{ 'tagManager.title' | transloco class="tags-close" data-testid="tag-manager-close" [attr.aria-label]="'tagManager.close' | transloco" + [attr.title]="'tagManager.close' | transloco" (click)="closed.emit()" > diff --git a/src/app/notes/overlays/tag-manager/tag-manager.component.spec.ts b/src/app/notes/overlays/tag-manager/tag-manager.component.spec.ts index 930aedf5..66c2a483 100644 --- a/src/app/notes/overlays/tag-manager/tag-manager.component.spec.ts +++ b/src/app/notes/overlays/tag-manager/tag-manager.component.spec.ts @@ -61,8 +61,8 @@ describe('TagManagerComponent', () => { it('lists each tag with the number of notes carrying it', () => { expect(items().map((item) => item.textContent?.replace(/\s+/g, ' ').trim())).toEqual([ - '#api 1 note(s)', - '#auth 4 note(s)', + '#api 1 note', + '#auth 4 notes', ]); }); diff --git a/src/app/notes/overlays/trash-panel/trash-panel.component.html b/src/app/notes/overlays/trash-panel/trash-panel.component.html index dd3e8fb0..9fbe01e5 100644 --- a/src/app/notes/overlays/trash-panel/trash-panel.component.html +++ b/src/app/notes/overlays/trash-panel/trash-panel.component.html @@ -6,6 +6,7 @@

{{ 'trash.title' | transloco }} diff --git a/src/app/notes/overlays/trash-panel/trash-panel.component.spec.ts b/src/app/notes/overlays/trash-panel/trash-panel.component.spec.ts index b95ee8c2..3c5870c5 100644 --- a/src/app/notes/overlays/trash-panel/trash-panel.component.spec.ts +++ b/src/app/notes/overlays/trash-panel/trash-panel.component.spec.ts @@ -63,6 +63,27 @@ describe('TrashPanelComponent', () => { expect(meta).toContain('30'); }); + /** + * ⚠️ The panel says notes are kept 30 days and this said 31 one line under it. The cause + * is not arithmetic on the retention — it is that `ClockService` ticks every 30 s, so the + * `now` a card renders against can be **behind** an instant Rust has just stamped. The + * gap then reads as 30 days *and change*, and rounding up made the change a whole day. + */ + it('agrees with the retention rule stated above it, on a clock that has not ticked yet', async () => { + // Deleted 20 seconds after the clock last looked: `purgeAt` is that plus 30 days. + fixture.componentRef.setInput('notes', [ + trashed({ + deletedAt: new Date('2026-08-27T09:00:20Z'), + purgeAt: new Date('2026-09-26T09:00:20Z'), + }), + ]); + await fixture.whenStable(); + + const meta = rows()[0].querySelector('.trash-row-meta')?.textContent ?? ''; + expect(meta).toContain('30'); + expect(meta).not.toContain('31'); + }); + it('counts the last day as still the user’s', async () => { fixture.componentRef.setInput('notes', [trashed({ purgeAt: new Date('2026-08-27T23:00:00Z') })]); await fixture.whenStable(); diff --git a/src/app/notes/overlays/trash-panel/trash-panel.component.ts b/src/app/notes/overlays/trash-panel/trash-panel.component.ts index bca43282..c6818891 100644 --- a/src/app/notes/overlays/trash-panel/trash-panel.component.ts +++ b/src/app/notes/overlays/trash-panel/trash-panel.component.ts @@ -80,9 +80,15 @@ export class TrashPanelComponent { } } -/** Rounded up: "erased in 1 d" while there is any time left. */ +/** + * ⚠️ Rounded to the nearest day, and neither of the other two will do. Rounding **up** put + * "erased in 31 d" one line under a panel saying notes are kept 30 days: `purgeAt` is + * `deletedAt + 30 days`, so a note just deleted has a few milliseconds under 30 left and + * any fraction became a whole extra day. Rounding **down** says 29 for the same note, which + * is the same contradiction the other way round. + */ function purgeRef(purgeAt: Date, now: Date): TranslationRef { - const days = Math.ceil((purgeAt.getTime() - now.getTime()) / MS_PER_DAY); + const days = Math.round((purgeAt.getTime() - now.getTime()) / MS_PER_DAY); return days <= 0 ? { key: 'trash.purgesToday' } : { key: 'trash.purgesIn', params: { count: days } }; } diff --git a/src/app/notes/overlays/undo-bar/undo-bar.component.html b/src/app/notes/overlays/undo-bar/undo-bar.component.html index 832000d5..83f2ae30 100644 --- a/src/app/notes/overlays/undo-bar/undo-bar.component.html +++ b/src/app/notes/overlays/undo-bar/undo-bar.component.html @@ -8,6 +8,7 @@ class="undo-dismiss" data-testid="undo-dismiss" [attr.aria-label]="'undo.dismiss' | transloco" + [attr.title]="'undo.dismiss' | transloco" (click)="dismissed.emit()" > diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.html b/src/app/notes/sidebar/library-tree/library-tree.component.html index 0246a5ff..1761a764 100644 --- a/src/app/notes/sidebar/library-tree/library-tree.component.html +++ b/src/app/notes/sidebar/library-tree/library-tree.component.html @@ -6,6 +6,7 @@

{{ 'sidebar.library' | transloco class="rail-add" data-testid="space-create-open" [attr.aria-label]="'notes.newSpace' | transloco" + [attr.title]="'notes.newSpace' | transloco" (click)="startCreatingSpace()" > @@ -62,6 +63,7 @@

{{ 'sidebar.library' | transloco [attr.aria-label]=" (open ? 'sidebar.collapse' : 'sidebar.expand') | transloco: { name: space.name } " + [attr.title]="(open ? 'sidebar.collapse' : 'sidebar.expand') | transloco: { name: space.name }" (click)="toggleExpanded(space.id)" > @@ -88,6 +90,7 @@

{{ 'sidebar.library' | transloco [attr.data-space-id]="space.id" [attr.aria-expanded]="editedSpace() === space.id" [attr.aria-label]="'notes.spaceOptionsLabel' | transloco: { name: space.name }" + [attr.title]="'notes.spaceOptionsLabel' | transloco: { name: space.name }" (click)="editSpace(space.id)" > @@ -132,6 +135,7 @@

{{ 'sidebar.library' | transloco [attr.data-folder-id]="folder.id" [attr.aria-expanded]="editedFolder() === folder.id" [attr.aria-label]="'folders.optionsLabel' | transloco: { name: folder.name }" + [attr.title]="'folders.optionsLabel' | transloco: { name: folder.name }" (click)="editFolder(folder.id)" > diff --git a/src/app/titlebar/file-menu/settings-dialog/variables-page/variables-page.component.html b/src/app/titlebar/file-menu/settings-dialog/variables-page/variables-page.component.html index c6e88e0c..08232762 100644 --- a/src/app/titlebar/file-menu/settings-dialog/variables-page/variables-page.component.html +++ b/src/app/titlebar/file-menu/settings-dialog/variables-page/variables-page.component.html @@ -51,6 +51,7 @@ class="variable-remove" data-testid="variable-remove" [attr.aria-label]="'settings.variables.remove' | transloco: { name: nameInput.value || '—' }" + [attr.title]="'settings.variables.remove' | transloco: { name: nameInput.value || '—' }" (click)="remove($index)" > diff --git a/src/testing/provide-transloco-testing.ts b/src/testing/provide-transloco-testing.ts index 05052822..5cbb8975 100644 --- a/src/testing/provide-transloco-testing.ts +++ b/src/testing/provide-transloco-testing.ts @@ -1,16 +1,25 @@ -import { EnvironmentProviders } from '@angular/core'; -import { provideTransloco } from '@jsverse/transloco'; +import { EnvironmentProviders, Provider } from '@angular/core'; +import { TRANSLOCO_TRANSPILER, provideTransloco } from '@jsverse/transloco'; +import { PluralTranspiler } from '@core/services/i18n/plural-transpiler'; import { AppTranslocoLoader } from '@core/services/i18n/transloco-loader'; -/** The bundled loader is already synchronous, so no `TranslocoTestingModule` is needed. */ -export function provideTranslocoTesting(): EnvironmentProviders[] { - return provideTransloco({ - config: { - availableLangs: ['fr', 'en'], - defaultLang: 'fr', - reRenderOnLangChange: true, - missingHandler: { logMissingKey: false, useFallbackTranslation: false, allowEmpty: true }, - }, - loader: AppTranslocoLoader, - }); +/** + * The bundled loader is already synchronous, so no `TranslocoTestingModule` is needed. + * + * ⚠️ The plural transpiler comes with it, exactly as `app.config.ts` provides it: without it + * a spec asserting on a counted string reads the ICU source back instead of a sentence. + */ +export function provideTranslocoTesting(): (EnvironmentProviders | Provider)[] { + return [ + ...provideTransloco({ + config: { + availableLangs: ['fr', 'en'], + defaultLang: 'fr', + reRenderOnLangChange: true, + missingHandler: { logMissingKey: false, useFallbackTranslation: false, allowEmpty: true }, + }, + loader: AppTranslocoLoader, + }), + { provide: TRANSLOCO_TRANSPILER, useClass: PluralTranspiler }, + ]; } From 82f6fd00fe82378eed4b1c4b3cf103d29a3a0954 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Fri, 18 Sep 2026 12:07:56 +0200 Subject: [PATCH 2/2] Assert what the count does, not the language it does it in The zero case is written out in words, and the scenario asserted the French ones. The e2e suite runs in whatever language the machine is set to: French here, English on both runners. It passed locally and could not pass on CI. Co-Authored-By: Claude Opus 5 --- e2e/specs/06-search-and-filters.e2e.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/e2e/specs/06-search-and-filters.e2e.ts b/e2e/specs/06-search-and-filters.e2e.ts index 30719880..68d02961 100644 --- a/e2e/specs/06-search-and-filters.e2e.ts +++ b/e2e/specs/06-search-and-filters.e2e.ts @@ -110,11 +110,15 @@ describe('Search, filters and facets', () => { // Against what is on screen: the corpus is shared with every file that ran before. expect(await canvas.matchedCount()).toContain(String((await canvas.titles()).length)); - // ⚠️ In words, not as a digit. French keeps the singular at zero, so "0 résultat" - // would read as one — the count says "aucun résultat" instead, and this asserts the - // meaning rather than a character. + // ⚠️ Not on the words. The zero case is written out — French calls zero `one`, so + // "0 résultat" would read as a singular — but **the suite runs in whatever language + // the machine is set to**: French here, English on CI. Asserting "aucun" passed + // locally and failed on both runners. What the scenario is about is that the badge + // still says something, so that is what it asks. await canvas.search('nothing matches this'); - expect(await canvas.matchedCount()).toContain('aucun'); + const atZero = (await canvas.matchedCount()).replace('✕', '').trim(); + expect(atZero.length).toBeGreaterThan(0); + expect(await canvas.noResults().isExisting()).toBe(true); }); it('hides the count again once nothing is being filtered', async () => {