Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
142 changes: 118 additions & 24 deletions docs/architecture.md

Large diffs are not rendered by default.

58 changes: 19 additions & 39 deletions src-tauri/src/commands/error.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<String, String>,
/// 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,
}

Expand All @@ -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,
Expand All @@ -95,9 +79,6 @@ impl From<ValidationError> for AppError {

impl From<StorageError> 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 {
Expand All @@ -107,14 +88,13 @@ impl From<StorageError> 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)
}
Expand Down
29 changes: 7 additions & 22 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<std::sync::MutexGuard<'_, rusqlite::Connection>, AppError> {
db.lock().map_err(|_| AppError::storage_unavailable())
}
Expand Down
42 changes: 11 additions & 31 deletions src-tauri/src/commands/notes.rs
Original file line number Diff line number Diff line change
@@ -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<NotesView, AppError> {
let connection = lock(&db)?;
Expand All @@ -44,14 +25,13 @@ pub fn query_notes(query: NotesQuery, db: State<'_, Db>) -> Result<NotesView, Ap

#[tauri::command]
pub fn create_note(draft: NoteDraft, db: State<'_, Db>) -> Result<DisplayNote, AppError> {
// 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]
Expand All @@ -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]
Expand Down
36 changes: 12 additions & 24 deletions src-tauri/src/commands/spaces.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -37,17 +26,16 @@ pub fn list_spaces(db: State<'_, Db>) -> Result<Vec<Space>, 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<Space, AppError> {
// 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)?;

Ok(storage::spaces::create(&connection, &name)?)
}

/// Renomme un espace. Le brouillon est le même qu'à la création, donc la même
/// validation s'applique : un nom détouré et non vide.
/// Même brouillon qu'à la création, donc même validation.
#[tauri::command]
pub fn rename_space(id: String, draft: SpaceDraft, db: State<'_, Db>) -> Result<Space, AppError> {
let name = draft.validated_name()?;
Expand All @@ -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)?;
Expand Down
Loading
Loading