diff --git a/CLAUDE.md b/CLAUDE.md index 00ca6dc..4850fab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co DevBox — a desktop "Swiss Army knife" utility app for developers (notes-taking, hashing, encoding). Front-end is **Angular 22** (standalone components, signals, zoneless change detection), native engine is **Rust / Tauri v2**. -The **notes feature is complete end to end**: front-end (spaces with creation, renaming, deletion and filtering, search, filters, tag rail, sections, full note editing — content, format, tags, pin, deadline, move to another space, deletion), IPC (no mock data left, `Tauri*Repository` is the only data source, every read/write goes through `IpcService`), and Rust (`query_notes`, `create_note`, `update_note`, `delete_note`, `list_spaces`, `create_space`, `rename_space`, `delete_space` persist to an embedded SQLite database). `crypto` and `formatters` are documented placeholders — the module files carry the intended contract and no code. +The **notes feature is complete end to end**: front-end (spaces with creation, renaming, deletion and filtering, search, filters, tag rail, sections, full note editing — content, format, tags, pin, deadline, move to another space, deletion), IPC (no mock data left, `Tauri*Repository` is the only data source, every read/write goes through `IpcService`), and Rust (`query_notes`, `create_note`, `update_note`, `delete_note`, `list_spaces`, `create_space`, `rename_space`, `delete_space` persist to an embedded SQLite database). There is no crypto or formatters module: they were removed once it became clear they would ship dead code. **Data processing belongs to Rust.** Filtering (space, full-text, tags, languages, quick filters), grouping into sections, facet aggregation and tag normalisation, and the choice of what a card's footer shows all run in `src-tauri/src/domain/`. `query_notes` returns a ready-to-render `NotesView`; the front-end describes the query and displays the answer, it never filters, sorts or groups. Deliberate exceptions: relative-time **formatting** (labels must age without a round trip), the ISO ↔ `Date` conversion at the serialisation boundary, syntax highlighting (it colours the unsaved editor draft — a round trip per keystroke otherwise), and pure UI concerns (shortcuts, editor drafts, which space a new note goes to). @@ -18,6 +18,8 @@ The **notes feature is complete end to end**: front-end (spaces with creation, r Two greps guard the direction, and are worth running after any structural change: `grep -rn "rusqlite\|tauri::" src-tauri/src/domain/` and `grep -rn "use crate::commands" src-tauri/src/storage/` must both come back empty. `docs/architecture.md` has the details. +**The front-end is filed by subject, not by technical nature.** A feature owns its `data/`, `model/`, `state/` and `ui/` — `features/notes/` holds the DTOs, the repositories, the models, both stores and every notes component, so deleting the folder deletes the feature. `core/` is only what a second, unrelated tool would inject verbatim (`ipc`, `i18n`, `errors`, `time`, `preferences`, `updates`, `app-info`, `language`), one folder per subject with a service and its store together — there is no `core/stores/`. `shared/` is a presentation kit whose components **inject nothing**; anything that injects and frames the app belongs to `layout/`. Adding the hashing tool must not add a file under `core/`. + Primary language for code comments, docstrings, and UI strings in this repo is **French**. Match that convention when editing existing files. Test descriptions and test comments are the deliberate exception: they're written in **English**. `docs/architecture.md` is the detailed architecture reference (front-end structure, state and data-access patterns, i18n, theming, IPC boundary, testing conventions). Read it before making structural changes, and keep it in sync when you make them — it's the canonical place for that documentation, not this file and not the README. @@ -43,11 +45,11 @@ Run all commands from the repo root (`package.json` there wraps both Angular and These are the non-obvious constraints; the rest of the architecture is in `docs/architecture.md`. -- **Registering commands.** A new `#[tauri::command]` must be added to `tauri::generate_handler![...]` in `src-tauri/src/lib.rs`, **and** to `IpcContract` in `src/app/core/ipc/ipc-contract.ts`, or `invoke()` fails at runtime with "command not found". Tauri matches arguments by name, not position; `IpcContract` is what makes a wrong key a build error instead of a runtime serde rejection. Careful: Tauri v2 applies `rename_all = "camelCase"` to arguments, so a Rust `note_id` is `noteId` on the wire. -- **Serialisation contract.** JSON has no date type, so every `Date` crosses the bridge as an ISO string and is converted in `core/data/note.dto.ts` / `note-view.dto.ts` — never type an `invoke()` result as a domain model directly. On the Rust side the `Note`, `NotesQuery` and `NotesView` structs need `#[serde(rename_all = "camelCase")]` (`spaceId`, `createdAt`, `availableTags`…) and the lifecycle enum `#[serde(tag = "kind", rename_all = "camelCase")]`, or the front-end cannot read what it receives. -- **Errors are codes, not strings.** Commands return `Result` (`commands/error.rs`): a stable `code`, its interpolation `params`, and a technical `detail`. Returning a `String` would put a French sentence in the English UI and force callers to parse prose. The code→key mapping lives in exactly one place, `core/errors/ipc-notice.ts`, whose `Record` table fails the build until a new variant gets a key. `IpcErrorCode` mirrors `ErrorCode` — add a variant to both, plus the key in **both** locales. `IpcError.code` is `null` when Tauri itself rejects (unknown command, bad argument) _and_ when the code is unknown to this build, so handle that. -- **Input is validated in the domain, not just in the form.** `domain/validation.rs` + `language.rs`; commands call `draft.validate()` / `validated_name()` before locking. A rule held only by a form is not held. -- **Data-source seam.** Components and stores never touch a data source directly: everything goes through the `NOTES_REPOSITORY` / `SPACES_REPOSITORY` tokens, bound in `core/data/data.providers.ts`. Don't call `invoke()` from a component or a store — `IpcService` is the only caller. There is no in-memory dataset anymore; the only doubles live in `src/testing/`. `NotesRepository` has **no method returning a raw note list** — that's on purpose, one would invite re-filtering on the front. +- **Registering commands.** A new `#[tauri::command]` must be added to `tauri::generate_handler![...]` in `src-tauri/src/lib.rs`, **and** to `IpcContract` in `src/app/core/ipc/ipc.service.ts`, or `invoke()` fails at runtime with "command not found". Tauri matches arguments by name, not position; `IpcContract` is what makes a wrong key a build error instead of a runtime serde rejection. Careful: Tauri v2 applies `rename_all = "camelCase"` to arguments, so a Rust `note_id` is `noteId` on the wire. +- **Serialisation contract.** JSON has no date type, so every `Date` crosses the bridge as an ISO string and is converted in `features/notes/data/note.dto.ts` — never type an `invoke()` result as a domain model directly. On the Rust side the `Note`, `NotesQuery` and `NotesView` structs need `#[serde(rename_all = "camelCase")]` (`spaceId`, `createdAt`, `availableTags`…) and the lifecycle enum `#[serde(tag = "kind", rename_all = "camelCase")]`, or the front-end cannot read what it receives. +- **Errors are codes, not strings.** Commands return `Result` (`commands/error.rs`): a stable `code`, its interpolation `params`, and a technical `detail`. Returning a `String` would put a French sentence in the English UI and force callers to parse prose. The code→key mapping lives in exactly one place, `core/errors/error-notifier.service.ts`, whose `Record` table fails the build until a new variant gets a key. `IpcErrorCode` mirrors `ErrorCode` — add a variant to both, plus the key in **both** locales. `IpcError.code` is `null` when Tauri itself rejects (unknown command, bad argument) _and_ when the code is unknown to this build, so handle that. +- **Input is validated in the domain, not just in the form.** `domain/rules.rs`; commands call `draft.validate()` / `validated_name()` before locking. A rule held only by a form is not held. +- **Data-source seam.** Components and stores never touch a data source directly: everything goes through the `NOTES_REPOSITORY` / `SPACES_REPOSITORY` tokens, bound in `app.config.ts`. Don't call `invoke()` from a component or a store — `IpcService` is the only caller. There is no in-memory dataset anymore; the only doubles live in `src/testing/`. `NotesRepository` has **no method returning a raw note list** — that's on purpose, one would invite re-filtering on the front. - **`null` space means "all spaces".** `SpacesStore.activeSpaceId()` is `null` when the user wants every space, and that is a choice, not a loading state — don't add an "All" row to the spaces data, notes would end up filed into it. A note always has a `spaceId`; creating one with no space available is refused on purpose. - **Deleting a space needs a refuge.** `notes.space_id` carries `ON DELETE CASCADE`, so `delete_space(id, targetSpaceId)` moves the notes _then_ deletes, in one transaction — there is no one-argument variant, which would have made data loss the default. It leaves `updated_at` alone (the canvas sorts on it, and touching it would float the whole absorbed space to the top). A space can't be its own refuge: `domain::space::validate_move_target` refuses it before any SQL runs. `targetSpaceId` is the first multi-word command argument, so it's the one that actually exercises Tauri's camelCase renaming. - **"À trier" = a note with a deadline.** The `untriaged` filter, the `⏳` badge and the "à trier bientôt" section hint all read the same field, `lifecycle`. It's set from the editor's date field, converted to the **end of the local day** (`endOfLocalDay`) — midnight would make a note dated today expired on the spot — and read back in local time too. Remove that field and all three affordances go permanently empty, which is exactly the state they were in before it existed. @@ -59,14 +61,14 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **`NotesStore.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. - **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 domain layer — a new note gets an empty title, and the UI renders a translated placeholder. - **Accessibility is enforced by the linter.** 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. -- **CSS variables must stay global.** Theme variables live on `:root` in `src/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 *;`. +- **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 *;`. - **Fake timers in tests.** Use `vi.useFakeTimers({ toFake: ['Date'] })` for date-dependent components. Plain `vi.useFakeTimers()` also fakes `requestAnimationFrame`, which Angular's zoneless scheduler needs, and `await fixture.whenStable()` then hangs forever. - **Time comes from `ClockService`.** Pure time utils take `now: Date` as a parameter and callers pass `clock.now()`. Reading `new Date()` inside a `computed()` freezes the value: the computed depends on no signal representing time, so a card shows "4 min ago" forever. - **CSP is on.** `src-tauri/tauri.conf.json` locks the WebView down; `ipc:` and `http://ipc.localhost` must stay in `connect-src` or `invoke()` is blocked. Anything remote (fonts, images, APIs) needs a deliberate widening — fonts are self-hosted via `@fontsource` for exactly this reason. -- **The domain structs are a _contract_.** Don't change the shape of anything in `domain/note.rs`, `query.rs`, `display.rs` or `space.rs` without changing the DTOs on the front; their serde tests will fail if you do. `commands/crypto.rs` and `formatters.rs` hold only their intended contract as documentation — deliberately no placeholder code, which would ship dead in the binary. +- **The domain structs are a _contract_.** Don't change the shape of anything in `domain/note.rs`, `view.rs` or `space.rs` without changing the DTOs on the front; their serde tests will fail if you do. - **Search matching is Rust, not SQL.** SQLite's `LOWER()` only folds ASCII without ICU, so a `WHERE LOWER(title) LIKE …` would stop matching `Étape` against `étape`. Coarse filters (space, pin, lifecycle, language, tags) stay in SQL where they're indexed; text matching runs on the fetched rows via `to_lowercase()`. - **Syntax highlighting is highlight.js, front-side, in exactly one module.** `shared/ui/code-viewer/highlighter.ts` imports grammars **one by one** (`highlight.js/lib/languages/…`), never the default bundle. It colours the whole block — that's what handles multi-line comments and strings — then re-splits the output with `splitHighlightedLines`, which reopens the tag stack across each newline. The `.hljs-*` theme lives in the **global** `src/styles/_code-theme.scss`: injected by `[innerHTML]`, it carries no `_ngcontent` attribute, so a component-scoped rule would never match. -- **Tag normalisation lives in `domain::tags::normalize`, and only there.** Trim, strip leading `#`, drop blanks, collapse case-insensitive duplicates. The front sends the raw string. `storage::notes::replace_tags` calls it and sorts the result to match what a read gives back, or a note's tags reorder themselves on the next reload. `note_tags.tag` is `COLLATE NOCASE` (migration 2) so the folding extends across notes, not just within one. +- **Tag normalisation lives in `domain::rules::normalize_tags`, and only there.** Trim, strip leading `#`, drop blanks, collapse case-insensitive duplicates. The front sends the raw string. `storage::notes::replace_tags` calls it and sorts the result to match what a read gives back, or a note's tags reorder themselves on the next reload. `note_tags.tag` is `COLLATE NOCASE` (migration 2) so the folding extends across notes, not just within one. - **A `computed` feeding a `resource` needs an `equal` comparator.** `resource` compares params by identity. `NotesStore.queryParams` returns a fresh object literal and reads `clock.now()`: without `sameQueryParams`, every 30 s tick fired a full `query_notes` round trip, invisible behind the retained view. - **Migrations are append-only.** The SQLite schema is versioned by `PRAGMA user_version` in `src-tauri/src/storage/mod.rs`. Changing the model means a new `MIGRATION_N` and a new branch in `migrate` — never editing `MIGRATION_1`, which has already run on existing installs. Deleting the database file is a legitimate reset during development (`app_data_dir()/devbox.sqlite3`). - **`PRAGMA foreign_keys` is per connection, not per database.** It's set in `storage::configure`; without it the `ON DELETE CASCADE` clauses in the schema are inert and deleting a note leaves its tags behind. diff --git a/angular.json b/angular.json index 3f64fd0..a2ac3af 100644 --- a/angular.json +++ b/angular.json @@ -44,7 +44,7 @@ "@fontsource/jetbrains-mono/500.css", "@fontsource/jetbrains-mono/600.css", "@fontsource/jetbrains-mono/700.css", - "src/styles.scss" + "src/styles/styles.scss" ] }, "configurations": { diff --git a/docs/architecture.md b/docs/architecture.md index e8d6b0f..db99683 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,9 +11,9 @@ The two halves talk only through Tauri's `invoke()` bridge. Notes and spaces are complete end to end: the front-end has no in-memory dataset left, every read and write goes through `invoke()`, and the Rust commands persist to an embedded SQLite -database. The remaining domains (`crypto`, `formatters`) are module files carrying their -intended contract as documentation and no code — a placeholder implementation would ship dead -in the binary and be thrown away anyway. +database. The planned domains (crypto, formatters) have **no** module of their own yet: a +placeholder would ship dead code in the binary, and an empty file documenting a contract +drifts from whatever eventually gets written. **Where the work happens.** Data processing belongs to Rust. Filtering (space, full-text, tags, languages, quick filters), grouping into display sections, facet aggregation and tag @@ -27,15 +27,17 @@ yet — a round trip per keystroke), and plain UI concerns like keyboard shortcu ``` src/ Angular front-end ├── app/ -│ ├── core/ state, data access, models, IPC, i18n, time, errors, pure utils -│ ├── features/ feature screens (notes) -│ ├── layout/ app shell, titlebar -│ └── shared/ reusable presentational components and a11y directives +│ ├── core/ cross-cutting infrastructure, one folder per subject: +│ │ IPC, i18n, errors, time, preferences, updates, app-info, language +│ ├── features/ one folder per tool, owning its data/, model/, state/ and ui/ +│ ├── layout/ the app chrome: shell, titlebar, about, error banner, update prompt +│ └── shared/ presentation kit — a11y directives and components that inject nothing ├── assets/ static images -├── styles.scss global theme +├── styles/ global theme (styles.scss) and SCSS partials └── testing/ test doubles, fixtures and shared providers src-tauri/ Rust back-end ├── src/domain/ model and business rules — knows neither SQLite nor Tauri +│ (note, view, sections, space, rules) ├── src/storage/ SQLite persistence: schema, migrations, SQL only ├── src/commands/ Tauri adapters: lock, delegate, translate the error ├── src/lib.rs Tauri builder, database setup + command registration @@ -60,15 +62,70 @@ The payoff is concrete: the domain tests run without opening a database. `cargo covers section placement, timezone boundaries, tag normalisation, search folding, footer choice and expiry thresholds in a few milliseconds, with no fixture setup. +`domain/` is five modules, grouped by what they answer rather than by function: + +| Module | Holds | +| ------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `note.rs` | what a note **is** (`Note`, lifecycle, draft, patch) and how it is **shown** (`NoteFooter`, `DisplayNote`, `decorate`) | +| `view.rs` | what is asked (`NotesQuery`, `NoteFilter`) and what comes back (`NotesView`, `NoteSection`), plus `build()` | +| `sections.rs` | chronological placement and the timezone arithmetic it needs | +| `space.rs` | the space and its move-target rule | +| `rules.rs` | validation and matching: `ValidationError`, languages, tag normalisation, search | + +The split is deliberately coarse. A module per function meant four files wrapping one +function each, four module headers, and a reader chasing `normalize` across the tree. + Serde attributes sit on the domain types rather than on a separate DTO family. At this size a second set of types and their mapping would cost more than it protects; the wire shape is -pinned by tests in `domain/note.rs`, `domain/query.rs` and `domain/space.rs` instead. - -Imports use path aliases rather than deep relative paths: `@core/*`, `@shared/*`, -`@features/*`, `@layout/*`, `@testing/*` (declared in `tsconfig.json`). +pinned by tests in `domain/note.rs`, `domain/view.rs` and `domain/space.rs` instead. ## Front-end +### Where a file goes + +> A feature owns its data, its model, its state and its components. `core/` is cross-cutting +> infrastructure — one folder per subject, a service and its store together. `shared/` is a +> presentation kit: nothing in it injects. `layout/` is the chrome around the tools. + +The axis is the **subject**, never the technical nature. There is no `stores/` folder holding +every store, because that files one domain under four addresses; `UpdateStore` sits beside +`UpdaterService` in `core/updates/`, and `NotesStore` sits in `features/notes/state/`. State +lives beside what it manages. + +The payoff is the second tool: `features/hashing/` will hold `{data,model,state,ui}` and its +page, the slots it does not need simply will not exist, and **nothing in `core/` moves**. The +inverse test is just as useful — deleting `features/notes/` deletes the notes feature and +leaves nothing dangling. + +Membership is decidable, not a matter of taste: + +| Folder | Test | +| ----------- | ------------------------------------------------------------- | +| `features/` | does one tool need it, and no other? | +| `core/` | would a second, unrelated tool inject it verbatim? | +| `shared/` | does it take everything through `input()` and inject nothing? | +| `layout/` | is it the frame around a tool rather than part of one? | + +That last rule is why `ErrorBannerComponent`, `UpdatePromptComponent` and +`AboutDialogComponent` live in `layout/` and not in `shared/ui/`: they inject. And why +`LifecycleBadgeComponent`, which reads `NoteLifecycle`, lives under `features/notes/ui/`. + +### Imports + +Path aliases rather than deep relative paths: `@core/*`, `@shared/*`, `@features/*`, +`@layout/*`, `@testing/*` (declared in `tsconfig.json`). The rule is **relative when a single +`../` reaches the target, alias otherwise** — so `features/notes/state/notes.store.ts` reads +`../data/notes.repository`, while `features/notes/ui/note-card/` reaches the model through +`@features/notes/model/note.model`. There is no `../../` anywhere in `src/`, and that is worth +keeping: it is the property that makes an import line readable without opening a file tree. + +**No `index.ts` barrels.** Three reasons, in order of weight: a barrel at +`features/notes/index.ts` would pull `data/`, `state/` and every `ui/` component into the lazy +chunk _while hiding that it does_ — the explicit `loadComponent` path is what keeps the chunk +honest; barrels re-close import cycles by construction, and this codebase has one deliberate +cycle broken by hand (`core/ipc` ↔ `features/notes/data`, see below); and with five aliases the +import lines are already short. The tree has zero barrels — keep it that way. + The app bootstraps standalone components (`src/main.ts` → `bootstrapApplication`); there are no NgModules. Change detection is **zoneless** (`provideZonelessChangeDetection()`). State lives in signals and every component is `OnPush` — enforced by the @@ -94,12 +151,48 @@ presentational and stateless, which is what makes the components testable in iso Presentational components that could serve any feature live in `shared/ui/` — including `CodeViewerComponent`, which knows nothing about notes and will be reused by the formatters -feature. Components specific to notes live under the feature's own `components/` folder. +feature. Components specific to notes live under the feature's own `ui/` folder, next to the +page that composes them. Components never reach into each other imperatively. A keyboard shortcut belongs to the component that owns the affected element: `Ctrl/⌘+K` is handled inside `SearchBoxComponent`, which also renders the hint, rather than travelling down a chain of `viewChild` calls. +A component that only relays inputs and outputs is not a component. The page composes +`SpaceSwitcher`, `SearchBox`, `FilterChips` and `NoteSection` directly rather than through a +topbar and a canvas wrapper, which added two files and eleven declarations without a single +decision between them. + +### Shared behaviour lives in directives, not in copies + +Three menus (space switcher, card actions, about) and three modals (editor, about, update +prompt) share their interaction rules. Those rules live in `shared/a11y/`, and are applied +through `hostDirectives` so no wrapper element is needed: + +| Directive | Selector | Owns | +| ------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------- | +| `MenuTriggerDirective` | `[appMenuTrigger]`, `exportAs: 'appMenu'` | open state, outside click, Escape, focus returned to `[appMenuAnchor]` | +| `MenuPanelDirective` | `[appMenuPanel]` | `role="menu"`, focus on the first entry, arrows and Home/End over `[appMenuItem]` | +| `DialogBackdropDirective` | `[appDialogBackdrop]` | dismissal when the click lands on the backdrop itself | +| `FocusTrapDirective` | `[appFocusTrap]` | keyboard focus confined to a dialog, restored on destroy | + +Two details are load-bearing: + +- `MenuPanelDirective` walks `[appMenuItem]` rather than every button, because a menu may + carry a secondary action deliberately outside the arrow cycle — the `⋯` that opens a + space's edit panel is reachable by Tab, not by arrows. +- `MenuTriggerDirective` **emits** `escaped` instead of closing on Escape. A single-level menu + wires it straight to `close()`; the space switcher first collapses its create/edit panel and + only closes on the second press. + +Putting the click listener in a directive also removes the `click-events-have-key-events` +suppressions the three modal templates used to carry: the keyboard equivalent exists, it is +Escape, and the template no longer declares a bare `(click)` for the linter to flag. + +The matching CSS lives in `src/styles/_mixins.scss` as `backdrop($z-index)` and +`dialog-panel($width)`. The z-index stays with the caller: the stacking order (editor 50, +about 55, update 60) is a decision, not an implementation detail. + ### Syntax highlighting `CodeViewerComponent` renders read-only coloured code — a card excerpt, or the layer under the @@ -192,7 +285,7 @@ Rules of the house: why the view is recomputed rather than patched. - **Ids, timestamps and normalisation come from persistence**, never from the front-end. - Derived state is `computed()`, never a manually maintained signal. -- Formatting logic that needs no injection lives in `core/utils/` as pure functions taking +- Formatting logic that needs no injection lives beside its subject (relative time in `core/time/`) as pure functions taking `now: Date` as a parameter. ### Display sections @@ -332,7 +425,7 @@ persists through `PreferencesService`. ### Data access Stores never talk to a data source directly. They inject `NOTES_REPOSITORY` / -`SPACES_REPOSITORY`, and `core/data/data.providers.ts` binds those tokens — **the single +`SPACES_REPOSITORY`, and `app.config.ts` binds those tokens — **the single place where the application's data source is chosen**. Both are bound to the `Tauri*` repositories; the only remaining doubles are the test ones in `src/testing/`, injected by `provideAppTesting()`. @@ -357,7 +450,8 @@ Keep this seam intact: no component calls `invoke()`, and `IpcService` is its on All calls go through `IpcService` (`core/ipc/`) and every failure comes back as an `IpcError`. -The command **and its arguments** are typed by `core/ipc/ipc-contract.ts`, a table mapping +The command **and its arguments** are typed by the `IpcContract` table in +`core/ipc/ipc.service.ts`, mapping each command name to its argument shape and return type. This matters more than it looks: Tauri matches arguments **by name**, so a misspelled key used to compile fine and fail at runtime as a serde rejection — an `IpcError` with no code, the most opaque failure the app @@ -377,7 +471,7 @@ This exists because business rules live in Rust. A message written there would b an English UI, and branching on a cause would mean parsing a sentence that breaks at the first rewording. -The mapping lives in **one** place, `core/errors/ipc-notice.ts`: `ipcNotice(error, fallback)` +The mapping lives in **one** place, `core/errors/error-notifier.service.ts`: `ipcNotice(error, fallback)` turns a failure into the message that helps most. A named cause wins over the attempted action — "this note no longer exists" beats "could not save the note", which would leave the user retrying something that can never succeed. `fallback` is used when the cause adds @@ -399,7 +493,7 @@ giving it a code would advertise a case the front can never handle. ### Serialisation contract -`core/data/note.dto.ts` defines what actually travels over the bridge and converts it to the +`features/notes/data/note.dto.ts` defines what actually travels over the bridge and converts it to the domain model. Two traps it exists to handle: - **JSON has no date type.** Every `Date` becomes an ISO 8601 string on the wire. The mapper @@ -416,7 +510,7 @@ entry in `availableLanguages` is dropped from the rail: a newer backend may know this front-end build does not. Contrast with an unknown section key, which does throw — a rail missing one facet stays usable, a canvas with an unreadable section does not. -The known list is `domain/language.rs` (`LANGUAGES`), mirrored by `core/models/language.model.ts` +The known list is `domain/language.rs` (`LANGUAGES`), mirrored by `core/language/language.model.ts` (`LanguageTag` + `LANGUAGE_LABELS`). Adding a language means editing both, plus a `.lang-*` rule in `language-badge.component.scss` and, if it should be coloured, an entry in `GRAMMARS`. Nothing compares the two lists, so a drift only surfaces at runtime as a fallback to `txt`. @@ -427,7 +521,7 @@ serialise to `null` and overwrite the stored value instead of leaving it untouch ### Rules - Argument names must match between the TS call site and the Rust signature — Tauri matches - by name, not position. Declare each command in `ipc-contract.ts` and the compiler enforces + by name, not position. Declare each command in `IpcContract` and the compiler enforces it. ⚠️ Tauri v2 applies `rename_all = "camelCase"` to arguments, so a Rust parameter `note_id` is `noteId` on the wire. No parameter is multi-word today, but the first one will hit this. @@ -464,7 +558,7 @@ passes if it carries _at least one_ of the selected values), facets scoped to th than to the current filter, and a selection counts as `is_filtering` — which collapses the canvas into a single flat `results` section. The quick filters (pinned / untriaged) do not: they narrow a view that stays chronological. One asymmetry: selected tags go through -`domain::tags::normalize` before hitting SQL, selected languages do not — a language is picked +`domain::rules::normalize_tags` before hitting SQL, selected languages do not — a language is picked from a closed list, not typed, and `domain::language` compares it exactly. The serialisation contract is pinned by tests in `domain/note.rs`, `domain/query.rs`, @@ -477,7 +571,7 @@ error code serialises to `"noteNotFound"`. A serde attribute deleted by accident ### Input validation The back validates what the front already constrains, because a rule held only by a form is -not held at all. `domain/validation.rs` defines a `ValidationError` carrying the offending +not held at all. `domain/rules.rs` defines a `ValidationError` carrying the offending `field`; commands call `draft.validate()` / `draft.validated_name()` before touching the connection, and `AppError` turns the refusal into `invalidInput` with `{{field}}`. @@ -521,7 +615,7 @@ alongside the executable. The database file lives in Tauri's `app_data_dir()`. matching is done **in Rust** (`domain::search`), because SQLite's `LOWER()` only folds ASCII without ICU, so `Étape` would not match `étape`. Grouping is `domain::sections`, which touches no connection and is therefore testable without a database. -- **Tag normalisation lives in `domain::tags::normalize`, and only there.** Trimming, +- **Tag normalisation lives in `domain::rules::normalize_tags`, and only there.** Trimming, stripping leading `#`, dropping blanks and collapsing case-insensitive duplicates (first spelling wins) all happen on write, so the front sends what the user typed. The returned tags are sorted to match what a read gives back — otherwise a note's tags would reorder @@ -618,7 +712,7 @@ Treated as part of the definition of done, and partly enforced by ## Theming All colors, fonts and shadows are CSS custom properties defined on `:root` in the global -`src/styles.scss`; components only consume them via `var(…)`. +`src/styles/styles.scss`; components only consume them via `var(…)`. Those variables **must** stay in the global stylesheet. Angular's emulated encapsulation rewrites a `:root` selector written inside a `*.component.scss` into a form that never diff --git a/src-tauri/src/commands/error.rs b/src-tauri/src/commands/error.rs index 028baba..6a5a369 100644 --- a/src-tauri/src/commands/error.rs +++ b/src-tauri/src/commands/error.rs @@ -1,35 +1,21 @@ -//! Erreur traversant le pont Tauri. -//! -//! # Pourquoi pas une `String` -//! -//! Les commandes renvoyaient `Result<_, String>`, et le front affichait la -//! chaîne telle quelle. Deux conséquences, toutes deux gênantes maintenant que -//! les règles métier vivent ici : -//! -//! - le message est rédigé en français **dans le binaire**, donc l'interface -//! anglaise affichait du français dès qu'une règle du back se déclenchait ; -//! - pour réagir à une erreur précise (« ce nom d'espace est déjà pris »), le -//! front n'avait que l'analyse de la chaîne — qui casse au premier reformulage. -//! -//! D'où [`AppError`] : un **code** stable que le front mappe sur une clé de -//! traduction, ses **paramètres** d'interpolation, et un **détail** technique -//! affiché en second plan. Aucun texte destiné à l'utilisateur ne sort d'ici. +//! Erreur traversant le pont Tauri : un **code** stable que le front mappe sur +//! une clé de traduction, ses **paramètres** d'interpolation, et un **détail** +//! technique. Aucun texte destiné à l'utilisateur ne sort d'ici — une `String` +//! mettrait du français dans l'interface anglaise et forcerait le front à +//! analyser de la prose pour réagir à une cause précise. use std::collections::BTreeMap; use serde::Serialize; -use crate::domain::validation::ValidationError; +use crate::domain::rules::ValidationError; use crate::storage::StorageError; -/// Identifie la cause pour le front, qui la mappe sur une clé de traduction. -/// Ajouter une variante = ajouter la variante en face (`IpcErrorCode` dans -/// `src/app/core/ipc/ipc-error.ts`) **et** sa clé dans les deux locales. +/// ⚠️ Ajouter une variante impose d'ajouter la sienne dans `IpcErrorCode` +/// (`src/app/core/ipc/ipc-error.ts`) **et** sa clé dans les deux locales. /// -/// Il n'y a pas de variante pour un schéma trop récent : cette panne n'est -/// produite que par la migration, pendant le `setup()` de Tauri, où l'échec -/// avorte le lancement. Aucune commande ne peut la renvoyer, donc lui donner un -/// code laisserait croire au front qu'il a quelque chose à en faire. +/// Pas de variante « schéma trop récent » : cette panne avorte le lancement +/// pendant la migration, aucune commande ne peut la renvoyer. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub enum ErrorCode { @@ -48,11 +34,10 @@ pub enum ErrorCode { #[serde(rename_all = "camelCase")] pub struct AppError { pub code: ErrorCode, - /// Valeurs à interpoler dans le message traduit, ex. `{ "name": "Perso" }` - /// pour `errors.spaceNameTaken`. Vide quand le message n'en attend pas. + /// Valeurs à interpoler dans le message traduit, ex. `{ "name": "Perso" }`. pub params: BTreeMap, - /// Message technique. Le front l'affiche en second plan de la bannière : il - /// n'a pas à être traduit, mais il doit rester lisible. + /// Message technique, affiché en second plan de la bannière. Pas traduit, + /// mais lisible. pub detail: String, } @@ -71,9 +56,8 @@ impl AppError { error } - /// Connexion inaccessible. Le mutex n'est empoisonné que si une commande a - /// paniqué en le tenant : la base peut alors être incohérente, autant le - /// dire au lieu de paniquer une seconde fois. + /// Mutex empoisonné : une commande a paniqué en le tenant, la base peut + /// être incohérente. pub fn storage_unavailable() -> Self { Self::new( ErrorCode::StorageUnavailable, @@ -95,9 +79,6 @@ impl From for AppError { impl From for AppError { fn from(error: StorageError) -> Self { - // `detail` reprend le `Display` de `StorageError` : ces messages - // existaient déjà et restent utiles — ils changent seulement de rôle, - // de texte principal à détail technique. let detail = error.to_string(); match error { @@ -107,14 +88,13 @@ impl From for AppError { StorageError::SpaceNotFound(id) => { Self::with(ErrorCode::SpaceNotFound, detail, "id", &id) } - // Le nom voyage en paramètre : c'est lui que le front interpole - // dans `errors.spaceNameTaken`, sans jamais relire le message. + // Le nom voyage en paramètre : c'est lui que le front interpole, + // sans jamais relire le message. StorageError::DuplicateSpaceName(name) => { Self::with(ErrorCode::DuplicateSpaceName, detail, "name", &name) } - // Inatteignable par le pont (voir [`ErrorCode`]) : si cette - // conversion arrivait quand même, `Storage` reste vrai, et le - // `detail` porte déjà le numéro de version en clair. + // Inatteignable par le pont (voir [`ErrorCode`]) ; `Storage` reste + // honnête et le `detail` porte déjà la version en clair. StorageError::SchemaTooRecent(_) | StorageError::Sqlite(_) => { Self::new(ErrorCode::Storage, detail) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4e066ec..9e7a10d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,22 +1,10 @@ -//! Point d'entrée unique pour toutes les commandes Tauri exposées au front Angular. +//! Commandes Tauri exposées au front : verrouiller, déléguer, traduire l'erreur. //! -//! Cette couche est **mince par contrat** : elle verrouille la connexion, -//! délègue, et traduit l'erreur. Toute décision appartient à `crate::domain`, -//! tout SQL à `crate::storage`. Une commande qui grossit est le signe qu'une -//! règle a été écrite au mauvais endroit. +//! Une commande qui grossit signale qu'une règle est au mauvais endroit — les +//! décisions vivent dans `crate::domain`, le SQL dans `crate::storage`. //! -//! - `error` : erreur commune traversant le pont (code + paramètres) -//! - `notes` : prise de notes et interrogation de la vue -//! - `spaces` : espaces de rangement des notes -//! - `crypto` : hashing / chiffrement (à implémenter) -//! - `formatters` : encodage, décodage, formatage (à implémenter) -//! -//! Pour ajouter une nouvelle commande : -//! 1. L'écrire dans le fichier du domaine concerné (ou en créer un nouveau ici). -//! 2. La déclarer `pub` et l'annoter avec `#[tauri::command]`. -//! 3. L'enregistrer dans `tauri::generate_handler![...]` au sein de `lib.rs`. -//! 4. Lui faire renvoyer `Result<_, AppError>` — jamais `Result<_, String>`, -//! voir `error.rs`. +//! Une nouvelle commande doit être `pub`, annotée `#[tauri::command]`, renvoyer +//! `Result<_, AppError>` et être enregistrée dans `generate_handler!` (`lib.rs`). pub mod error; pub mod notes; @@ -25,11 +13,8 @@ pub mod spaces; use crate::storage::Db; use error::AppError; -/// Prend le verrou sur la connexion partagée. -/// -/// Un mutex empoisonné signifie qu'une commande a paniqué en le tenant : la -/// base peut être incohérente, autant le dire au front plutôt que de paniquer -/// une seconde fois. +/// Verrou sur la connexion partagée. Un mutex empoisonné signifie qu'une +/// commande a paniqué en le tenant : mieux vaut le dire que paniquer à nouveau. fn lock(db: &Db) -> Result, AppError> { db.lock().map_err(|_| AppError::storage_unavailable()) } diff --git a/src-tauri/src/commands/notes.rs b/src-tauri/src/commands/notes.rs index 5145e8e..078a39e 100644 --- a/src-tauri/src/commands/notes.rs +++ b/src-tauri/src/commands/notes.rs @@ -1,39 +1,20 @@ //! Commandes « Prise de notes ». //! -//! De simples **adaptateurs** : elles verrouillent la connexion partagée, -//! enchaînent persistance puis règles métier, et convertissent l'erreur en -//! [`AppError`] pour le pont Tauri. Aucune décision n'est prise ici — le modèle -//! et les règles sont dans `crate::domain`, le SQL dans `crate::storage`. -//! -//! # Le back décide de ce qui est affiché -//! -//! [`query_notes`] ne renvoie pas une liste mais une **vue** : notes filtrées, -//! déjà réparties en sections, accompagnées des tags proposables et du drapeau -//! « une recherche est en cours ». Le front ne refiltre, ne regroupe et ne trie -//! rien — il affiche ce qu'il reçoit. -//! -//! Règles que le front tient pour acquises, et que cette couche honore : -//! - [`create_note`] et [`update_note`] **renvoient la note telle que -//! persistée** (identifiant définitif, `updated_at` rafraîchi, tags -//! normalisés) : c'est cette valeur que l'éditeur adopte, et elle fait autorité. -//! - [`update_note`] / [`delete_note`] sur un identifiant inconnu renvoient -//! `Err`, jamais un `Ok` silencieux : sans ça le front croirait avoir enregistré. -//! - Dans un `NotePatch`, un champ **absent** signifie « ne pas toucher ». Le -//! front n'envoie jamais `null` pour cela (voir `toNotePatchDto`), donc un -//! `Option::None` n'écrase jamais la valeur stockée. +//! Trois garanties dont le front dépend : [`create_note`] et [`update_note`] +//! renvoient la note **telle que persistée** (c'est elle que l'éditeur adopte) ; +//! un identifiant inconnu renvoie `Err`, jamais un `Ok` silencieux ; et dans un +//! `NotePatch` un champ absent signifie « ne pas toucher ». use tauri::State; use super::error::AppError; use super::lock; -use crate::domain::display::{self, DisplayNote}; -use crate::domain::note::{NoteDraft, NotePatch}; -use crate::domain::query::{NotesQuery, NotesView}; -use crate::domain::view; +use crate::domain::note::{self, DisplayNote, NoteDraft, NotePatch}; +use crate::domain::view::{self, NotesQuery, NotesView}; use crate::storage::{self, Db}; -/// Notes filtrées **et** regroupées, prêtes à afficher. Il n'existe pas de -/// commande renvoyant la liste brute : elle inviterait à refiltrer côté front. +/// Notes filtrées **et** regroupées, prêtes à afficher. Aucune commande ne rend +/// la liste brute : elle inviterait à refiltrer côté front. #[tauri::command] pub fn query_notes(query: NotesQuery, db: State<'_, Db>) -> Result { let connection = lock(&db)?; @@ -44,14 +25,13 @@ pub fn query_notes(query: NotesQuery, db: State<'_, Db>) -> Result) -> Result { - // Validé avant d'ouvrir la connexion : rien ne sert de verrouiller pour - // écrire une donnée qu'on refuse. + // Validé avant de verrouiller : inutile de prendre le verrou pour un refus. draft.validate()?; let mut connection = lock(&db)?; let note = storage::notes::create(&mut connection, &draft, &storage::now_iso())?; - Ok(display::decorate_now(note)) + Ok(note::decorate_now(note)) } #[tauri::command] @@ -65,7 +45,7 @@ pub fn update_note( let mut connection = lock(&db)?; let note = storage::notes::update(&mut connection, &id, &patch, &storage::now_iso())?; - Ok(display::decorate_now(note)) + Ok(note::decorate_now(note)) } #[tauri::command] diff --git a/src-tauri/src/commands/spaces.rs b/src-tauri/src/commands/spaces.rs index feb86d3..3391740 100644 --- a/src-tauri/src/commands/spaces.rs +++ b/src-tauri/src/commands/spaces.rs @@ -1,24 +1,13 @@ //! Commandes « Espaces » : les classeurs dans lesquels les notes sont rangées. //! -//! Même statut que `notes` : de simples adaptateurs au-dessus de -//! `storage::spaces`. Le modèle est dans `crate::domain::space`. +//! `notes.space_id` porte un `ON DELETE CASCADE`, donc un `DELETE` nu +//! emporterait les notes. [`delete_space`] exige un espace **refuge** et y +//! transfère les notes dans la même transaction — il n'existe volontairement +//! aucune variante sans refuge. //! -//! # Suppression : les notes sont déplacées, jamais perdues -//! -//! Le schéma porte un `ON DELETE CASCADE` sur `notes.space_id`, donc un -//! `DELETE` nu emporterait les notes de l'espace. [`delete_space`] exige pour -//! cette raison un espace **refuge** et y transfère les notes dans la même -//! transaction, avant la suppression. Il n'existe volontairement aucune variante -//! sans refuge : la seule façon de perdre une note reste `delete_note`, où -//! l'utilisateur voit ce qu'il supprime. -//! -//! # Ce qui reste à décider -//! -//! - **Un espace par défaut au premier lancement.** [`list_spaces`] renvoie -//! aujourd'hui une liste vide au premier démarrage : l'application refuse -//! alors de créer une note (il n'y a nulle part où la ranger) et affiche -//! « Créez d'abord un espace ». Créer un espace initial (« Perso », par ex.) -//! éviterait cet écran ; c'est un choix produit, pas une contrainte technique. +//! À décider : `list_spaces` renvoie une liste vide au premier lancement, et +//! l'application refuse alors de créer une note. Créer un espace initial est un +//! choix produit, pas une contrainte technique. use tauri::State; @@ -37,8 +26,8 @@ pub fn list_spaces(db: State<'_, Db>) -> Result, AppError> { /// Le front sélectionne aussitôt l'espace à partir de la valeur renvoyée. #[tauri::command] pub fn create_space(draft: SpaceDraft, db: State<'_, Db>) -> Result { - // La persistance reçoit un nom déjà détouré et non vide : elle n'a plus - // qu'à trancher l'unicité, qui est la seule chose qu'elle seule sait voir. + // Nom déjà détouré et non vide : le stockage n'a plus qu'à trancher + // l'unicité, la seule chose que lui seul peut voir. let name = draft.validated_name()?; let connection = lock(&db)?; @@ -46,8 +35,7 @@ pub fn create_space(draft: SpaceDraft, db: State<'_, Db>) -> Result) -> Result { let name = draft.validated_name()?; @@ -67,8 +55,8 @@ pub fn delete_space( target_space_id: String, db: State<'_, Db>, ) -> Result<(), AppError> { - // Refusé avant de verrouiller : un espace qui serait son propre refuge - // verrait ses notes emportées par la cascade juste après le transfert. + // Un espace son propre refuge verrait ses notes emportées par la cascade + // juste après le transfert : refusé avant même de verrouiller. space::validate_move_target(&id, &target_space_id)?; let mut connection = lock(&db)?; diff --git a/src-tauri/src/domain/display.rs b/src-tauri/src/domain/display.rs deleted file mode 100644 index 98c5c74..0000000 --- a/src-tauri/src/domain/display.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Ce que la carte d'une note affiche — la **décision**, pas le rendu. -//! -//! # Où passe la frontière -//! -//! Le back décide *quoi* montrer, le front décide *comment* le rendre. Un pied -//! de carte peut porter trois choses selon la note, et ce choix est une règle -//! produit : il vit donc ici, pas dans un composant Angular. -//! -//! En revanche les variantes portent une **date**, pas un libellé. « il y a -//! 4 min » doit vieillir tout seul à l'écran ; le calculer ici figerait le texte -//! jusqu'à la requête suivante, ou imposerait un aller-retour IPC toutes les -//! 30 secondes. Le formatage — et lui seul — reste au front. - -use chrono::{DateTime, FixedOffset, Utc}; -use serde::Serialize; - -use super::note::{Note, NoteLifecycle}; - -/// Au-delà de ce délai, une note éphémère n'est plus « bientôt à trier ». -/// -/// Seuil **unique**. Il vivait auparavant côté front pendant que le back -/// calculait de son côté « la section contient une note qui expire » : deux -/// définitions de « bientôt » pour un libellé qui n'en promet qu'une. -const EXPIRING_SOON_DAYS: i64 = 3; - -const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000; - -/// Contenu du pied d'une carte. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum NoteFooter { - /// Note épinglée portant un contexte : elle est là pour durer, savoir d'où - /// elle vient est plus utile que son âge. - Source { value: String }, - /// Échéance d'une note éphémère. - Expiry { at: String }, - /// Âge de la dernière modification — le cas ordinaire. - Age { at: String }, -} - -/// Une note augmentée de ce que l'affichage a besoin de savoir. -/// -/// `#[serde(flatten)]` aplatit la note dans l'objet JSON : le front reçoit un -/// seul objet et n'a donc qu'un seul type de note. Côté Rust les deux restent -/// distincts — [`Note`] est ce qui est **persisté**, `DisplayNote` ce qui est -/// **affiché**, et la persistance ignore tout du second. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DisplayNote { - #[serde(flatten)] - pub note: Note, - pub footer: NoteFooter, - pub expiring_soon: bool, -} - -/// Une note décorée reste une note : lire `display_note.id` plutôt que -/// `display_note.note.id` évite de faire remonter l'emballage chez l'appelant. -impl std::ops::Deref for DisplayNote { - type Target = Note; - - fn deref(&self) -> &Self::Target { - &self.note - } -} - -pub fn decorate(note: Note, now: &DateTime) -> DisplayNote { - DisplayNote { - footer: footer_of(¬e), - expiring_soon: expires_soon(¬e, now), - note, - } -} - -/// Décoration d'une note qu'on vient d'écrire : `create_note` et `update_note` -/// ne reçoivent pas d'instant de référence du front, contrairement à une requête. -pub fn decorate_now(note: Note) -> DisplayNote { - decorate(note, &Utc::now().fixed_offset()) -} - -fn footer_of(note: &Note) -> NoteFooter { - if let NoteLifecycle::Expires { at } = ¬e.lifecycle { - return NoteFooter::Expiry { at: at.clone() }; - } - - // Le chemin de contexte est un fil d'Ariane ("API Gateway / Auth") : son - // premier segment suffit à situer la note sans déborder de la carte. - if note.pinned - && let Some(root) = note - .source - .split(" / ") - .next() - .filter(|root| !root.is_empty()) - { - return NoteFooter::Source { - value: root.to_string(), - }; - } - - NoteFooter::Age { - at: note.updated_at.clone(), - } -} - -/// Une échéance illisible ne rend pas la note urgente : elle relève de la -/// corruption, et l'afficher en alerte serait un faux signal permanent. -fn expires_soon(note: &Note, now: &DateTime) -> bool { - let NoteLifecycle::Expires { at } = ¬e.lifecycle else { - return false; - }; - let Ok(deadline) = DateTime::parse_from_rfc3339(at) else { - return false; - }; - - // Comparaison en millisecondes et non en jours entiers : à 3 jours et 1 - // heure, un arrondi au jour basculerait la note en alerte un jour trop tôt. - deadline.signed_duration_since(*now).num_milliseconds() <= EXPIRING_SOON_DAYS * MS_PER_DAY -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::fixtures::note as sample; - - const NOW: &str = "2026-07-25T09:00:00.000Z"; - - fn now() -> DateTime { - DateTime::parse_from_rfc3339(NOW).unwrap() - } - - fn expiring(at: &str) -> Note { - Note { - lifecycle: NoteLifecycle::Expires { at: at.to_string() }, - ..sample() - } - } - - #[test] - fn an_ordinary_note_shows_the_age_of_its_last_change() { - let footer = footer_of(&sample()); - - assert_eq!( - footer, - NoteFooter::Age { - at: "2026-07-25T09:00:00.000Z".to_string() - } - ); - } - - #[test] - fn a_pinned_note_shows_the_first_segment_of_its_context() { - let note = Note { - pinned: true, - source: "API Gateway / Auth / Tokens".to_string(), - ..sample() - }; - - assert_eq!( - footer_of(¬e), - NoteFooter::Source { - value: "API Gateway".to_string() - } - ); - } - - #[test] - fn a_pinned_note_without_context_falls_back_to_its_age() { - let note = Note { - pinned: true, - source: String::new(), - ..sample() - }; - - assert!(matches!(footer_of(¬e), NoteFooter::Age { .. })); - } - - #[test] - fn an_expiring_note_shows_its_deadline_even_when_pinned() { - let note = Note { - pinned: true, - source: "API Gateway".to_string(), - ..expiring("2026-08-01T00:00:00.000Z") - }; - - // The deadline is the more urgent thing to know; the context can wait. - assert!(matches!(footer_of(¬e), NoteFooter::Expiry { .. })); - } - - #[test] - fn a_permanent_note_never_counts_as_expiring_soon() { - assert!(!expires_soon(&sample(), &now())); - } - - #[test] - fn the_threshold_is_measured_in_fractions_of_a_day() { - // Three days and one hour is not "soon"; rounding to whole days would - // raise the alert a day early. - assert!(!expires_soon(&expiring("2026-07-28T10:00:00.000Z"), &now())); - assert!(expires_soon(&expiring("2026-07-28T08:00:00.000Z"), &now())); - } - - #[test] - fn an_already_expired_note_counts_as_expiring_soon() { - assert!(expires_soon(&expiring("2026-07-01T00:00:00.000Z"), &now())); - } - - #[test] - fn an_unreadable_deadline_does_not_raise_a_permanent_alert() { - assert!(!expires_soon(&expiring("pas une date"), &now())); - } - - #[test] - fn a_decorated_note_serialises_flat_with_its_footer() { - let json = serde_json::to_value(decorate(sample(), &now())).unwrap(); - - // The front reads one object: the note's own fields sit alongside the - // display ones, not nested under a `note` key. - assert_eq!(json["id"], "n-1"); - assert_eq!(json["spaceId"], "s-1"); - assert_eq!(json["expiringSoon"], false); - assert_eq!( - json["footer"], - serde_json::json!({ "kind": "age", "at": "2026-07-25T09:00:00.000Z" }) - ); - assert!(json.get("note").is_none()); - } - - #[test] - fn a_source_footer_serialises_with_the_kind_the_front_discriminates_on() { - let note = Note { - pinned: true, - source: "API Gateway / Auth".to_string(), - ..sample() - }; - - let json = serde_json::to_value(decorate(note, &now())).unwrap(); - - assert_eq!( - json["footer"], - serde_json::json!({ "kind": "source", "value": "API Gateway" }) - ); - } -} diff --git a/src-tauri/src/domain/language.rs b/src-tauri/src/domain/language.rs deleted file mode 100644 index 56224e0..0000000 --- a/src-tauri/src/domain/language.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Langages reconnus pour la coloration d'une note. -//! -//! Cette liste est **la** référence : le front en a un miroir -//! (`src/app/core/models/language.model.ts`) pour peupler son sélecteur et pour -//! dégrader vers `txt` ce qu'une version plus récente du back lui enverrait. -//! Mais c'est ici que l'écriture est refusée — sinon une valeur arbitraire -//! finirait en base, et plus aucune version du front ne saurait l'afficher. - -use super::validation::ValidationError; - -pub const LANGUAGES: [&str; 13] = [ - "json", "js", "ts", "py", "sql", "yml", "toml", "xml", "html", "css", "sh", "md", "txt", -]; - -pub fn validate(language: &str) -> Result<(), ValidationError> { - if LANGUAGES.contains(&language) { - return Ok(()); - } - - Err(ValidationError::new( - "language", - format!("« {language} » n'est pas un langage reconnu"), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn every_known_language_is_accepted() { - for language in LANGUAGES { - assert!(validate(language).is_ok()); - } - } - - #[test] - fn an_unknown_language_is_refused_with_its_field() { - let error = validate("rust").unwrap_err(); - - assert_eq!(error.field, "language"); - assert!(error.detail.contains("rust")); - } - - #[test] - fn the_comparison_is_exact_rather_than_case_insensitive() { - // The front sends the tag from a fixed list, never free text; accepting - // "JSON" would put a second spelling of one language into the database. - assert!(validate("JSON").is_err()); - assert!(validate("").is_err()); - } -} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 15f93fc..f76fa74 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -1,39 +1,17 @@ -//! Modèle et règles métier de DevBox. +//! Modèle et règles métier : `commands/ ──► domain/ ◄── storage/`. //! -//! # Ce que cette couche ne connaît pas -//! -//! Ni la base de données, ni Tauri. Elle ne dépend que de `serde` et `chrono`, -//! et c'est ce qui permet d'éprouver une règle — regroupement en sections, -//! normalisation des tags, correspondance d'une recherche — sans ouvrir de +//! Ne connaît ni SQLite ni Tauri — d'où des règles éprouvables sans ouvrir de //! connexion ni lancer l'application. //! -//! # Sens des dépendances -//! -//! ```text -//! commands/ ──► domain/ ◄── storage/ -//! ``` -//! -//! La persistance sait lire et écrire ce modèle, le transport sait le -//! sérialiser, aucun des deux ne le définit. L'inverse — le modèle rangé dans -//! `commands/` — faisait dépendre la persistance du transport. -//! -//! # Pourquoi serde vit ici -//! -//! Les attributs de sérialisation sont portés par le modèle plutôt que par une -//! seconde famille de DTO : à cette échelle, le mapping coûterait plus qu'il ne -//! protège. Le contrat traversant le pont est figé par les tests de [`note`], -//! [`query`] et [`space`] — la seule chose que le compilateur ne peut pas -//! vérifier et qui casse silencieusement le front. +//! Les attributs serde sont portés par le modèle plutôt que par une seconde +//! famille de DTO. Le contrat traversant le pont est figé par des tests de +//! sérialisation : c'est ce que le compilateur ne peut pas vérifier et qui +//! casse silencieusement le front. -pub mod display; -pub mod language; pub mod note; -pub mod query; -pub mod search; +pub mod rules; pub mod sections; pub mod space; -pub mod tags; -pub mod validation; pub mod view; /// Reference note shared by the domain tests, so a field added to `Note` is diff --git a/src-tauri/src/domain/note.rs b/src-tauri/src/domain/note.rs index 9eaacde..00795ba 100644 --- a/src-tauri/src/domain/note.rs +++ b/src-tauri/src/domain/note.rs @@ -1,24 +1,22 @@ -//! La note et ses formes d'écriture. +//! La note : ce qui est persisté ([`Note`]) et ce qui est affiché +//! ([`DisplayNote`]). La persistance ignore tout du second. //! -//! # Contrat de sérialisation — à ne pas casser +//! ⚠️ **Contrat de sérialisation.** Deux attributs sont indispensables, sinon le +//! front reçoit des données qu'il ne sait pas relire : +//! - `rename_all = "camelCase"`, sans quoi serde émet `space_id` là où le DTO +//! TypeScript attend `spaceId` ; +//! - `tag = "kind"` sur les enums à données, dont la représentation serde par +//! défaut est `{"Expires":{…}}` alors que le front discrimine sur `kind`. //! -//! Deux pièges, sinon le front reçoit des données qu'il ne sait pas relire : +//! Les dates transitent en chaîne ISO 8601 UTC (JSON n'a pas de type date). //! -//! - `#[serde(rename_all = "camelCase")]` sur [`Note`] : sans ça, serde émet -//! `space_id` / `created_at` alors que le DTO TypeScript attend `spaceId` / -//! `createdAt`. -//! - `#[serde(tag = "kind", rename_all = "camelCase")]` sur [`NoteLifecycle`] : -//! la représentation serde par défaut d'une enum à données produit -//! `{"Expires":{"at":"…"}}`, alors que le front discrimine sur un champ -//! `kind` — il attend `{"kind":"expires","at":"…"}`. -//! -//! Les dates transitent en **chaîne ISO 8601 / RFC 3339 UTC** (JSON n'a pas de -//! type date) ; elles sont produites par `storage::now_iso`. +//! Les variantes de pied de carte portent une **date**, pas un libellé : « il y +//! a 4 min » doit vieillir tout seul à l'écran. Le formatage reste au front. +use chrono::{DateTime, FixedOffset, Utc}; use serde::{Deserialize, Serialize}; -use super::language; -use super::validation::ValidationError; +use super::rules::{self, ValidationError}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -83,7 +81,7 @@ pub struct NotePatch { impl NoteDraft { pub fn validate(&self) -> Result<(), ValidationError> { - language::validate(&self.language) + rules::validate_language(&self.language) } } @@ -91,20 +89,126 @@ impl NotePatch { /// Un champ absent n'est pas validé : il ne sera pas écrit. pub fn validate(&self) -> Result<(), ValidationError> { match &self.language { - Some(language) => language::validate(language), + Some(language) => rules::validate_language(language), None => Ok(()), } } } -/// Ces tests ne vérifient pas du code métier : ils figent la **forme JSON** -/// traversant le pont, la seule chose que le compilateur ne peut pas contrôler -/// et qui casse silencieusement le front (voir `src/app/core/data/note.dto.ts`). +/// Au-delà de ce délai, une note éphémère n'est plus « bientôt à trier ». +/// Seuil **unique** : le front en tenait un second, pour un libellé qui ne +/// promet qu'une définition de « bientôt ». +const EXPIRING_SOON_DAYS: i64 = 3; + +const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000; + +/// Contenu du pied d'une carte — la **décision**, pas le rendu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum NoteFooter { + /// Note épinglée portant un contexte : elle est là pour durer, savoir d'où + /// elle vient est plus utile que son âge. + Source { value: String }, + /// Échéance d'une note éphémère. + Expiry { at: String }, + /// Âge de la dernière modification — le cas ordinaire. + Age { at: String }, +} + +/// Note augmentée de ce que l'affichage doit savoir. `flatten` aplatit la note +/// dans l'objet JSON : le front n'a qu'un seul type de note. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DisplayNote { + #[serde(flatten)] + pub note: Note, + pub footer: NoteFooter, + pub expiring_soon: bool, +} + +/// Lire `display_note.id` plutôt que `display_note.note.id` évite de faire +/// remonter l'emballage chez l'appelant. +impl std::ops::Deref for DisplayNote { + type Target = Note; + + fn deref(&self) -> &Self::Target { + &self.note + } +} + +pub fn decorate(note: Note, now: &DateTime) -> DisplayNote { + DisplayNote { + footer: footer_of(¬e), + expiring_soon: expires_soon(¬e, now), + note, + } +} + +/// Pour une note qu'on vient d'écrire : `create_note` et `update_note` ne +/// reçoivent pas d'instant de référence du front, contrairement à une requête. +pub fn decorate_now(note: Note) -> DisplayNote { + decorate(note, &Utc::now().fixed_offset()) +} + +fn footer_of(note: &Note) -> NoteFooter { + if let NoteLifecycle::Expires { at } = ¬e.lifecycle { + return NoteFooter::Expiry { at: at.clone() }; + } + + // `source` est un fil d'Ariane ("API Gateway / Auth") : son premier segment + // situe la note sans déborder de la carte. + if note.pinned + && let Some(root) = note + .source + .split(" / ") + .next() + .filter(|root| !root.is_empty()) + { + return NoteFooter::Source { + value: root.to_string(), + }; + } + + NoteFooter::Age { + at: note.updated_at.clone(), + } +} + +/// Une échéance illisible ne rend pas la note urgente : ce serait un faux signal +/// permanent. +fn expires_soon(note: &Note, now: &DateTime) -> bool { + let NoteLifecycle::Expires { at } = ¬e.lifecycle else { + return false; + }; + let Ok(deadline) = DateTime::parse_from_rfc3339(at) else { + return false; + }; + + // En millisecondes et non en jours entiers : à 3 jours et 1 heure, un + // arrondi basculerait la note en alerte un jour trop tôt. + deadline.signed_duration_since(*now).num_milliseconds() <= EXPIRING_SOON_DAYS * MS_PER_DAY +} + +/// Ces tests figent la **forme JSON** traversant le pont : la seule chose que le +/// compilateur ne peut pas contrôler et qui casse silencieusement le front. #[cfg(test)] mod tests { use super::*; use crate::domain::fixtures::note as sample; + const NOW: &str = "2026-07-25T09:00:00.000Z"; + + fn now() -> DateTime { + DateTime::parse_from_rfc3339(NOW).unwrap() + } + + fn expiring(at: &str) -> Note { + Note { + lifecycle: NoteLifecycle::Expires { at: at.to_string() }, + ..sample() + } + } + #[test] fn a_note_serialises_with_camel_case_keys() { let json = serde_json::to_value(sample()).unwrap(); @@ -181,4 +285,110 @@ mod tests { assert!(draft.pinned); assert!(matches!(draft.lifecycle, NoteLifecycle::Expires { .. })); } + + #[test] + fn an_ordinary_note_shows_the_age_of_its_last_change() { + let footer = footer_of(&sample()); + + assert_eq!( + footer, + NoteFooter::Age { + at: "2026-07-25T09:00:00.000Z".to_string() + } + ); + } + + #[test] + fn a_pinned_note_shows_the_first_segment_of_its_context() { + let note = Note { + pinned: true, + source: "API Gateway / Auth / Tokens".to_string(), + ..sample() + }; + + assert_eq!( + footer_of(¬e), + NoteFooter::Source { + value: "API Gateway".to_string() + } + ); + } + + #[test] + fn a_pinned_note_without_context_falls_back_to_its_age() { + let note = Note { + pinned: true, + source: String::new(), + ..sample() + }; + + assert!(matches!(footer_of(¬e), NoteFooter::Age { .. })); + } + + #[test] + fn an_expiring_note_shows_its_deadline_even_when_pinned() { + let note = Note { + pinned: true, + source: "API Gateway".to_string(), + ..expiring("2026-08-01T00:00:00.000Z") + }; + + // The deadline is the more urgent thing to know; the context can wait. + assert!(matches!(footer_of(¬e), NoteFooter::Expiry { .. })); + } + + #[test] + fn a_permanent_note_never_counts_as_expiring_soon() { + assert!(!expires_soon(&sample(), &now())); + } + + #[test] + fn the_threshold_is_measured_in_fractions_of_a_day() { + // Three days and one hour is not "soon"; rounding to whole days would + // raise the alert a day early. + assert!(!expires_soon(&expiring("2026-07-28T10:00:00.000Z"), &now())); + assert!(expires_soon(&expiring("2026-07-28T08:00:00.000Z"), &now())); + } + + #[test] + fn an_already_expired_note_counts_as_expiring_soon() { + assert!(expires_soon(&expiring("2026-07-01T00:00:00.000Z"), &now())); + } + + #[test] + fn an_unreadable_deadline_does_not_raise_a_permanent_alert() { + assert!(!expires_soon(&expiring("pas une date"), &now())); + } + + #[test] + fn a_decorated_note_serialises_flat_with_its_footer() { + let json = serde_json::to_value(decorate(sample(), &now())).unwrap(); + + // The front reads one object: the note's own fields sit alongside the + // display ones, not nested under a `note` key. + assert_eq!(json["id"], "n-1"); + assert_eq!(json["spaceId"], "s-1"); + assert_eq!(json["expiringSoon"], false); + assert_eq!( + json["footer"], + serde_json::json!({ "kind": "age", "at": "2026-07-25T09:00:00.000Z" }) + ); + assert!(json.get("note").is_none()); + } + + #[test] + fn a_source_footer_serialises_with_the_kind_the_front_discriminates_on() { + let note = Note { + pinned: true, + source: "API Gateway / Auth".to_string(), + ..sample() + }; + + let json = serde_json::to_value(decorate(note, &now())).unwrap(); + + assert_eq!( + json["footer"], + serde_json::json!({ "kind": "source", "value": "API Gateway" }) + ); + } } diff --git a/src-tauri/src/domain/query.rs b/src-tauri/src/domain/query.rs deleted file mode 100644 index 8d55065..0000000 --- a/src-tauri/src/domain/query.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Ce que l'utilisateur demande à voir, et ce que le canevas affiche en retour. -//! -//! [`NotesQuery`] décrit une intention, [`NotesView`] une réponse **prête à -//! afficher**. Il n'existe volontairement pas de type intermédiaire « liste de -//! notes » exposé au front : il inviterait à refiltrer côté interface. - -use serde::{Deserialize, Serialize}; - -use super::display::DisplayNote; - -/// Ce que l'utilisateur a demandé à voir. Tout y est explicite : la requête ne -/// lit ni horloge ni fuseau, ce qui la rend reproductible en test. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NotesQuery { - /// `None` = « tous les espaces ». Ce n'est pas une absence de choix mais un - /// choix : il n'existe aucun espace « Tous » côté données. - pub space_id: Option, - /// Texte recherché dans le titre, les tags et le contenu. Vide = pas de recherche. - pub search: String, - pub filter: NoteFilter, - /// Tags sélectionnés dans le rail. Une note passe si elle en porte **au - /// moins un** (et non tous) : c'est le comportement d'un rail de facettes. - pub tags: Vec, - /// Langages sélectionnés dans le rail, même sémantique d'union que `tags` : - /// une note passe si elle est écrite dans **l'un** d'eux. Vide = tous. - pub languages: Vec, - /// Instant de référence, ISO 8601 UTC — fourni par `ClockService` côté front. - pub now: String, - /// `Date#getTimezoneOffset()` du front. Nécessaire parce que les sections - /// raisonnent en **jours locaux** : à 23 h à Paris, `now` en UTC est déjà - /// demain, et une note d'aujourd'hui tomberait dans « cette semaine ». - /// - /// ⚠️ Convention JavaScript : la valeur est l'opposé du décalage. UTC+2 - /// donne −120, d'où le `-` dans la conversion en `FixedOffset`. - pub tz_offset_minutes: i32, -} - -/// Filtre rapide de la barre d'outils. `Untriaged` = notes portant une date -/// d'expiration : une note éphémère est précisément celle dont on n'a pas encore -/// décidé du sort. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum NoteFilter { - All, - Pinned, - Untriaged, -} - -/// Ce que les rails de facettes ont à proposer, dans l'espace courant. -/// -/// Regroupées plutôt que passées côte à côte : ce sont deux `Vec` que -/// rien ne distinguerait à l'appel de [`super::view::build`], et la persistance -/// les calcule ensemble (cf. `storage::notes::fetch`). -/// -/// Ne traverse pas le pont : le front lit les deux champs correspondants de -/// [`NotesView`]. -#[derive(Debug, Clone, Default)] -pub struct Facets { - pub tags: Vec, - pub languages: Vec, -} - -/// Ce que le canevas affiche, tel quel. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NotesView { - pub sections: Vec, - /// Tags proposés par le rail. Portée à l'**espace**, pas au filtre courant : - /// n'afficher que les tags des notes déjà filtrées rendrait le rail - /// inutilisable dès la première sélection. - pub available_tags: Vec, - /// Langages proposés par le rail, portés à l'espace pour la même raison que - /// `available_tags`. - pub available_languages: Vec, - /// Une recherche ou une sélection de facettes (tag, langage) est active. Le - /// front s'en sert pour distinguer « aucun résultat » d'« espace vide ». - pub is_filtering: bool, - /// Nombre de notes retenues, toutes sections confondues. - pub matched: usize, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NoteSection { - pub key: NoteSectionKey, - pub notes: Vec, - /// Au moins une note de la section arrive à échéance — au sens du seuil - /// unique de `display`, celui que le libellé « à trier bientôt » promet. - pub has_expiring_notes: bool, - /// Affiche la carte fantôme « coller ou créer » à la fin de la section. - pub show_create_ghost: bool, -} - -/// Sert de **clé de traduction** côté front (`sections.`) : c'est pourquoi -/// aucun libellé lisible ne traverse le pont. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum NoteSectionKey { - Pinned, - Today, - Week, - Older, - Results, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::display; - use crate::domain::fixtures::note as sample; - - fn displayed() -> DisplayNote { - let now = chrono::DateTime::parse_from_rfc3339("2026-07-25T09:00:00.000Z").unwrap(); - display::decorate(sample(), &now) - } - - #[test] - fn a_view_serialises_with_camel_case_keys() { - let view = NotesView { - sections: vec![NoteSection { - key: NoteSectionKey::Week, - notes: vec![displayed()], - has_expiring_notes: false, - show_create_ghost: true, - }], - available_tags: vec!["auth".to_string()], - available_languages: vec!["json".to_string()], - is_filtering: false, - matched: 1, - }; - - let json = serde_json::to_value(view).unwrap(); - - assert!(json.get("availableTags").is_some()); - assert!(json.get("availableLanguages").is_some()); - assert!(json.get("isFiltering").is_some()); - assert!(json.get("available_tags").is_none()); - assert!(json.get("available_languages").is_none()); - assert!(json["sections"][0].get("hasExpiringNotes").is_some()); - assert!(json["sections"][0].get("showCreateGhost").is_some()); - } - - #[test] - fn a_section_key_serialises_as_the_translation_key_the_front_expects() { - let section = NoteSection { - key: NoteSectionKey::Older, - notes: Vec::new(), - has_expiring_notes: false, - show_create_ghost: false, - }; - - let json = serde_json::to_value(section).unwrap(); - - // The front builds `sections.older` from this; serde's default would - // emit "Older" and the lookup would miss. - assert_eq!(json["key"], "older"); - } - - #[test] - fn a_query_is_read_from_the_camel_case_payload_the_front_sends() { - let query: NotesQuery = serde_json::from_value(serde_json::json!({ - "spaceId": "s-1", - "search": "deploy", - "filter": "untriaged", - "tags": ["urgent"], - "languages": ["json", "yml"], - "now": "2026-07-25T09:00:00.000Z", - "tzOffsetMinutes": -120 - })) - .unwrap(); - - assert_eq!(query.space_id.as_deref(), Some("s-1")); - assert_eq!(query.filter, NoteFilter::Untriaged); - assert_eq!(query.languages, ["json", "yml"]); - assert_eq!(query.tz_offset_minutes, -120); - } - - #[test] - fn a_null_space_is_read_as_every_space() { - // The front sends null, not an omitted key, when the user picks - // "all spaces" — that is a choice, not a missing value. - let query: NotesQuery = serde_json::from_value(serde_json::json!({ - "spaceId": null, - "search": "", - "filter": "all", - "tags": [], - "languages": [], - "now": "2026-07-25T09:00:00.000Z", - "tzOffsetMinutes": 0 - })) - .unwrap(); - - assert!(query.space_id.is_none()); - } -} diff --git a/src-tauri/src/domain/rules.rs b/src-tauri/src/domain/rules.rs new file mode 100644 index 0000000..bd67685 --- /dev/null +++ b/src-tauri/src/domain/rules.rs @@ -0,0 +1,192 @@ +//! Règles de validation et de correspondance, et le type d'erreur qu'elles +//! renvoient. +//! +//! Le back valide ce que le front contrôle déjà : une règle tenue par un seul +//! formulaire n'est pas tenue. Un appel direct au pont ou un front d'une autre +//! version suffisent à écrire une donnée que plus rien ne saura interpréter. + +use std::fmt; + +use super::note::Note; + +/// Donnée reçue non conforme. Voyage comme les autres erreurs : un code et un +/// paramètre `field`, jamais une phrase rédigée en français. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationError { + /// Champ en cause, tel que le front le nomme. + pub field: &'static str, + /// Détail technique, affiché en second plan. + pub detail: String, +} + +impl ValidationError { + pub fn new(field: &'static str, detail: impl Into) -> Self { + Self { + field, + detail: detail.into(), + } + } +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Champ « {} » invalide : {}", self.field, self.detail) + } +} + +impl std::error::Error for ValidationError {} + +/// Langages reconnus pour la coloration. **La** référence : le front en a un +/// miroir (`core/models/language.model.ts`) pour peupler son sélecteur et +/// dégrader vers `txt`, mais c'est ici que l'écriture est refusée. +pub const LANGUAGES: [&str; 13] = [ + "json", "js", "ts", "py", "sql", "yml", "toml", "xml", "html", "css", "sh", "md", "txt", +]; + +pub fn validate_language(language: &str) -> Result<(), ValidationError> { + if LANGUAGES.contains(&language) { + return Ok(()); + } + + Err(ValidationError::new( + "language", + format!("« {language} » n'est pas un langage reconnu"), + )) +} + +/// Nettoie les tags : espaces, `#` de tête, vides et doublons. +/// +/// Règle unique — le front envoie ce que l'utilisateur a tapé, la persistance +/// écrit ce que cette fonction renvoie, et la requête y fait passer les tags +/// sélectionnés, sinon un `#urgent` saisi ne retrouverait pas `urgent` stocké. +/// +/// La déduplication est insensible à la casse et garde la première graphie ; +/// `COLLATE NOCASE` (migration 2) prolonge la règle à tout le corpus. +pub fn normalize_tags(tags: &[String]) -> Vec { + let mut seen: Vec = Vec::new(); + let mut normalized: Vec = Vec::new(); + + for tag in tags { + let cleaned = tag.trim().trim_start_matches('#').trim(); + if cleaned.is_empty() { + continue; + } + + let folded = cleaned.to_lowercase(); + if seen.contains(&folded) { + continue; + } + + seen.push(folded); + normalized.push(cleaned.to_string()); + } + + normalized +} + +/// Correspondance d'une note avec un texte cherché. `needle` est attendu **déjà +/// replié en minuscules et détouré**. +/// +/// Le repliage est fait en Rust et non en SQL : le `LOWER()` de SQLite ne traite +/// que l'ASCII sans ICU, donc `Étape` ne correspondrait pas à `étape`. D'où une +/// recherche qui ne descend pas dans le `WHERE`, contrairement aux filtres +/// grossiers, qui eux y restent indexés. +pub fn matches(note: &Note, needle: &str) -> bool { + note.title.to_lowercase().contains(needle) + || note + .tags + .iter() + .any(|tag| tag.to_lowercase().contains(needle)) + || note.content.to_lowercase().contains(needle) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::fixtures::note as sample; + + #[test] + fn every_known_language_is_accepted() { + for language in LANGUAGES { + assert!(validate_language(language).is_ok()); + } + } + + #[test] + fn an_unknown_language_is_refused_with_its_field() { + let error = validate_language("rust").unwrap_err(); + + assert_eq!(error.field, "language"); + assert!(error.detail.contains("rust")); + } + + #[test] + fn the_comparison_is_exact_rather_than_case_insensitive() { + // The front sends the tag from a fixed list, never free text; accepting + // "JSON" would put a second spelling of one language into the database. + assert!(validate_language("JSON").is_err()); + assert!(validate_language("").is_err()); + } + + fn normalized(tags: &[&str]) -> Vec { + normalize_tags(&tags.iter().map(|tag| tag.to_string()).collect::>()) + } + + #[test] + fn padding_and_blanks_are_dropped() { + assert_eq!( + normalized(&[" urgent ", "", " ", "later"]), + ["urgent", "later"] + ); + } + + #[test] + fn a_duplicate_keeps_its_first_spelling() { + assert_eq!(normalized(&["Urgent", "urgent", "URGENT"]), ["Urgent"]); + } + + #[test] + fn only_leading_hashes_are_stripped() { + assert_eq!(normalized(&["##c++", "a#b"]), ["c++", "a#b"]); + } + + #[test] + fn a_tag_reduced_to_nothing_is_dropped_rather_than_stored_empty() { + // " # " trims to "#", then to "" — storing that would put a blank facet + // in the rail that selects every note carrying it. + assert!(normalized(&[" # ", "#"]).is_empty()); + } + + #[test] + fn tag_case_folding_reaches_beyond_ascii() { + // SQLite's NOCASE would not collapse these; `to_lowercase` is Unicode. + assert_eq!(normalized(&["Étape", "étape"]), ["Étape"]); + } + + #[test] + fn the_title_the_tags_and_the_content_are_all_searched() { + let note = Note { + title: "Déploiement".to_string(), + content: "kubectl apply".to_string(), + tags: vec!["ops".to_string()], + ..sample() + }; + + assert!(matches(¬e, "déploi")); + assert!(matches(¬e, "kubectl")); + assert!(matches(¬e, "ops")); + assert!(!matches(¬e, "terraform")); + } + + #[test] + fn search_case_folding_reaches_beyond_ascii() { + let note = Note { + title: "Étape suivante".to_string(), + ..sample() + }; + + // SQLite's LOWER() leaves É alone without ICU, so this match is exactly + // what moving the comparison into Rust buys. + assert!(matches(¬e, "étape")); + } +} diff --git a/src-tauri/src/domain/search.rs b/src-tauri/src/domain/search.rs deleted file mode 100644 index 9fb8d79..0000000 --- a/src-tauri/src/domain/search.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Correspondance d'une note avec un texte cherché. -//! -//! Le repliage de casse est fait **en Rust** et non en SQL : le `LOWER()` de -//! SQLite ne traite que l'ASCII (sans extension ICU), donc `Étape` ne -//! correspondrait pas à `étape`. `to_lowercase` est Unicode. -//! -//! C'est aussi pourquoi la recherche ne descend pas dans le `WHERE` : les -//! filtres grossiers — espace, épinglage, cycle de vie, tags — restent en SQL -//! où ils sont indexés, le texte est traité ici. - -use super::note::Note; - -/// `needle` est attendu **déjà replié en minuscules et détouré**. -pub fn matches(note: &Note, needle: &str) -> bool { - note.title.to_lowercase().contains(needle) - || note - .tags - .iter() - .any(|tag| tag.to_lowercase().contains(needle)) - || note.content.to_lowercase().contains(needle) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::fixtures::note as sample; - use crate::domain::note::Note; - - #[test] - fn the_title_the_tags_and_the_content_are_all_searched() { - let note = Note { - title: "Déploiement".to_string(), - content: "kubectl apply".to_string(), - tags: vec!["ops".to_string()], - ..sample() - }; - - assert!(matches(¬e, "déploi")); - assert!(matches(¬e, "kubectl")); - assert!(matches(¬e, "ops")); - assert!(!matches(¬e, "terraform")); - } - - #[test] - fn case_folding_reaches_beyond_ascii() { - let note = Note { - title: "Étape suivante".to_string(), - ..sample() - }; - - // SQLite's LOWER() leaves É alone without ICU, so this match is exactly - // what moving the comparison into Rust buys. - assert!(matches(¬e, "étape")); - } -} diff --git a/src-tauri/src/domain/sections.rs b/src-tauri/src/domain/sections.rs index f566218..a2f17e1 100644 --- a/src-tauri/src/domain/sections.rs +++ b/src-tauri/src/domain/sections.rs @@ -1,20 +1,13 @@ //! Regroupement des notes en sections d'affichage. //! -//! Module **pur** : aucune connexion, aucune horloge lue en interne. Il ne -//! manipule que des notes déjà filtrées et un instant de référence, ce qui le -//! rend testable sans base et sans figer le temps. -//! -//! # L'exhaustivité est une garantie, pas un détail -//! -//! Hors notes épinglées, chaque note tombe dans **exactement une** section parmi -//! `today`, `week` et `older`. Une note n'appartenant à aucune section serait -//! introuvable dans l'interface, recherche comprise. +//! ⚠️ **L'exhaustivité est une garantie.** Hors notes épinglées, chaque note +//! tombe dans exactement une section parmi `today`, `week` et `older` : une note +//! sans section serait introuvable dans l'interface, recherche comprise. use chrono::{DateTime, Datelike, FixedOffset}; -use super::display::{self, DisplayNote}; -use super::note::Note; -use super::query::{NoteSection, NoteSectionKey}; +use super::note::{self, DisplayNote, Note}; +use super::view::{NoteSection, NoteSectionKey}; const WEEK_DAYS: i64 = 7; @@ -23,15 +16,12 @@ const MAX_TZ_OFFSET_MINUTES: u32 = 14 * 60; /// Décalage du front (`Date#getTimezoneOffset()`) en `FixedOffset` chrono. /// -/// Le signe s'inverse : JavaScript compte les minutes à **ajouter** à l'heure -/// locale pour obtenir UTC (−120 pour UTC+2), là où chrono attend le décalage -/// à l'est de UTC. Une valeur aberrante retombe sur UTC plutôt que de faire -/// échouer la requête : au pire les journées sont découpées à l'heure UTC. +/// ⚠️ Le signe s'inverse : JavaScript compte les minutes à **ajouter** à l'heure +/// locale pour obtenir UTC (−120 pour UTC+2), chrono attend le décalage à l'est. /// -/// La borne est vérifiée **avant** la multiplication : la valeur vient du pont -/// IPC, et `-i32::MIN` comme `i32::MAX * 60` déborderaient — panique en debug, -/// et le mutex de connexion resterait empoisonné pour le reste du processus. -/// `unsigned_abs` plutôt que `abs`, qui déborde lui aussi sur `i32::MIN`. +/// La borne est vérifiée **avant** la multiplication — la valeur vient du pont +/// IPC, et `-i32::MIN` comme `i32::MAX * 60` déborderaient, empoisonnant le +/// mutex pour le reste du processus. D'où aussi `unsigned_abs` plutôt qu'`abs`. pub fn offset_from_minutes(tz_offset_minutes: i32) -> FixedOffset { let utc = FixedOffset::east_opt(0).expect("UTC est un décalage valide"); @@ -42,9 +32,8 @@ pub fn offset_from_minutes(tz_offset_minutes: i32) -> FixedOffset { FixedOffset::east_opt(-tz_offset_minutes * 60).unwrap_or(utc) } -/// Une date ISO illisible ne doit pas faire disparaître la note : elle est -/// classée dans `older`, où elle reste atteignable. `created_at` vient de notre -/// propre écriture, donc le cas relève de la corruption, pas du fonctionnement. +/// `None` classe la note dans `older`, où elle reste atteignable — une date +/// illisible ne doit pas la faire disparaître. fn parse(value: &str, offset: &FixedOffset) -> Option> { DateTime::parse_from_rfc3339(value) .ok() @@ -60,8 +49,7 @@ fn is_within_days(date: &DateTime, now: &DateTime, day elapsed.num_milliseconds() >= 0 && elapsed.num_days() <= days } -/// Point unique où une note persistée devient une note affichable : c'est la -/// seule porte de sortie vers le front, donc rien ne peut lui échapper. +/// Seule porte de sortie vers le front : rien ne peut lui échapper. fn section( key: NoteSectionKey, notes: Vec, @@ -70,7 +58,7 @@ fn section( ) -> NoteSection { let notes: Vec = notes .into_iter() - .map(|note| display::decorate(note, now)) + .map(|note| note::decorate(note, now)) .collect(); NoteSection { @@ -81,11 +69,8 @@ fn section( } } -/// Vue plate, utilisée dès qu'une recherche ou un filtre par tag est actif. -/// -/// Répartir des résultats de recherche dans des sections de date les dilue et -/// laisse croire qu'il n'y a rien à voir quand tout est tombé dans une section -/// en bas de page. Un résultat de recherche se lit en liste. +/// Vue plate dès qu'une recherche ou une facette est active : répartis par date, +/// les résultats se diluent et semblent absents quand tout tombe en bas de page. fn results(notes: Vec, now: &DateTime) -> Vec { vec![section(NoteSectionKey::Results, notes, false, now)] } @@ -115,9 +100,8 @@ pub fn build( continue; } - // Le `else` du `match` couvre à la fois « plus vieux qu'une semaine » et - // « date illisible » : dans les deux cas la note atterrit dans `older`, - // jamais nulle part. + // Le bras `_` couvre « plus vieux qu'une semaine » et « date illisible » : + // dans les deux cas `older`, jamais nulle part. match parse(¬e.created_at, offset) { Some(created) if is_same_local_day(&created, now) => today.push(note), Some(created) if is_within_days(&created, now, WEEK_DAYS) => this_week.push(note), @@ -387,13 +371,6 @@ mod tests { assert_eq!(ids_in(§ions, NoteSectionKey::Today), ["after-midnight"]); } - #[test] - fn a_javascript_offset_is_inverted_into_a_chrono_offset() { - // getTimezoneOffset() returns -120 for UTC+2: the opposite sign. - assert_eq!(offset_from_minutes(-120).local_minus_utc(), 2 * 3600); - assert_eq!(offset_from_minutes(300).local_minus_utc(), -5 * 3600); - } - #[test] fn an_unparsable_creation_date_keeps_the_note_reachable() { let offset = utc(); diff --git a/src-tauri/src/domain/space.rs b/src-tauri/src/domain/space.rs index d95273e..be5ff07 100644 --- a/src-tauri/src/domain/space.rs +++ b/src-tauri/src/domain/space.rs @@ -1,25 +1,23 @@ -//! L'espace : le classeur dans lequel les notes sont rangées. +//! L'espace : le classeur dans lequel les notes sont rangées. C'est le +//! `space_id` de la note qui porte la relation. //! -//! Volontairement minimal — un identifiant, un nom. C'est le `space_id` de -//! chaque note qui porte la relation. Il n'existe **aucune** entrée « Tous les -//! espaces » côté données : c'est un mode d'affichage, pas un espace ; en créer -//! un ferait ranger des notes dedans. +//! Aucune entrée « Tous les espaces » côté données : c'est un mode d'affichage, +//! et en créer un ferait ranger des notes dedans. use serde::{Deserialize, Serialize}; -use super::validation::ValidationError; +use super::rules::ValidationError; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Space { pub id: String, - /// Nom affiché dans le sélecteur. L'unicité, insensible à la casse, est - /// tranchée par la persistance : un doublon ressort en - /// `ErrorCode::DuplicateSpaceName`, que le front traduit. + /// L'unicité, insensible à la casse, est tranchée par la persistance : un + /// doublon ressort en `ErrorCode::DuplicateSpaceName`. pub name: String, } -/// Création : pas d'identifiant, il est attribué par la persistance. +/// Pas d'identifiant : il est attribué par la persistance. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SpaceDraft { @@ -27,12 +25,9 @@ pub struct SpaceDraft { } impl SpaceDraft { - /// Nom retenu : détouré et non vide. - /// - /// Le détourage n'est pas cosmétique. L'unicité est vérifiée en - /// `COLLATE NOCASE`, qui ne replie pas les espaces : sans lui, « Perso » et - /// « Perso » suivi d'une espace cohabiteraient dans le sélecteur, tous deux - /// affichés à l'identique. + /// Nom détouré et non vide. Le détourage n'est pas cosmétique : + /// `COLLATE NOCASE` ne replie pas les espaces, donc « Perso » et « Perso » + /// suivi d'une espace cohabiteraient, affichés à l'identique. pub fn validated_name(&self) -> Result { let trimmed = self.name.trim(); if trimmed.is_empty() { @@ -46,12 +41,9 @@ impl SpaceDraft { } } -/// Refuge acceptable pour les notes d'un espace supprimé. -/// -/// Un espace ne peut pas être son propre refuge : le `DELETE` porte un -/// `ON DELETE CASCADE`, donc les notes qu'on viendrait d'y « déplacer » seraient -/// emportées juste après. La persistance ne peut pas trancher ça — des deux -/// côtés, l'espace existe. +/// Un espace ne peut pas être son propre refuge : le `ON DELETE CASCADE` +/// emporterait les notes juste après le transfert. La persistance ne peut pas +/// trancher — des deux côtés, l'espace existe. pub fn validate_move_target(id: &str, target_id: &str) -> Result<(), ValidationError> { if id == target_id { return Err(ValidationError::new( @@ -63,10 +55,9 @@ pub fn validate_move_target(id: &str, target_id: &str) -> Result<(), ValidationE Ok(()) } -/// `rename_all` est sans effet tant que les champs tiennent en un mot — et c'est -/// précisément pourquoi ces tests existent : ils échoueront le jour où un -/// `created_at` s'ajoutera sans que l'attribut ait été porté, au lieu de laisser -/// le front lire `undefined`. +/// `rename_all` est sans effet tant que les champs tiennent en un mot : ces +/// tests échoueront le jour où un `created_at` s'ajoutera sans l'attribut, au +/// lieu de laisser le front lire `undefined`. #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/domain/tags.rs b/src-tauri/src/domain/tags.rs deleted file mode 100644 index e8f38f5..0000000 --- a/src-tauri/src/domain/tags.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Normalisation des tags. -//! -//! Règle unique et sans dépendance : le front envoie ce que l'utilisateur a -//! tapé, la persistance écrit ce que cette fonction renvoie, et la requête fait -//! passer les tags sélectionnés par la même moulinette — sinon un `#urgent` -//! saisi au clavier ne retrouverait pas le tag `urgent` stocké. - -/// Nettoie les tags : espaces, `#` de tête, vides et doublons. -/// -/// La déduplication est **insensible à la casse** et garde la première graphie -/// rencontrée. Sans elle, `urgent` et `URGENT` produiraient deux entrées dans le -/// rail alors qu'ils désignent la même chose pour l'utilisateur. Le stockage -/// prolonge cette règle à tout le corpus via `COLLATE NOCASE` (migration 2). -pub fn normalize(tags: &[String]) -> Vec { - let mut seen: Vec = Vec::new(); - let mut normalized: Vec = Vec::new(); - - for tag in tags { - let cleaned = tag.trim().trim_start_matches('#').trim(); - if cleaned.is_empty() { - continue; - } - - let folded = cleaned.to_lowercase(); - if seen.contains(&folded) { - continue; - } - - seen.push(folded); - normalized.push(cleaned.to_string()); - } - - normalized -} - -#[cfg(test)] -mod tests { - use super::*; - - fn normalized(tags: &[&str]) -> Vec { - normalize(&tags.iter().map(|tag| tag.to_string()).collect::>()) - } - - #[test] - fn padding_and_blanks_are_dropped() { - assert_eq!( - normalized(&[" urgent ", "", " ", "later"]), - ["urgent", "later"] - ); - } - - #[test] - fn a_duplicate_keeps_its_first_spelling() { - assert_eq!(normalized(&["Urgent", "urgent", "URGENT"]), ["Urgent"]); - } - - #[test] - fn only_leading_hashes_are_stripped() { - assert_eq!(normalized(&["##c++", "a#b"]), ["c++", "a#b"]); - } - - #[test] - fn a_tag_reduced_to_nothing_is_dropped_rather_than_stored_empty() { - // " # " trims to "#", then to "" — storing that would put a blank facet - // in the rail that selects every note carrying it. - assert!(normalized(&[" # ", "#"]).is_empty()); - } - - #[test] - fn case_folding_reaches_beyond_ascii() { - // SQLite's NOCASE would not collapse these; `to_lowercase` is Unicode. - assert_eq!(normalized(&["Étape", "étape"]), ["Étape"]); - } -} diff --git a/src-tauri/src/domain/validation.rs b/src-tauri/src/domain/validation.rs deleted file mode 100644 index dc22937..0000000 --- a/src-tauri/src/domain/validation.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Refus d'une donnée reçue du front. -//! -//! # Pourquoi le back valide ce que le front contrôle déjà -//! -//! L'interface n'offre pas de créer un espace sans nom ni de choisir un langage -//! inexistant. Mais la doctrine du projet est que **le back fait autorité** : si -//! une règle n'est tenue que par un formulaire, elle n'est pas tenue. Un appel -//! direct au pont, un front d'une autre version ou un bug d'affichage suffisent -//! à écrire en base une donnée que plus rien ne pourra interpréter. -//! -//! Le refus voyage comme n'importe quelle autre erreur : un **code** et un -//! paramètre `field`, jamais une phrase rédigée en français. - -use std::fmt; - -/// Donnée reçue non conforme au contrat du domaine. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ValidationError { - /// Champ en cause, tel que le front le nomme — sert de paramètre au message traduit. - pub field: &'static str, - /// Détail technique, affiché en second plan. - pub detail: String, -} - -impl ValidationError { - pub fn new(field: &'static str, detail: impl Into) -> Self { - Self { - field, - detail: detail.into(), - } - } -} - -impl fmt::Display for ValidationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Champ « {} » invalide : {}", self.field, self.detail) - } -} - -impl std::error::Error for ValidationError {} diff --git a/src-tauri/src/domain/view.rs b/src-tauri/src/domain/view.rs index 7ebe943..769c0bc 100644 --- a/src-tauri/src/domain/view.rs +++ b/src-tauri/src/domain/view.rs @@ -1,34 +1,108 @@ -//! Assemblage de la vue renvoyée au canevas. +//! Ce que l'utilisateur demande à voir ([`NotesQuery`]), ce que le canevas +//! affiche en retour ([`NotesView`]), et l'assemblage de l'un vers l'autre. //! -//! Fonction **pure** : elle reçoit les notes que le SQL a déjà dégrossies et -//! n'ouvre aucune connexion. C'est ici que vivent les décisions que la -//! persistance n'a pas à connaître — ce qui compte comme une correspondance, -//! ce qui vaut « une recherche est en cours », et comment tout cela se range. +//! Aucun type intermédiaire « liste de notes » n'est exposé au front : il +//! inviterait à refiltrer côté interface. //! -//! # Le partage des tâches -//! -//! - **SQL** pour ce qu'il indexe bien : espace, épinglage, cycle de vie, -//! langage, et l'appartenance à un tag via `note_tags` (voir -//! `storage::notes::fetch`) ; -//! - **ici** pour la recherche texte, qui demande un repliage de casse Unicode -//! (voir [`super::search`]) ; -//! - [`super::sections`] pour le regroupement. +//! [`build`] est pure — elle reçoit les notes que le SQL a dégrossies et n'ouvre +//! aucune connexion. Le partage : **SQL** pour ce qu'il indexe (espace, +//! épinglage, cycle de vie, langage, tag), **ici** pour la recherche texte qui +//! demande un repliage Unicode, [`super::sections`] pour le regroupement. use chrono::DateTime; -use super::note::Note; -use super::query::{Facets, NotesQuery, NotesView}; -use super::validation::ValidationError; -use super::{search, sections, tags}; +use serde::{Deserialize, Serialize}; -/// Vue complète : notes retenues, réparties en sections, plus les tags du rail. -/// -/// Une vue sans aucune note est une réponse valide (premier lancement, ou -/// recherche infructueuse — `is_filtering` permet de distinguer les deux). +use super::note::{DisplayNote, Note}; +use super::rules::{self, ValidationError}; +use super::sections; + +/// Tout y est explicite : la requête ne lit ni horloge ni fuseau, ce qui la +/// rend reproductible en test. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotesQuery { + /// `None` = « tous les espaces » — un choix, pas une absence de choix : il + /// n'existe aucun espace « Tous » côté données. + pub space_id: Option, + /// Cherché dans le titre, les tags et le contenu. Vide = pas de recherche. + pub search: String, + pub filter: NoteFilter, + /// Tags du rail. Une note passe si elle en porte **au moins un**. + pub tags: Vec, + /// Langages du rail, même sémantique d'union. Vide = tous. + pub languages: Vec, + /// Instant de référence ISO 8601 UTC, fourni par `ClockService`. + pub now: String, + /// ⚠️ `Date#getTimezoneOffset()`, dont la valeur est l'**opposé** du décalage + /// (UTC+2 donne −120). Nécessaire parce que les sections raisonnent en jours + /// locaux : à 23 h à Paris, `now` en UTC est déjà demain. + pub tz_offset_minutes: i32, +} + +/// Filtre rapide de la barre d'outils. `Untriaged` = notes portant une date +/// d'expiration, c'est-à-dire celles dont on n'a pas encore décidé du sort. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NoteFilter { + All, + Pinned, + Untriaged, +} + +/// Ce que les rails ont à proposer. Regroupées parce que la persistance les +/// calcule ensemble ; ne traverse pas le pont. +#[derive(Debug, Clone, Default)] +pub struct Facets { + pub tags: Vec, + pub languages: Vec, +} + +/// Ce que le canevas affiche, tel quel. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotesView { + pub sections: Vec, + /// Portés à l'**espace**, pas au filtre courant : n'afficher que les tags des + /// notes déjà filtrées rendrait le rail inutilisable dès la 1re sélection. + pub available_tags: Vec, + /// Portés à l'espace, même raison. + pub available_languages: Vec, + /// Une recherche ou une facette est active. Le front distingue ainsi + /// « aucun résultat » d'« espace vide ». + pub is_filtering: bool, + /// Notes retenues, toutes sections confondues. + pub matched: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NoteSection { + pub key: NoteSectionKey, + /// Au moins une note arrive à échéance, au sens du seuil unique de `note`. + pub has_expiring_notes: bool, + pub notes: Vec, + /// Affiche la carte fantôme « coller ou créer » en fin de section. + pub show_create_ghost: bool, +} + +/// Sert de **clé de traduction** côté front (`sections.`) : aucun libellé +/// lisible ne traverse le pont. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum NoteSectionKey { + Pinned, + Today, + Week, + Older, + Results, +} + +/// Vue complète. Une vue vide est une réponse valide (premier lancement, ou +/// recherche infructueuse — `is_filtering` distingue les deux). /// -/// Un `now` illisible est **refusé** et non remplacé par l'horloge du serveur : -/// un repli muet ferait basculer tout le découpage en sections sur un autre -/// instant, et les notes changeraient de jour sans que rien ne le signale. +/// Un `now` illisible est **refusé**, jamais remplacé par l'horloge du serveur : +/// un repli muet ferait basculer tout le découpage sur un autre instant. pub fn build( notes: Vec, facets: Facets, @@ -39,14 +113,14 @@ pub fn build( // La recherche porte aussi sur les tags, déjà rattachés par la persistance. let needle = request.search.trim().to_lowercase(); if !needle.is_empty() { - notes.retain(|note| search::matches(note, &needle)); + notes.retain(|note| rules::matches(note, &needle)); } // Le filtre rapide (épinglées / à trier) ne bascule pas en mode résultats : // il restreint une vue qui reste chronologique. Une recherche ou une - // sélection de facettes — tag ou langage —, si. + // facette — tag ou langage —, si. let is_filtering = !needle.is_empty() - || !tags::normalize(&request.tags).is_empty() + || !rules::normalize_tags(&request.tags).is_empty() || !request.languages.is_empty(); let matched = notes.len(); @@ -71,13 +145,97 @@ pub fn build( #[cfg(test)] mod tests { - use super::super::query::{NoteFilter, NoteSectionKey}; use super::build as try_build; use super::*; use crate::domain::fixtures::note as sample; + use crate::domain::note::decorate; const NOW: &str = "2026-07-25T09:00:00.000Z"; + fn displayed() -> DisplayNote { + let now = DateTime::parse_from_rfc3339(NOW).unwrap(); + decorate(sample(), &now) + } + + #[test] + fn a_view_serialises_with_camel_case_keys() { + let view = NotesView { + sections: vec![NoteSection { + key: NoteSectionKey::Week, + notes: vec![displayed()], + has_expiring_notes: false, + show_create_ghost: true, + }], + available_tags: vec!["auth".to_string()], + available_languages: vec!["json".to_string()], + is_filtering: false, + matched: 1, + }; + + let json = serde_json::to_value(view).unwrap(); + + assert!(json.get("availableTags").is_some()); + assert!(json.get("availableLanguages").is_some()); + assert!(json.get("isFiltering").is_some()); + assert!(json.get("available_tags").is_none()); + assert!(json.get("available_languages").is_none()); + assert!(json["sections"][0].get("hasExpiringNotes").is_some()); + assert!(json["sections"][0].get("showCreateGhost").is_some()); + } + + #[test] + fn a_section_key_serialises_as_the_translation_key_the_front_expects() { + let section = NoteSection { + key: NoteSectionKey::Older, + notes: Vec::new(), + has_expiring_notes: false, + show_create_ghost: false, + }; + + let json = serde_json::to_value(section).unwrap(); + + // The front builds `sections.older` from this; serde's default would + // emit "Older" and the lookup would miss. + assert_eq!(json["key"], "older"); + } + + #[test] + fn a_query_is_read_from_the_camel_case_payload_the_front_sends() { + let query: NotesQuery = serde_json::from_value(serde_json::json!({ + "spaceId": "s-1", + "search": "deploy", + "filter": "untriaged", + "tags": ["urgent"], + "languages": ["json", "yml"], + "now": "2026-07-25T09:00:00.000Z", + "tzOffsetMinutes": -120 + })) + .unwrap(); + + assert_eq!(query.space_id.as_deref(), Some("s-1")); + assert_eq!(query.filter, NoteFilter::Untriaged); + assert_eq!(query.languages, ["json", "yml"]); + assert_eq!(query.tz_offset_minutes, -120); + } + + #[test] + fn a_null_space_is_read_as_every_space() { + // The front sends null, not an omitted key, when the user picks + // "all spaces" — that is a choice, not a missing value. + let query: NotesQuery = serde_json::from_value(serde_json::json!({ + "spaceId": null, + "search": "", + "filter": "all", + "tags": [], + "languages": [], + "now": "2026-07-25T09:00:00.000Z", + "tzOffsetMinutes": 0 + })) + .unwrap(); + + assert!(query.space_id.is_none()); + } + /// Les cas nominaux fournissent tous un instant valide ; seul le test dédié /// s'intéresse au refus. fn build(notes: Vec, facets: Facets, request: &NotesQuery) -> NotesView { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c84703a..88eabb0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,5 @@ -// Adaptateurs Tauri exposés au front (voir commands/mod.rs). mod commands; -// Modèle et règles métier — ne dépend ni de SQLite ni de Tauri (voir domain/mod.rs). mod domain; -// Persistance SQLite (voir storage/mod.rs). mod storage; use std::sync::Mutex; @@ -12,9 +9,7 @@ use tauri::Manager; use commands::notes::{create_note, delete_note, query_notes, update_note}; use commands::spaces::{create_space, delete_space, list_spaces, rename_space}; -/// Point d'entrée de l'application Tauri. -/// Sur mobile, cette même fonction sert aussi de point d'entrée natif -/// (voir l'attribut `mobile_entry_point` ci-dessous). +/// Point d'entrée de l'application, natif sur mobile via `mobile_entry_point`. #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -22,28 +17,24 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { - // L'updater est absent des cibles mobiles (voir Cargo.toml) : le - // `cfg` doit donc aussi couvrir son enregistrement, sinon la - // compilation Android/iOS bute sur un crate inconnu. + // L'updater est absent des cibles mobiles (voir Cargo.toml), sinon + // la compilation Android/iOS bute sur un crate inconnu. #[cfg(desktop)] app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; - // La base vit dans le répertoire de données de l'application, pas à - // côté de l'exécutable : c'est le seul emplacement inscriptible - // garanti une fois l'application installée. + // Seul emplacement inscriptible garanti une fois l'app installée. let directory = app.path().app_data_dir()?; std::fs::create_dir_all(&directory)?; - // Connexion unique, partagée derrière un mutex : une `Connection` - // rusqlite n'est pas `Sync`, et deux commandes peuvent se chevaucher. + // Connexion unique derrière un mutex : `Connection` n'est pas + // `Sync`, et deux commandes peuvent se chevaucher. let connection = storage::open(&directory.join(storage::DB_FILE_NAME))?; app.manage(Mutex::new(connection)); Ok(()) }) - // Chaque nouvelle commande doit être ajoutée ici pour devenir - // accessible depuis Angular via invoke("nom_de_la_commande", ...). + // Sans enregistrement ici, `invoke()` échoue sur « command not found ». .invoke_handler(tauri::generate_handler![ query_notes, create_note, diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 3d142d6..b66f808 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -1,34 +1,12 @@ -//! Couche de persistance : SQLite embarqué via `rusqlite`. +//! Persistance : SQLite embarqué (`rusqlite`, feature `bundled`, base dans +//! `app_data_dir()`). Ouverture, configuration et migrations ici ; les lectures +//! et écritures dans `storage::notes` et `storage::spaces`, sous forme de +//! fonctions prenant une `&Connection` — d'où des tests sur base en mémoire, +//! sans lancer Tauri. **Aucune règle métier** : elles sont dans `crate::domain`. //! -//! Le fichier de base vit dans `app_data_dir()` (voir `lib.rs`), la feature -//! `bundled` de `rusqlite` compile SQLite depuis les sources et le lie en -//! statique : rien à installer ni à distribuer à côté de l'exécutable. -//! -//! # Organisation -//! -//! Ce module ne contient que l'ouverture, la configuration et les migrations. -//! Les lectures et écritures sont dans `storage::notes` et `storage::spaces`, -//! sous forme de fonctions ordinaires prenant une `&Connection`. Les -//! `#[tauri::command]` de `commands/` ne sont que des adaptateurs par-dessus : -//! c'est ce qui permet de tester la persistance sur une base en mémoire, sans -//! lancer Tauri. -//! -//! Cette couche ne porte **aucune règle métier** : elle lit et écrit le modèle -//! défini dans `crate::domain`, dont elle dépend, et qui décide de tout le reste. -//! -//! # Concurrence -//! -//! Une `Connection` rusqlite n'est pas `Sync`. L'unique connexion est donc -//! partagée via `tauri::State` (`Db = Mutex`), enregistrée avec -//! `.manage(...)` dans `lib.rs` — jamais par une variable globale. Deux commandes -//! qui se chevauchent se sérialisent sur ce mutex. -//! -//! # Migrations -//! -//! Le schéma est versionné par `PRAGMA user_version`. Faire évoluer le modèle = -//! ajouter une constante `MIGRATION_N` et une branche dans [`migrate`] ; ne -//! jamais modifier une migration déjà livrée, elle a déjà tourné chez -//! l'utilisateur. Chaque migration est atomique (DDL transactionnel). +//! ⚠️ **Les migrations sont append-only.** Le schéma est versionné par +//! `PRAGMA user_version` : faire évoluer le modèle = ajouter un `MIGRATION_N` et +//! une branche dans [`migrate`], jamais modifier une migration déjà livrée. pub mod notes; pub mod spaces; @@ -40,32 +18,25 @@ use std::sync::Mutex; use chrono::{SecondsFormat, Utc}; use rusqlite::Connection; -/// Horodatage courant dans le format attendu de l'autre côté du pont : ISO 8601 -/// / RFC 3339 UTC, ex. `2026-07-25T09:12:00.000Z`. -/// -/// La milliseconde n'est pas décorative : deux notes modifiées dans la même -/// seconde deviendraient impossibles à départager par le tri de [`notes::list`]. +/// Instant courant en ISO 8601 UTC, ex. `2026-07-25T09:12:00.000Z`. La +/// milliseconde n'est pas décorative : sans elle, deux notes modifiées dans la +/// même seconde seraient impossibles à départager au tri. pub fn now_iso() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) } -/// État partagé enregistré dans Tauri. Voir la section « Concurrence ». +/// Connexion unique partagée via `tauri::State` : `Connection` n'est pas `Sync`, +/// et deux commandes qui se chevauchent se sérialisent sur ce mutex. pub type Db = Mutex; -/// Nom du fichier de base, créé dans le répertoire de données de l'application. pub const DB_FILE_NAME: &str = "devbox.sqlite3"; /// Version de schéma attendue par ce binaire. const SCHEMA_VERSION: i32 = 3; -/// Schéma initial. -/// -/// Deux choix structurants, qui ont permis au filtrage de descendre côté Rust -/// sans re-migration (cf. `storage::notes::query`) : -/// - `lifecycle` est éclaté en deux colonnes plutôt que stocké en JSON, sinon -/// « ce qui expire avant telle date » ne serait pas requêtable ; -/// - les tags sont dans leur propre table plutôt qu'en colonne sérialisée, sinon -/// le filtre par tag imposerait de relire tout le corpus. +/// Schéma initial. Deux choix rendent le filtrage requêtable : `lifecycle` +/// éclaté en deux colonnes plutôt qu'en JSON, et les tags dans leur propre table +/// plutôt qu'en colonne sérialisée. const MIGRATION_1: &str = r" BEGIN; @@ -74,9 +45,8 @@ CREATE TABLE spaces ( name TEXT NOT NULL ); --- L'unicité des noms est tranchée ici et nulle part ailleurs : `spaces::create` --- la vérifie pour produire une erreur lisible, cet index la garantit même si --- une écriture passait à côté. NOCASE ne replie que l'ASCII. +-- `spaces::create` vérifie l'unicité pour produire une erreur lisible, cet index +-- la garantit même si une écriture passait à côté. NOCASE ne replie que l'ASCII. CREATE UNIQUE INDEX spaces_name_unique ON spaces (name COLLATE NOCASE); CREATE TABLE notes ( @@ -91,8 +61,7 @@ CREATE TABLE notes ( updated_at TEXT NOT NULL, lifecycle_kind TEXT NOT NULL CHECK (lifecycle_kind IN ('permanent', 'expires')), lifecycle_expires_at TEXT, - -- Une note « expires » a forcément une date, une note permanente n'en a - -- jamais : la lecture peut donc reconstruire l'enum sans cas ambigu. + -- Garantit que la lecture peut reconstruire l'enum sans cas ambigu. CHECK ((lifecycle_kind = 'expires') = (lifecycle_expires_at IS NOT NULL)) ); @@ -112,15 +81,11 @@ PRAGMA user_version = 1; COMMIT; "; -/// Replie la casse des tags au niveau du stockage. +/// Replie la casse des tags **entre** notes, là où `rules::normalize_tags` ne la +/// repliait qu'au sein d'une note : `Urgent` et `urgent` produisaient deux +/// facettes dans le rail, dont `tag IN (…)` n'en retrouvait qu'une. /// -/// `normalize_tags` dédoublonnait déjà sans tenir compte de la casse, mais -/// **au sein d'une seule note** : sans collation, `Urgent` et `urgent` portés -/// par deux notes différentes produisaient deux facettes dans le rail, dont -/// `tag IN (…)` — en collation BINARY — n'en retrouvait qu'une, alors que la -/// recherche texte, elle, les confondait. Trois comportements pour un concept. -/// -/// La collation d'une colonne ne s'altère pas : la table est recréée. +/// La collation d'une colonne ne s'altère pas, d'où la table recréée ; /// `INSERT OR IGNORE` absorbe les doublons que la nouvelle clé primaire fusionne. const MIGRATION_2: &str = r" BEGIN; @@ -143,14 +108,11 @@ PRAGMA user_version = 2; COMMIT; "; -/// Index sur le langage, devenu une facette de filtrage à part entière (rail -/// « Format »). Sans lui, `language IN (…)` impose un balayage complet, et -/// `SELECT DISTINCT language` — recalculé à chaque requête pour alimenter le -/// rail — aussi. +/// Index sur le langage, devenu une facette de filtrage. Sans lui, +/// `language IN (…)` et le `SELECT DISTINCT` du rail balaient tout. /// -/// Pas de contrainte `CHECK` sur la colonne : la liste des langages reconnus -/// vit dans `domain::language` et bouge d'une version à l'autre. La figer dans -/// le schéma obligerait à une migration pour chaque ajout. +/// Pas de `CHECK` sur la colonne : la liste des langages vit dans le domaine et +/// bouge d'une version à l'autre — la figer imposerait une migration par ajout. const MIGRATION_3: &str = r" BEGIN; @@ -161,24 +123,19 @@ PRAGMA user_version = 3; COMMIT; "; -/// Échecs de la couche de persistance. -/// -/// Les commandes convertissent ces variantes en `AppError` (voir -/// `commands/error.rs`) : la variante devient un **code** que le front traduit, -/// et le `Display` ci-dessous n'est plus que le détail technique affiché en -/// second plan. C'est pourquoi il peut rester en français. +/// Les commandes convertissent ces variantes en `AppError` : la variante devient +/// un **code** que le front traduit, et le `Display` ci-dessous n'est plus que +/// le détail technique — c'est pourquoi il peut rester en français. #[derive(Debug)] pub enum StorageError { - /// Note introuvable — jamais un `Ok` silencieux, sinon le front croirait - /// avoir enregistré. + /// Jamais un `Ok` silencieux : le front croirait avoir enregistré. NoteNotFound(String), - /// Espace visé par une note inexistant : la note n'aurait nulle part où être rangée. + /// Espace visé inexistant : la note n'aurait nulle part où être rangée. SpaceNotFound(String), - /// Un espace porte déjà ce nom (comparaison insensible à la casse). + /// Nom déjà pris (comparaison insensible à la casse). DuplicateSpaceName(String), - /// La base a été écrite par une version plus récente de l'application. + /// Base écrite par une version plus récente de l'application. SchemaTooRecent(i32), - /// Panne de lecture ou d'écriture. Sqlite(rusqlite::Error), } @@ -207,8 +164,7 @@ impl From for StorageError { } } -/// Ouvre la base au chemin donné (en la créant au besoin), la configure et -/// applique les migrations manquantes. +/// Ouvre la base (en la créant au besoin), la configure, migre. pub fn open(path: &Path) -> Result { let connection = Connection::open(path)?; configure(&connection)?; @@ -226,10 +182,9 @@ pub fn open_in_memory() -> Result { } fn configure(connection: &Connection) -> Result<(), StorageError> { - // `foreign_keys` est désactivé par défaut dans SQLite et se règle **par - // connexion** : sans lui, les `ON DELETE CASCADE` du schéma ne s'appliquent - // pas et les tags d'une note supprimée resteraient orphelins. - // WAL : un lecteur ne bloque plus un écrivain (sans effet sur une base en mémoire). + // ⚠️ `foreign_keys` se règle **par connexion** et est désactivé par défaut : + // sans lui les `ON DELETE CASCADE` sont inertes et les tags d'une note + // supprimée resteraient orphelins. WAL : un lecteur ne bloque plus un écrivain. connection.execute_batch( "PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;", @@ -240,15 +195,12 @@ fn configure(connection: &Connection) -> Result<(), StorageError> { fn migrate(connection: &Connection) -> Result<(), StorageError> { let version: i32 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?; - // Base écrite par une version plus récente de l'application : ses tables ne - // sont pas celles que ce binaire sait lire. Refuser franchement vaut mieux - // que lire de travers et écraser des données. + // Refuser franchement vaut mieux que lire de travers et écraser des données. if version > SCHEMA_VERSION { return Err(StorageError::SchemaTooRecent(version)); } - // Chaque migration manquante est appliquée dans l'ordre : une base neuve - // les traverse toutes, une base existante ne reprend qu'à partir de la sienne. + // Une base neuve les traverse toutes, une base existante reprend à la sienne. if version < 1 { connection.execute_batch(MIGRATION_1)?; } diff --git a/src-tauri/src/storage/notes.rs b/src-tauri/src/storage/notes.rs index dec2597..70f730f 100644 --- a/src-tauri/src/storage/notes.rs +++ b/src-tauri/src/storage/notes.rs @@ -1,16 +1,12 @@ //! Lecture et écriture des notes : **du SQL, et rien d'autre**. //! -//! Fonctions ordinaires prenant une `&Connection` : les `#[tauri::command]` de -//! `commands/notes.rs` ne font que les appeler. Voir `storage/mod.rs`. +//! Ne descendent dans le `WHERE` que les critères indexés par SQLite — espace, +//! épinglage, cycle de vie, langage, tag. Correspondance de recherche, +//! regroupement en sections et normalisation des tags sont des règles, et vivent +//! dans `crate::domain`. //! -//! Ce module ne décide de rien. Ce qui compte comme une correspondance de -//! recherche, ce qui vaut « une recherche est en cours », la répartition en -//! sections et la normalisation des tags sont des règles métier et vivent dans -//! `crate::domain`. Ici, seulement les critères que SQLite indexe : espace, -//! épinglage, cycle de vie, langage, appartenance à un tag. -//! -//! L'horodatage n'est jamais lu ici depuis l'horloge système : il est passé en -//! paramètre (`now`), ce qui rend les écritures reproductibles en test. +//! `now` est passé en paramètre plutôt que lu de l'horloge : les écritures +//! restent reproductibles en test. use std::collections::HashMap; @@ -19,20 +15,19 @@ use uuid::Uuid; use super::{StorageError, spaces}; use crate::domain::note::{Note, NoteDraft, NoteLifecycle, NotePatch}; -use crate::domain::query::{Facets, NoteFilter, NotesQuery}; -use crate::domain::tags; +use crate::domain::rules; +use crate::domain::view::{Facets, NoteFilter, NotesQuery}; const NOTE_COLUMNS: &str = "id, space_id, title, language, content, source, pinned, \ created_at, updated_at, lifecycle_kind, lifecycle_expires_at"; -/// Reconstruit une note **sans ses tags** : ils vivent dans `note_tags` et sont -/// rattachés ensuite, en une seule requête pour toute la liste (cf. [`list`]). +/// Note **sans ses tags** : ils vivent dans `note_tags` et sont rattachés +/// ensuite, en une requête pour toute la liste. fn row_to_note(row: &Row<'_>) -> rusqlite::Result { let lifecycle_kind: String = row.get("lifecycle_kind")?; let expires_at: Option = row.get("lifecycle_expires_at")?; - // La contrainte CHECK du schéma garantit que « expires » implique une date : - // le cas `("expires", None)` est donc inatteignable, pas un cas dégradé. + // Le `CHECK` du schéma rend `("expires", None)` inatteignable. let lifecycle = match (lifecycle_kind.as_str(), expires_at) { ("expires", Some(at)) => NoteLifecycle::Expires { at }, _ => NoteLifecycle::Permanent, @@ -60,8 +55,8 @@ fn lifecycle_columns(lifecycle: &NoteLifecycle) -> (&'static str, Option<&str>) } } -/// Tags de toutes les notes, en une requête — la version naïve ferait une -/// requête par note et le coût deviendrait visible dès quelques centaines. +/// Tags de toutes les notes en une requête ; une par note coûterait cher dès +/// quelques centaines. fn all_tags(connection: &Connection) -> Result>, StorageError> { let mut statement = connection.prepare("SELECT note_id, tag FROM note_tags ORDER BY tag")?; let mut grouped: HashMap> = HashMap::new(); @@ -90,22 +85,18 @@ fn tags_of(connection: &Connection, note_id: &str) -> Result, Storag Ok(tags) } -/// Remplace intégralement les tags d'une note. Appelée uniquement quand le patch -/// porte un `tags` : un patch sans ce champ ne doit rien toucher. -/// -/// Renvoie les tags réellement écrits, qui peuvent différer de ceux reçus -/// (cf. [`tags::normalize`]) : l'appelant doit adopter cette valeur, sinon il -/// renverrait au front une note qui ne correspond pas à la base. +/// Remplace intégralement les tags, uniquement quand le patch en porte. /// -/// Le tri final aligne l'écriture sur la lecture (`ORDER BY tag`) : sans lui, -/// une note fraîchement enregistrée afficherait ses tags dans un ordre, puis -/// dans un autre au rechargement suivant. +/// Renvoie les tags **réellement écrits**, qui peuvent différer de ceux reçus : +/// l'appelant doit adopter cette valeur, sinon il rendrait au front une note qui +/// ne correspond pas à la base. Le tri final aligne l'écriture sur la lecture, +/// sans quoi les tags se réordonneraient au rechargement suivant. fn replace_tags( connection: &Connection, note_id: &str, requested: &[String], ) -> Result, StorageError> { - let mut normalized = tags::normalize(requested); + let mut normalized = rules::normalize_tags(requested); connection.execute("DELETE FROM note_tags WHERE note_id = ?1", [note_id])?; @@ -120,9 +111,8 @@ fn replace_tags( Ok(normalized) } -/// Toutes les notes, tous espaces confondus. **Réservée aux tests** : en -/// production tout passe par [`fetch`] puis `domain::view::build`. Exposer une -/// liste brute au front l'inviterait à refiltrer lui-même. +/// **Réservée aux tests** : en production tout passe par [`fetch`] puis +/// `domain::view::build`. #[cfg(test)] pub fn list(connection: &Connection) -> Result, StorageError> { fetch( @@ -140,6 +130,19 @@ pub fn list(connection: &Connection) -> Result, StorageError> { .map(|(notes, _)| notes) } +/// Lie `values` et rend la liste de placeholders correspondante (`?3, ?4`). +/// Les numéros suivent `params`, qui peut déjà porter d'autres critères. +fn placeholders(values: &[String], params: &mut Vec>) -> String { + values + .iter() + .map(|value| { + params.push(Box::new(value.clone())); + format!("?{}", params.len()) + }) + .collect::>() + .join(", ") +} + /// Valeurs distinctes d'une colonne, portées à un espace. Sert les deux rails de /// facettes, qui posent la même question à deux colonnes près. fn distinct( @@ -188,14 +191,8 @@ fn facets(connection: &Connection, space_id: Option<&str>) -> Result conditions.push("lifecycle_kind = 'expires'".to_string()), } - // Pas de normalisation ici, contrairement aux tags : un langage n'est pas - // saisi au clavier mais choisi dans une liste fermée, et `domain::language` - // le compare à l'identique. Une valeur inconnue ne correspondra à rien, ce - // qui est exactement le résultat attendu. + // Pas de normalisation, contrairement aux tags : un langage est choisi dans + // une liste fermée, et une valeur inconnue ne correspond à rien — c'est le + // résultat attendu. if !request.languages.is_empty() { - let placeholders: Vec = request - .languages - .iter() - .map(|language| { - params.push(Box::new(language.clone())); - format!("?{}", params.len()) - }) - .collect(); // Union, comme les tags : sélectionner JSON puis YAML montre les deux. - conditions.push(format!("language IN ({})", placeholders.join(", "))); + let bound = placeholders(&request.languages, &mut params); + conditions.push(format!("language IN ({bound})")); } - // Les tags reçus passent par la même normalisation que ceux écrits, sinon - // un `#urgent` saisi au clavier ne retrouverait pas le tag `urgent` stocké. - let selected_tags = tags::normalize(&request.tags); + // Même normalisation qu'à l'écriture, sinon un `#urgent` saisi au clavier ne + // retrouverait pas le tag `urgent` stocké. + let selected_tags = rules::normalize_tags(&request.tags); if !selected_tags.is_empty() { - let placeholders: Vec = selected_tags - .iter() - .map(|tag| { - params.push(Box::new(tag.clone())); - format!("?{}", params.len()) - }) - .collect(); - // « au moins un tag », pas « tous » : c'est le comportement d'un rail de facettes. + // « au moins un tag », pas « tous » : comportement d'un rail de facettes. + let bound = placeholders(&selected_tags, &mut params); conditions.push(format!( - "EXISTS (SELECT 1 FROM note_tags WHERE note_id = notes.id AND tag IN ({}))", - placeholders.join(", ") + "EXISTS (SELECT 1 FROM note_tags WHERE note_id = notes.id AND tag IN ({bound}))" )); } @@ -255,13 +237,10 @@ pub fn fetch( format!(" WHERE {}", conditions.join(" AND ")) }; - // Le tri est décidé ici, une fois : le front conserve l'ordre reçu. - // - // Le tri porte sur `updated_at` alors que les sections regroupent sur - // `created_at`, et c'est délibéré : la section répond à « quand cette note - // est-elle née », l'ordre interne à « laquelle ai-je touchée en dernier ». - // Une note ancienne rouverte aujourd'hui remonte donc en tête de « plus - // anciennes » — elle reste ancienne, on vient juste de s'en servir. + // Tri décidé ici une fois pour toutes ; le front conserve l'ordre reçu. + // Sur `updated_at` alors que les sections regroupent sur `created_at` : + // la section dit quand la note est née, l'ordre interne laquelle a été + // touchée en dernier. let mut statement = connection.prepare(&format!( "SELECT {NOTE_COLUMNS} FROM notes{where_clause} ORDER BY updated_at DESC, id" ))?; @@ -270,8 +249,7 @@ pub fn fetch( .query_map(bound.as_slice(), row_to_note)? .collect::>>()?; - // Les tags sont rattachés avant de rendre la main : la recherche du domaine - // porte aussi sur eux. + // Rattachés avant de rendre la main : la recherche du domaine porte dessus. let mut grouped = all_tags(connection)?; for note in &mut notes { note.tags = grouped.remove(¬e.id).unwrap_or_default(); @@ -296,9 +274,8 @@ fn find(connection: &Connection, id: &str) -> Result, StorageError> })) } -/// Crée une note et renvoie sa version persistée — identifiant définitif et -/// horodatages compris. Le store front remplace sa copie locale par cette -/// valeur : ce qui est renvoyé ici est ce que l'utilisateur voit. +/// Renvoie la version persistée — identifiant définitif et horodatages compris. +/// Le front adopte cette valeur telle quelle. pub fn create( connection: &mut Connection, draft: &NoteDraft, @@ -344,8 +321,7 @@ pub fn create( expires_at, ], )?; - // La note renvoyée doit refléter ce qui est réellement en base : les tags - // écrits sont normalisés, pas ceux du brouillon. + // Les tags écrits sont normalisés, pas ceux du brouillon. note.tags = replace_tags(&transaction, ¬e.id, &draft.tags)?; transaction.commit()?; @@ -353,15 +329,11 @@ pub fn create( Ok(note) } -/// Applique **uniquement** les champs renseignés du patch, rafraîchit -/// `updated_at` et renvoie la note mise à jour. +/// Applique **uniquement** les champs renseignés du patch et rafraîchit +/// `updated_at`. Un `None` signifie « ne pas toucher » — d'où le +/// lire-modifier-écrire, en transaction pour qu'aucune commande ne s'intercale. /// -/// Un `None` signifie « ne pas toucher » et ne doit jamais écraser la valeur -/// stockée : c'est ce que garantit le lire-modifier-écrire ci-dessous, exécuté -/// dans une transaction pour qu'une commande concurrente ne s'intercale pas. -/// -/// Identifiant inconnu ⇒ `Err`, jamais un `Ok` silencieux : sans ça le front -/// croirait avoir enregistré. +/// Identifiant inconnu ⇒ `Err` : le front croirait sinon avoir enregistré. pub fn update( connection: &mut Connection, id: &str, @@ -428,10 +400,8 @@ pub fn update( Ok(note) } -/// Supprime une note. Ses tags partent avec elle par cascade (d'où le -/// `PRAGMA foreign_keys = ON` de `storage::configure`). -/// -/// Identifiant inconnu ⇒ `Err` : sans ça le front croirait avoir supprimé. +/// Ses tags partent par cascade (d'où le `PRAGMA foreign_keys = ON` de +/// `storage::configure`). Identifiant inconnu ⇒ `Err`. pub fn delete(connection: &Connection, id: &str) -> Result<(), StorageError> { let deleted = connection.execute("DELETE FROM notes WHERE id = ?1", [id])?; @@ -445,8 +415,8 @@ pub fn delete(connection: &Connection, id: &str) -> Result<(), StorageError> { #[cfg(test)] mod tests { use super::*; - use crate::domain::query::NotesView; use crate::domain::view; + use crate::domain::view::NotesView; use crate::storage::open_in_memory; const T0: &str = "2026-07-25T09:00:00.000Z"; diff --git a/src-tauri/src/storage/spaces.rs b/src-tauri/src/storage/spaces.rs index 4866170..20084f8 100644 --- a/src-tauri/src/storage/spaces.rs +++ b/src-tauri/src/storage/spaces.rs @@ -16,11 +16,8 @@ fn row_to_space(row: &Row<'_>) -> rusqlite::Result { }) } -/// Tous les espaces, triés par nom. -/// -/// Une liste vide est une réponse valide : c'est l'état du premier lancement. -/// Aucun espace « Tous » n'est fabriqué ici — c'est un mode d'affichage du -/// front, pas une donnée ; en créer un ferait ranger des notes dedans. +/// Tous les espaces, triés par nom. Une liste vide est valide : c'est l'état du +/// premier lancement. Aucun espace « Tous » n'est fabriqué ici. pub fn list(connection: &Connection) -> Result, StorageError> { let mut statement = connection.prepare("SELECT id, name FROM spaces ORDER BY name COLLATE NOCASE")?; @@ -31,9 +28,8 @@ pub fn list(connection: &Connection) -> Result, StorageError> { Ok(spaces) } -/// Vérifie qu'un espace existe. Utilisé par `storage::notes` avant de ranger une -/// note : la contrainte de clé étrangère l'attraperait aussi, mais avec un -/// message SQLite illisible là où le front affiche l'erreur telle quelle. +/// Vérifié avant de ranger une note : la clé étrangère l'attraperait aussi, mais +/// avec un message SQLite illisible là où le front affiche l'erreur. pub fn exists(connection: &Connection, id: &str) -> Result { let count: i64 = connection.query_row("SELECT COUNT(*) FROM spaces WHERE id = ?1", [id], |row| { @@ -43,28 +39,48 @@ pub fn exists(connection: &Connection, id: &str) -> Result { Ok(count > 0) } -/// Crée un espace et renvoie sa version persistée : le front sélectionne -/// aussitôt l'espace à partir de cette valeur de retour. +/// Doublon détecté ici plutôt que laissé à l'index unique, pour remonter au +/// front un code qu'il sait traduire. /// -/// `name` est attendu **déjà validé** (voir `SpaceDraft::validated_name`) : -/// détouré et non vide. Cette couche ne tranche que l'unicité. +/// `except_id` exclut l'espace renommé : sans lui, corriger la casse d'un nom +/// (« perso » → « Perso ») se ferait refuser comme un doublon de lui-même, la +/// comparaison étant en `COLLATE NOCASE`. +fn ensure_unique_name( + connection: &Connection, + name: &str, + except_id: Option<&str>, +) -> Result<(), StorageError> { + let taken: i64 = match except_id { + Some(id) => connection.query_row( + "SELECT COUNT(*) FROM spaces WHERE name = ?1 COLLATE NOCASE AND id <> ?2", + (name, id), + |row| row.get(0), + ), + None => connection.query_row( + "SELECT COUNT(*) FROM spaces WHERE name = ?1 COLLATE NOCASE", + [name], + |row| row.get(0), + ), + }?; + + if taken > 0 { + return Err(StorageError::DuplicateSpaceName(name.to_string())); + } + + Ok(()) +} + +/// Renvoie la version persistée : le front sélectionne aussitôt l'espace à +/// partir de cette valeur. `name` est attendu **déjà validé** (détouré, non +/// vide) — cette couche ne tranche que l'unicité. pub fn create(connection: &Connection, name: &str) -> Result { + ensure_unique_name(connection, name, None)?; + let space = Space { id: Uuid::new_v4().to_string(), name: name.to_string(), }; - // Le doublon est détecté ici plutôt que laissé à l'index unique, pour - // remonter au front un message qu'il peut afficher tel quel. - let taken: i64 = connection.query_row( - "SELECT COUNT(*) FROM spaces WHERE name = ?1 COLLATE NOCASE", - [&space.name], - |row| row.get(0), - )?; - if taken > 0 { - return Err(StorageError::DuplicateSpaceName(space.name)); - } - connection.execute( "INSERT INTO spaces (id, name) VALUES (?1, ?2)", (&space.id, &space.name), @@ -73,25 +89,13 @@ pub fn create(connection: &Connection, name: &str) -> Result ?2`, corriger la casse -/// d'un nom (« perso » → « Perso ») se ferait refuser comme un doublon de -/// lui-même, la comparaison étant en `COLLATE NOCASE`. +/// Renomme et renvoie la version persistée. `name` est attendu **déjà validé**. pub fn rename(connection: &Connection, id: &str, name: &str) -> Result { if !exists(connection, id)? { return Err(StorageError::SpaceNotFound(id.to_string())); } - let taken: i64 = connection.query_row( - "SELECT COUNT(*) FROM spaces WHERE name = ?1 COLLATE NOCASE AND id <> ?2", - (name, id), - |row| row.get(0), - )?; - if taken > 0 { - return Err(StorageError::DuplicateSpaceName(name.to_string())); - } + ensure_unique_name(connection, name, Some(id))?; connection.execute("UPDATE spaces SET name = ?2 WHERE id = ?1", (id, name))?; @@ -103,16 +107,14 @@ pub fn rename(connection: &Connection, id: &str, name: &str) -> Result Result<(), StorageError> { let transaction = connection.transaction()?; diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 3106b3f..7a63406 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -10,12 +10,13 @@ import { provideRouter, withHashLocation } from '@angular/router'; import { provideTransloco } from '@jsverse/transloco'; import { routes } from './app.routes'; -import { provideDataAccess } from '@core/data/data.providers'; +import { NOTES_REPOSITORY, TauriNotesRepository } from '@features/notes/data/notes.repository'; +import { SPACES_REPOSITORY, TauriSpacesRepository } from '@features/notes/data/spaces.repository'; import { AppErrorHandler } from '@core/errors/app-error-handler'; import { APP_LOCALES, DEFAULT_LOCALE, LocaleService } from '@core/i18n/locale.service'; import { AppTranslocoLoader } from '@core/i18n/transloco-loader'; import { PreferencesService } from '@core/preferences/preferences.service'; -import { provideUpdateCheck } from '@core/updates/update.providers'; +import { UpdateStore } from '@core/updates/update.store'; export const appConfig: ApplicationConfig = { providers: [ @@ -23,8 +24,8 @@ export const appConfig: ApplicationConfig = { provideZonelessChangeDetection(), // Routage par fragment : les fichiers sont servis depuis le protocole - // interne de Tauri, où une URL profonde rechargée n'a pas de serveur pour - // la réécrire vers index.html. Le fragment évite entièrement le problème. + // interne de Tauri, où une URL profonde rechargée n'a pas de serveur pour la + // réécrire vers index.html. provideRouter(routes, withHashLocation()), provideTransloco({ @@ -36,9 +37,10 @@ export const appConfig: ApplicationConfig = { }, loader: AppTranslocoLoader, }), - // Un seul initialiseur pour les deux étapes, et non deux enchaînés : - // Angular lance les initialiseurs ensemble et n'attend que leurs promesses - // en bloc, donc `restore()` lirait un cache encore vide. + + // Un seul initialiseur pour les deux étapes, et non deux enchaînés : Angular + // les lance ensemble et n'attend leurs promesses qu'en bloc, donc + // `restore()` lirait un cache encore vide. provideAppInitializer(async () => { const preferences = inject(PreferencesService); const locale = inject(LocaleService); @@ -47,11 +49,17 @@ export const appConfig: ApplicationConfig = { locale.restore(); }), - // Liaison des dépôts vers le backend Rust : voir core/data/data.providers.ts. - ...provideDataAccess(), + // Recherche de mise à jour au lancement. La promesse n'est délibérément pas + // retournée : Angular attend celles d'un initialiseur, et l'application + // resterait sur un écran vide le temps d'un appel réseau — indéfiniment si + // l'endpoint ne répond pas. La pop-in apparaît quand la réponse arrive. + provideAppInitializer(() => { + void inject(UpdateStore).check(); + }), - // Recherche de mise à jour au lancement, sans bloquer le démarrage. - provideUpdateCheck(), + // Seule source de données de l'application : le backend Rust. + { provide: NOTES_REPOSITORY, useClass: TauriNotesRepository }, + { provide: SPACES_REPOSITORY, useClass: TauriSpacesRepository }, { provide: ErrorHandler, useClass: AppErrorHandler }, ], diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 92f67b1..0ef7385 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -8,8 +8,7 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ { path: 'notes', - loadComponent: () => - import('@features/notes/notes-page/notes-page.component').then((m) => m.NotesPageComponent), + loadComponent: () => import('@features/notes/notes-page.component').then((m) => m.NotesPageComponent), }, { path: '', pathMatch: 'full', redirectTo: 'notes' }, { path: '**', redirectTo: 'notes' }, diff --git a/src/app/core/app-info/app-info.service.ts b/src/app/core/app-info/app-info.service.ts index 8a7a319..0323561 100644 --- a/src/app/core/app-info/app-info.service.ts +++ b/src/app/core/app-info/app-info.service.ts @@ -1,23 +1,36 @@ import { Injectable, Signal, computed, resource } from '@angular/core'; import { getVersion } from '@tauri-apps/api/app'; import { openUrl } from '@tauri-apps/plugin-opener'; -import { REPOSITORY_URL } from './app-info'; /** - * Seam vers les API Tauri qui décrivent l'application elle-même. + * ⚠️ Doit rester couverte par la portée déclarée pour `opener:allow-open-url` + * dans `src-tauri/capabilities/default.json`, sinon l'ouverture est refusée à + * l'exécution. + */ +export const REPOSITORY_URL = 'https://github.com/vmillet-dev/devbox-rs'; + +export const AUTHOR_NAME = 'Valentin MILLET'; +export const AUTHOR_HANDLE = '@vmillet-dev'; + +/** Nom affiché dans la barre de titre. */ +export const APP_NAME = 'DevBox'; + +/** + * Seam vers les API Tauri décrivant l'application. Même raison d'être que + * `UpdaterService` : ce sont des commandes du cœur et d'un plugin, absentes + * d'`IpcContract`, et un composant qui les importerait deviendrait intestable — + * jsdom n'a pas de pont Tauri. * - * Même raison d'être que `UpdaterService` : ce sont des commandes du cœur et - * d'un plugin, absentes d'`IpcContract`, et un composant qui les importerait - * directement deviendrait intestable — jsdom n'a pas de pont Tauri. + * Pas de description ici : c'est du texte visible, donc une clé de traduction. */ @Injectable({ providedIn: 'root' }) export class AppInfoService { private readonly versionResource = resource({ loader: () => getVersion() }); /** - * Version déclarée par `tauri.conf.json`, source de vérité que le job CI - * `check-version` maintient alignée sur le tag. `null` hors runtime Tauri - * (`ng serve` seul) : la fiche affiche alors un tiret plutôt que de mentir. + * Version de `tauri.conf.json`, que le job CI `check-version` maintient + * alignée sur le tag. `null` hors runtime Tauri (`ng serve` seul) : la fiche + * affiche alors un tiret plutôt que de mentir. */ readonly version: Signal = computed(() => this.versionResource.hasValue() ? this.versionResource.value() : null, diff --git a/src/app/core/app-info/app-info.ts b/src/app/core/app-info/app-info.ts deleted file mode 100644 index 14b13a5..0000000 --- a/src/app/core/app-info/app-info.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Identité du projet, telle qu'affichée dans la fiche « À propos ». - * - * Pas de description ici : c'est du texte visible par l'utilisateur, donc une - * clé de traduction (`about.description`), pas une constante figée en français. - * - * ⚠️ `REPOSITORY_URL` doit rester couverte par la portée déclarée pour - * `opener:allow-open-url` dans `src-tauri/capabilities/default.json`, sinon - * l'ouverture est refusée à l'exécution. - */ -export const REPOSITORY_URL = 'https://github.com/vmillet-dev/devbox-rs'; - -export const AUTHOR_NAME = 'Valentin MILLET'; - -export const AUTHOR_HANDLE = '@vmillet-dev'; diff --git a/src/app/core/data/data.providers.ts b/src/app/core/data/data.providers.ts deleted file mode 100644 index dae2714..0000000 --- a/src/app/core/data/data.providers.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Provider } from '@angular/core'; -import { NOTES_REPOSITORY } from './notes-repository.token'; -import { SPACES_REPOSITORY } from './spaces-repository.token'; -import { TauriNotesRepository } from './tauri-notes-repository'; -import { TauriSpacesRepository } from './tauri-spaces-repository'; - -/** - * Source de données de l'application : le backend Rust, via les dépôts `Tauri*`. - * Il n'y a plus de jeu de données en mémoire — les seuls doubles restants sont - * ceux des tests (`src/testing/`), injectés par `provideAppTesting`. - * - * Les commandes sont énumérées par `core/ipc/ipc-contract.ts` — qui type aussi - * leurs arguments — et enregistrées dans `src-tauri/src/lib.rs`. Le contrat de - * sérialisation est figé par les tests de `src-tauri/src/domain/note.rs` et - * décrit côté front dans `note.dto.ts` / `note-view.dto.ts`. - */ -export function provideDataAccess(): Provider[] { - return [ - { provide: NOTES_REPOSITORY, useClass: TauriNotesRepository }, - { provide: SPACES_REPOSITORY, useClass: TauriSpacesRepository }, - ]; -} diff --git a/src/app/core/data/note-view.dto.ts b/src/app/core/data/note-view.dto.ts deleted file mode 100644 index 8e8a01c..0000000 --- a/src/app/core/data/note-view.dto.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { LanguageTag, isLanguageTag } from '../models/language.model'; -import { NoteSection, NoteSectionKey } from '../models/note-section.model'; -import { NoteFilter, NotesQuery, NotesView } from '../models/notes-query.model'; -import { NoteDto, toIsoString, toNote } from './note.dto'; - -/** - * Représentation de la vue sur le pont Tauri. Même contrainte que `note.dto.ts` : - * JSON n'a pas de type date, donc `now` part en chaîne ISO. - * - * Côté Rust, `NotesQuery` / `NotesView` portent `#[serde(rename_all = "camelCase")]` - * (voir `src-tauri/src/domain/query.rs`) — sans quoi le front recevrait - * `available_tags` là où il lit `availableTags`. - */ -export interface NotesQueryDto { - readonly spaceId: string | null; - readonly search: string; - readonly filter: NoteFilter; - readonly tags: readonly string[]; - readonly languages: readonly string[]; - readonly now: string; - readonly tzOffsetMinutes: number; -} - -export interface NotesViewDto { - readonly sections: readonly NoteSectionDto[]; - readonly availableTags: readonly string[]; - readonly availableLanguages: readonly string[]; - readonly isFiltering: boolean; - readonly matched: number; -} - -export interface NoteSectionDto { - readonly key: string; - readonly notes: readonly NoteDto[]; - readonly hasExpiringNotes: boolean; - readonly showCreateGhost: boolean; -} - -const SECTION_KEYS: readonly NoteSectionKey[] = ['pinned', 'today', 'week', 'older', 'results']; - -function isSectionKey(value: string): value is NoteSectionKey { - return (SECTION_KEYS as readonly string[]).includes(value); -} - -/** Rupture de contrat entre la vue reçue et ce que le front sait afficher. */ -export class NotesViewContractError extends Error { - constructor(key: string) { - super(`Vue reçue invalide : section « ${key} » inconnue`); - this.name = 'NotesViewContractError'; - } -} - -export function toNotesQueryDto(query: NotesQuery): NotesQueryDto { - return { - spaceId: query.spaceId, - search: query.search, - filter: query.filter, - tags: [...query.tags], - languages: [...query.languages], - now: toIsoString(query.now, 'now'), - tzOffsetMinutes: query.tzOffsetMinutes, - }; -} - -function toSection(dto: NoteSectionDto): NoteSection { - // Une clé inconnue n'a pas de traduction : elle produirait une section au - // titre vide plutôt qu'une erreur visible. Mieux vaut échouer franchement. - if (!isSectionKey(dto.key)) { - throw new NotesViewContractError(dto.key); - } - - return { - key: dto.key, - notes: dto.notes.map(toNote), - hasExpiringNotes: dto.hasExpiringNotes, - showCreateGhost: dto.showCreateGhost, - }; -} - -export function toNotesView(dto: NotesViewDto): NotesView { - return { - sections: dto.sections.map(toSection), - availableTags: [...dto.availableTags], - // Une facette inconnue est écartée, là où une section inconnue fait échouer : - // un rail auquel il manque un choix reste utilisable, un canevas dont une - // section est illisible ne l'est pas. Même tolérance que `toNote`, qui replie - // le langage d'une note sur `txt`. - availableLanguages: dto.availableLanguages.filter((language): language is LanguageTag => - isLanguageTag(language), - ), - isFiltering: dto.isFiltering, - matched: dto.matched, - }; -} diff --git a/src/app/core/data/note.dto.ts b/src/app/core/data/note.dto.ts deleted file mode 100644 index e86b9e0..0000000 --- a/src/app/core/data/note.dto.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { FALLBACK_LANGUAGE, isLanguageTag } from '../models/language.model'; -import { Note, NoteDraft, NoteFooter, NoteLifecycle, NotePatch } from '../models/note.model'; - -/** - * Représentation transportée sur le pont Tauri. Elle diffère du modèle métier - * sur un point décisif : **JSON n'a pas de type date**, donc tout ce qui est - * `Date` côté Angular arrive et repart en chaîne ISO 8601. - * - * Côté Rust, la struct correspondante doit porter - * `#[serde(rename_all = "camelCase")]` (sinon on reçoit `created_at`) et - * l'enum de cycle de vie `#[serde(tag = "kind", rename_all = "camelCase")]` - * (sinon serde produit `{"Expires":{…}}`, que le discriminant TS ne reconnaît pas). - * Voir `src-tauri/src/domain/note.rs`, où ces attributs sont figés par des tests. - * - * `footer` et `expiringSoon` viennent de `domain::display` et sont aplatis dans - * le même objet (`#[serde(flatten)]`) : le front n'a qu'un type de note. - */ -export interface NoteDto { - readonly id: string; - readonly spaceId: string; - readonly title: string; - readonly language: string; - readonly content: string; - readonly source: string; - readonly tags: readonly string[]; - readonly pinned: boolean; - readonly createdAt: string; - readonly updatedAt: string; - readonly lifecycle: NoteLifecycleDto; - readonly footer: NoteFooterDto; - readonly expiringSoon: boolean; -} - -export type NoteLifecycleDto = - { readonly kind: 'permanent' } | { readonly kind: 'expires'; readonly at: string }; - -export type NoteFooterDto = - | { readonly kind: 'source'; readonly value: string } - | { readonly kind: 'expiry'; readonly at: string } - | { readonly kind: 'age'; readonly at: string }; - -export type NoteDraftDto = Omit; -export type NotePatchDto = Partial; - -/** Rupture de contrat entre un DTO et ce que le front sait interpréter. */ -export class NoteContractError extends Error { - constructor(field: string, value: unknown) { - super(`Contrat de note rompu : champ « ${field} » inexploitable (${JSON.stringify(value)})`); - this.name = 'NoteContractError'; - } -} - -function parseIsoDate(value: string, field: string): Date { - const date = new Date(value); - // Échouer bruyamment ici plutôt que de laisser une `Invalid Date` se propager - // et ressortir en `NaN` dans les libellés de temps relatif. - if (Number.isNaN(date.getTime())) { - throw new NoteContractError(field, value); - } - return date; -} - -/** - * Même exigence dans l'autre sens : `toISOString()` lève un `RangeError` nu sur - * une `Invalid Date`, sans dire quel champ est en cause. Le chemin d'écriture - * mérite le même diagnostic que le chemin de lecture. - */ -export function toIsoString(date: Date, field: string): string { - if (Number.isNaN(date.getTime())) { - throw new NoteContractError(field, date); - } - return date.toISOString(); -} - -function toLifecycle(dto: NoteLifecycleDto): NoteLifecycle { - return dto.kind === 'expires' - ? { kind: 'expires', at: parseIsoDate(dto.at, 'lifecycle.at') } - : { kind: 'permanent' }; -} - -function toLifecycleDto(lifecycle: NoteLifecycle): NoteLifecycleDto { - return lifecycle.kind === 'expires' - ? { kind: 'expires', at: toIsoString(lifecycle.at, 'lifecycle.at') } - : { kind: 'permanent' }; -} - -function toFooter(dto: NoteFooterDto): NoteFooter { - if (dto.kind === 'source') return { kind: 'source', value: dto.value }; - if (dto.kind === 'expiry') return { kind: 'expiry', at: parseIsoDate(dto.at, 'footer.at') }; - if (dto.kind === 'age') return { kind: 'age', at: parseIsoDate(dto.at, 'footer.at') }; - // Une variante ajoutée côté Rust sans être répercutée ici : mieux vaut le dire - // que rendre un pied de carte vide. - throw new NoteContractError('footer.kind', (dto satisfies never as { kind: string }).kind); -} - -export function toNote(dto: NoteDto): Note { - return { - id: dto.id, - spaceId: dto.spaceId, - title: dto.title, - // Un langage inconnu dégrade l'affichage, il ne casse pas le chargement. - language: isLanguageTag(dto.language) ? dto.language : FALLBACK_LANGUAGE, - content: dto.content, - source: dto.source, - tags: [...dto.tags], - pinned: dto.pinned, - createdAt: parseIsoDate(dto.createdAt, 'createdAt'), - updatedAt: parseIsoDate(dto.updatedAt, 'updatedAt'), - lifecycle: toLifecycle(dto.lifecycle), - footer: toFooter(dto.footer), - expiringSoon: dto.expiringSoon, - }; -} - -export function toNoteDraftDto(draft: NoteDraft): NoteDraftDto { - return { - spaceId: draft.spaceId, - title: draft.title, - language: draft.language, - content: draft.content, - source: draft.source, - tags: [...draft.tags], - pinned: draft.pinned, - lifecycle: toLifecycleDto(draft.lifecycle), - }; -} - -export function toNotePatchDto(patch: NotePatch): NotePatchDto { - const dto: Record = {}; - // Recopie champ par champ : un `undefined` sérialisé deviendrait `null` côté - // serde et écraserait la valeur existante au lieu de la laisser intacte. - if (patch.spaceId !== undefined) dto['spaceId'] = patch.spaceId; - if (patch.title !== undefined) dto['title'] = patch.title; - if (patch.language !== undefined) dto['language'] = patch.language; - if (patch.content !== undefined) dto['content'] = patch.content; - if (patch.source !== undefined) dto['source'] = patch.source; - if (patch.tags !== undefined) dto['tags'] = [...patch.tags]; - if (patch.pinned !== undefined) dto['pinned'] = patch.pinned; - if (patch.lifecycle !== undefined) dto['lifecycle'] = toLifecycleDto(patch.lifecycle); - return dto as NotePatchDto; -} diff --git a/src/app/core/data/notes-repository.token.ts b/src/app/core/data/notes-repository.token.ts deleted file mode 100644 index e4d699f..0000000 --- a/src/app/core/data/notes-repository.token.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { InjectionToken } from '@angular/core'; -import { Note, NoteDraft, NotePatch } from '../models/note.model'; -import { NotesQuery, NotesView } from '../models/notes-query.model'; - -/** - * Point d'accès aux notes, indépendant de la source de données. - * - * Conventions imposées aux implémentations : - * - `query` renvoie une **vue déjà filtrée et regroupée** : il n'existe pas de - * méthode rendant la liste brute, précisément pour qu'aucun appelant ne soit - * tenté de refiltrer. - * - `create` et `update` **renvoient la note telle que persistée** : c'est la - * couche de persistance qui attribue l'`id` et les horodatages, jamais le front. - * - toute erreur est propagée en rejet ; le store notifie et recharge. - * - * Implémentations : `TauriNotesRepository` (le backend Rust, seule source de - * données de l'application — voir `data.providers.ts`) et `FakeNotesRepository` - * (`src/testing/`, réservée aux specs). - */ -export interface NotesRepository { - query(query: NotesQuery): Promise; - create(draft: NoteDraft): Promise; - update(id: string, patch: NotePatch): Promise; - delete(id: string): Promise; -} - -export const NOTES_REPOSITORY = new InjectionToken('NOTES_REPOSITORY'); diff --git a/src/app/core/data/space.dto.ts b/src/app/core/data/space.dto.ts deleted file mode 100644 index 6355444..0000000 --- a/src/app/core/data/space.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Space, SpaceDraft } from '../models/space.model'; - -/** - * Un espace n'a aucun champ nécessitant une conversion (pas de date), mais il - * garde son DTO pour que la frontière IPC reste explicite et symétrique avec - * les notes : le jour où un champ typé s'y ajoute, le point de traduction existe déjà. - */ -export interface SpaceDto { - readonly id: string; - readonly name: string; -} - -export type SpaceDraftDto = Omit; - -export function toSpace(dto: SpaceDto): Space { - return { id: dto.id, name: dto.name }; -} - -export function toSpaceDraftDto(draft: SpaceDraft): SpaceDraftDto { - return { name: draft.name }; -} diff --git a/src/app/core/data/spaces-repository.token.ts b/src/app/core/data/spaces-repository.token.ts deleted file mode 100644 index 31d2c7a..0000000 --- a/src/app/core/data/spaces-repository.token.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { InjectionToken } from '@angular/core'; -import { Space, SpaceDraft } from '../models/space.model'; - -/** - * Point d'accès aux espaces, sur le même modèle que `NOTES_REPOSITORY` : aucun - * composant ne doit importer un jeu de données directement, sans quoi le - * passage au backend Rust obligerait à retoucher les composants. - * - * Mêmes conventions que pour les notes : `create` **renvoie l'espace tel que - * persisté** (c'est la persistance qui attribue l'`id`), et toute erreur est - * propagée en rejet. - * - * `delete` prend un espace **refuge** et non un simple identifiant : le schéma - * SQLite emporte les notes d'un espace supprimé (`ON DELETE CASCADE`), donc une - * signature à un seul argument aurait fait de la perte de données le - * comportement par défaut. Le transfert et la suppression sont atomiques côté - * Rust ; il n'existe pas de variante sans refuge. - */ -export interface SpacesRepository { - loadAll(): Promise; - create(draft: SpaceDraft): Promise; - /** Renvoie l'espace renommé tel que persisté. */ - rename(id: string, draft: SpaceDraft): Promise; - /** `targetSpaceId` recueille les notes de l'espace supprimé. */ - delete(id: string, targetSpaceId: string): Promise; -} - -export const SPACES_REPOSITORY = new InjectionToken('SPACES_REPOSITORY'); diff --git a/src/app/core/data/tauri-notes-repository.ts b/src/app/core/data/tauri-notes-repository.ts deleted file mode 100644 index ff603eb..0000000 --- a/src/app/core/data/tauri-notes-repository.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { IpcService } from '../ipc/ipc.service'; -import { Note, NoteDraft, NotePatch } from '../models/note.model'; -import { NotesQuery, NotesView } from '../models/notes-query.model'; -import { toNotesQueryDto, toNotesView } from './note-view.dto'; -import { toNote, toNoteDraftDto, toNotePatchDto } from './note.dto'; -import { NotesRepository } from './notes-repository.token'; - -/** - * Dépôt de notes adossé aux commandes Rust — c'est la source de données active - * de l'application (voir `data.providers.ts`). - * - * Les noms d'arguments et les types de retour ne sont plus déclarés ici : ils - * viennent de `IpcContract`, qui les tient alignés sur les signatures Rust. - */ -@Injectable() -export class TauriNotesRepository implements NotesRepository { - private readonly ipc = inject(IpcService); - - async query(query: NotesQuery): Promise { - return toNotesView(await this.ipc.invoke('query_notes', { query: toNotesQueryDto(query) })); - } - - async create(draft: NoteDraft): Promise { - return toNote(await this.ipc.invoke('create_note', { draft: toNoteDraftDto(draft) })); - } - - async update(id: string, patch: NotePatch): Promise { - return toNote(await this.ipc.invoke('update_note', { id, patch: toNotePatchDto(patch) })); - } - - async delete(id: string): Promise { - await this.ipc.invoke('delete_note', { id }); - } -} diff --git a/src/app/core/data/tauri-spaces-repository.ts b/src/app/core/data/tauri-spaces-repository.ts deleted file mode 100644 index 855ca8d..0000000 --- a/src/app/core/data/tauri-spaces-repository.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { IpcService } from '../ipc/ipc.service'; -import { Space, SpaceDraft } from '../models/space.model'; -import { toSpace, toSpaceDraftDto } from './space.dto'; -import { SpacesRepository } from './spaces-repository.token'; - -/** - * Dépôt d'espaces adossé aux commandes Rust `list_spaces` / `create_space`. - * Même statut que `TauriNotesRepository` : c'est la source de données active de - * l'application (voir `data.providers.ts`). - */ -@Injectable() -export class TauriSpacesRepository implements SpacesRepository { - private readonly ipc = inject(IpcService); - - async loadAll(): Promise { - return (await this.ipc.invoke('list_spaces')).map(toSpace); - } - - async create(draft: SpaceDraft): Promise { - return toSpace(await this.ipc.invoke('create_space', { draft: toSpaceDraftDto(draft) })); - } - - async rename(id: string, draft: SpaceDraft): Promise { - return toSpace(await this.ipc.invoke('rename_space', { id, draft: toSpaceDraftDto(draft) })); - } - - async delete(id: string, targetSpaceId: string): Promise { - await this.ipc.invoke('delete_space', { id, targetSpaceId }); - } -} diff --git a/src/app/core/errors/app-error-handler.spec.ts b/src/app/core/errors/app-error-handler.spec.ts index e3c9b28..7356dc6 100644 --- a/src/app/core/errors/app-error-handler.spec.ts +++ b/src/app/core/errors/app-error-handler.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { IpcError } from '@core/ipc/ipc.service'; +import { IpcError } from '@core/ipc/ipc.error'; import { AppErrorHandler } from './app-error-handler'; import { ErrorNotifier } from './error-notifier.service'; diff --git a/src/app/core/errors/app-error-handler.ts b/src/app/core/errors/app-error-handler.ts index 8bb7130..07b8488 100644 --- a/src/app/core/errors/app-error-handler.ts +++ b/src/app/core/errors/app-error-handler.ts @@ -1,14 +1,11 @@ import { ErrorHandler, Injectable, inject } from '@angular/core'; -import { IpcError } from '../ipc/ipc.service'; -import { ErrorNotifier } from './error-notifier.service'; +import { IpcError } from '../ipc/ipc.error'; +import { ErrorNotifier, errorDetail } from './error-notifier.service'; /** - * Dernier filet de sécurité : toute exception non rattrapée est journalisée - * *et* portée à l'écran via `ErrorNotifier`. - * - * Les échecs d'IPC reçoivent leur propre message : c'est le mode de panne - * attendu en production (backend indisponible, commande non enregistrée), et - * il mérite mieux qu'un « une erreur est survenue » générique. + * Dernier filet : toute exception non rattrapée est journalisée **et** portée à + * l'écran. Les échecs d'IPC reçoivent leur propre message — c'est le mode de + * panne attendu en production, il mérite mieux qu'un « erreur est survenue ». */ @Injectable() export class AppErrorHandler implements ErrorHandler { @@ -25,9 +22,6 @@ export class AppErrorHandler implements ErrorHandler { return; } - this.notifier.notify({ - ref: { key: 'errors.unexpected' }, - detail: error instanceof Error ? error.message : String(error), - }); + this.notifier.notify({ ref: { key: 'errors.unexpected' }, detail: errorDetail(error) }); } } diff --git a/src/app/core/errors/ipc-notice.spec.ts b/src/app/core/errors/error-notifier.service.spec.ts similarity index 96% rename from src/app/core/errors/ipc-notice.spec.ts rename to src/app/core/errors/error-notifier.service.spec.ts index dba4844..4ab72be 100644 --- a/src/app/core/errors/ipc-notice.spec.ts +++ b/src/app/core/errors/error-notifier.service.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { IpcError } from '@core/ipc/ipc.service'; -import { ipcNotice } from './ipc-notice'; +import { IpcError } from '@core/ipc/ipc.error'; +import { ipcNotice } from './error-notifier.service'; const FALLBACK = { key: 'errors.noteSaveFailed' }; diff --git a/src/app/core/errors/error-notifier.service.ts b/src/app/core/errors/error-notifier.service.ts index 53391e9..7f20160 100644 --- a/src/app/core/errors/error-notifier.service.ts +++ b/src/app/core/errors/error-notifier.service.ts @@ -1,20 +1,67 @@ import { Injectable, Signal, signal } from '@angular/core'; -import { TranslationRef } from '../models/translation-ref.model'; +import { TranslationRef } from '../i18n/translation-ref.model'; +import { IpcError, IpcErrorCode } from '../ipc/ipc.error'; /** Message d'erreur destiné à l'utilisateur, exprimé en clé de traduction. */ export interface AppNotice { readonly ref: TranslationRef; - /** Détail technique brut (message d'erreur), affiché en second plan. */ + /** Détail technique brut, affiché en second plan. */ readonly detail?: string; } +/** Message lisible d'une valeur levée, qui n'est pas toujours une `Error`. */ +export function errorDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Message propre à chaque cause que le back sait nommer. + * + * `Record` et non `Partial` : ajouter une variante casse la + * compilation ici tant que sa clé n'est pas décidée — c'est ce qui rend le + * miroir Rust ↔ front vérifiable par le compilateur. + * + * `null` = rien de plus utile à dire que le message d'action de l'appelant. Une + * panne SQLite générique est dans ce cas. + */ +const CODE_KEYS: Record = { + noteNotFound: 'errors.noteGone', + spaceNotFound: 'errors.spaceGone', + duplicateSpaceName: 'errors.spaceNameTaken', + invalidInput: 'errors.invalidInput', + storageUnavailable: 'errors.storageUnavailable', + storage: null, +}; + +/** + * Traduit un échec en message affichable. `fallback` porte l'action tentée, + * employée quand la cause n'apprend rien de plus — ou quand Tauri a rejeté + * lui-même, auquel cas il n'y a pas de code. Les paramètres du back priment sur + * ceux de l'appelant. + */ +export function ipcNotice( + error: unknown, + fallback: TranslationRef, + params: Record = {}, +): AppNotice { + const detail = errorDetail(error); + + if (error instanceof IpcError && error.code !== null) { + const key = CODE_KEYS[error.code]; + if (key) { + return { ref: { key, params: { ...params, ...error.params } }, detail }; + } + } + + return { ref: fallback, detail }; +} + /** * Canal de remontée des erreurs vers l'interface. * - * Sur une app de bureau, l'utilisateur n'ouvre pas la console : une écriture - * qui échoue ou une exception non rattrapée doit être visible à l'écran, sans - * quoi l'app paraît simplement « ne rien faire ». Une seule erreur est - * conservée — la plus récente — pour éviter d'empiler des bannières. + * Sur une app de bureau l'utilisateur n'ouvre pas la console : une écriture qui + * échoue doit être visible à l'écran, sans quoi l'app paraît « ne rien faire ». + * Une seule erreur est conservée, pour ne pas empiler les bannières. */ @Injectable({ providedIn: 'root' }) export class ErrorNotifier { @@ -29,4 +76,14 @@ export class ErrorNotifier { dismiss(): void { this._notice.set(null); } + + /** + * Échec d'une action : `key` décrit ce qui était tenté, mais si le back a + * nommé la cause elle prime — « cette note n'existe plus » est plus utile que + * « impossible d'enregistrer ». + */ + reportFailure(key: string, error: unknown, params?: Record): void { + console.error(error); + this.notify(ipcNotice(error, { key }, params)); + } } diff --git a/src/app/core/errors/ipc-notice.ts b/src/app/core/errors/ipc-notice.ts deleted file mode 100644 index 5e86a8d..0000000 --- a/src/app/core/errors/ipc-notice.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { IpcError, IpcErrorCode } from '../ipc/ipc.service'; -import { TranslationRef } from '../models/translation-ref.model'; -import { AppNotice } from './error-notifier.service'; - -/** - * Message propre à chaque cause que le back sait nommer. - * - * `Record` et non `Partial` : ajouter une variante à - * `IpcErrorCode` casse la compilation ici tant que sa clé n'a pas été décidée. - * C'est ce qui rend le miroir Rust ↔ front vérifiable par le compilateur plutôt - * que par la relecture. - * - * `null` = rien de plus utile à dire que le message d'action de l'appelant - * (« Impossible d'enregistrer la note »). Une panne SQLite générique est dans ce - * cas : nommer la cause n'aiderait pas l'utilisateur à agir. - */ -const CODE_KEYS: Record = { - noteNotFound: 'errors.noteGone', - spaceNotFound: 'errors.spaceGone', - duplicateSpaceName: 'errors.spaceNameTaken', - invalidInput: 'errors.invalidInput', - storageUnavailable: 'errors.storageUnavailable', - storage: null, -}; - -/** - * Traduit un échec d'écriture en message affichable. - * - * `fallback` porte l'action tentée, employée quand la cause n'apprend rien de - * plus — ou quand Tauri a rejeté lui-même, auquel cas il n'y a pas de code. - * `params` fournit les valeurs d'interpolation dont l'appelant dispose déjà, - * celles du back prenant le dessus. - */ -export function ipcNotice( - error: unknown, - fallback: TranslationRef, - params: Record = {}, -): AppNotice { - const detail = error instanceof Error ? error.message : String(error); - - if (error instanceof IpcError && error.code !== null) { - const key = CODE_KEYS[error.code]; - if (key) { - return { ref: { key, params: { ...params, ...error.params } }, detail }; - } - } - - return { ref: fallback, detail }; -} diff --git a/src/app/core/i18n/translation-ref.model.ts b/src/app/core/i18n/translation-ref.model.ts new file mode 100644 index 0000000..9500a2e --- /dev/null +++ b/src/app/core/i18n/translation-ref.model.ts @@ -0,0 +1,12 @@ +/** + * Référence vers une clé Transloco, consommée par le pipe `transloco` dans le + * template. + * + * Règle de la maison : le code qui produit du texte destiné à l'utilisateur + * renvoie une référence, jamais une chaîne formatée — la traduction a donc + * toujours lieu dans la langue active. + */ +export interface TranslationRef { + readonly key: string; + readonly params?: Record; +} diff --git a/src/app/core/ipc/ipc-contract.ts b/src/app/core/ipc/ipc-contract.ts deleted file mode 100644 index fc82d48..0000000 --- a/src/app/core/ipc/ipc-contract.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { NotesQueryDto, NotesViewDto } from '../data/note-view.dto'; -import type { NoteDraftDto, NoteDto, NotePatchDto } from '../data/note.dto'; -import type { SpaceDraftDto, SpaceDto } from '../data/space.dto'; - -/** - * Signature de chaque commande Tauri : nom, arguments, valeur de retour. - * - * # Pourquoi cette table existe - * - * Tauri apparie les arguments **par nom**, pas par position. Une clé mal - * orthographiée compile sans broncher et n'échoue qu'à l'exécution, sous la - * forme d'un rejet de désérialisation serde — donc un `IpcError` sans code, le - * cas le plus opaque à diagnostiquer. Cette table transforme cette faute en - * erreur de build. - * - * Les clés doivent donc correspondre **exactement** aux paramètres des - * `#[tauri::command]` de `src-tauri/src/commands/`. ⚠️ Tauri v2 applique - * `rename_all = "camelCase"` aux arguments : un paramètre Rust `note_id` - * s'écrirait `noteId` ici. Aucun paramètre n'est composé aujourd'hui, mais la - * règle mord au premier qui le sera. - * - * Les imports sont volontairement `import type` : ils s'effacent à la - * compilation, donc ce module ne crée aucun cycle avec `core/data`. - */ -export interface IpcContract { - readonly query_notes: { args: { query: NotesQueryDto }; result: NotesViewDto }; - readonly create_note: { args: { draft: NoteDraftDto }; result: NoteDto }; - readonly update_note: { args: { id: string; patch: NotePatchDto }; result: NoteDto }; - readonly delete_note: { args: { id: string }; result: void }; - /** Pas d'arguments : `db: State` est injecté par Tauri, pas fourni par le front. */ - readonly list_spaces: { args: undefined; result: readonly SpaceDto[] }; - readonly create_space: { args: { draft: SpaceDraftDto }; result: SpaceDto }; - readonly rename_space: { args: { id: string; draft: SpaceDraftDto }; result: SpaceDto }; - /** - * ⚠️ Premier paramètre composé du contrat : le Rust déclare `target_space_id`, - * Tauri v2 l'expose en `targetSpaceId`. L'écrire en snake_case ici compilerait - * et échouerait à l'exécution sur un rejet serde sans code. - */ - readonly delete_space: { args: { id: string; targetSpaceId: string }; result: void }; -} - -export type IpcCommand = keyof IpcContract; - -export type IpcArgs = IpcContract[C]['args']; -export type IpcResult = IpcContract[C]['result']; - -/** - * Argument optionnel exactement pour les commandes qui n'en prennent pas, et - * obligatoire pour les autres — au lieu d'un `args?` uniformément facultatif - * qui laisserait passer un appel dépourvu de sa charge utile. - */ -export type IpcInvocation = IpcArgs extends undefined ? [] : [IpcArgs]; diff --git a/src/app/core/ipc/ipc-error.ts b/src/app/core/ipc/ipc.error.ts similarity index 98% rename from src/app/core/ipc/ipc-error.ts rename to src/app/core/ipc/ipc.error.ts index 862d629..ea786c2 100644 --- a/src/app/core/ipc/ipc-error.ts +++ b/src/app/core/ipc/ipc.error.ts @@ -1,4 +1,4 @@ -import { IpcCommand } from './ipc-contract'; +import { IpcCommand } from './ipc.service'; /** * Causes d'échec que le backend sait nommer, en miroir de `ErrorCode` diff --git a/src/app/core/ipc/ipc.service.spec.ts b/src/app/core/ipc/ipc.service.spec.ts index fb36776..f70764a 100644 --- a/src/app/core/ipc/ipc.service.spec.ts +++ b/src/app/core/ipc/ipc.service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { IpcError } from './ipc.service'; +import { IpcError } from './ipc.error'; describe('IpcError', () => { it('keeps the failing command and the raw cause', () => { diff --git a/src/app/core/ipc/ipc.service.ts b/src/app/core/ipc/ipc.service.ts index d14e4aa..9743b7c 100644 --- a/src/app/core/ipc/ipc.service.ts +++ b/src/app/core/ipc/ipc.service.ts @@ -1,20 +1,55 @@ import { Injectable } from '@angular/core'; import { InvokeArgs, invoke } from '@tauri-apps/api/core'; -import { IpcCommand, IpcInvocation, IpcResult } from './ipc-contract'; -import { IpcError } from './ipc-error'; - -export { IpcError } from './ipc-error'; -export type { IpcErrorCode } from './ipc-error'; -export type { IpcCommand } from './ipc-contract'; +import type { + NoteDraftDto, + NoteDto, + NotePatchDto, + NotesQueryDto, + NotesViewDto, +} from '@features/notes/data/note.dto'; +import type { SpaceDraftDto, SpaceDto } from '@features/notes/data/space.dto'; +import { IpcError } from './ipc.error'; /** - * Unique point de passage vers le backend Rust. Aucun composant ni store - * n'appelle `invoke()` directement : ça centralise la normalisation des erreurs - * et ça rend les dépôts testables en se contentant de doubler ce service. + * Signature de chaque commande Tauri : nom, arguments, valeur de retour. + * + * Tauri apparie les arguments **par nom**. Une clé mal orthographiée compilerait + * sans broncher et n'échouerait qu'à l'exécution, sur un rejet serde — donc un + * `IpcError` sans code, le cas le plus opaque à diagnostiquer. Cette table en + * fait une erreur de build. * - * Le nom de commande, la forme de ses arguments et son type de retour viennent - * tous de `IpcContract` : une clé d'argument mal orthographiée ne compile plus, - * alors qu'elle ne se voyait auparavant qu'à l'exécution. + * ⚠️ Tauri v2 applique `rename_all = "camelCase"` aux arguments : un paramètre + * Rust `target_space_id` s'écrit `targetSpaceId` ici. Les imports sont + * `import type` pour ne créer aucun cycle avec `core/data`. + */ +export interface IpcContract { + readonly query_notes: { args: { query: NotesQueryDto }; result: NotesViewDto }; + readonly create_note: { args: { draft: NoteDraftDto }; result: NoteDto }; + readonly update_note: { args: { id: string; patch: NotePatchDto }; result: NoteDto }; + readonly delete_note: { args: { id: string }; result: void }; + /** `db: State` est injecté par Tauri, pas fourni par le front. */ + readonly list_spaces: { args: undefined; result: readonly SpaceDto[] }; + readonly create_space: { args: { draft: SpaceDraftDto }; result: SpaceDto }; + readonly rename_space: { args: { id: string; draft: SpaceDraftDto }; result: SpaceDto }; + readonly delete_space: { args: { id: string; targetSpaceId: string }; result: void }; +} + +export type IpcCommand = keyof IpcContract; +export type IpcResult = IpcContract[C]['result']; + +/** + * Argument optionnel exactement pour les commandes qui n'en prennent pas, au + * lieu d'un `args?` uniformément facultatif qui laisserait passer un appel + * dépourvu de sa charge utile. + */ +export type IpcInvocation = IpcContract[C]['args'] extends undefined + ? [] + : [IpcContract[C]['args']]; + +/** + * Unique point de passage vers le backend Rust : aucun composant ni store + * n'appelle `invoke()` directement. Centralise la normalisation des erreurs et + * rend les dépôts testables en doublant ce seul service. */ @Injectable({ providedIn: 'root' }) export class IpcService { diff --git a/src/app/core/models/language.model.ts b/src/app/core/language/language.model.ts similarity index 100% rename from src/app/core/models/language.model.ts rename to src/app/core/language/language.model.ts diff --git a/src/app/core/models/note-section.model.ts b/src/app/core/models/note-section.model.ts deleted file mode 100644 index 67dd7d3..0000000 --- a/src/app/core/models/note-section.model.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Note } from './note.model'; - -/** - * Sections d'affichage du canevas. - * - `pinned` / `today` / `week` / `older` : regroupement chronologique par défaut. - * - `results` : liste plate, utilisée quand une recherche ou un filtre par tag est actif - * (regrouper par date masquerait des résultats pertinents). - * - * La clé sert aussi de clé de traduction : le titre est `'sections.' + key`. - */ -export type NoteSectionKey = 'pinned' | 'today' | 'week' | 'older' | 'results'; - -export interface NoteSection { - readonly key: NoteSectionKey; - readonly notes: readonly Note[]; - /** Au moins une note de la section expire : le template en dérive l'indice `sections.expiringHint`. */ - readonly hasExpiringNotes: boolean; - /** Affiche la carte fantôme « coller ou créer » à la fin de la section. */ - readonly showCreateGhost: boolean; -} diff --git a/src/app/core/models/note.model.ts b/src/app/core/models/note.model.ts deleted file mode 100644 index 00db93b..0000000 --- a/src/app/core/models/note.model.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { LanguageTag } from './language.model'; - -export type NoteLifecycle = { readonly kind: 'permanent' } | { readonly kind: 'expires'; readonly at: Date }; - -/** - * Ce que le pied d'une carte affiche. - * - * La variante est **choisie par le back** (`domain::display`) : « une note - * épinglée montre son contexte plutôt que son âge » est une règle produit, pas - * une préoccupation de composant. Deux variantes portent une date et non un - * libellé, pour que le texte vieillisse à l'écran sans aller-retour IPC. - */ -export type NoteFooter = - | { readonly kind: 'source'; readonly value: string } - | { readonly kind: 'expiry'; readonly at: Date } - | { readonly kind: 'age'; readonly at: Date }; - -/** - * Une note est **immuable** : toute modification produit un nouvel objet - * (voir `NotesStore`). Le `readonly` généralisé fait garantir cette règle par - * le compilateur plutôt que par la discipline. - */ -export interface Note { - readonly id: string; - /** Espace auquel la note appartient — jamais vide : une note vit forcément dans un espace. */ - readonly spaceId: string; - /** Peut être vide (note tout juste créée) : l'UI affiche alors `notes.untitled`. */ - readonly title: string; - readonly language: LanguageTag; - readonly content: string; - /** Chemin de contexte, ex. "API Gateway / Auth" — le premier segment sert de libellé compact sur la carte. */ - readonly source: string; - readonly tags: readonly string[]; - readonly pinned: boolean; - readonly createdAt: Date; - readonly updatedAt: Date; - readonly lifecycle: NoteLifecycle; - /** Dérivé par le back, jamais écrit : voir [NoteFooter]. */ - readonly footer: NoteFooter; - /** Échéance proche. Seuil unique, tenu par le back. */ - readonly expiringSoon: boolean; -} - -/** - * Champs fournis à la création. `id`, `createdAt` et `updatedAt` sont attribués - * par la persistance ; `footer` et `expiringSoon` sont **dérivés** — les - * envoyer laisserait croire que le front en décide. - */ -export type NoteDraft = Omit; - -/** Modification partielle d'une note existante ; `updatedAt` est rafraîchi par la persistance. */ -export type NotePatch = Partial; diff --git a/src/app/core/models/notes-query.model.ts b/src/app/core/models/notes-query.model.ts deleted file mode 100644 index 622ae79..0000000 --- a/src/app/core/models/notes-query.model.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { LanguageTag } from './language.model'; -import { NoteSection } from './note-section.model'; - -/** - * `untriaged` = notes portant une date d'expiration. Dans le modèle du produit, - * une note éphémère est précisément une note dont on n'a pas encore décidé du - * sort — d'où le libellé « à trier ». - */ -export type NoteFilter = 'all' | 'pinned' | 'untriaged'; - -/** - * Ce que l'utilisateur demande à voir. Envoyé tel quel à `query_notes` : le - * front décrit son intention, le backend décide de ce qui s'affiche. - */ -export interface NotesQuery { - /** `null` = « tous les espaces », un choix et non une absence de choix. */ - readonly spaceId: string | null; - readonly search: string; - readonly filter: NoteFilter; - readonly tags: readonly string[]; - /** Même sémantique d'union que `tags` : au moins l'un d'eux. Vide = tous. */ - readonly languages: readonly LanguageTag[]; - /** Instant de référence, lu via `ClockService` pour rester testable. */ - readonly now: Date; - /** - * `Date#getTimezoneOffset()`. Les sections raisonnent en jours **locaux** : - * sans ce décalage, une note créée à 23 h tomberait dans la mauvaise section. - */ - readonly tzOffsetMinutes: number; -} - -/** - * Ce que le canevas affiche. Il n'y a volontairement pas de liste plate ici : - * la trouver inviterait à refiltrer ou re-trier côté front, ce que le backend - * a déjà fait. - */ -export interface NotesView { - readonly sections: readonly NoteSection[]; - /** Tags du rail, portés à l'espace actif et non au filtre courant. */ - readonly availableTags: readonly string[]; - /** Langages du rail, de même portée que `availableTags`. */ - readonly availableLanguages: readonly LanguageTag[]; - /** Une recherche ou une sélection de facettes (tag, langage) est active. */ - readonly isFiltering: boolean; - /** Nombre de notes retenues, toutes sections confondues. */ - readonly matched: number; -} diff --git a/src/app/core/models/translation-ref.model.ts b/src/app/core/models/translation-ref.model.ts deleted file mode 100644 index 6f5735f..0000000 --- a/src/app/core/models/translation-ref.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Référence vers une clé de traduction Transloco, à consommer via le pipe - * `transloco` dans le template. - * - * Règle de la maison : le code qui produit du texte destiné à l'utilisateur - * renvoie une référence, jamais une chaîne déjà formatée — la traduction a - * toujours lieu dans le template, donc toujours dans la langue active. - */ -export interface TranslationRef { - readonly key: string; - readonly params?: Record; -} diff --git a/src/app/core/preferences/preferences.service.ts b/src/app/core/preferences/preferences.service.ts index 3a9a3e4..ac805a2 100644 --- a/src/app/core/preferences/preferences.service.ts +++ b/src/app/core/preferences/preferences.service.ts @@ -3,51 +3,37 @@ import { load } from '@tauri-apps/plugin-store'; import type { Store, StoreOptions } from '@tauri-apps/plugin-store'; /** - * Tout ce que ce service attend du plugin : ouvrir un fichier. - * - * Passer par un jeton plutôt que d'appeler `load` directement suit la même règle - * que `NOTES_REPOSITORY` — le seul point de contact avec l'extérieur est - * remplaçable. Ici c'est aussi une nécessité pratique : le builder Angular - * regroupe les modules avant que Vitest ne les voie, et `vi.mock` sur un paquet - * externe n'intercepte alors qu'une fois sur deux. + * Tout ce que ce service attend du plugin : ouvrir un fichier. Un jeton plutôt + * qu'un appel direct à `load`, aussi par nécessité pratique — le builder + * Angular regroupe les modules avant que Vitest ne les voie, et `vi.mock` sur un + * paquet externe n'intercepte alors qu'une fois sur deux. */ -export type PreferencesStoreLoader = (path: string, options: StoreOptions) => Promise; +type PreferencesStoreLoader = (path: string, options: StoreOptions) => Promise; export const PREFERENCES_STORE_LOADER = new InjectionToken( 'PREFERENCES_STORE_LOADER', { providedIn: 'root', factory: () => load }, ); -/** Fichier créé dans `app_config_dir()` par le plugin — au même titre que la base SQLite. */ +/** Créé dans `app_config_dir()`, au même titre que la base SQLite. */ const STORE_FILE = 'preferences.json'; -/** - * Délai avant écriture sur disque. Une préférence bascule par clic, jamais en - * rafale : quelques centaines de millisecondes suffisent à grouper un - * aller-retour de bouton sans risquer de perdre le dernier état. - */ +/** Une préférence bascule par clic, jamais en rafale. */ const AUTO_SAVE_MS = 300; /** - * Préférences locales de l'utilisateur (langue de l'UI, plein écran de l'éditeur). - * - * Adossé à `tauri-plugin-store` : un vrai fichier dans le répertoire de - * configuration de l'application, lisible depuis Rust et insensible à un vidage - * du WebView — contrairement au `localStorage` qu'il remplace. - * - * # Pourquoi l'API reste synchrone + * Préférences locales (langue de l'UI, plein écran de l'éditeur), adossées à + * `tauri-plugin-store` : un vrai fichier, insensible à un vidage du WebView + * contrairement au `localStorage` qu'il remplace. * - * Celle du plugin ne l'est pas. Mais une préférence est lue au moment où un - * composant se construit (`fullscreen` dans l'éditeur, la langue avant le - * premier rendu) : rendre `read` asynchrone ferait apparaître l'interface dans - * un état puis dans l'autre. Le fichier est donc chargé **une fois** par - * [`hydrate`], depuis un `provideAppInitializer`, et les lectures suivantes - * tapent dans le cache mémoire. Les écritures sont appliquées au cache - * immédiatement puis poussées sans être attendues : une préférence non - * persistée ne doit jamais faire tomber l'application, ni la faire patienter. + * L'API reste **synchrone** alors que celle du plugin ne l'est pas : une + * préférence est lue à la construction d'un composant, et un `read` asynchrone + * ferait apparaître l'interface dans un état puis dans l'autre. Le fichier est + * donc chargé une fois par [`hydrate`], et les écritures partent sans être + * attendues — une préférence non persistée ne doit ni faire tomber + * l'application ni la faire patienter. * - * Hors Tauri (tests unitaires en jsdom), `load` échoue : le service dégrade - * alors en cache purement mémoire, ce qui reste un comportement correct. + * Hors Tauri (jsdom), `load` échoue et le service dégrade en cache mémoire. */ @Injectable({ providedIn: 'root' }) export class PreferencesService { @@ -57,11 +43,10 @@ export class PreferencesService { /** * Charge le fichier et remplit le cache. À appeler **avant** la première - * lecture — sans quoi elle rendrait `null` et la préférence serait perdue. + * lecture, qui rendrait sinon `null`. * - * Les valeurs déjà présentes dans le `localStorage` d'une version antérieure - * sont reprises quand le fichier ne les porte pas encore : sans cette reprise, - * la mise à jour réinitialiserait silencieusement la langue de l'interface. + * Les valeurs d'une version antérieure encore dans `localStorage` sont + * reprises : sans ça, la mise à jour réinitialiserait la langue de l'interface. */ async hydrate(): Promise { try { @@ -74,9 +59,8 @@ export class PreferencesService { this.store = store; this.adoptLegacyValues(); } catch { - // Plugin indisponible (hors Tauri, permission refusée) : le cache mémoire - // suffit à faire fonctionner la session, elle ne survivra simplement pas - // à un redémarrage. + // Plugin indisponible : le cache mémoire fait tourner la session, elle ne + // survivra simplement pas au redémarrage. } } @@ -86,8 +70,7 @@ export class PreferencesService { write(key: string, value: string): void { this.cache.set(key, value); - // Volontairement non attendu : l'appelant bascule un état d'interface, il - // n'a rien à faire du moment où l'écriture touche le disque. + // Non attendu : l'appelant bascule un état d'interface. void this.store?.set(key, value).catch(() => undefined); } @@ -96,9 +79,8 @@ export class PreferencesService { try { for (let index = 0; index < localStorage.length; index++) { const key = localStorage.key(index); - // Seules les clés de l'application sont reprises : le stockage du - // WebView peut porter autre chose, et tout y déverser polluerait le - // fichier de préférences durablement. + // Seules nos clés : le WebView peut en porter d'autres, et tout + // déverser polluerait durablement le fichier de préférences. if (!key?.startsWith('devbox.') || this.cache.has(key)) continue; const value = localStorage.getItem(key); diff --git a/src/app/core/time/clock.service.ts b/src/app/core/time/clock.service.ts index f235d25..13343bf 100644 --- a/src/app/core/time/clock.service.ts +++ b/src/app/core/time/clock.service.ts @@ -1,22 +1,15 @@ import { DestroyRef, Injectable, Signal, inject, signal } from '@angular/core'; -/** - * Cadence de rafraîchissement de l'horloge. 30 s : assez court pour que - * « à l'instant » devienne « il y a 1 min » sans décalage visible, assez long - * pour que le réveil périodique du moteur de rendu reste négligeable. - */ +/** Assez court pour que « à l'instant » devienne « il y a 1 min » à temps. */ export const CLOCK_TICK_MS = 30_000; /** - * Horloge applicative exposée sous forme de signal. - * - * Elle existe parce qu'un libellé de temps relatif calculé avec `new Date()` - * à l'intérieur d'un `computed()` se fige : le `computed` ne dépend d'aucun - * signal représentant le temps, il ne se réévalue donc jamais tant que la note - * ne change pas — une carte affiche « il y a 4 min » indéfiniment. + * Horloge applicative sous forme de signal. * - * Injecter `now()` rend ces `computed()` à la fois **purs** (plus de lecture - * cachée de l'horloge système) et **auto-rafraîchissants**. + * ⚠️ Un `new Date()` lu dans un `computed()` le **fige** : il ne dépend alors + * d'aucun signal représentant le temps et ne se réévalue jamais — une carte + * afficherait « il y a 4 min » indéfiniment. Injecter `now()` rend ces + * `computed()` purs et auto-rafraîchissants. */ @Injectable({ providedIn: 'root' }) export class ClockService { diff --git a/src/app/core/utils/relative-time.util.spec.ts b/src/app/core/time/relative-time.util.spec.ts similarity index 100% rename from src/app/core/utils/relative-time.util.spec.ts rename to src/app/core/time/relative-time.util.spec.ts diff --git a/src/app/core/utils/relative-time.util.ts b/src/app/core/time/relative-time.util.ts similarity index 93% rename from src/app/core/utils/relative-time.util.ts rename to src/app/core/time/relative-time.util.ts index a8d6741..af5ff00 100644 --- a/src/app/core/utils/relative-time.util.ts +++ b/src/app/core/time/relative-time.util.ts @@ -1,4 +1,4 @@ -import { TranslationRef } from '../models/translation-ref.model'; +import { TranslationRef } from '../i18n/translation-ref.model'; const MS_PER_DAY = 24 * 60 * 60 * 1000; diff --git a/src/app/core/updates/update.providers.ts b/src/app/core/updates/update.providers.ts deleted file mode 100644 index b2e35c6..0000000 --- a/src/app/core/updates/update.providers.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { EnvironmentProviders, inject, provideAppInitializer } from '@angular/core'; -import { UpdateStore } from './update.store'; - -/** - * Lance la recherche de mise à jour au démarrage. - * - * La promesse est volontairement **non retournée** : Angular attend celles que - * lui rend un initialiseur, et l'application resterait sur un écran vide le - * temps d'un appel réseau — plusieurs secondes derrière un DNS lent, indéfiniment - * si l'endpoint ne répond pas. La pop-in apparaît quand la réponse arrive. - */ -export function provideUpdateCheck(): EnvironmentProviders { - return provideAppInitializer(() => { - void inject(UpdateStore).check(); - }); -} diff --git a/src/app/core/updates/update.store.ts b/src/app/core/updates/update.store.ts index d537b78..141b6c4 100644 --- a/src/app/core/updates/update.store.ts +++ b/src/app/core/updates/update.store.ts @@ -11,28 +11,23 @@ export type UpdateStatus = 'idle' | 'available' | 'installing' | 'installed'; /** * Ce que la dernière vérification laisse à dire dans le menu, **distinct** de - * `UpdateStatus` qui décrit le cycle d'installation. Les mélanger ferait dépendre - * l'état de la pop-in du résultat d'une recherche sans suite. - * - * `idle` couvre les deux cas où le menu n'a rien à annoncer : avant toute - * recherche, et après une recherche fructueuse — c'est alors la pop-in qui parle. + * `UpdateStatus` : les mélanger ferait dépendre l'état de la pop-in d'une + * recherche sans suite. `idle` couvre les deux cas muets — avant toute + * recherche, et après une recherche fructueuse, où c'est la pop-in qui parle. */ export type CheckState = 'idle' | 'checking' | 'upToDate' | 'failed'; /** * État de la mise à jour applicative. * - * Rien ne s'installe sans un geste explicite de l'utilisateur : `check()` - * n'expose qu'une proposition, et seul `accept()` télécharge. Une mise à jour - * silencieuse redémarrerait l'application au milieu d'une note en cours de - * frappe — les brouillons de l'éditeur ne sont confirmés qu'au blur. + * Rien ne s'installe sans geste explicite : une mise à jour silencieuse + * redémarrerait l'application au milieu d'une frappe, or les brouillons de + * l'éditeur ne sont confirmés qu'au blur. * - * Un échec de vérification reste **muet** : hors ligne, derrière un proxy ou sur - * une build de développement dont la clé publique est un gabarit, `check()` - * échoue à chaque lancement. Un bandeau rouge y répondrait par un reproche - * quotidien alors que l'application fonctionne et que l'utilisateur n'a rien à - * corriger. Un échec d'**installation**, lui, fait suite à une action explicite - * et doit donc se voir. + * Un échec de **vérification** reste muet : hors ligne ou sur une build de dev + * dont la clé publique est un gabarit, `check()` échoue à chaque lancement, et + * un bandeau rouge serait un reproche quotidien sans rien à corriger. Un échec + * d'**installation** fait suite à une action explicite et doit se voir. */ @Injectable({ providedIn: 'root' }) export class UpdateStore { diff --git a/src/app/features/notes/components/note-canvas/note-canvas.component.html b/src/app/features/notes/components/note-canvas/note-canvas.component.html deleted file mode 100644 index 23a4b43..0000000 --- a/src/app/features/notes/components/note-canvas/note-canvas.component.html +++ /dev/null @@ -1,31 +0,0 @@ -@let error = loadError(); - -
- @if (error) { - - - } @else if (isLoading()) { -

{{ 'notes.loading' | transloco }}

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

{{ 'notes.noResults' | transloco }}

- } @else { - @for (section of sections(); track section.key) { - - } - } -
diff --git a/src/app/features/notes/components/note-canvas/note-canvas.component.spec.ts b/src/app/features/notes/components/note-canvas/note-canvas.component.spec.ts deleted file mode 100644 index b27813d..0000000 --- a/src/app/features/notes/components/note-canvas/note-canvas.component.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { createSection } from '@testing/section.fixture'; -import { provideAppTesting } from '@testing/testing.providers'; -import { NoteSectionComponent } from '../note-section/note-section.component'; -import { NoteCanvasComponent } from './note-canvas.component'; - -describe('NoteCanvasComponent', () => { - let fixture: ComponentFixture; - - function sections(): NoteSectionComponent[] { - return fixture.debugElement - .queryAll(By.directive(NoteSectionComponent)) - .map((el) => el.componentInstance as NoteSectionComponent); - } - - beforeEach(() => { - TestBed.configureTestingModule({ imports: [NoteCanvasComponent], providers: [provideAppTesting()] }); - fixture = TestBed.createComponent(NoteCanvasComponent); - fixture.componentRef.setInput('sections', [ - createSection('pinned'), - createSection('week', [], { showCreateGhost: true }), - ]); - fixture.autoDetectChanges(); - }); - - it('renders one note-section per section, forwarding the selected note id', async () => { - fixture.componentRef.setInput('selectedNoteId', 'note-1'); - await fixture.whenStable(); - - expect(sections().map((s) => s.section().key)).toEqual(['pinned', 'week']); - expect(sections().every((s) => s.selectedNoteId() === 'note-1')).toBe(true); - }); - - it('forwards noteOpened from a section', () => { - let emitted: string | undefined; - fixture.componentInstance.noteOpened.subscribe((id) => (emitted = id)); - - sections()[0].noteOpened.emit('note-7'); - - expect(emitted).toBe('note-7'); - }); - - it('forwards createRequested from a section', () => { - let emitted = false; - fixture.componentInstance.createRequested.subscribe(() => (emitted = true)); - - sections()[0].createRequested.emit(); - - expect(emitted).toBe(true); - }); - - describe('states', () => { - it('shows a loading message instead of the sections while loading', async () => { - fixture.componentRef.setInput('isLoading', true); - await fixture.whenStable(); - - expect(fixture.nativeElement.textContent).toContain('Chargement des notes'); - expect(sections()).toHaveLength(0); - expect(fixture.nativeElement.querySelector('.canvas').getAttribute('aria-busy')).toBe('true'); - }); - - it('shows an empty-search message instead of an empty results section', async () => { - fixture.componentRef.setInput('hasNoResults', true); - await fixture.whenStable(); - - expect(fixture.nativeElement.textContent).toContain('Aucune note ne correspond'); - expect(sections()).toHaveLength(0); - }); - - it('shows the load failure with its detail and offers a retry', async () => { - // A failed load empties the whole screen, so it gets its own recovery - // path rather than relying on the global banner alone. - fixture.componentRef.setInput('loadError', new Error('database is locked')); - await fixture.whenStable(); - - expect(fixture.nativeElement.textContent).toContain('Impossible de charger les notes'); - expect(fixture.nativeElement.textContent).toContain('database is locked'); - expect(fixture.nativeElement.querySelector('.canvas-state').getAttribute('role')).toBe('alert'); - expect(sections()).toHaveLength(0); - }); - - it('emits reloadRequested when the retry button is clicked', async () => { - fixture.componentRef.setInput('loadError', new Error('nope')); - await fixture.whenStable(); - let emitted = false; - fixture.componentInstance.reloadRequested.subscribe(() => (emitted = true)); - - fixture.debugElement.query(By.css('.canvas-retry')).triggerEventHandler('click'); - - expect(emitted).toBe(true); - }); - - it('prefers the error state over the loading state', async () => { - fixture.componentRef.setInput('isLoading', true); - fixture.componentRef.setInput('loadError', new Error('nope')); - await fixture.whenStable(); - - expect(fixture.nativeElement.querySelector('.canvas-retry')).not.toBeNull(); - }); - }); -}); diff --git a/src/app/features/notes/components/note-canvas/note-canvas.component.ts b/src/app/features/notes/components/note-canvas/note-canvas.component.ts deleted file mode 100644 index 49cc9f7..0000000 --- a/src/app/features/notes/components/note-canvas/note-canvas.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { TranslocoPipe } from '@jsverse/transloco'; -import { NoteSection } from '@core/models/note-section.model'; -import { Space } from '@core/models/space.model'; -import { NoteMove } from '../note-card/note-card.component'; -import { NoteSectionComponent } from '../note-section/note-section.component'; - -@Component({ - selector: 'app-note-canvas', - imports: [NoteSectionComponent, TranslocoPipe], - templateUrl: './note-canvas.component.html', - styleUrl: './note-canvas.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class NoteCanvasComponent { - readonly sections = input.required(); - readonly selectedNoteId = input(null); - readonly isLoading = input(false); - /** Échec du chargement initial : le canevas propose alors de réessayer. */ - readonly loadError = input(undefined); - /** Une recherche est active mais ne renvoie rien — à distinguer d'un espace vide. */ - readonly hasNoResults = input(false); - /** Relayé jusqu'aux cartes : leur menu propose d'y déplacer la note. */ - readonly spaces = input([]); - - readonly noteOpened = output(); - readonly noteMoved = output(); - readonly noteDeleted = output(); - readonly createRequested = output(); - readonly reloadRequested = output(); -} diff --git a/src/app/features/notes/components/note-card-menu/note-card-menu.component.ts b/src/app/features/notes/components/note-card-menu/note-card-menu.component.ts deleted file mode 100644 index 5e93d81..0000000 --- a/src/app/features/notes/components/note-card-menu/note-card-menu.component.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - computed, - effect, - inject, - input, - output, - signal, - viewChild, - viewChildren, -} from '@angular/core'; -import { TranslocoPipe } from '@jsverse/transloco'; -import { Space } from '@core/models/space.model'; - -/** - * Menu d'actions d'une carte de note : déplacer vers un autre espace, supprimer. - * - * Composant séparé de `NoteCardComponent` parce qu'il apporte ce que la carte - * n'a pas : un état ouvert/fermé, une écoute du clic global et une gestion du - * focus. La carte reste ainsi purement dérivée de sa note. - * - * Il n'émet **jamais l'identifiant de la note** — il ne le connaît pas. C'est la - * carte qui l'ajoute en relayant, comme l'éditeur laisse le store décider de la - * note ouverte. - */ -@Component({ - selector: 'app-note-card-menu', - imports: [TranslocoPipe], - templateUrl: './note-card-menu.component.html', - styleUrl: './note-card-menu.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, - host: { - '(document:click)': 'onDocumentClick($event)', - '(keydown.escape)': 'onEscape()', - }, -}) -export class NoteCardMenuComponent { - /** Titre déjà affichable : la carte a résolu le libellé de remplacement. */ - readonly noteTitle = input.required(); - readonly spaces = input.required(); - readonly currentSpaceId = input.required(); - - /** Identifiant de l'espace de destination. */ - readonly moveRequested = output(); - readonly deleteRequested = output(); - - protected readonly open = signal(false); - - /** Suppression en deux temps : la WebView bloque tout pendant un `confirm()` natif. */ - protected readonly confirmingDelete = signal(false); - - private readonly elementRef = inject>(ElementRef); - private readonly trigger = viewChild.required>('trigger'); - private readonly items = viewChildren>('item'); - - /** Déplacer une note là où elle est déjà n'a pas de sens : l'espace courant est retiré. */ - protected readonly moveTargets = computed(() => - this.spaces().filter((space) => space.id !== this.currentSpaceId()), - ); - - constructor() { - // Ouvrir un menu sans y amener le focus le rend inatteignable au clavier. - // `items()` participe à la dépendance : la requête de vue n'est pas à jour - // au moment où `open` bascule, l'effet se rejoue dès qu'elle l'est. - effect(() => { - if (this.open()) { - this.items()[0]?.nativeElement.focus(); - } - }); - } - - protected toggle(event: MouseEvent): void { - // La carte entière est un bouton d'ouverture : sans ça, un clic sur le ⋯ - // remonterait jusqu'à elle et ouvrirait l'éditeur en même temps que le menu. - event.stopPropagation(); - this.open.update((value) => !value); - this.confirmingDelete.set(false); - } - - protected move(spaceId: string): void { - this.moveRequested.emit(spaceId); - this.closeAndRestoreFocus(); - } - - protected onDeleteClick(): void { - if (!this.confirmingDelete()) { - this.confirmingDelete.set(true); - return; - } - this.deleteRequested.emit(); - this.closeAndRestoreFocus(); - } - - protected onEscape(): void { - this.closeAndRestoreFocus(); - } - - protected onDocumentClick(event: MouseEvent): void { - if (this.open() && !this.elementRef.nativeElement.contains(event.target as Node)) { - this.open.set(false); - this.confirmingDelete.set(false); - } - } - - protected onMenuKeydown(event: KeyboardEvent): void { - const items = this.items().map((ref) => ref.nativeElement); - if (items.length === 0) return; - - const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement); - const focusAt = (index: number): void => { - event.preventDefault(); - items[(index + items.length) % items.length].focus(); - }; - - switch (event.key) { - case 'ArrowDown': - focusAt(currentIndex + 1); - break; - case 'ArrowUp': - focusAt(currentIndex - 1); - break; - case 'Home': - focusAt(0); - break; - case 'End': - focusAt(items.length - 1); - break; - } - } - - private closeAndRestoreFocus(): void { - if (!this.open()) return; - this.open.set(false); - this.confirmingDelete.set(false); - // Sans ça, le focus disparaît avec l'élément détruit et repart sur . - this.trigger().nativeElement.focus(); - } -} diff --git a/src/app/features/notes/components/notes-topbar/notes-topbar.component.html b/src/app/features/notes/components/notes-topbar/notes-topbar.component.html deleted file mode 100644 index c1aac28..0000000 --- a/src/app/features/notes/components/notes-topbar/notes-topbar.component.html +++ /dev/null @@ -1,19 +0,0 @@ -
- - - - -
diff --git a/src/app/features/notes/components/notes-topbar/notes-topbar.component.scss b/src/app/features/notes/components/notes-topbar/notes-topbar.component.scss deleted file mode 100644 index e2d1a01..0000000 --- a/src/app/features/notes/components/notes-topbar/notes-topbar.component.scss +++ /dev/null @@ -1,25 +0,0 @@ -@use 'mixins' as *; - -.topbar { - display: flex; - align-items: center; - gap: 14px; - padding: 14px 24px; - border-bottom: 1px solid var(--line-soft); - flex-shrink: 0; -} - -.new-note-btn { - @include unstyled-control; - display: flex; - align-items: center; - gap: 6px; - background: var(--amber); - color: var(--amber-ink); - border-radius: 8px; - padding: 8px 14px; - font-weight: 700; - font-size: 13px; - cursor: pointer; - white-space: nowrap; -} diff --git a/src/app/features/notes/components/notes-topbar/notes-topbar.component.spec.ts b/src/app/features/notes/components/notes-topbar/notes-topbar.component.spec.ts deleted file mode 100644 index ca8dd28..0000000 --- a/src/app/features/notes/components/notes-topbar/notes-topbar.component.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { Space } from '@core/models/space.model'; -import { provideTranslocoTesting } from '@testing/provide-transloco-testing'; -import { FilterChipsComponent } from '../filter-chips/filter-chips.component'; -import { SearchBoxComponent } from '../search-box/search-box.component'; -import { SpaceSwitcherComponent } from '../space-switcher/space-switcher.component'; -import { NotesTopbarComponent } from './notes-topbar.component'; - -const SPACES: readonly Space[] = [{ id: 'work', name: 'Work' }]; - -describe('NotesTopbarComponent', () => { - let fixture: ComponentFixture; - - function child(type: new (...args: never[]) => T): T { - return fixture.debugElement.query(By.directive(type)).componentInstance as T; - } - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [NotesTopbarComponent], - providers: [provideTranslocoTesting()], - }); - fixture = TestBed.createComponent(NotesTopbarComponent); - fixture.componentRef.setInput('spaces', SPACES); - fixture.componentRef.setInput('activeSpace', SPACES[0]); - fixture.componentRef.setInput('activeFilter', 'all'); - fixture.autoDetectChanges(); - }); - - it('passes its inputs down to the space switcher, search box and filter chips', async () => { - fixture.componentRef.setInput('searchQuery', 'hello'); - await fixture.whenStable(); - - expect(child(SpaceSwitcherComponent).spaces()).toEqual(SPACES); - expect(child(SpaceSwitcherComponent).activeSpace()).toEqual(SPACES[0]); - expect(child(SearchBoxComponent).query()).toBe('hello'); - expect(child(FilterChipsComponent).active()).toBe('all'); - }); - - it('relays the shortcut-enabled flag to the search box', async () => { - fixture.componentRef.setInput('searchShortcutEnabled', false); - await fixture.whenStable(); - - expect(child(SearchBoxComponent).shortcutEnabled()).toBe(false); - }); - - it('enables the search shortcut by default', () => { - expect(child(SearchBoxComponent).shortcutEnabled()).toBe(true); - }); - - it('forwards spaceChanged from the space switcher', () => { - let emitted: string | null | undefined; - fixture.componentInstance.spaceChanged.subscribe((id) => (emitted = id)); - - child(SpaceSwitcherComponent).spaceChanged.emit('work'); - - expect(emitted).toBe('work'); - }); - - it('forwards the "all spaces" selection as null', () => { - let emitted: string | null | undefined; - fixture.componentInstance.spaceChanged.subscribe((id) => (emitted = id)); - - child(SpaceSwitcherComponent).spaceChanged.emit(null); - - expect(emitted).toBeNull(); - }); - - it('forwards spaceCreated from the space switcher', () => { - let emitted: string | undefined; - fixture.componentInstance.spaceCreated.subscribe((name) => (emitted = name)); - - child(SpaceSwitcherComponent).spaceCreated.emit('Side project'); - - expect(emitted).toBe('Side project'); - }); - - it('forwards searchQueryChanged from the search box', () => { - let emitted: string | undefined; - fixture.componentInstance.searchQueryChanged.subscribe((query) => (emitted = query)); - - child(SearchBoxComponent).query.set('term'); - - expect(emitted).toBe('term'); - }); - - it('forwards filterChanged from the filter chips', () => { - let emitted: string | undefined; - fixture.componentInstance.filterChanged.subscribe((filter) => (emitted = filter)); - - child(FilterChipsComponent).filterChanged.emit('pinned'); - - expect(emitted).toBe('pinned'); - }); - - it('emits newNoteRequested when the "new note" button is clicked', () => { - let emitted = false; - fixture.componentInstance.newNoteRequested.subscribe(() => (emitted = true)); - - fixture.debugElement.query(By.css('.new-note-btn')).triggerEventHandler('click'); - - expect(emitted).toBe(true); - }); - - it('relays the "all spaces" mode down to the switcher', async () => { - fixture.componentRef.setInput('activeSpace', null); - await fixture.whenStable(); - - expect(child(SpaceSwitcherComponent).activeSpace()).toBeNull(); - }); -}); diff --git a/src/app/features/notes/components/notes-topbar/notes-topbar.component.ts b/src/app/features/notes/components/notes-topbar/notes-topbar.component.ts deleted file mode 100644 index f9eddc6..0000000 --- a/src/app/features/notes/components/notes-topbar/notes-topbar.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { TranslocoPipe } from '@jsverse/transloco'; -import { Space } from '@core/models/space.model'; -import { NoteFilter } from '@core/stores/notes.store'; -import { FilterChipsComponent } from '../filter-chips/filter-chips.component'; -import { SearchBoxComponent } from '../search-box/search-box.component'; -import { - SpaceDeletion, - SpaceRenaming, - SpaceSwitcherComponent, -} from '../space-switcher/space-switcher.component'; - -@Component({ - selector: 'app-notes-topbar', - imports: [SpaceSwitcherComponent, SearchBoxComponent, FilterChipsComponent, TranslocoPipe], - templateUrl: './notes-topbar.component.html', - styleUrl: './notes-topbar.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class NotesTopbarComponent { - readonly spaces = input.required(); - readonly activeSpace = input.required(); - readonly searchQuery = input(''); - readonly activeFilter = input.required(); - /** Simplement relayé : le raccourci appartient désormais au champ de recherche. */ - readonly searchShortcutEnabled = input(true); - - readonly spaceChanged = output(); - readonly spaceCreated = output(); - readonly spaceRenamed = output(); - readonly spaceDeleted = output(); - readonly searchQueryChanged = output(); - readonly filterChanged = output(); - readonly newNoteRequested = output(); -} diff --git a/src/app/core/data/note.dto.spec.ts b/src/app/features/notes/data/note.dto.spec.ts similarity index 94% rename from src/app/core/data/note.dto.spec.ts rename to src/app/features/notes/data/note.dto.spec.ts index 89bb987..d9a3165 100644 --- a/src/app/core/data/note.dto.spec.ts +++ b/src/app/features/notes/data/note.dto.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { Note, NoteDraft } from '@core/models/note.model'; -import { NoteContractError, NoteDto, toNote, toNoteDraftDto, toNotePatchDto } from './note.dto'; +import { Note, NoteDraft } from '../model/note.model'; +import { ContractError, NoteDto, toNote, toNoteDraftDto, toNotePatchDto } from './note.dto'; const BASE_DTO: NoteDto = { id: 'note-1', @@ -59,13 +59,11 @@ describe('toNote', () => { }); it('throws a contract error on an unparseable date rather than yielding an Invalid Date', () => { - expect(() => toNote({ ...BASE_DTO, createdAt: 'not-a-date' })).toThrow(NoteContractError); + expect(() => toNote({ ...BASE_DTO, createdAt: 'not-a-date' })).toThrow(ContractError); }); it('throws a contract error on an unparseable expiry date', () => { - expect(() => toNote({ ...BASE_DTO, lifecycle: { kind: 'expires', at: 'nope' } })).toThrow( - NoteContractError, - ); + expect(() => toNote({ ...BASE_DTO, lifecycle: { kind: 'expires', at: 'nope' } })).toThrow(ContractError); }); describe('footer', () => { @@ -91,7 +89,7 @@ describe('toNote', () => { // A newer backend variant must be reported, not rendered as a blank footer. const unknown = { ...BASE_DTO, footer: { kind: 'weather', at: '2026-01-01' } } as unknown as NoteDto; - expect(() => toNote(unknown)).toThrow(NoteContractError); + expect(() => toNote(unknown)).toThrow(ContractError); }); it('carries the expiry proximity the backend decided', () => { diff --git a/src/app/features/notes/data/note.dto.ts b/src/app/features/notes/data/note.dto.ts new file mode 100644 index 0000000..ec60a8d --- /dev/null +++ b/src/app/features/notes/data/note.dto.ts @@ -0,0 +1,223 @@ +import { FALLBACK_LANGUAGE, LanguageTag, isLanguageTag } from '@core/language/language.model'; +import { + Note, + NoteDraft, + NoteFilter, + NoteFooter, + NoteLifecycle, + NotePatch, + NoteSection, + NoteSectionKey, + NotesQuery, + NotesView, +} from '../model/note.model'; + +/** + * Représentation transportée sur le pont Tauri. Elle diffère du modèle sur un + * point décisif : **JSON n'a pas de type date**, donc toute `Date` arrive et + * repart en chaîne ISO 8601. + * + * ⚠️ Côté Rust les structs correspondantes portent + * `#[serde(rename_all = "camelCase")]` (sinon on reçoit `created_at`) et les + * enums à données `#[serde(tag = "kind", …)]` (sinon serde produit + * `{"Expires":{…}}`, que le discriminant TS ne reconnaît pas). Ces attributs + * sont figés par des tests dans `src-tauri/src/domain/`. + * + * `footer` et `expiringSoon` sont aplatis dans le même objet : un seul type de + * note côté front. + */ +export interface NoteDto { + readonly id: string; + readonly spaceId: string; + readonly title: string; + readonly language: string; + readonly content: string; + readonly source: string; + readonly tags: readonly string[]; + readonly pinned: boolean; + readonly createdAt: string; + readonly updatedAt: string; + readonly lifecycle: NoteLifecycleDto; + readonly footer: NoteFooterDto; + readonly expiringSoon: boolean; +} + +type NoteLifecycleDto = { readonly kind: 'permanent' } | { readonly kind: 'expires'; readonly at: string }; + +type NoteFooterDto = + | { readonly kind: 'source'; readonly value: string } + | { readonly kind: 'expiry'; readonly at: string } + | { readonly kind: 'age'; readonly at: string }; + +export type NoteDraftDto = Omit; +export type NotePatchDto = Partial; + +export interface NotesQueryDto { + readonly spaceId: string | null; + readonly search: string; + readonly filter: NoteFilter; + readonly tags: readonly string[]; + readonly languages: readonly string[]; + readonly now: string; + readonly tzOffsetMinutes: number; +} + +export interface NotesViewDto { + readonly sections: readonly NoteSectionDto[]; + readonly availableTags: readonly string[]; + readonly availableLanguages: readonly string[]; + readonly isFiltering: boolean; + readonly matched: number; +} + +interface NoteSectionDto { + readonly key: string; + readonly notes: readonly NoteDto[]; + readonly hasExpiringNotes: boolean; + readonly showCreateGhost: boolean; +} + +/** Rupture de contrat entre ce que le pont livre et ce que le front sait lire. */ +export class ContractError extends Error { + constructor(field: string, value: unknown) { + super(`Contrat rompu : champ « ${field} » inexploitable (${JSON.stringify(value)})`); + this.name = 'ContractError'; + } +} + +function parseIsoDate(value: string, field: string): Date { + const date = new Date(value); + // Échouer bruyamment plutôt que de laisser une `Invalid Date` ressortir en + // `NaN` dans les libellés de temps relatif. + if (Number.isNaN(date.getTime())) { + throw new ContractError(field, value); + } + return date; +} + +/** + * Même exigence dans l'autre sens : `toISOString()` lève un `RangeError` nu sur + * une `Invalid Date`, sans dire quel champ est en cause. + */ +export function toIsoString(date: Date, field: string): string { + if (Number.isNaN(date.getTime())) { + throw new ContractError(field, date); + } + return date.toISOString(); +} + +function toLifecycle(dto: NoteLifecycleDto): NoteLifecycle { + return dto.kind === 'expires' + ? { kind: 'expires', at: parseIsoDate(dto.at, 'lifecycle.at') } + : { kind: 'permanent' }; +} + +function toLifecycleDto(lifecycle: NoteLifecycle): NoteLifecycleDto { + return lifecycle.kind === 'expires' + ? { kind: 'expires', at: toIsoString(lifecycle.at, 'lifecycle.at') } + : { kind: 'permanent' }; +} + +function toFooter(dto: NoteFooterDto): NoteFooter { + if (dto.kind === 'source') return { kind: 'source', value: dto.value }; + if (dto.kind === 'expiry') return { kind: 'expiry', at: parseIsoDate(dto.at, 'footer.at') }; + if (dto.kind === 'age') return { kind: 'age', at: parseIsoDate(dto.at, 'footer.at') }; + // Variante ajoutée côté Rust sans être répercutée ici : mieux vaut le dire que + // rendre un pied de carte vide. + throw new ContractError('footer.kind', (dto satisfies never as { kind: string }).kind); +} + +export function toNote(dto: NoteDto): Note { + return { + id: dto.id, + spaceId: dto.spaceId, + title: dto.title, + // Un langage inconnu dégrade l'affichage, il ne casse pas le chargement. + language: isLanguageTag(dto.language) ? dto.language : FALLBACK_LANGUAGE, + content: dto.content, + source: dto.source, + tags: [...dto.tags], + pinned: dto.pinned, + createdAt: parseIsoDate(dto.createdAt, 'createdAt'), + updatedAt: parseIsoDate(dto.updatedAt, 'updatedAt'), + lifecycle: toLifecycle(dto.lifecycle), + footer: toFooter(dto.footer), + expiringSoon: dto.expiringSoon, + }; +} + +export function toNoteDraftDto(draft: NoteDraft): NoteDraftDto { + return { + spaceId: draft.spaceId, + title: draft.title, + language: draft.language, + content: draft.content, + source: draft.source, + tags: [...draft.tags], + pinned: draft.pinned, + lifecycle: toLifecycleDto(draft.lifecycle), + }; +} + +export function toNotePatchDto(patch: NotePatch): NotePatchDto { + const dto: Record = {}; + // Recopie champ par champ : un `undefined` sérialisé deviendrait `null` côté + // serde et écraserait la valeur existante au lieu de la laisser intacte. + if (patch.spaceId !== undefined) dto['spaceId'] = patch.spaceId; + if (patch.title !== undefined) dto['title'] = patch.title; + if (patch.language !== undefined) dto['language'] = patch.language; + if (patch.content !== undefined) dto['content'] = patch.content; + if (patch.source !== undefined) dto['source'] = patch.source; + if (patch.tags !== undefined) dto['tags'] = [...patch.tags]; + if (patch.pinned !== undefined) dto['pinned'] = patch.pinned; + if (patch.lifecycle !== undefined) dto['lifecycle'] = toLifecycleDto(patch.lifecycle); + return dto as NotePatchDto; +} + +const SECTION_KEYS: readonly NoteSectionKey[] = ['pinned', 'today', 'week', 'older', 'results']; + +function isSectionKey(value: string): value is NoteSectionKey { + return (SECTION_KEYS as readonly string[]).includes(value); +} + +export function toNotesQueryDto(query: NotesQuery): NotesQueryDto { + return { + spaceId: query.spaceId, + search: query.search, + filter: query.filter, + tags: [...query.tags], + languages: [...query.languages], + now: toIsoString(query.now, 'now'), + tzOffsetMinutes: query.tzOffsetMinutes, + }; +} + +function toSection(dto: NoteSectionDto): NoteSection { + // Une clé inconnue n'a pas de traduction : elle produirait une section au + // titre vide plutôt qu'une erreur visible. + if (!isSectionKey(dto.key)) { + throw new ContractError('section.key', dto.key); + } + + return { + key: dto.key, + notes: dto.notes.map(toNote), + hasExpiringNotes: dto.hasExpiringNotes, + showCreateGhost: dto.showCreateGhost, + }; +} + +export function toNotesView(dto: NotesViewDto): NotesView { + return { + sections: dto.sections.map(toSection), + availableTags: [...dto.availableTags], + // Une facette inconnue est écartée là où une section inconnue fait échouer : + // un rail auquel il manque un choix reste utilisable, un canevas dont une + // section est illisible ne l'est pas. + availableLanguages: dto.availableLanguages.filter((language): language is LanguageTag => + isLanguageTag(language), + ), + isFiltering: dto.isFiltering, + matched: dto.matched, + }; +} diff --git a/src/app/core/data/note-view.dto.spec.ts b/src/app/features/notes/data/note.dto.view.spec.ts similarity index 95% rename from src/app/core/data/note-view.dto.spec.ts rename to src/app/features/notes/data/note.dto.view.spec.ts index 23cda57..b8a8491 100644 --- a/src/app/core/data/note-view.dto.spec.ts +++ b/src/app/features/notes/data/note.dto.view.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { NotesQuery } from '@core/models/notes-query.model'; -import { NotesViewDto, toNotesQueryDto, toNotesView } from './note-view.dto'; +import { NotesQuery } from '../model/note.model'; +import { NotesViewDto, toNotesQueryDto, toNotesView } from './note.dto'; const BASE_VIEW: NotesViewDto = { sections: [], diff --git a/src/app/features/notes/data/notes.repository.ts b/src/app/features/notes/data/notes.repository.ts new file mode 100644 index 0000000..e8e9a74 --- /dev/null +++ b/src/app/features/notes/data/notes.repository.ts @@ -0,0 +1,48 @@ +import { Injectable, InjectionToken, inject } from '@angular/core'; +import { IpcService } from '@core/ipc/ipc.service'; +import { Note, NoteDraft, NotePatch, NotesQuery, NotesView } from '../model/note.model'; +import { toNote, toNoteDraftDto, toNotePatchDto, toNotesQueryDto, toNotesView } from './note.dto'; + +/** + * Point d'accès aux notes. Aucun composant ne touche une source de données + * directement : tout passe par ce jeton. + * + * `query` renvoie une **vue déjà filtrée et regroupée** ; il n'existe pas de + * méthode rendant la liste brute, précisément pour qu'aucun appelant ne soit + * tenté de refiltrer. `create` et `update` renvoient la note **telle que + * persistée** — l'`id` et les horodatages viennent du back, jamais du front. + */ +export interface NotesRepository { + query(query: NotesQuery): Promise; + create(draft: NoteDraft): Promise; + update(id: string, patch: NotePatch): Promise; + delete(id: string): Promise; +} + +export const NOTES_REPOSITORY = new InjectionToken('NOTES_REPOSITORY'); + +/** + * Implémentation active : les commandes Rust (voir `app.config.ts`). Les noms + * d'arguments et les types de retour viennent d'`IpcContract`, qui les tient + * alignés sur les signatures Rust. + */ +@Injectable() +export class TauriNotesRepository implements NotesRepository { + private readonly ipc = inject(IpcService); + + async query(query: NotesQuery): Promise { + return toNotesView(await this.ipc.invoke('query_notes', { query: toNotesQueryDto(query) })); + } + + async create(draft: NoteDraft): Promise { + return toNote(await this.ipc.invoke('create_note', { draft: toNoteDraftDto(draft) })); + } + + async update(id: string, patch: NotePatch): Promise { + return toNote(await this.ipc.invoke('update_note', { id, patch: toNotePatchDto(patch) })); + } + + async delete(id: string): Promise { + await this.ipc.invoke('delete_note', { id }); + } +} diff --git a/src/app/features/notes/data/space.dto.ts b/src/app/features/notes/data/space.dto.ts new file mode 100644 index 0000000..bffb68e --- /dev/null +++ b/src/app/features/notes/data/space.dto.ts @@ -0,0 +1,8 @@ +import { Space, SpaceDraft } from '../model/space.model'; + +/** + * Un espace n'a aucun champ à convertir : le DTO est structurellement identique + * au modèle, d'où des alias plutôt que des mappeurs identité. + */ +export type SpaceDto = Space; +export type SpaceDraftDto = SpaceDraft; diff --git a/src/app/features/notes/data/spaces.repository.ts b/src/app/features/notes/data/spaces.repository.ts new file mode 100644 index 0000000..7e918d8 --- /dev/null +++ b/src/app/features/notes/data/spaces.repository.ts @@ -0,0 +1,45 @@ +import { Injectable, InjectionToken, inject } from '@angular/core'; +import { IpcService } from '@core/ipc/ipc.service'; +import { Space, SpaceDraft } from '../model/space.model'; + +/** + * Point d'accès aux espaces. Aucun composant ne touche une source de données + * directement : tout passe par ce jeton. + * + * `create` et `rename` renvoient l'espace **tel que persisté** — c'est la + * persistance qui attribue l'`id`. `delete` prend un espace **refuge** : le + * schéma emporte les notes d'un espace supprimé (`ON DELETE CASCADE`), donc une + * signature à un argument aurait fait de la perte de données le défaut. Le + * transfert et la suppression sont atomiques côté Rust. + */ +export interface SpacesRepository { + loadAll(): Promise; + create(draft: SpaceDraft): Promise; + rename(id: string, draft: SpaceDraft): Promise; + /** `targetSpaceId` recueille les notes de l'espace supprimé. */ + delete(id: string, targetSpaceId: string): Promise; +} + +export const SPACES_REPOSITORY = new InjectionToken('SPACES_REPOSITORY'); + +/** Implémentation active : les commandes Rust (voir `app.config.ts`). */ +@Injectable() +export class TauriSpacesRepository implements SpacesRepository { + private readonly ipc = inject(IpcService); + + async loadAll(): Promise { + return this.ipc.invoke('list_spaces'); + } + + async create(draft: SpaceDraft): Promise { + return this.ipc.invoke('create_space', { draft }); + } + + async rename(id: string, draft: SpaceDraft): Promise { + return this.ipc.invoke('rename_space', { id, draft }); + } + + async delete(id: string, targetSpaceId: string): Promise { + await this.ipc.invoke('delete_space', { id, targetSpaceId }); + } +} diff --git a/src/app/features/notes/model/note.model.ts b/src/app/features/notes/model/note.model.ts new file mode 100644 index 0000000..ff702a5 --- /dev/null +++ b/src/app/features/notes/model/note.model.ts @@ -0,0 +1,98 @@ +import { LanguageTag } from '@core/language/language.model'; + +export type NoteLifecycle = { readonly kind: 'permanent' } | { readonly kind: 'expires'; readonly at: Date }; + +/** + * La variante est choisie par le back : « une note épinglée montre son contexte + * plutôt que son âge » est une règle produit. Deux variantes portent une date et + * non un libellé, pour que le texte vieillisse sans aller-retour IPC. + */ +export type NoteFooter = + | { readonly kind: 'source'; readonly value: string } + | { readonly kind: 'expiry'; readonly at: Date } + | { readonly kind: 'age'; readonly at: Date }; + +/** Immuable : toute modification produit un nouvel objet (voir `NotesStore`). */ +export interface Note { + readonly id: string; + /** Jamais vide : une note vit forcément dans un espace. */ + readonly spaceId: string; + /** Peut être vide (note tout juste créée) : l'UI affiche `notes.untitled`. */ + readonly title: string; + readonly language: LanguageTag; + readonly content: string; + /** Contexte, ex. "API Gateway / Auth" — son premier segment sert de libellé. */ + readonly source: string; + readonly tags: readonly string[]; + readonly pinned: boolean; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly lifecycle: NoteLifecycle; + /** Dérivé par le back, jamais écrit. */ + readonly footer: NoteFooter; + /** Échéance proche. Seuil unique, tenu par le back. */ + readonly expiringSoon: boolean; +} + +/** + * `id` et les horodatages sont attribués par la persistance ; `footer` et + * `expiringSoon` sont dérivés — les envoyer laisserait croire que le front décide. + */ +export type NoteDraft = Omit; + +export type NotePatch = Partial; + +/** + * `untriaged` = notes portant une date d'expiration : une note éphémère est + * précisément celle dont on n'a pas encore décidé du sort. + */ +export type NoteFilter = 'all' | 'pinned' | 'untriaged'; + +/** Envoyé tel quel à `query_notes` : le front décrit son intention. */ +export interface NotesQuery { + /** `null` = « tous les espaces », un choix et non une absence de choix. */ + readonly spaceId: string | null; + readonly search: string; + readonly filter: NoteFilter; + readonly tags: readonly string[]; + /** Même sémantique d'union que `tags` : au moins l'un d'eux. Vide = tous. */ + readonly languages: readonly LanguageTag[]; + /** Instant de référence, lu via `ClockService` pour rester testable. */ + readonly now: Date; + /** + * `Date#getTimezoneOffset()`. Les sections raisonnent en jours **locaux** : + * sans ce décalage, une note créée à 23 h tomberait dans la mauvaise section. + */ + readonly tzOffsetMinutes: number; +} + +/** + * Ce que le canevas affiche. Pas de liste plate ici : elle inviterait à + * refiltrer ou re-trier ce que le backend a déjà fait. + */ +export interface NotesView { + readonly sections: readonly NoteSection[]; + /** Tags du rail, portés à l'espace actif et non au filtre courant. */ + readonly availableTags: readonly string[]; + readonly availableLanguages: readonly LanguageTag[]; + /** Une recherche ou une sélection de facettes est active. */ + readonly isFiltering: boolean; + readonly matched: number; +} + +/** + * `pinned` / `today` / `week` / `older` regroupent chronologiquement ; + * `results` est la liste plate d'une recherche, où grouper par date masquerait + * des résultats pertinents. La clé sert aussi de clé de traduction + * (`'sections.' + key`). + */ +export type NoteSectionKey = 'pinned' | 'today' | 'week' | 'older' | 'results'; + +export interface NoteSection { + readonly key: NoteSectionKey; + readonly notes: readonly Note[]; + /** Au moins une note expire : le template en dérive `sections.expiringHint`. */ + readonly hasExpiringNotes: boolean; + /** Affiche la carte fantôme « coller ou créer » en fin de section. */ + readonly showCreateGhost: boolean; +} diff --git a/src/app/core/models/space.model.ts b/src/app/features/notes/model/space.model.ts similarity index 100% rename from src/app/core/models/space.model.ts rename to src/app/features/notes/model/space.model.ts diff --git a/src/app/features/notes/notes-page.component.html b/src/app/features/notes/notes-page.component.html new file mode 100644 index 0000000..b255c26 --- /dev/null +++ b/src/app/features/notes/notes-page.component.html @@ -0,0 +1,80 @@ +
+ + + + +
+ + + + + +@let error = store.loadError(); + +
+ @if (error) { + + + } @else if (store.isLoading()) { +

{{ 'notes.loading' | transloco }}

+ } @else if (store.hasNoResults()) { +

{{ 'notes.noResults' | transloco }}

+ } @else { + @for (section of store.sections(); track section.key) { + + } + } +
+ + +@if (store.selectedNote(); as note) { + +} diff --git a/src/app/features/notes/components/note-canvas/note-canvas.component.scss b/src/app/features/notes/notes-page.component.scss similarity index 52% rename from src/app/features/notes/components/note-canvas/note-canvas.component.scss rename to src/app/features/notes/notes-page.component.scss index 7c7473f..cea23e2 100644 --- a/src/app/features/notes/components/note-canvas/note-canvas.component.scss +++ b/src/app/features/notes/notes-page.component.scss @@ -1,5 +1,37 @@ @use 'mixins' as *; +:host { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.topbar { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 24px; + border-bottom: 1px solid var(--line-soft); + flex-shrink: 0; +} + +.new-note-btn { + @include unstyled-control; + display: flex; + align-items: center; + gap: 6px; + background: var(--amber); + color: var(--amber-ink); + border-radius: 8px; + padding: 8px 14px; + font-weight: 700; + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} + .canvas { flex: 1; overflow-y: auto; @@ -19,7 +51,7 @@ } .canvas-state-detail { - font-family: var(--font-mono); + font-family: var(--font-mono), serif; font-size: 11px; color: var(--text-2); word-break: break-word; diff --git a/src/app/features/notes/notes-page.component.spec.ts b/src/app/features/notes/notes-page.component.spec.ts new file mode 100644 index 0000000..a8afb2e --- /dev/null +++ b/src/app/features/notes/notes-page.component.spec.ts @@ -0,0 +1,292 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Space } from './model/space.model'; +import { NotesStore } from './state/notes.store'; +import { SpacesStore } from './state/spaces.store'; +import { FakeNotesRepository } from '@testing/fake-notes-repository'; +import { createNote } from '@testing/note.fixture'; +import { createSection } from '@testing/section.fixture'; +import { provideAppTesting } from '@testing/testing.providers'; +import { FilterChipsComponent } from './ui/filter-chips/filter-chips.component'; +import { NoteEditorOverlayComponent } from './ui/note-editor-overlay/note-editor-overlay.component'; +import { NoteSectionComponent } from './ui/note-section/note-section.component'; +import { SearchBoxComponent } from './ui/search-box/search-box.component'; +import { SpaceSwitcherComponent } from './ui/space-switcher/space-switcher.component'; +import { TagRailComponent } from './ui/tag-rail/tag-rail.component'; +import { NotesPageComponent } from './notes-page.component'; + +const SPACES: readonly Space[] = [ + { id: 'space-1', name: 'Space one' }, + { id: 'work', name: 'Work' }, +]; + +describe('NotesPageComponent', () => { + let fixture: ComponentFixture; + let store: NotesStore; + let spaces: SpacesStore; + let repository: FakeNotesRepository; + + function child(type: new (...args: never[]) => T): T { + return fixture.debugElement.query(By.directive(type)).componentInstance as T; + } + + function maybeChild(type: new (...args: never[]) => T): T | null { + return (fixture.debugElement.query(By.directive(type))?.componentInstance as T) ?? null; + } + + function sections(): NoteSectionComponent[] { + return fixture.debugElement + .queryAll(By.directive(NoteSectionComponent)) + .map((el) => el.componentInstance as NoteSectionComponent); + } + + beforeEach(async () => { + TestBed.resetTestingModule(); + repository = new FakeNotesRepository([createNote({ id: 'note-42' })]); + TestBed.configureTestingModule({ + imports: [NotesPageComponent], + providers: [provideAppTesting({ notesRepository: repository, spaces: SPACES })], + }); + fixture = TestBed.createComponent(NotesPageComponent); + store = TestBed.inject(NotesStore); + spaces = TestBed.inject(SpacesStore); + fixture.autoDetectChanges(); + await vi.waitFor(() => expect(spaces.spaces()).toHaveLength(SPACES.length)); + }); + + it('renders the toolbar with the store search/filter state and the active space', async () => { + store.setSearchQuery('hello'); + store.setFilter('pinned'); + spaces.selectSpace('work'); + await fixture.whenStable(); + + expect(child(SpaceSwitcherComponent).spaces()).toEqual(SPACES); + expect(child(SpaceSwitcherComponent).activeSpace()).toEqual(SPACES[1]); + expect(child(SearchBoxComponent).query()).toBe('hello'); + expect(child(FilterChipsComponent).active()).toBe('pinned'); + }); + + it('starts on "all spaces"', async () => { + await fixture.whenStable(); + + expect(child(SpaceSwitcherComponent).activeSpace()).toBeNull(); + }); + + it('updates the active space when the switcher reports a space change', async () => { + child(SpaceSwitcherComponent).spaceChanged.emit('work'); + await fixture.whenStable(); + + expect(child(SpaceSwitcherComponent).activeSpace()).toEqual(SPACES[1]); + }); + + it('goes back to "all spaces" when the switcher reports a null space', async () => { + child(SpaceSwitcherComponent).spaceChanged.emit('work'); + await fixture.whenStable(); + + child(SpaceSwitcherComponent).spaceChanged.emit(null); + await fixture.whenStable(); + + expect(child(SpaceSwitcherComponent).activeSpace()).toBeNull(); + }); + + it('creates a space when the switcher reports one', () => { + const createSpace = vi.spyOn(spaces, 'createSpace').mockResolvedValue(null); + + child(SpaceSwitcherComponent).spaceCreated.emit('Side project'); + + expect(createSpace).toHaveBeenCalledWith('Side project'); + }); + + it('delegates search, filter and new-note requests to the store', () => { + const setSearchQuery = vi.spyOn(store, 'setSearchQuery'); + const setFilter = vi.spyOn(store, 'setFilter'); + const createNoteSpy = vi.spyOn(store, 'createNote').mockResolvedValue(); + + // `query` is a model signal: writing it is what emits `queryChange`. + child(SearchBoxComponent).query.set('term'); + child(FilterChipsComponent).filterChanged.emit('untriaged'); + fixture.debugElement.query(By.css('.new-note-btn')).triggerEventHandler('click'); + + expect(setSearchQuery).toHaveBeenCalledWith('term'); + expect(setFilter).toHaveBeenCalledWith('untriaged'); + expect(createNoteSpy).toHaveBeenCalled(); + }); + + it('disables the search shortcut while the editor overlay is open', async () => { + // Otherwise Ctrl+K focuses a field sitting behind the modal. + expect(child(SearchBoxComponent).shortcutEnabled()).toBe(true); + + store.openNote('note-42'); + await fixture.whenStable(); + + expect(child(SearchBoxComponent).shortcutEnabled()).toBe(false); + }); + + it('delegates tag toggling from the tag rail to the store', () => { + const toggleTag = vi.spyOn(store, 'toggleTag'); + + child(TagRailComponent).tagToggled.emit('urgent'); + + expect(toggleTag).toHaveBeenCalledWith('urgent'); + }); + + describe('canvas', () => { + beforeEach(async () => { + repository.setView({ + sections: [ + // The note must sit in a section: the store resolves an opened note by + // looking it up in the view it currently shows. + createSection('pinned', [createNote({ id: 'note-42' })]), + createSection('week', [], { showCreateGhost: true }), + ], + }); + store.reload(); + await vi.waitFor(() => expect(sections()).toHaveLength(2)); + }); + + it('renders one note-section per section, forwarding the selected note id', async () => { + store.openNote('note-42'); + await fixture.whenStable(); + + expect(sections().map((s) => s.section().key)).toEqual(['pinned', 'week']); + expect(sections().every((s) => s.selectedNoteId() === 'note-42')).toBe(true); + }); + + it('delegates note opening, create and reload requests to the store', () => { + const openNote = vi.spyOn(store, 'openNote'); + const createNoteSpy = vi.spyOn(store, 'createNote').mockResolvedValue(); + + sections()[0].noteOpened.emit('note-1'); + sections()[0].createRequested.emit(); + + expect(openNote).toHaveBeenCalledWith('note-1'); + expect(createNoteSpy).toHaveBeenCalled(); + }); + }); + + describe('canvas states', () => { + it('shows a loading message instead of the sections while loading', async () => { + // The store reports loading only until its first view lands, so this + // needs its own fixture whose very first query stays in flight. + TestBed.resetTestingModule(); + const held = new FakeNotesRepository([createNote({ id: 'note-42' })]); + held.hold(); + TestBed.configureTestingModule({ + imports: [NotesPageComponent], + providers: [provideAppTesting({ notesRepository: held, spaces: SPACES })], + }); + const loading = TestBed.createComponent(NotesPageComponent); + loading.autoDetectChanges(); + + await vi.waitFor(() => expect(loading.nativeElement.textContent).toContain('Chargement des notes')); + + expect(loading.debugElement.queryAll(By.directive(NoteSectionComponent))).toHaveLength(0); + expect(loading.nativeElement.querySelector('.canvas').getAttribute('aria-busy')).toBe('true'); + held.release(); + }); + + it('shows an empty-search message instead of an empty results section', async () => { + // "No results" is the backend's verdict, not something the page recomputes: + // it reports a filtered view that matched nothing. + repository.setView({ sections: [], isFiltering: true, matched: 0 }); + + store.setFilter('pinned'); + await vi.waitFor(() => + expect(fixture.nativeElement.textContent).toContain('Aucune note ne correspond'), + ); + + expect(sections()).toHaveLength(0); + }); + + it('shows the load failure with its detail and offers a retry', async () => { + // A failed load empties the whole screen, so it gets its own recovery + // path rather than relying on the global banner alone. + repository.failNext = new Error('database is locked'); + store.reload(); + await vi.waitFor(() => + expect(fixture.nativeElement.textContent).toContain('Impossible de charger les notes'), + ); + + expect(fixture.nativeElement.textContent).toContain('database is locked'); + expect(fixture.nativeElement.querySelector('.canvas-state').getAttribute('role')).toBe('alert'); + expect(sections()).toHaveLength(0); + // The error branch comes first in the template: a failed load must not + // also read as "still loading". + expect(fixture.nativeElement.textContent).not.toContain('Chargement des notes'); + }); + + it('reloads when the retry button is clicked', async () => { + repository.failNext = new Error('nope'); + store.reload(); + await vi.waitFor(() => expect(fixture.nativeElement.querySelector('.canvas-retry')).not.toBeNull()); + const reload = vi.spyOn(store, 'reload').mockImplementation(() => undefined); + + fixture.debugElement.query(By.css('.canvas-retry')).triggerEventHandler('click'); + + expect(reload).toHaveBeenCalled(); + }); + }); + + describe('editor overlay', () => { + it('is not rendered until a note is selected', () => { + // The overlay never reports which note it holds — the store owns that — so + // it simply does not exist while nothing is open, and no stray event can + // be applied to whatever note happens to be around. + expect(maybeChild(NoteEditorOverlayComponent)).toBeNull(); + }); + + it('closes the overlay via the store when the editor overlay reports closed', async () => { + store.openNote('note-42'); + await fixture.whenStable(); + const closeOverlay = vi.spyOn(store, 'closeOverlay'); + + child(NoteEditorOverlayComponent).closed.emit(); + + expect(closeOverlay).toHaveBeenCalled(); + }); + + it('renames the selected note when the editor overlay reports a title change', async () => { + store.openNote('note-42'); + await fixture.whenStable(); + const renameNote = vi.spyOn(store, 'renameNote').mockResolvedValue(); + + child(NoteEditorOverlayComponent).titleChanged.emit('New title'); + + expect(renameNote).toHaveBeenCalledWith('note-42', 'New title'); + }); + + it('toggles the pin state of the selected note when the overlay reports pinToggled', async () => { + store.openNote('note-42'); + await fixture.whenStable(); + const togglePinned = vi.spyOn(store, 'togglePinned').mockResolvedValue(); + + child(NoteEditorOverlayComponent).pinToggled.emit(); + + expect(togglePinned).toHaveBeenCalledWith('note-42'); + }); + + it('applies the remaining editor changes to the selected note', async () => { + store.openNote('note-42'); + await fixture.whenStable(); + const updateContent = vi.spyOn(store, 'updateContent').mockResolvedValue(); + const setLanguage = vi.spyOn(store, 'setLanguage').mockResolvedValue(); + const addTag = vi.spyOn(store, 'addTag').mockResolvedValue(); + const removeTag = vi.spyOn(store, 'removeTag').mockResolvedValue(); + const deleteNote = vi.spyOn(store, 'deleteNote').mockResolvedValue(); + const overlay = child(NoteEditorOverlayComponent); + + overlay.contentChanged.emit('new body'); + overlay.languageChanged.emit('json'); + overlay.tagAdded.emit('urgent'); + overlay.tagRemoved.emit('later'); + overlay.deleteRequested.emit(); + + expect(updateContent).toHaveBeenCalledWith('note-42', 'new body'); + expect(setLanguage).toHaveBeenCalledWith('note-42', 'json'); + expect(addTag).toHaveBeenCalledWith('note-42', 'urgent'); + expect(removeTag).toHaveBeenCalledWith('note-42', 'later'); + expect(deleteNote).toHaveBeenCalledWith('note-42'); + }); + }); +}); diff --git a/src/app/features/notes/notes-page.component.ts b/src/app/features/notes/notes-page.component.ts new file mode 100644 index 0000000..3b2f7eb --- /dev/null +++ b/src/app/features/notes/notes-page.component.ts @@ -0,0 +1,60 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { NotesStore } from './state/notes.store'; +import { SpacesStore } from './state/spaces.store'; +import { FilterChipsComponent } from './ui/filter-chips/filter-chips.component'; +import { LanguageRailComponent } from './ui/language-rail/language-rail.component'; +import { NoteEditorOverlayComponent } from './ui/note-editor-overlay/note-editor-overlay.component'; +import { NoteSectionComponent } from './ui/note-section/note-section.component'; +import { SearchBoxComponent } from './ui/search-box/search-box.component'; +import { + SpaceDeletion, + SpaceRenaming, + SpaceSwitcherComponent, +} from './ui/space-switcher/space-switcher.component'; +import { TagRailComponent } from './ui/tag-rail/tag-rail.component'; + +/** + * Seule page de la feature : elle branche les deux stores sur les composants + * d'affichage. Les enfants restent purement dérivés de leurs entrées. + */ +@Component({ + selector: 'app-notes-page', + imports: [ + SpaceSwitcherComponent, + SearchBoxComponent, + FilterChipsComponent, + TagRailComponent, + LanguageRailComponent, + NoteSectionComponent, + NoteEditorOverlayComponent, + TranslocoPipe, + ], + templateUrl: './notes-page.component.html', + styleUrl: './notes-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NotesPageComponent { + protected readonly store = inject(NotesStore); + protected readonly spaces = inject(SpacesStore); + + /** Le raccourci de recherche est neutralisé tant que la modale est ouverte. */ + protected readonly searchShortcutEnabled = computed(() => this.store.selectedNote() === null); + + protected onSpaceRenamed({ id, name }: SpaceRenaming): void { + // Rien à recharger : une note ne porte que le `spaceId`, jamais le nom. + void this.spaces.renameSpace(id, name); + } + + /** + * `SpacesStore` ne connaît pas `NotesStore` — l'injecter serait un cycle. Le + * rechargement est donc enchaîné ici : les notes de l'espace supprimé ont + * changé de `spaceId` côté base, et rien ne le signalerait autrement quand la + * requête courante ne dépend pas de l'espace disparu. + */ + protected async onSpaceDeleted({ id, targetSpaceId }: SpaceDeletion): Promise { + if (await this.spaces.deleteSpace(id, targetSpaceId)) { + this.store.reload(); + } + } +} diff --git a/src/app/features/notes/notes-page/notes-page.component.html b/src/app/features/notes/notes-page/notes-page.component.html deleted file mode 100644 index 0df8e18..0000000 --- a/src/app/features/notes/notes-page/notes-page.component.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - diff --git a/src/app/features/notes/notes-page/notes-page.component.scss b/src/app/features/notes/notes-page/notes-page.component.scss deleted file mode 100644 index 96418c7..0000000 --- a/src/app/features/notes/notes-page/notes-page.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -:host { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} diff --git a/src/app/features/notes/notes-page/notes-page.component.spec.ts b/src/app/features/notes/notes-page/notes-page.component.spec.ts deleted file mode 100644 index 2229933..0000000 --- a/src/app/features/notes/notes-page/notes-page.component.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Space } from '@core/models/space.model'; -import { NotesStore } from '@core/stores/notes.store'; -import { SpacesStore } from '@core/stores/spaces.store'; -import { FakeNotesRepository } from '@testing/fake-notes-repository'; -import { createNote } from '@testing/note.fixture'; -import { provideAppTesting } from '@testing/testing.providers'; -import { NoteCanvasComponent } from '../components/note-canvas/note-canvas.component'; -import { NoteEditorOverlayComponent } from '../components/note-editor-overlay/note-editor-overlay.component'; -import { NotesTopbarComponent } from '../components/notes-topbar/notes-topbar.component'; -import { TagRailComponent } from '../components/tag-rail/tag-rail.component'; -import { NotesPageComponent } from './notes-page.component'; - -const SPACES: readonly Space[] = [ - { id: 'space-1', name: 'Space one' }, - { id: 'work', name: 'Work' }, -]; - -describe('NotesPageComponent', () => { - let fixture: ComponentFixture; - let store: NotesStore; - let spaces: SpacesStore; - let repository: FakeNotesRepository; - - function child(type: new (...args: never[]) => T): T { - return fixture.debugElement.query(By.directive(type)).componentInstance as T; - } - - beforeEach(async () => { - TestBed.resetTestingModule(); - repository = new FakeNotesRepository([createNote({ id: 'note-42' })]); - TestBed.configureTestingModule({ - imports: [NotesPageComponent], - providers: [provideAppTesting({ notesRepository: repository, spaces: SPACES })], - }); - fixture = TestBed.createComponent(NotesPageComponent); - store = TestBed.inject(NotesStore); - spaces = TestBed.inject(SpacesStore); - fixture.autoDetectChanges(); - await vi.waitFor(() => expect(spaces.spaces()).toHaveLength(SPACES.length)); - }); - - it('renders the topbar with the store search/filter state and the active space', async () => { - store.setSearchQuery('hello'); - store.setFilter('pinned'); - spaces.selectSpace('work'); - await fixture.whenStable(); - - const topbar = child(NotesTopbarComponent); - expect(topbar.spaces()).toEqual(SPACES); - expect(topbar.activeSpace()).toEqual(SPACES[1]); - expect(topbar.searchQuery()).toBe('hello'); - expect(topbar.activeFilter()).toBe('pinned'); - }); - - it('starts on "all spaces"', async () => { - await fixture.whenStable(); - - expect(child(NotesTopbarComponent).activeSpace()).toBeNull(); - }); - - it('updates the active space when the topbar reports a space change', async () => { - child(NotesTopbarComponent).spaceChanged.emit('work'); - await fixture.whenStable(); - - expect(child(NotesTopbarComponent).activeSpace()).toEqual(SPACES[1]); - }); - - it('goes back to "all spaces" when the topbar reports a null space', async () => { - child(NotesTopbarComponent).spaceChanged.emit('work'); - await fixture.whenStable(); - - child(NotesTopbarComponent).spaceChanged.emit(null); - await fixture.whenStable(); - - expect(child(NotesTopbarComponent).activeSpace()).toBeNull(); - }); - - it('creates a space when the topbar reports one', () => { - const createSpace = vi.spyOn(spaces, 'createSpace').mockResolvedValue(null); - - child(NotesTopbarComponent).spaceCreated.emit('Side project'); - - expect(createSpace).toHaveBeenCalledWith('Side project'); - }); - - it('delegates search, filter and new-note requests from the topbar to the store', () => { - const setSearchQuery = vi.spyOn(store, 'setSearchQuery'); - const setFilter = vi.spyOn(store, 'setFilter'); - const createNoteSpy = vi.spyOn(store, 'createNote').mockResolvedValue(); - const topbar = child(NotesTopbarComponent); - - topbar.searchQueryChanged.emit('term'); - topbar.filterChanged.emit('untriaged'); - topbar.newNoteRequested.emit(); - - expect(setSearchQuery).toHaveBeenCalledWith('term'); - expect(setFilter).toHaveBeenCalledWith('untriaged'); - expect(createNoteSpy).toHaveBeenCalled(); - }); - - it('disables the search shortcut while the editor overlay is open', async () => { - // Otherwise Ctrl+K focuses a field sitting behind the modal. - expect(child(NotesTopbarComponent).searchShortcutEnabled()).toBe(true); - - store.openNote('note-42'); - await fixture.whenStable(); - - expect(child(NotesTopbarComponent).searchShortcutEnabled()).toBe(false); - }); - - it('delegates tag toggling from the tag rail to the store', () => { - const toggleTag = vi.spyOn(store, 'toggleTag'); - - child(TagRailComponent).tagToggled.emit('urgent'); - - expect(toggleTag).toHaveBeenCalledWith('urgent'); - }); - - it('delegates note opening, create and reload requests from the canvas to the store', () => { - const openNote = vi.spyOn(store, 'openNote'); - const createNoteSpy = vi.spyOn(store, 'createNote').mockResolvedValue(); - const reload = vi.spyOn(store, 'reload').mockImplementation(() => undefined); - const canvas = child(NoteCanvasComponent); - - canvas.noteOpened.emit('note-1'); - canvas.createRequested.emit(); - canvas.reloadRequested.emit(); - - expect(openNote).toHaveBeenCalledWith('note-1'); - expect(createNoteSpy).toHaveBeenCalled(); - expect(reload).toHaveBeenCalled(); - }); - - it('passes the store loading and empty-result state to the canvas', async () => { - // "No results" is the backend's verdict, not something the page recomputes: - // it reports a filtered view that matched nothing. - repository.setView({ sections: [], isFiltering: true, matched: 0 }); - - store.setFilter('pinned'); - await vi.waitFor(() => expect(child(NoteCanvasComponent).hasNoResults()).toBe(true)); - - expect(child(NoteCanvasComponent).isLoading()).toBe(false); - }); - - it('closes the overlay via the store when the editor overlay reports closed', () => { - const closeOverlay = vi.spyOn(store, 'closeOverlay'); - - child(NoteEditorOverlayComponent).closed.emit(); - - expect(closeOverlay).toHaveBeenCalled(); - }); - - it('renames the selected note when the editor overlay reports a title change', () => { - store.openNote('note-42'); - const renameNote = vi.spyOn(store, 'renameNote').mockResolvedValue(); - - child(NoteEditorOverlayComponent).titleChanged.emit('New title'); - - expect(renameNote).toHaveBeenCalledWith('note-42', 'New title'); - }); - - it('does nothing on title change when no note is selected', () => { - const renameNote = vi.spyOn(store, 'renameNote').mockResolvedValue(); - - child(NoteEditorOverlayComponent).titleChanged.emit('New title'); - - expect(renameNote).not.toHaveBeenCalled(); - }); - - it('toggles the pin state of the selected note when the editor overlay reports pinToggled', () => { - store.openNote('note-42'); - const togglePinned = vi.spyOn(store, 'togglePinned').mockResolvedValue(); - - child(NoteEditorOverlayComponent).pinToggled.emit(); - - expect(togglePinned).toHaveBeenCalledWith('note-42'); - }); - - it('does nothing on pinToggled when no note is selected', () => { - const togglePinned = vi.spyOn(store, 'togglePinned').mockResolvedValue(); - - child(NoteEditorOverlayComponent).pinToggled.emit(); - - expect(togglePinned).not.toHaveBeenCalled(); - }); - - it('applies the remaining editor changes to the selected note', () => { - store.openNote('note-42'); - const updateContent = vi.spyOn(store, 'updateContent').mockResolvedValue(); - const setLanguage = vi.spyOn(store, 'setLanguage').mockResolvedValue(); - const addTag = vi.spyOn(store, 'addTag').mockResolvedValue(); - const removeTag = vi.spyOn(store, 'removeTag').mockResolvedValue(); - const deleteNote = vi.spyOn(store, 'deleteNote').mockResolvedValue(); - const overlay = child(NoteEditorOverlayComponent); - - overlay.contentChanged.emit('new body'); - overlay.languageChanged.emit('json'); - overlay.tagAdded.emit('urgent'); - overlay.tagRemoved.emit('later'); - overlay.deleteRequested.emit(); - - expect(updateContent).toHaveBeenCalledWith('note-42', 'new body'); - expect(setLanguage).toHaveBeenCalledWith('note-42', 'json'); - expect(addTag).toHaveBeenCalledWith('note-42', 'urgent'); - expect(removeTag).toHaveBeenCalledWith('note-42', 'later'); - expect(deleteNote).toHaveBeenCalledWith('note-42'); - }); - - it('ignores editor changes when no note is selected', () => { - // The overlay never reports which note it holds — the store owns that — so - // a stray event must not be applied to whatever note happens to be around. - const updateContent = vi.spyOn(store, 'updateContent').mockResolvedValue(); - const deleteNote = vi.spyOn(store, 'deleteNote').mockResolvedValue(); - const overlay = child(NoteEditorOverlayComponent); - - overlay.contentChanged.emit('new body'); - overlay.deleteRequested.emit(); - - expect(updateContent).not.toHaveBeenCalled(); - expect(deleteNote).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/features/notes/notes-page/notes-page.component.ts b/src/app/features/notes/notes-page/notes-page.component.ts deleted file mode 100644 index 0b5b787..0000000 --- a/src/app/features/notes/notes-page/notes-page.component.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; -import { LanguageTag } from '@core/models/language.model'; -import { NoteLifecycle } from '@core/models/note.model'; -import { NotesStore } from '@core/stores/notes.store'; -import { SpacesStore } from '@core/stores/spaces.store'; -import { SpaceDeletion, SpaceRenaming } from '../components/space-switcher/space-switcher.component'; -import { LanguageRailComponent } from '../components/language-rail/language-rail.component'; -import { NoteCanvasComponent } from '../components/note-canvas/note-canvas.component'; -import { NoteEditorOverlayComponent } from '../components/note-editor-overlay/note-editor-overlay.component'; -import { NotesTopbarComponent } from '../components/notes-topbar/notes-topbar.component'; -import { TagRailComponent } from '../components/tag-rail/tag-rail.component'; - -@Component({ - selector: 'app-notes-page', - imports: [ - NotesTopbarComponent, - TagRailComponent, - LanguageRailComponent, - NoteCanvasComponent, - NoteEditorOverlayComponent, - ], - templateUrl: './notes-page.component.html', - styleUrl: './notes-page.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class NotesPageComponent { - protected readonly store = inject(NotesStore); - protected readonly spaces = inject(SpacesStore); - - /** Le raccourci de recherche est neutralisé tant que la modale est ouverte. */ - protected readonly searchShortcutEnabled = computed(() => this.store.selectedNote() === null); - - protected onSpaceRenamed({ id, name }: SpaceRenaming): void { - // Rien à recharger : une note ne porte que le `spaceId`, jamais le nom. - void this.spaces.renameSpace(id, name); - } - - /** - * `SpacesStore` ne connaît pas `NotesStore` — l'injecter serait un cycle. Le - * rechargement est donc enchaîné ici : les notes de l'espace supprimé ont - * changé de `spaceId` côté base, et rien ne le signalerait autrement quand la - * requête courante ne dépend pas de l'espace disparu. - */ - protected async onSpaceDeleted({ id, targetSpaceId }: SpaceDeletion): Promise { - if (await this.spaces.deleteSpace(id, targetSpaceId)) { - this.store.reload(); - } - } - - protected onTitleChanged(title: string): void { - this.withSelectedNote((id) => this.store.renameNote(id, title)); - } - - protected onContentChanged(content: string): void { - this.withSelectedNote((id) => this.store.updateContent(id, content)); - } - - protected onLanguageChanged(language: LanguageTag): void { - this.withSelectedNote((id) => this.store.setLanguage(id, language)); - } - - protected onTagAdded(tag: string): void { - this.withSelectedNote((id) => this.store.addTag(id, tag)); - } - - protected onTagRemoved(tag: string): void { - this.withSelectedNote((id) => this.store.removeTag(id, tag)); - } - - protected onPinToggled(): void { - this.withSelectedNote((id) => this.store.togglePinned(id)); - } - - protected onLifecycleChanged(lifecycle: NoteLifecycle): void { - this.withSelectedNote((id) => this.store.setLifecycle(id, lifecycle)); - } - - protected onDeleteRequested(): void { - this.withSelectedNote((id) => this.store.deleteNote(id)); - } - - /** - * L'éditeur n'émet jamais l'identifiant de la note qu'il affiche : c'est le - * store qui décide de la note ouverte, et le lui faire renvoyer ouvrirait la - * porte à une modification appliquée à une note qui n'est plus la bonne. - */ - private withSelectedNote(action: (id: string) => Promise): void { - const id = this.store.selectedNoteId(); - if (id) { - void action(id); - } - } -} diff --git a/src/app/core/stores/notes.store.spec.ts b/src/app/features/notes/state/notes.store.spec.ts similarity index 98% rename from src/app/core/stores/notes.store.spec.ts rename to src/app/features/notes/state/notes.store.spec.ts index e594ea0..10b5cd5 100644 --- a/src/app/core/stores/notes.store.spec.ts +++ b/src/app/features/notes/state/notes.store.spec.ts @@ -2,11 +2,11 @@ import { WritableSignal, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ErrorNotifier } from '@core/errors/error-notifier.service'; -import { IpcError } from '@core/ipc/ipc.service'; -import { Note } from '@core/models/note.model'; -import { NotesView } from '@core/models/notes-query.model'; -import { Space } from '@core/models/space.model'; -import { SpacesStore } from '@core/stores/spaces.store'; +import { IpcError } from '@core/ipc/ipc.error'; +import { Note } from '../model/note.model'; +import { NotesView } from '../model/note.model'; +import { Space } from '../model/space.model'; +import { SpacesStore } from './spaces.store'; import { ClockService } from '@core/time/clock.service'; import { FakeNotesRepository } from '@testing/fake-notes-repository'; import { createNote } from '@testing/note.fixture'; diff --git a/src/app/core/stores/notes.store.ts b/src/app/features/notes/state/notes.store.ts similarity index 51% rename from src/app/core/stores/notes.store.ts rename to src/app/features/notes/state/notes.store.ts index df54c2e..7d29087 100644 --- a/src/app/core/stores/notes.store.ts +++ b/src/app/features/notes/state/notes.store.ts @@ -9,26 +9,28 @@ import { signal, untracked, } from '@angular/core'; -import { NOTES_REPOSITORY } from '../data/notes-repository.token'; -import { ErrorNotifier } from '../errors/error-notifier.service'; -import { ipcNotice } from '../errors/ipc-notice'; -import { FALLBACK_LANGUAGE, LanguageTag } from '../models/language.model'; -import { NoteSection } from '../models/note-section.model'; -import { Note, NoteDraft, NoteLifecycle, NotePatch } from '../models/note.model'; -import { NoteFilter, NotesQuery, NotesView } from '../models/notes-query.model'; -import { ClockService } from '../time/clock.service'; +import { NOTES_REPOSITORY } from '../data/notes.repository'; +import { ErrorNotifier } from '@core/errors/error-notifier.service'; +import { FALLBACK_LANGUAGE, LanguageTag } from '@core/language/language.model'; +import { + Note, + NoteDraft, + NoteFilter, + NoteLifecycle, + NotePatch, + NoteSection, + NotesQuery, + NotesView, +} from '../model/note.model'; +import { ClockService } from '@core/time/clock.service'; import { SpacesStore } from './spaces.store'; -export type { NoteFilter } from '../models/notes-query.model'; +export type { NoteFilter } from '../model/note.model'; -/** - * Délai avant qu'une frappe ne parte en requête. Chaque caractère déclencherait - * sinon un aller-retour IPC ; 150 ms couvrent une frappe continue tout en - * restant sous le seuil de perception. - */ +/** La recherche traverse le pont IPC : un appel par caractère serait gâché. */ export const SEARCH_DEBOUNCE_MS = 150; -/** Clé de journée **locale**, utilisée pour ne re-interroger qu'au changement de jour. */ +/** Journée **locale** : on ne re-interroge qu'au changement de jour. */ function localDayKey(now: Date): string { return `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`; } @@ -47,16 +49,15 @@ function sameFacets(a: readonly string[], b: readonly string[]): boolean { } /** - * Égalité **par valeur** de deux cycles de vie : `Date` se compare par identité, - * donc rejouer la même échéance déclencherait sinon une écriture inutile à - * chaque passage dans le champ date. + * Par **valeur** : `Date` se compare par identité, donc rejouer la même échéance + * déclencherait une écriture à chaque passage dans le champ date. */ function sameLifecycle(a: NoteLifecycle, b: NoteLifecycle): boolean { if (a.kind !== b.kind) return false; return a.kind !== 'expires' || a.at.getTime() === (b as { at: Date }).at.getTime(); } -/** Sélection de facettes avec `value` basculée. Un ensemble neuf, jamais muté. */ +/** Un ensemble neuf, jamais muté. */ function toggled(selection: ReadonlySet, value: T): ReadonlySet { const next = new Set(selection); if (!next.delete(value)) { @@ -66,10 +67,8 @@ function toggled(selection: ReadonlySet, value: T): ReadonlySet { } /** - * Égalité **par valeur** des critères de requête. - * - * `resource` compare ses paramètres par identité (`===`) : sans ce comparateur, - * le littéral neuf que produit `queryParams` à chaque battement d'horloge + * ⚠️ `resource` compare ses paramètres par identité : sans ce comparateur, le + * littéral neuf que produit `queryParams` à chaque battement d'horloge * relancerait une requête toutes les 30 s, masquée par le cache de vue. */ function sameQueryParams(a: QueryParams, b: QueryParams): boolean { @@ -84,20 +83,12 @@ function sameQueryParams(a: QueryParams, b: QueryParams): boolean { } /** - * État des notes. - * - * Ce store ne filtre pas, ne trie pas et ne regroupe pas : il décrit ce que - * l'utilisateur demande (espace, recherche, filtre, tags, langages) et affiche la - * **vue** que le backend renvoie. Toute la logique correspondante vit dans - * `src-tauri/src/storage/` — voir `docs/architecture.md`. + * État des notes. Ne filtre pas, ne trie pas, ne regroupe pas : décrit ce que + * l'utilisateur demande et affiche la **vue** que le backend renvoie. * - * Deux principes structurent la classe : - * - * 1. **Les signaux inscriptibles restent privés**, exposés en lecture seule. - * Toute mutation passe donc par une méthode. - * 2. **Le backend fait autorité.** Une écriture n'est pas appliquée localement : - * on persiste, puis on recharge la vue. Rien à annuler en cas d'échec, et - * aucun risque que l'écran diverge de la base. + * Deux principes : les signaux inscriptibles restent privés (toute mutation + * passe par une méthode), et **le backend fait autorité** — on persiste puis on + * recharge, donc rien à annuler en cas d'échec. */ @Injectable({ providedIn: 'root' }) export class NotesStore { @@ -124,12 +115,9 @@ export class NotesStore { private searchTimeout: ReturnType | null = null; /** - * Critères déclenchant une nouvelle requête. L'instant exact n'en fait pas - * partie — seule la **journée** compte pour le découpage en sections. - * - * ⚠️ Le comparateur `equal` est indispensable : `localDayKey` stabilise la - * *valeur* du jour, mais l'objet qui l'enveloppe est neuf à chaque battement - * d'horloge, et `resource` compare ses paramètres par identité. + * Critères déclenchant une requête. L'instant exact n'en fait pas partie : + * seule la **journée** compte pour le découpage en sections. Le comparateur + * `equal` est indispensable, voir `sameQueryParams`. */ private readonly queryParams = computed( () => ({ @@ -163,16 +151,12 @@ export class NotesStore { }); /** - * Dernière vue obtenue, conservée pendant les rechargements. - * - * Sans ça, chaque frappe viderait le canevas le temps de l'aller-retour et - * l'écran clignoterait entre « Chargement… » et les résultats. Une vue - * légèrement en retard vaut mieux qu'une vue absente. + * Dernière vue obtenue, conservée pendant les rechargements : sans ça, chaque + * frappe viderait le canevas et l'écran clignoterait. * - * ⚠️ Un `linkedSignal` ne retient que ce qu'il a **vu passer** : sa valeur - * n'est recalculée qu'à la lecture. Tout ce que ce store expose lit donc - * `view()`, et sans court-circuit (cf. `isLoading`) — sans quoi une vue - * chargée puis remplacée entre deux lectures serait perdue. + * ⚠️ Un `linkedSignal` ne retient que ce qu'il a **vu passer**, sa valeur + * n'étant recalculée qu'à la lecture. Tout ce que ce store expose lit donc + * `view()`, et sans court-circuit (cf. `isLoading`). */ private readonly view = linkedSignal({ source: () => (this.viewResource.hasValue() ? this.viewResource.value() : undefined), @@ -191,12 +175,11 @@ export class NotesStore { }); /** - * Vrai uniquement tant qu'aucune vue n'a jamais été obtenue : un rechargement - * ultérieur laisse les résultats précédents à l'écran. + * Vrai seulement tant qu'aucune vue n'a jamais été obtenue. * - * `view()` est lu **avant** l'état de la ressource, et non après : un `&&` - * dans l'autre sens court-circuiterait la lecture dès que le chargement est - * terminé, et la vue fraîchement chargée ne serait jamais retenue. + * ⚠️ `view()` est lu **avant** l'état de la ressource : un `&&` dans l'autre + * sens court-circuiterait la lecture dès le chargement terminé, et la vue + * fraîchement chargée ne serait jamais retenue. */ readonly isLoading = computed(() => { const hasView = this.view() !== null; @@ -213,10 +196,7 @@ export class NotesStore { this.viewResource.reload(); } - /** - * Met le champ à jour immédiatement et diffère la requête : la recherche - * traverse le pont IPC, un appel par caractère serait gâché. - */ + /** Met le champ à jour immédiatement, diffère la requête. */ setSearchQuery(query: string): void { this._searchQuery.set(query); this.cancelPendingSearch(); @@ -246,88 +226,64 @@ export class NotesStore { this._selectedNote.set(null); } - async togglePinned(id: string): Promise { - const target = this.find(id); - if (!target) return; - - await this.persist(id, { pinned: !target.pinned }); + togglePinned(id: string): Promise { + return this.edit(id, (note) => ({ pinned: !note.pinned })); } - async renameNote(id: string, title: string): Promise { - const target = this.find(id); - // L'éditeur peut confirmer un titre inchangé (fermeture sans modification) : - // ne rien persister dans ce cas. - if (!target || target.title === title) return; - - await this.persist(id, { title }); + /** + * L'éditeur confirme aussi un titre inchangé (fermeture sans modification), + * d'où le `null` : rien à persister. + */ + renameNote(id: string, title: string): Promise { + return this.edit(id, (note) => (note.title === title ? null : { title })); } - async updateContent(id: string, content: string): Promise { - const target = this.find(id); - if (!target || target.content === content) return; - - await this.persist(id, { content }); + updateContent(id: string, content: string): Promise { + return this.edit(id, (note) => (note.content === content ? null : { content })); } /** - * Range la note dans un autre espace. Le `spaceId` est le seul champ que le - * front pousse sans que l'utilisateur l'ait saisi : c'est aussi le seul dont - * le stockage refuse la valeur si l'espace n'existe plus (`spaceGone`). + * `spaceId` est le seul champ que le front pousse sans saisie de + * l'utilisateur, et le seul dont le stockage refuse la valeur si l'espace + * n'existe plus. */ - async moveNote(id: string, spaceId: string): Promise { - const target = this.find(id); - if (!target || target.spaceId === spaceId) return; - - await this.persist(id, { spaceId }); + moveNote(id: string, spaceId: string): Promise { + return this.edit(id, (note) => (note.spaceId === spaceId ? null : { spaceId })); } - async setLanguage(id: string, language: LanguageTag): Promise { - const target = this.find(id); - if (!target || target.language === language) return; - - await this.persist(id, { language }); + setLanguage(id: string, language: LanguageTag): Promise { + return this.edit(id, (note) => (note.language === language ? null : { language })); } /** - * Pose ou retire l'échéance d'une note. Une note éphémère est celle dont on - * n'a pas encore décidé du sort : c'est cette écriture, et elle seule, qui - * alimente le filtre « À trier » et l'indice « à trier bientôt » des sections. + * Pose ou retire l'échéance. C'est cette écriture, et elle seule, qui alimente + * le filtre « À trier » et l'indice « à trier bientôt » des sections. */ - async setLifecycle(id: string, lifecycle: NoteLifecycle): Promise { - const target = this.find(id); - if (!target || sameLifecycle(target.lifecycle, lifecycle)) return; - - await this.persist(id, { lifecycle }); + setLifecycle(id: string, lifecycle: NoteLifecycle): Promise { + return this.edit(id, (note) => (sameLifecycle(note.lifecycle, lifecycle) ? null : { lifecycle })); } /** - * Ajoute un tag. Aucune normalisation ici : trim, `#` de tête et doublons sont - * tranchés par `domain::tags::normalize`, seul endroit où la règle - * vit. Le front envoie ce que l'utilisateur a tapé. + * Aucune normalisation ici : trim, `#` de tête et doublons sont tranchés par + * `domain::rules::normalize_tags`, seul endroit où la règle vit. */ - async addTag(id: string, tag: string): Promise { - const target = this.find(id); - if (!target) return; - - await this.persist(id, { tags: [...target.tags, tag] }); + addTag(id: string, tag: string): Promise { + return this.edit(id, (note) => ({ tags: [...note.tags, tag] })); } - async removeTag(id: string, tag: string): Promise { - const target = this.find(id); - if (!target || !target.tags.includes(tag)) return; - - await this.persist(id, { tags: target.tags.filter((existing) => existing !== tag) }); + removeTag(id: string, tag: string): Promise { + return this.edit(id, (note) => + note.tags.includes(tag) ? { tags: note.tags.filter((existing) => existing !== tag) } : null, + ); } /** - * Crée une note vide dans l'espace actif et l'ouvre. L'identifiant est - * attribué par la persistance : afficher la note sous un identifiant - * provisoire obligerait à le corriger après coup, sélection comprise. + * Crée une note vide dans l'espace actif et l'ouvre ; l'identifiant est + * attribué par la persistance. * - * Sans espace actif (mode « tous les espaces »), la note part dans le premier - * espace : il faut bien en choisir un, et le premier est celui que le - * sélecteur montre en tête. S'il n'existe aucun espace, la création échoue — - * une note sans espace serait invisible dès qu'un filtre d'espace est posé. + * En mode « tous les espaces », la note part dans le premier — il faut bien en + * choisir un. Sans aucun espace la création échoue : une note sans espace + * serait invisible dès qu'un filtre d'espace est posé. */ async createNote(): Promise { const spaceId = this.spaces.activeSpaceId() ?? this.spaces.spaces()[0]?.id; @@ -355,7 +311,7 @@ export class NotesStore { this._selectedNote.set(created); this.reload(); } catch (error) { - this.reportFailure('errors.noteCreateFailed', error); + this.notifier.reportFailure('errors.noteCreateFailed', error); } } @@ -369,18 +325,29 @@ export class NotesStore { } this.reload(); } catch (error) { - this.reportFailure('errors.noteDeleteFailed', error); + this.notifier.reportFailure('errors.noteDeleteFailed', error); } } /** - * Persiste puis recharge. La note renvoyée fait autorité : elle est adoptée - * telle quelle dans l'éditeur, car elle porte ce que le backend a réellement - * écrit (`updatedAt`, tags normalisés, pied de carte). + * Squelette commun des huit écritures : retrouver la note, décider du patch, + * persister. `changes` renvoie `null` quand rien n'a bougé — une note + * introuvable et une modification nulle ne produisent aucun aller-retour. + */ + private async edit(id: string, changes: (note: Note) => NotePatch | null): Promise { + const target = this.find(id); + const patch = target && changes(target); + if (patch) { + await this.persist(id, patch); + } + } + + /** + * Persiste puis recharge. La note renvoyée fait autorité : elle porte ce que + * le backend a réellement écrit (`updatedAt`, tags normalisés, pied de carte). * - * Le patch est typé `NotePatch` et non `Partial` : le second laisserait - * passer `id`, `createdAt` ou `footer` jusqu'à la frontière du dépôt, où seule - * la recopie manuelle de `toNotePatchDto` les arrêterait. + * `NotePatch` et non `Partial` : le second laisserait passer `id`, + * `createdAt` ou `footer` jusqu'à la frontière du dépôt. */ private async persist(id: string, patch: NotePatch): Promise { try { @@ -390,14 +357,13 @@ export class NotesStore { } this.reload(); } catch (error) { - this.reportFailure('errors.noteSaveFailed', error); + this.notifier.reportFailure('errors.noteSaveFailed', error); } } /** - * La note ouverte est consultée en premier : elle peut être sortie de la vue - * filtrée depuis son ouverture (retrait de son dernier tag filtrant) sans - * cesser d'être éditable. + * La note ouverte est consultée en premier : elle a pu sortir de la vue + * filtrée depuis son ouverture sans cesser d'être éditable. */ private find(id: string): Note | null { const selected = this._selectedNote(); @@ -416,14 +382,4 @@ export class NotesStore { this.searchTimeout = null; } } - - /** - * `key` décrit l'action tentée ; si le back a nommé la cause, `ipcNotice` lui - * donne la priorité — « cette note n'existe plus » est plus utile que - * « impossible d'enregistrer ». - */ - private reportFailure(key: string, error: unknown): void { - console.error(error); - this.notifier.notify(ipcNotice(error, { key })); - } } diff --git a/src/app/core/stores/spaces.store.spec.ts b/src/app/features/notes/state/spaces.store.spec.ts similarity index 99% rename from src/app/core/stores/spaces.store.spec.ts rename to src/app/features/notes/state/spaces.store.spec.ts index 8d44a52..eb151f3 100644 --- a/src/app/core/stores/spaces.store.spec.ts +++ b/src/app/features/notes/state/spaces.store.spec.ts @@ -1,8 +1,8 @@ import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ErrorNotifier } from '@core/errors/error-notifier.service'; -import { IpcError } from '@core/ipc/ipc.service'; -import { Space } from '@core/models/space.model'; +import { IpcError } from '@core/ipc/ipc.error'; +import { Space } from '../model/space.model'; import { FakeSpacesRepository } from '@testing/fake-spaces-repository'; import { provideAppTesting } from '@testing/testing.providers'; import { SpacesStore } from './spaces.store'; diff --git a/src/app/core/stores/spaces.store.ts b/src/app/features/notes/state/spaces.store.ts similarity index 52% rename from src/app/core/stores/spaces.store.ts rename to src/app/features/notes/state/spaces.store.ts index d1adf45..7053a7c 100644 --- a/src/app/core/stores/spaces.store.ts +++ b/src/app/features/notes/state/spaces.store.ts @@ -1,20 +1,15 @@ import { Injectable, Signal, computed, effect, inject, resource, signal } from '@angular/core'; -import { SPACES_REPOSITORY } from '../data/spaces-repository.token'; -import { ErrorNotifier } from '../errors/error-notifier.service'; -import { ipcNotice } from '../errors/ipc-notice'; -import { Space } from '../models/space.model'; +import { SPACES_REPOSITORY } from '../data/spaces.repository'; +import { ErrorNotifier } from '@core/errors/error-notifier.service'; +import { Space } from '../model/space.model'; /** - * Espaces disponibles et espace actif. + * Espaces disponibles et espace actif. L'espace actif est un **filtre** : + * `NotesStore` le lit pour restreindre la vue, et `createNote` y range la note. * - * L'espace actif est un **filtre**, pas une simple étiquette : `NotesStore` le - * lit pour ne montrer que les notes de l'espace, et `createNote` y range la - * note créée. - * - * `null` n'est pas un état d'attente mais un choix à part entière — « tous les - * espaces ». Il n'existe donc aucune entrée « Tous » côté données : ce serait un - * espace fantôme dans lequel des notes pourraient être rangées par erreur. Le - * libellé correspondant vit dans les traductions (`notes.allSpaces`). + * `null` n'est pas un état d'attente mais un choix — « tous les espaces ». Aucune + * entrée « Tous » n'existe côté données : ce serait un espace fantôme dans lequel + * des notes pourraient être rangées par erreur. */ @Injectable({ providedIn: 'root' }) export class SpacesStore { @@ -36,9 +31,8 @@ export class SpacesStore { private readonly _activeSpaceId = signal(null); /** - * Identifiant retenu pour le filtrage, `null` valant « tous les espaces ». - * Un identifiant qui ne correspond à aucun espace chargé retombe sur `null` - * plutôt que de masquer toutes les notes. + * `null` vaut « tous les espaces ». Un identifiant inconnu y retombe plutôt + * que de masquer toutes les notes. */ readonly activeSpaceId = computed(() => this.activeSpace()?.id ?? null); @@ -48,9 +42,8 @@ export class SpacesStore { }); constructor() { - // Contrairement aux notes, un échec de chargement des espaces ne vide aucun - // écran : le sélecteur affiche simplement « tous les espaces ». Sans - // bandeau, la panne passerait donc totalement inaperçue. + // Contrairement aux notes, un échec ici ne vide aucun écran : le sélecteur + // affiche « tous les espaces ». Sans bandeau, la panne passerait inaperçue. effect(() => { const error = this.loadError(); if (error) { @@ -69,15 +62,11 @@ export class SpacesStore { } /** - * Crée un espace et le rend actif. C'est la persistance qui attribue - * l'identifiant, et le sélectionner avant de le connaître obligerait à le - * corriger après coup. + * Crée un espace et le rend actif ; l'identifiant vient de la persistance. * - * Un nom vide est ignoré silencieusement (l'utilisateur a validé un champ - * vide). L'unicité, elle, n'est **pas** vérifiée ici : elle est tranchée par - * le stockage, qui seul voit l'état réel de la base. Le refus revient sous la - * forme d'un code (`duplicateSpaceName`), traduit ci-dessous — un message - * rédigé en Rust s'afficherait en français dans l'interface anglaise. + * Un nom vide est ignoré silencieusement. L'unicité n'est **pas** vérifiée + * ici : seul le stockage voit l'état réel de la base, et son refus revient + * sous forme de code traduit. */ async createSpace(name: string): Promise { const trimmed = name.trim(); @@ -89,20 +78,17 @@ export class SpacesStore { this.selectSpace(created.id); return created; } catch (error) { - console.error(error); // Le nom saisi sert de repli d'interpolation si le back n'en fournit pas : // « Un espace nommé {{name}} existe déjà » doit rester lisible. - this.notifier.notify(ipcNotice(error, { key: 'errors.spaceCreateFailed' }, { name: trimmed })); + this.notifier.reportFailure('errors.spaceCreateFailed', error, { name: trimmed }); return null; } } /** - * Renomme un espace. Comme la création, l'écriture n'est pas optimiste : la - * liste n'adopte que ce que la persistance a renvoyé. - * - * L'unicité est de nouveau tranchée par le stockage, qui exclut l'espace - * renommé de la comparaison — corriger la casse d'un nom est légitime. + * L'écriture n'est pas optimiste : la liste n'adopte que ce que la + * persistance a renvoyé. L'unicité y exclut l'espace renommé — corriger la + * casse d'un nom est légitime. */ async renameSpace(id: string, name: string): Promise { const trimmed = name.trim(); @@ -114,26 +100,23 @@ export class SpacesStore { this.spacesResource.set(this.spaces().map((space) => (space.id === id ? renamed : space))); return true; } catch (error) { - console.error(error); - this.notifier.notify(ipcNotice(error, { key: 'errors.spaceRenameFailed' }, { name: trimmed })); + this.notifier.reportFailure('errors.spaceRenameFailed', error, { name: trimmed }); return false; } } /** - * Supprime un espace en transférant ses notes vers `targetSpaceId`. - * - * L'espace refuge devient actif : les notes viennent d'y atterrir, et retomber - * sur « tous les espaces » ferait perdre de vue où elles sont passées. + * Supprime un espace en transférant ses notes vers `targetSpaceId`, qui + * devient actif : les notes viennent d'y atterrir, et retomber sur « tous les + * espaces » ferait perdre de vue où elles sont passées. * - * Ne recharge **pas** les notes — ce store ne connaît pas `NotesStore`, et - * l'inverse serait un cycle d'injection. C'est l'appelant qui enchaîne, d'où - * le booléen renvoyé. + * Ne recharge **pas** les notes — ce store ne connaît pas `NotesStore`, + * l'inverse serait un cycle d'injection. D'où le booléen renvoyé. */ async deleteSpace(id: string, targetSpaceId: string): Promise { - // Un espace ne peut pas être son propre refuge : la cascade du schéma - // emporterait les notes juste après le transfert. Le back refuse aussi, ce - // garde évite simplement un aller-retour pour une erreur évitable. + // Un espace ne peut pas être son propre refuge : la cascade emporterait les + // notes juste après le transfert. Le back refuse aussi ; ce garde évite + // seulement un aller-retour. if (id === targetSpaceId || !this.spaces().some((space) => space.id === targetSpaceId)) { return false; } @@ -144,8 +127,7 @@ export class SpacesStore { this.selectSpace(targetSpaceId); return true; } catch (error) { - console.error(error); - this.notifier.notify(ipcNotice(error, { key: 'errors.spaceDeleteFailed' })); + this.notifier.reportFailure('errors.spaceDeleteFailed', error); return false; } } diff --git a/src/app/features/notes/components/filter-chips/filter-chips.component.html b/src/app/features/notes/ui/filter-chips/filter-chips.component.html similarity index 100% rename from src/app/features/notes/components/filter-chips/filter-chips.component.html rename to src/app/features/notes/ui/filter-chips/filter-chips.component.html diff --git a/src/app/features/notes/components/filter-chips/filter-chips.component.scss b/src/app/features/notes/ui/filter-chips/filter-chips.component.scss similarity index 91% rename from src/app/features/notes/components/filter-chips/filter-chips.component.scss rename to src/app/features/notes/ui/filter-chips/filter-chips.component.scss index 0c2773c..f225994 100644 --- a/src/app/features/notes/components/filter-chips/filter-chips.component.scss +++ b/src/app/features/notes/ui/filter-chips/filter-chips.component.scss @@ -7,7 +7,7 @@ .chip { @include unstyled-control; - font-family: var(--font-mono); + font-family: var(--font-mono), serif; font-size: 11.5px; padding: 6px 11px; @include surface(transparent, 7px); diff --git a/src/app/features/notes/components/filter-chips/filter-chips.component.spec.ts b/src/app/features/notes/ui/filter-chips/filter-chips.component.spec.ts similarity index 100% rename from src/app/features/notes/components/filter-chips/filter-chips.component.spec.ts rename to src/app/features/notes/ui/filter-chips/filter-chips.component.spec.ts diff --git a/src/app/features/notes/components/filter-chips/filter-chips.component.ts b/src/app/features/notes/ui/filter-chips/filter-chips.component.ts similarity index 93% rename from src/app/features/notes/components/filter-chips/filter-chips.component.ts rename to src/app/features/notes/ui/filter-chips/filter-chips.component.ts index c9c8350..5a0cec4 100644 --- a/src/app/features/notes/components/filter-chips/filter-chips.component.ts +++ b/src/app/features/notes/ui/filter-chips/filter-chips.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { TranslocoPipe } from '@jsverse/transloco'; -import { NoteFilter } from '@core/stores/notes.store'; +import { NoteFilter } from '@features/notes/state/notes.store'; interface FilterChip { readonly key: NoteFilter; diff --git a/src/app/features/notes/components/language-rail/language-rail.component.html b/src/app/features/notes/ui/language-rail/language-rail.component.html similarity index 100% rename from src/app/features/notes/components/language-rail/language-rail.component.html rename to src/app/features/notes/ui/language-rail/language-rail.component.html diff --git a/src/app/features/notes/components/language-rail/language-rail.component.scss b/src/app/features/notes/ui/language-rail/language-rail.component.scss similarity index 100% rename from src/app/features/notes/components/language-rail/language-rail.component.scss rename to src/app/features/notes/ui/language-rail/language-rail.component.scss diff --git a/src/app/features/notes/components/language-rail/language-rail.component.spec.ts b/src/app/features/notes/ui/language-rail/language-rail.component.spec.ts similarity index 97% rename from src/app/features/notes/components/language-rail/language-rail.component.spec.ts rename to src/app/features/notes/ui/language-rail/language-rail.component.spec.ts index dd6619d..bd57d4c 100644 --- a/src/app/features/notes/components/language-rail/language-rail.component.spec.ts +++ b/src/app/features/notes/ui/language-rail/language-rail.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { beforeEach, describe, expect, it } from 'vitest'; -import { LanguageTag } from '@core/models/language.model'; +import { LanguageTag } from '@core/language/language.model'; import { LanguageBadgeComponent } from '@shared/ui/language-badge/language-badge.component'; import { provideTranslocoTesting } from '@testing/provide-transloco-testing'; import { LanguageRailComponent } from './language-rail.component'; diff --git a/src/app/features/notes/components/language-rail/language-rail.component.ts b/src/app/features/notes/ui/language-rail/language-rail.component.ts similarity index 94% rename from src/app/features/notes/components/language-rail/language-rail.component.ts rename to src/app/features/notes/ui/language-rail/language-rail.component.ts index dfac0f2..521afc8 100644 --- a/src/app/features/notes/components/language-rail/language-rail.component.ts +++ b/src/app/features/notes/ui/language-rail/language-rail.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { TranslocoPipe } from '@jsverse/transloco'; -import { LanguageTag } from '@core/models/language.model'; +import { LanguageTag } from '@core/language/language.model'; import { LanguageBadgeComponent } from '@shared/ui/language-badge/language-badge.component'; /** diff --git a/src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.html b/src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.html similarity index 62% rename from src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.html rename to src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.html index 290c5ae..95b8a57 100644 --- a/src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.html +++ b/src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.html @@ -1,3 +1,3 @@ - {{ label().key | transloco: label().params }} diff --git a/src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.scss b/src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.scss similarity index 100% rename from src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.scss rename to src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.scss diff --git a/src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.spec.ts b/src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.spec.ts similarity index 100% rename from src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.spec.ts rename to src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.spec.ts diff --git a/src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.ts b/src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.ts similarity index 83% rename from src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.ts rename to src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.ts index 9d0db91..e547895 100644 --- a/src/app/shared/ui/lifecycle-badge/lifecycle-badge.component.ts +++ b/src/app/features/notes/ui/lifecycle-badge/lifecycle-badge.component.ts @@ -1,9 +1,9 @@ import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { TranslocoPipe } from '@jsverse/transloco'; -import { NoteLifecycle } from '@core/models/note.model'; -import { TranslationRef } from '@core/models/translation-ref.model'; +import { NoteLifecycle } from '@features/notes/model/note.model'; +import { TranslationRef } from '@core/i18n/translation-ref.model'; import { ClockService } from '@core/time/clock.service'; -import { expiryRef } from '@core/utils/relative-time.util'; +import { expiryRef } from '@core/time/relative-time.util'; @Component({ selector: 'app-lifecycle-badge', @@ -32,6 +32,4 @@ export class LifecycleBadgeComponent { ? { key: 'lifecycle.neverExpires' } : expiryRef(lifecycle.at, this.clock.now()); }); - - protected readonly stale = this.expiringSoon; } diff --git a/src/app/features/notes/components/note-card-menu/note-card-menu.component.html b/src/app/features/notes/ui/note-card-menu/note-card-menu.component.html similarity index 58% rename from src/app/features/notes/components/note-card-menu/note-card-menu.component.html rename to src/app/features/notes/ui/note-card-menu/note-card-menu.component.html index 064b6fb..baa91c5 100644 --- a/src/app/features/notes/components/note-card-menu/note-card-menu.component.html +++ b/src/app/features/notes/ui/note-card-menu/note-card-menu.component.html @@ -1,31 +1,27 @@ -@if (open()) { - - -