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..68d02961 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,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)); + // ⚠️ 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('0'); + 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 () => { 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 }, + ]; }