La demande initiale était de retirer l'envie ; c'est refusé. Le catalogue est commun et la liste d'envies personnelle : supprimer modifierait la liste d'un autre en silence, avec sa note. Et le rapprochement par clé d'œuvre est faillible, alors qu'une suppression ne se rattrape pas. L'étiquette « Déjà au catalogue » mène à la fiche, pour vérifier avant de retirer. Le critère est celui des doublons, la convention d'auteur commun étant désormais partagée par les deux (RapprochementAuteurs). Au passage : accolade manquante sur .etiquette-souhaite, qui avalait la règle suivante — le bandeau de mise à jour perdait son style. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1467 lines
94 KiB
Markdown
1467 lines
94 KiB
Markdown
# CLAUDE.md — Contexte projet MaBibli
|
||
|
||
Ce fichier donne le contexte complet du projet à Claude Code. Lis-le en entier avant de commencer à coder.
|
||
|
||
Voir aussi **`IDEES.md`** : améliorations identifiées mais **non encore actées**. Rien n'y fait autorité — ce fichier-ci reste la référence.
|
||
|
||
## Objectif du projet
|
||
|
||
Application self-hosted de gestion de bibliothèque personnelle, à héberger sur YunoHost, accessible depuis smartphone et PC.
|
||
|
||
## Fonctionnalités attendues (v1)
|
||
|
||
1. **Catalogue de livres physiques** — liste, ajout, édition, suppression
|
||
2. **Catalogue de livres numériques (ebooks)** — même chose, avec un champ format distinct du physique
|
||
3. **Gestion de prêts**
|
||
- Prêter un livre à une personne (nom, date de prêt)
|
||
- Marquer comme récupéré (date de retour)
|
||
- Historique des prêts passés par livre (pas juste l'état courant)
|
||
4. **Récupération automatique des infos via ISBN**
|
||
- Scan caméra du code-barres (EAN-13 / ISBN)
|
||
- Saisie manuelle de l'ISBN
|
||
- Dans les deux cas : appel à une ou plusieurs API pour pré-remplir titre, auteur, éditeur, couverture
|
||
5. **Statuts de lecture** — à lire / en cours / lu (au minimum), assignable à chaque livre
|
||
|
||
## Décisions techniques actées
|
||
|
||
| Sujet | Décision | Pourquoi |
|
||
|---|---|---|
|
||
| Langage backend | C# / ASP.NET Core | Choix de l'utilisateur, typage fort |
|
||
| Frontend | Blazor WebAssembly | Support PWA quasi natif (`dotnet new blazorwasm --pwa`), tout en C#, offline partiel |
|
||
| Base de données | SQLite + Entity Framework Core | Fichier unique, pas de serveur DB séparé, adapté à un usage perso/familial |
|
||
| Auth / multi-utilisateur | SSO YunoHost via **en-têtes SSOwat** (`YNH_USER`) | Pas de login custom. OIDC **écarté** : non documenté par YunoHost (vérifié le 2026-08-17). Voir « Intégration SSO » |
|
||
| Portée des données | **Collection commune** à tous les utilisateurs, avec traçabilité de qui a ajouté chaque livre | Usage familial : une bibliothèque de foyer, pas des collections étanches. Laisse la possibilité de cloisonner plus tard sans migration lourde |
|
||
| Statut de lecture | **Par utilisateur**, pas commun (décidé le 2026-08-17, après la phase 3) | Le livre est commun, sa lecture est personnelle : deux membres du foyer lisent le même exemplaire à des rythmes différents. Sort `Statut` de `Livre` vers une table dédiée |
|
||
| Prêts | **Communs au foyer**, jamais filtrés par utilisateur ; un seul prêt ouvert par livre, garanti par index unique partiel | Un livre absent l'est pour tout le monde, et n'importe qui doit pouvoir noter son retour. Voir « Prêts » |
|
||
| Auteurs | **Table dédiée** avec nom normalisé, remplaçant le champ texte libre | Nécessaire au regroupement par auteur et à la recherche insensible aux accents. Regroupement **automatique seulement quand c'est sûr**, sinon proposé à l'utilisateur |
|
||
| Liste d'envies | **Table dédiée `LivreSouhaite`**, personnelle (par `YNH_USER`), jamais dans `Livres` | Un livre souhaité n'est pas possédé. Dans `Livres`, il entrerait dans le catalogue, les compteurs et les prêts, et il faudrait répéter « et qui n'est pas souhaité » à chaque lecture. Voir « Liste d'envies » |
|
||
| Bibliographie par auteur | **SRU BnF, index `bib.author`**, avec post-filtre obligatoire sur l'auteur réel de la notice | Vérifié le 2026-08-18. `all` rapproche les mots sur l'ensemble des auteurs d'une notice : sans post-filtre, « Émile Zola » remonte l'œuvre de sa fille. Voir « Bibliographie par auteur » |
|
||
| Export de la liste d'envies | **`.txt` et `.csv`**, produits côté serveur, sans dépendance | Les deux usages d'IDEES.md diffèrent : le texte s'emporte en librairie, le CSV s'ouvre dans un tableur. Voir « Export » |
|
||
| Ebooks | **Fiches uniquement**, pas de stockage de fichiers | Inventaire, pas hébergement. Évite l'espace disque YunoHost, les sauvegardes lourdes, et garde le cache hors-ligne léger |
|
||
| Architecture serveur | **x86_64** → publish `linux-x64` | Serveur PC/VPS confirmé par l'utilisateur |
|
||
| Production des binaires | **Compilation locale + release manuelle**, via un script réutilisable en CI plus tard | Ne pas se bloquer sur l'outillage ; Gitea Actions nécessiterait un runner, non vérifié |
|
||
| Cache hors-ligne | **IndexedDB** (pas le cache du service worker) | Seule option permettant recherche et tri hors-ligne sur toute la bibliothèque, et l'affichage de la date de dernière synchro |
|
||
| Notices BnF multiples | **Demander systématiquement** à l'utilisateur | Exactitude de l'édition privilégiée sur la vitesse de saisie en série |
|
||
| AOT WebAssembly | **Désactivé par défaut**, à réévaluer après mesure | Le mode interprété devrait suffire (~5-10 ms/frame estimés) ; ne pas payer le coût de build avant d'avoir constaté un problème |
|
||
| Hébergement | YunoHost, installation **native** (pas Docker) | YunoHost déconseille Docker pour ses apps (moins fiable, plus lourd) ; installation native = meilleures perfs sur petit matériel |
|
||
| Packaging YunoHost | S'inspirer de [`radarr_ynh`](https://github.com/YunoHost-Apps/radarr_ynh) | Radarr est aussi en .NET, packagé sans Docker sur YunoHost. Leur `manifest.toml` montre un déploiement **self-contained** (`dotnet publish -r linux-x64 --self-contained`), donc pas besoin d'installer `dotnet-runtime` via apt côté serveur — le binaire embarque son propre runtime |
|
||
| Scan ISBN | **zbar** (LGPL-2.1) compilé en WebAssembly, via les assets du paquet `ZBar.Blazor` ; notre JS garde la caméra | ZXing.Net **ne lisait pas** des codes-barres que zbar lit sur le même livre (2026-08-19). Plus léger de surcroît. Voir la section dédiée ci-dessous |
|
||
| Consultation hors-ligne | **Instantanés JSON en IndexedDB**, lecture seule, implémenté le 2026-08-18 | Le besoin est de **consulter la bibliothèque existante** sans réseau, pas d'enrichir de nouveaux livres. Voir « Stratégie hors-ligne » |
|
||
|
||
## Scan du code-barres — zbar (décision actée le 2026-08-19)
|
||
|
||
Le décodage EAN-13 se fait par **[zbar](https://github.com/mchehab/zbar) compilé en
|
||
WebAssembly**, appelé depuis `wwwroot/js/scanner-camera.js`. Les assets viennent du paquet
|
||
NuGet [`ZBar.Blazor`](https://www.nuget.org/packages/ZBar.Blazor) (LGPL-2.1).
|
||
|
||
### Pourquoi ZXing.Net a été abandonné
|
||
|
||
**Constat d'usage** : l'ISBN `9782846391009` n'était pas décodé par l'application, alors qu'il
|
||
l'était par une application d'essai utilisant zbar, **sur le même livre et le même appareil**.
|
||
|
||
⚠️ **Le raisonnement qui avait écarté cette piste était faux, et la faute est instructive.**
|
||
Ce fichier concluait que « le décodeur n'y est pour rien » à partir de mesures de **vitesse**
|
||
(4-6 ms/frame en WASM interprété). Or **la vitesse ne dit rien du taux de réussite** : un
|
||
décodeur peut être rapide et rater. Les mesures ne pouvaient pas soutenir cette conclusion.
|
||
|
||
Ne jamais reconduire ce raccourci : pour juger un décodeur, il faut mesurer **ce qu'il lit**,
|
||
pas ce qu'il coûte.
|
||
|
||
### Ce qui est établi, et ce qui ne l'est pas
|
||
|
||
**Établi** : zbar lit ce code-barres, ZXing.Net ne le lit pas, dans les mêmes conditions.
|
||
|
||
**Non établi** : que le moteur soit *seul* en cause. L'ancienne implémentation ne faisait pas que
|
||
décoder, elle **recadrait** sur la bande centrale (45 % de la hauteur) après réduction à 640 px,
|
||
pour limiter ce qui traversait le pont JS→C#. Ce recadrage était la seconde cause possible.
|
||
La bascule l'a supprimé — l'image entière est désormais analysée, à 960 px de large — donc la
|
||
question ne se pose plus.
|
||
|
||
### Ce que la bascule change dans le code
|
||
|
||
| | Avant (ZXing.Net) | Après (zbar) |
|
||
|---|---|---|
|
||
| Décodage | C#, `IsbnScanner.TryDecode` | JS, `window.zbar.scanImageData` |
|
||
| Pixels sur le pont JS→C# | **un `byte[]` par frame** | aucun — seule la valeur décodée passe |
|
||
| Zone analysée | bande centrale, 640 px | image entière, 960 px |
|
||
| Caméra | `scanner-camera.js` | `scanner-camera.js`, **inchangé** |
|
||
|
||
`IsbnScanner.cs`, `BancEssaiScan.cs` et `IsbnScannerTests.cs` ont disparu.
|
||
|
||
⚠️ **Coût assumé : six tests de décodage en moins.** Ils s'exécutaient en C# sur des images
|
||
générées ; le décodeur étant maintenant en JS, ils n'ont plus d'équivalent dans xUnit. Le
|
||
décodage a été vérifié dans le navigateur, sur un EAN-13 rendu en canvas — `9782846391009`
|
||
ressort bien en `ZBAR_EAN13`. C'est une vérification, pas un garde-fou permanent.
|
||
|
||
### ⚠️ On n'utilise PAS le composant `ZBarCamera` du paquet — et il ne faut pas y revenir
|
||
|
||
Son `camera.js` ouvre la caméra lui-même et **avale les erreurs** :
|
||
|
||
```js
|
||
navigator.mediaDevices.getUserMedia(constraints).then(...).catch(function (error) {
|
||
console.log(error); // et c'est tout
|
||
});
|
||
```
|
||
|
||
On perdrait d'un coup :
|
||
|
||
- les messages qui distinguent **permission refusée**, **aucune caméra**, **caméra occupée** et
|
||
**contexte non sécurisé** — ce que ce fichier documente comme durement acquis (une
|
||
`DOMException` perd son `name` en traversant le pont, d'où les codes de statut) ;
|
||
- `facingMode: { ideal: 'environment' }`, donc la **caméra arrière** sur téléphone : le composant
|
||
demande `{ video: true }`, c'est-à-dire la caméra frontale par défaut ;
|
||
- l'indication de résolution `1280×720`.
|
||
|
||
Le paquet est donc référencé **pour ses assets statiques seulement** (`zbar.js`, `zbar.wasm`).
|
||
Vérifié après bascule : caméra refusée → « L'accès à la caméra a été refusé… », message intact.
|
||
|
||
### `zbar.wasm` est chargé à la demande, pas au démarrage
|
||
|
||
`scanner-camera.js` injecte `_content/ZBar.Blazor/zbar.js` **à la première ouverture du
|
||
scanner**. Personne ne télécharge 139 Ko pour consulter sa bibliothèque.
|
||
|
||
⚠️ `zbar.js` **n'est pas un module ES** : il pose `window.zbar`. D'où l'injection d'une balise
|
||
`<script>` plutôt qu'un `import()`.
|
||
|
||
### Poids : mesuré par comparaison de deux publish complets
|
||
|
||
Somme brotli, `MaBibli.Api` publié en `Release`, avant et après :
|
||
|
||
| | ZXing.Net | zbar |
|
||
|---|---|---|
|
||
| `_framework` (téléchargé au démarrage) | 3 281 558 o | **2 978 700 o** |
|
||
| `_content` (à la demande) | 0 | 146 841 o |
|
||
| **Total** | 3 281 558 o | **3 125 541 o** |
|
||
|
||
**−152 Kio au total, et −296 Kio au démarrage.** L'écart ne vient pas du moteur mais de la
|
||
**traîne** que ZXing.Net imposait au trimmer :
|
||
|
||
| Assembly | ZXing.Net | zbar |
|
||
|---|---|---|
|
||
| `zxing.wasm` | 192 495 o | absent |
|
||
| `System.Text.RegularExpressions` | 98 374 o | **7 137 o** |
|
||
| `System.Runtime.Numerics` | 30 701 o | absent |
|
||
| `ZBar.Blazor` (assembly .NET, inutilisé mais embarqué) | absent | 21 232 o |
|
||
|
||
`System.Text.RegularExpressions` retombe à sa taille trimmée parce que plus rien n'utilise
|
||
`Regex` : c'est ZXing qui l'imposait. ⚠️ L'assembly `ZBar.Blazor` est embarqué bien qu'aucun de
|
||
ses types ne soit utilisé — 21 Ko dont on se passerait, mais le paquet reste la manière propre
|
||
d'obtenir `zbar.wasm` et sa licence.
|
||
|
||
**Leçon confirmée, dans les deux sens** : le coût d'une bibliothèque n'est pas le poids de son
|
||
assembly. Toujours comparer deux publish.
|
||
|
||
### Ce qui reste vrai de l'ancienne section
|
||
|
||
- Le scan caméra exige **HTTPS** (garanti par YunoHost ; `localhost` est considéré comme sûr).
|
||
Tester depuis un téléphone en pointant l'IP locale du PC (`http://192.168.x.x`) **échouera
|
||
toujours** — ce n'est pas un contexte sécurisé, et le composant le dit.
|
||
- **Une `DOMException` perd son `name` en traversant le pont JS→C#.** Le JS doit renvoyer un
|
||
code de statut ; parser le message côté C# ne marche pas.
|
||
- **L'import du module se fait sur un chemin nu** (`./js/scanner-camera.js`), les empreintes WASM
|
||
étant désactivées (voir « Empreintes WASM désactivées »).
|
||
|
||
### Ce que zbar apporte en plus
|
||
|
||
- Il remonte le **format** du symbole (`ZBAR_EAN13`, `ZBAR_ISBN13`, `ZBAR_EAN5`…), là où nous
|
||
déduisons le type du préfixe.
|
||
- Il décode les **add-ons EAN-2 (numéro de parution) et EAN-5 (prix)** imprimés à côté du code
|
||
principal. ⚠️ `decoder()` les **écarte** : ce ne sont pas le code du livre. L'EAN-2 deviendra
|
||
utile le jour où les périodiques seront catalogués.
|
||
|
||
## Saisie d'un code-barres — décisions actées le 2026-08-18 (2ᵉ série)
|
||
|
||
### La douchette USB est le vrai remède au scan raté sur PC
|
||
|
||
Constaté en usage : sur PC, la webcam décode mal l'EAN-13. Le décodeur n'y est pour rien
|
||
(4-6 ms/frame mesurés) — c'est l'optique : mise au point fixe, cadrage large, peu de lumière,
|
||
là où un capteur de téléphone fait le point à 10 cm.
|
||
|
||
**Une douchette USB se présente au système comme un clavier** : elle « tape » les chiffres puis
|
||
`Entrée`. Elle ne demande donc **aucune permission, aucun HTTPS, aucun décodage** — rien de ce
|
||
qui rend le scan caméra fragile. Tout ce qu'elle exige du code tient en deux points, et les deux
|
||
sont maintenant tenus par `AjoutIsbn.razor` :
|
||
|
||
| Exigence | Mise en œuvre |
|
||
|---|---|
|
||
| Le champ ISBN est **focalisé à l'ouverture** | `FocusAsync` dans `OnAfterRenderAsync`, une seule fois par entrée dans l'étape de saisie |
|
||
| Le champ **valide sur `Entrée`** | déjà présent (`SurTouche`) |
|
||
|
||
⚠️ **Ne jamais déclencher la recherche à chaque frappe.** Une douchette tape plus vite qu'un
|
||
humain : chaque caractère partirait en requête réseau. Le champ se lie bien sur `oninput`, mais
|
||
seul `Entrée` (ou le bouton) lance le lookup.
|
||
|
||
⚠️ **Ne pas reprendre le focus à chaque rendu** : le drapeau `_focusAFaire` existe pour ça.
|
||
Sans lui, le curseur serait arraché à l'utilisateur en pleine frappe manuelle.
|
||
|
||
Le préfixe/suffixe que certaines douchettes ajoutent ne demande aucun traitement :
|
||
`IsbnUtils.Normaliser` ne garde que les chiffres et le `X` final.
|
||
|
||
Pistes **non retenues pour l'instant**, si la douchette ne suffisait pas : choix de la caméra,
|
||
résolution plus haute, zoom/torche, dépôt d'une photo à décoder.
|
||
|
||
### Après un scan réussi, la recherche s'enchaîne — et ça doit se voir
|
||
|
||
**Décidé : enchaînement automatique**, pas de bouton de confirmation. Le champ garde le code lu,
|
||
donc un décodage erroné se corrige et se relance ; et c'est le seul comportement cohérent avec la
|
||
douchette, qui envoie `Entrée` d'elle-même.
|
||
|
||
⚠️ Le défaut d'origine n'était pas l'absence d'enchaînement — il existait déjà — mais son
|
||
**invisibilité** : l'écran retombait sur la saisie avec un bouton « Chercher » intact, et l'on
|
||
croyait que le scan n'avait rien déclenché. D'où une étape `Recherche` à part entière, qui
|
||
affiche le code interrogé. Ne pas la supprimer en la prenant pour du décor.
|
||
|
||
### Le préfixe `977` est un magazine, pas un livre — et on le dit
|
||
|
||
Un EAN-13 en `977` désigne une **publication en série** : il porte un **ISSN**, donc un *titre de
|
||
revue*, là où un livre porte `978`/`979`. Aucune recherche `bib.isbn` ne peut aboutir. Avant
|
||
correction, `9772466671438` traversait toute la cascade pour finir sur « aucun résultat » — ce
|
||
qui envoie chercher un défaut là où il n'y en a pas.
|
||
|
||
⚠️ **L'interception se fait avant la validation ISBN**, dans `IsbnLookupService` : un code de
|
||
périodique est un EAN-13 **parfaitement valide** (`EstIsbn13Valide` le confirme), seul son
|
||
préfixe le trahit. Un test le verrouille : ni la BnF ni OpenLibrary ne sont interrogées sur
|
||
l'ISBN.
|
||
|
||
Structure du code, vérifiée le 2026-08-18 :
|
||
|
||
```
|
||
977 | 2466671 | 43 | 8
|
||
└ ISSN sans sa clé └ parution └ clé EAN-13
|
||
```
|
||
|
||
La clé de l'ISSN **n'est pas dans le code-barres**, elle se recalcule (somme pondérée 8→2,
|
||
modulo 11, `X` si 10) — même principe que la clé ISBN-10, sur sept chiffres. `CodePeriodique`
|
||
fait les deux.
|
||
|
||
**Décidé : on nomme la revue** plutôt que de se contenter d'un message générique. La BnF indexe
|
||
l'ISSN sous `bib.issn`, sans clé, avec le tiret :
|
||
|
||
```
|
||
bib.issn all "2466-6718" → 1 notice
|
||
dc:title = Médor (Bruxelles) ⚠ le parenthésé est un qualificatif de
|
||
dc:title = Médor : les yeux ouverts… désambiguïsation, pas une ville — il se retire
|
||
dc:date = 2015 (NettoyageIsbd.TitrePeriodique)
|
||
```
|
||
|
||
Deux points à ne pas « corriger » :
|
||
|
||
- **Une notice de revue porte plusieurs `dc:title`.** Le premier est le titre clé, le second
|
||
l'accroche commerciale avec sa mention de responsabilité. C'est le **premier** qu'on retient.
|
||
- **Une BnF muette ou injoignable n'est pas un échec** : l'ISSN se déduit du code-barres, on le
|
||
donne quand même. Retomber sur « aucun résultat » serait reproduire le bug d'origine.
|
||
|
||
**Ce qui n'est délibérément pas fait** : cataloguer la revue. Le modèle de données n'a ni numéro
|
||
ni date de parution, et les deux chiffres de parution du code ne sont pas exploitables comme
|
||
numéro fiable — douze numéros d'un même magazine partagent leur ISSN et créeraient douze fiches
|
||
identiques. L'écran bascule donc sur la saisie manuelle avec le **titre** pré-rempli, et
|
||
**surtout pas l'ISBN** : ranger un code de périodique dans `Livre.Isbn` ferait échouer tout
|
||
lookup ultérieur sur cette fiche.
|
||
|
||
### Les ISBN s'affichent avec des tirets — table officielle, français exact
|
||
|
||
`9782846391009` → `978-2-84639-100-9`. **Purement de l'affichage** : la valeur stockée reste nue,
|
||
et les tirets ne franchissent jamais la frontière de la base ni celle des requêtes aux sources —
|
||
la BnF indexe l'ISBN **sans** tiret. Le seul point d'entrée est `FormatageIsbn.Afficher` ; un test
|
||
vérifie que `IsbnUtils.Normaliser` défait exactement ce que `Afficher` a posé.
|
||
|
||
Les tranches ne se devinent pas : elles dépendent du groupe linguistique **puis** de l'éditeur, et
|
||
sont publiées par l'agence internationale. Les tables embarquées sont extraites du
|
||
[`RangeMessage.xml`](https://www.isbn-international.org/export_rangemessage.xml) officiel, version
|
||
du 2026-08-18 — **ne pas les réécrire de mémoire**, plusieurs tranches ne sont pas celles qu'on
|
||
suppose (`8400000-8999999` vaut 5 chiffres d'éditeur, pas 6, et c'est précisément la tranche du
|
||
cas remonté).
|
||
|
||
⚠️ **Le principe tenu n'est pas « un découpage approché » mais « aucune coupure fausse ».**
|
||
IDEES.md envisageait d'assumer une erreur d'un chiffre hors du français ; ce n'était pas
|
||
nécessaire :
|
||
|
||
| Cas | Découpage | Pourquoi |
|
||
|---|---|---|
|
||
| `978-2` (francophone) | **complet**, éditeur compris | 19 tranches, exactes |
|
||
| autres groupes | groupe et clé **seulement** (`978-0-26203384-8`) | la frontière éditeur/publication demanderait ~220 tables ; on l'omet au lieu de la deviner |
|
||
| pas un ISBN valide | **inchangé** | un code mal saisi doit se voir mal saisi, pas déguisé par des tirets |
|
||
|
||
Une tranche en moins se lit encore ; une tranche fausse trompe. C'est aussi ce qui borne le poids
|
||
embarqué dans le WASM : seule la table francophone est présente, la collection l'étant aussi.
|
||
|
||
**Où c'est appliqué** : fiche livre, liste d'envies, écran d'ajout par ISBN (code recherché et
|
||
provenance d'un candidat), export `.txt` et export `.csv`. Le catalogue **n'affiche pas d'ISBN**
|
||
et n'a donc rien à découper — ne pas en ajouter un pour la circonstance.
|
||
|
||
⚠️ Le CSV y gagne plus que de la lisibilité : **un ISBN nu de treize chiffres est lu comme un
|
||
nombre par Excel**, affiché « 9,78225E+12 », derniers chiffres perdus. Les tirets en font un
|
||
texte. Ne pas « simplifier » en remettant la valeur nue dans l'export.
|
||
|
||
Les champs de **saisie** gardent la valeur nue : découper pendant la frappe se battrait avec
|
||
l'utilisateur, et la douchette tape des chiffres.
|
||
|
||
## Stratégie hors-ligne — décidé : consultation seule
|
||
|
||
**Besoin réel** : consulter la bibliothèque **déjà enregistrée** sans réseau (liste des livres, statuts, prêts en cours). Il ne s'agit **pas** d'enrichir de nouveaux livres hors-ligne — le lookup ISBN exige de toute façon un accès réseau.
|
||
|
||
⚠️ **Piège à ne pas sous-estimer** : « les données sont déjà en local » n'est vrai qu'au sens *serveur*. En Blazor WebAssembly, le code tourne dans le navigateur, alors que SQLite vit côté serveur YunoHost. Le service worker de la PWA met en cache les *assets* (HTML/CSS/WASM), mais **pas les réponses de l'API**. Sans travail explicite, l'app se lancera hors-ligne et affichera **une liste vide**.
|
||
|
||
**Décision** : cache client des réponses `GET` de l'API (IndexedDB, ou cache du service worker), en **lecture seule**.
|
||
|
||
- Pas de file d'attente d'écritures, pas de synchronisation, pas de résolution de conflits.
|
||
- Hors-ligne, l'interface doit **désactiver explicitement** les actions d'écriture (ajout, édition, prêt) plutôt que de les laisser échouer silencieusement, et indiquer que les données affichées proviennent du cache.
|
||
- SQLite compilé en WASM côté client a été **écarté** : ne se justifierait que si l'écriture hors-ligne devenait nécessaire.
|
||
|
||
### Ce qui a été implémenté le 2026-08-18
|
||
|
||
| Pièce | Rôle |
|
||
|---|---|
|
||
| `wwwroot/js/cache-hors-ligne.js` | IndexedDB (base `mabibli`, magasin `instantanes`) : ranger, relire, et signaler les bascules `online`/`offline`. Aucune logique métier |
|
||
| `Services/CacheHorsLigne.cs` | Sérialisation C# des instantanés ; **avale toute panne du stockage** (navigation privée, quota) — ne pas pouvoir cacher n'empêche pas de fonctionner en ligne |
|
||
| `Services/EtatReseau.cs` | État réseau visible + date de dernière synchronisation, avec deux événements distincts |
|
||
| `Services/FiltreLivresLocal.cs` | Recherche, filtres et tri hors-ligne sur l'instantané |
|
||
| `Services/ServiceLivresApi.cs` | Lectures avec repli sur le cache, écritures refusées |
|
||
|
||
**Cinq instantanés, un par vue de l'API**, jamais un par requête : `catalogue` (le catalogue
|
||
**entier**, sans filtre), `auteurs`, `prets-en-cours`, `utilisateur`, et `souhaits` depuis le
|
||
2026-08-18 (voir « La liste d'envies est le cinquième instantané »). C'est exactement ce qui
|
||
justifie IndexedDB plutôt que le cache du service worker : un cache de réponses HTTP ne
|
||
restituerait que les URL déjà visitées, donc **une recherche jamais tapée en ligne ne rendrait
|
||
rien**. Vérifié en exécution — hors-ligne, chercher un auteur jamais affiché auparavant remonte
|
||
bien son livre.
|
||
|
||
L'instantané est réécrit à chaque lecture non filtrée réussie **et après chaque écriture**
|
||
(rafraîchissement en tâche de fond) : sans cela, une coupure juste après un ajout montrerait un
|
||
catalogue d'avant.
|
||
|
||
### Le filtre existe en deux exemplaires, et c'est assumé
|
||
|
||
`FiltreLivres` (serveur, sur `IQueryable<Livre>`, colonnes normalisées déjà calculées) et
|
||
`FiltreLivresLocal` (navigateur, sur des `LivreDto` qui n'en portent pas) **ne peuvent pas être
|
||
le même code**. La normalisation est refaite à la volée côté client — sans coût perceptible sur
|
||
une bibliothèque de foyer — mais par **les mêmes fonctions** (`NormalisationTexte`,
|
||
`RapprochementAuteurs.Cle`), et le tri est **ordinal** pour reproduire ce que fait SQLite sur une
|
||
colonne sans collation.
|
||
|
||
⚠️ Le garde-fou est un test qui **confronte les deux implémentations** sur le même jeu de données
|
||
et 17 jeux de critères (`FiltreLivresLocalTests`). Une divergence silencieuse serait pire qu'un
|
||
cache absent : l'utilisateur conclurait que le livre n'est pas dans sa bibliothèque.
|
||
|
||
### ⚠️ `navigator.onLine` ne suffit pas — le piège coûte le retour en ligne
|
||
|
||
`navigator.onLine` **ne vaut que par sa négation** : « faux » est fiable, « vrai » ne prouve rien
|
||
(portail captif, serveur arrêté, Wi-Fi sans Internet). D'où deux notions distinctes dans
|
||
`EtatReseau`, et il faut tenir les deux :
|
||
|
||
| Propriété | Sens | Usage |
|
||
|---|---|---|
|
||
| `EnLigne` | navigateur en ligne **et** dernier appel réussi | ce que l'interface affiche et ce qui active les boutons |
|
||
| `TenterLeReseau` | navigateur en ligne, **même si le dernier appel a échoué** | faut-il tenter un appel HTTP |
|
||
|
||
**Constaté en essai avant correction** : quand la panne vient du *serveur*, `navigator.onLine`
|
||
n'a jamais changé, donc aucun événement `online` ne viendra jamais — et une lecture qui
|
||
court-circuitait sur `EnLigne` ne retentait plus rien. L'application restait bloquée sur le cache
|
||
**jusqu'au rechargement de la page**. Le prix de la correction est d'une requête qui échoue par
|
||
lecture tant que le serveur est absent : elle échoue vite, et l'affichage retombe sur le cache.
|
||
|
||
Vérifié après correction : serveur arrêté puis redémarré, **sans aucun événement `online`**, la
|
||
navigation suivante a fait disparaître le bandeau, réactivé les actions et fait apparaître un
|
||
livre ajouté côté serveur pendant la coupure.
|
||
|
||
### ⚠️ Deux événements, sinon la boucle infinie
|
||
|
||
`EtatReseau` expose `Change` (bascule en ligne ↔ hors ligne) **et** `SynchroChange` (nouvelle date
|
||
de synchronisation). Les écrans se rechargent sur `Change` uniquement. Avec un événement unique,
|
||
un rechargement écrirait un instantané → nouvel événement → nouveau rechargement, sans fin.
|
||
|
||
### La liste d'envies est le cinquième instantané — et pourquoi elle manquait
|
||
|
||
Symptôme remonté en usage : `net_http_message_not_success_statuscode_reason, 404, Not Found`
|
||
en plein écran, réseau coupé. **Deux défauts distincts**, et il faut retenir les deux :
|
||
|
||
1. `ListerSouhaitsAsync` appelait `http.GetFromJsonAsync` **directement**, sans passer par
|
||
`EssayerAsync`. Elle était donc le seul point de lecture hors du dispositif hors-ligne : pas
|
||
de repli sur instantané, pas de `SignalerEchecReseau`, et l'exception HTTP remontait telle
|
||
quelle jusqu'à l'écran.
|
||
2. L'écran affichait `ex.Message`. ⚠️ **Aucun message HTTP brut ne doit atteindre l'utilisateur** :
|
||
il n'a aucun moyen d'interpréter un « 404 » qui, ici, ne veut même pas dire ce qu'il dit.
|
||
|
||
La correction rend la liste d'envies conforme au reste : lecture avec repli sur `souhaits`,
|
||
écritures passées par `EcrireAsync`, et rafraîchissement de l'instantané après chaque écriture.
|
||
|
||
**Pourquoi elle mérite un instantané et pas une simple erreur polie** : la liste s'emporte **en
|
||
librairie**, précisément là où le réseau manque. C'est exactement l'usage que servait l'export
|
||
`.txt` — et cet export est produit **par le serveur**, donc mort hors-ligne. Sans instantané, la
|
||
fonctionnalité n'existait pas au seul endroit où elle sert.
|
||
|
||
⚠️ **C'est le seul instantané personnel.** Catalogue, auteurs et prêts sont communs au foyer ;
|
||
`souhaits` ne contient que ce que le serveur a rendu à l'appelant. Ne jamais lui ajouter de
|
||
paramètre d'utilisateur : l'API ne sait pas produire la liste d'un autre, et c'est voulu.
|
||
|
||
Trois pièges rencontrés en chemin :
|
||
|
||
- **La suppression ne passe pas par `EcrireAsync`.** Elle répond `204 No Content`, et
|
||
désérialiser un corps vide lèverait une exception là où tout s'est bien passé. Elle reprend
|
||
la forme de la suppression d'un livre.
|
||
- **Les liens d'export basculent en boutons désactivés hors-ligne.** Un `<a>` ne se désactive
|
||
pas : laissé actif, il quitterait l'application pour une page d'erreur du navigateur.
|
||
- **Hors-ligne, « liste vide » et « jamais synchronisée » sont indiscernables.** L'écran dit les
|
||
deux au lieu d'affirmer la première — même règle que pour une fiche absente de l'instantané.
|
||
|
||
#### Vérifié en exécution le 2026-08-18
|
||
|
||
API lancée, une envie créée, puis **serveur réellement arrêté** :
|
||
|
||
- la liste s'affiche depuis l'instantané `souhaits`, avec le bandeau « Données enregistrées
|
||
aujourd'hui à 22:20 » ;
|
||
- **plus aucun message HTTP** — le 404 a disparu de l'écran ;
|
||
- « Chercher », « Ajouter à ma liste » et « Retirer » désactivés avec le motif hors-ligne ;
|
||
« Exporter en texte » et « Exporter en CSV » désactivés avec **leur** motif, distinct
|
||
(« produit par le serveur ») ;
|
||
- serveur redémarré : le bandeau disparaît, les liens d'export redeviennent des `<a>` et
|
||
« Retirer » se réactive **à la navigation suivante**, `navigator.onLine` n'ayant jamais changé
|
||
et aucun événement `online` n'ayant donc été émis. Le piège documenté plus haut se vérifie
|
||
aussi sur cet écran.
|
||
|
||
### Ce que le hors-ligne ne couvre pas, volontairement
|
||
|
||
- **L'historique des prêts d'un livre** (`GET /api/livres/{id}/prets`) : une requête par livre pour
|
||
une information rarement consultée. En revanche l'état *courant* vient de `LivreDto.PreteA`,
|
||
donc de l'instantané — c'est lui qui répond à « où est ce livre ? », la seule question qui se
|
||
pose devant l'étagère. Le composant **dit** que l'historique est indisponible plutôt que
|
||
d'afficher une liste vide, qui se lirait « jamais prêté ».
|
||
- **Les rapprochements d'auteurs** : une liste de décisions à prendre, or aucune décision ne peut
|
||
être enregistrée hors-ligne.
|
||
- **Le lookup ISBN et le scan** : ils interrogent la BnF et OpenLibrary. L'écran le dit
|
||
explicitement au lieu de laisser expirer un délai d'attente incompréhensible.
|
||
- Une fiche absente de l'instantané affiche « pas dans les données enregistrées sur cet
|
||
appareil », **pas** « n'existe plus » : hors-ligne, les deux ne se distinguent pas.
|
||
|
||
### Ce qui a été observé, réseau coupé
|
||
|
||
Éprouvé sur un `publish Release` servi par l'API, en coupant réellement l'accès à `/api/*`
|
||
(même origine, donc même IndexedDB) puis en **rechargeant** la page :
|
||
|
||
- démarrage à froid : les 9 livres s'affichent, bandeau « Hors ligne. Données enregistrées
|
||
aujourd'hui à 13:59. » — instantanés écrits à 13:59:32, page rechargée à 14:00 ;
|
||
- recherche sur tout le fonds : `saint-exupery` (auteur jamais affiché auparavant), `bete` → « La
|
||
Bête humaine », `emile` → les Zola, `zola emile` (ordre inversé) → les mêmes ;
|
||
- filtres format et statut opérants sur l'instantané ;
|
||
- « Ajouter par ISBN », « Saisie manuelle », « Éditer », les quatre boutons de statut, « Prêter »,
|
||
« Rendu » et « Ajouter au catalogue » : **désactivés**, chacun portant sa raison ;
|
||
- « Prêts en cours » et « Auteurs » servis depuis le cache, le nom d'utilisateur aussi.
|
||
|
||
## Sources de données ISBN — point d'attention important
|
||
|
||
**Ne pas dépendre d'une seule source, et éviter Google Books si possible** (préférence explicite de l'utilisateur : pas de dépendance à Google).
|
||
|
||
Stratégie : interroger plusieurs sources **libres et gratuites, sans clé API obligatoire**, en cascade (si la première ne répond pas ou renvoie des données incomplètes, essayer la suivante).
|
||
|
||
**Ordre décidé : BnF d'abord, OpenLibrary ensuite.** La collection est majoritairement francophone, or OpenLibrary (Internet Archive) est très fourni sur l'édition anglophone mais **lacunaire sur le fonds français** — éditions françaises récentes et poches d'éditeurs modestes y manquent souvent, ou n'ont qu'un titre sans auteur. Une source unique lacunaire ruinerait l'intérêt du scan, qui est précisément d'éviter la saisie manuelle.
|
||
|
||
1. **BnF** — source principale, via son API **SRU**, gratuite et sans clé. Le dépôt légal français garantit structurellement la meilleure couverture possible sur le francophone. **API testée et validée le 2026-08-17** — voir la section « API BnF » ci-dessous pour les pièges, dont un bloquant.
|
||
2. **OpenLibrary** (`https://openlibrary.org/isbn/{isbn}.json`) — source de secours, pour les livres étrangers et tout ce que la BnF ne connaît pas
|
||
- ⚠️ **Bug connu identifié lors des tests avec BookLogr** : l'endpoint `/isbn/{isbn}.json` renvoie souvent le titre mais l'**auteur est juste une référence** (`/authors/OL...A`), pas le nom directement. Il faut faire un **second appel** vers `/authors/{id}.json` pour récupérer le nom. Un projet qui oublie ce second appel se retrouve avec titre rempli mais auteur vide (symptôme exact observé et diagnostiqué chez BookLogr) — **ne pas reproduire ce bug**.
|
||
- La couverture est un service séparé : `https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg` (peut exister même si la fiche bibliographique est incomplète)
|
||
3. **Fallback ultérieur envisageable** : Wikidata. Ne pas l'implémenter tant que la cascade BnF → OpenLibrary n'a pas montré ses limites en usage réel.
|
||
|
||
**Google Books reste écarté** conformément à la préférence de l'utilisateur, sauf changement d'avis explicite de sa part.
|
||
|
||
Quelle que soit la source, prévoir que le formulaire de saisie manuelle reste **toujours accessible** pour compléter ou corriger une fiche incomplète.
|
||
|
||
## API BnF — validée le 2026-08-17
|
||
|
||
Endpoint SRU, gratuit, **sans clé API**, réponse en ~240 ms :
|
||
|
||
```
|
||
https://catalogue.bnf.fr/api/SRU
|
||
?version=1.2
|
||
&operation=searchRetrieve
|
||
&query=bib.isbn all "{isbn}"
|
||
&recordSchema=dublincore
|
||
&maximumRecords=5
|
||
```
|
||
|
||
**Utiliser `recordSchema=dublincore`, pas MARC.** Le Dublin Core renvoie directement `dc:title`, `dc:creator`, `dc:publisher`, `dc:date`, `dc:language`, `dc:format` — bien plus simple à mapper que l'UNIMARC. Le nombre de résultats se lit dans `<srw:numberOfRecords>`.
|
||
|
||
### ⚠️ Piège bloquant : ISBN-13 vs ISBN-10
|
||
|
||
**La BnF indexe l'ISBN tel qu'imprimé sur le livre.** Les ouvrages publiés avant 2007 ne portent qu'un **ISBN-10** et sont donc **introuvables par leur ISBN-13**, alors que le scanner de code-barres lit toujours un EAN-13.
|
||
|
||
Mesuré sur un échantillon de livres français dont l'existence a été confirmée via OpenLibrary :
|
||
|
||
| ISBN-13 | Recherche ISBN-13 | Recherche ISBN-10 |
|
||
|---|---|---|
|
||
| 9782070612758 (Le Petit Prince, 2007) | **1 notice** | 0 |
|
||
| 9782253004226 (Germinal, Livre de poche) | 0 | **3 notices** |
|
||
| 9782080704092 (Le Horla, Flammarion) | 0 | **1 notice** |
|
||
|
||
Sans conversion, on perd la majorité du fonds ancien — précisément les livres d'une bibliothèque constituée. **Toujours interroger les deux formes** : ISBN-13 d'abord, puis ISBN-10 converti si aucun résultat.
|
||
|
||
Conversion ISBN-13 → ISBN-10 (uniquement pour le préfixe `978`) : retirer `978`, garder les 9 chiffres, recalculer la clé (somme pondérée 10→2, modulo 11, `X` si le reste vaut 10).
|
||
|
||
### Autres pièges confirmés
|
||
|
||
- **Pas de couverture.** Le Dublin Core BnF n'en fournit aucune. Utiliser OpenLibrary pour l'image, **quelle que soit la source des métadonnées** : `https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg?default=false`. Le `?default=false` est **indispensable** — sans lui, OpenLibrary renvoie une image placeholder au lieu d'un 404. Testé : disponible pour les 4 ISBN de l'échantillon, y compris ceux absents d'OpenLibrary côté bibliographique.
|
||
- **Plusieurs notices pour un même ISBN.** Germinal en renvoie 3 (rééditions successives partageant l'ISBN, éditeurs et années différents). Ne pas prendre aveuglément la première : soit proposer le choix à l'utilisateur, soit retenir la plus récente via `dc:date`. À trancher à l'implémentation.
|
||
- **Ponctuation ISBD à nettoyer.** Les champs ne sont pas exploitables bruts. Règles validées :
|
||
|
||
| Champ | Brut | Nettoyé |
|
||
|---|---|---|
|
||
| `dc:title` | `Germinal / Émile Zola ; préface d'Armand Lanoux` | `Germinal` — couper au premier ` / ` |
|
||
| `dc:creator` | `Zola, Émile (1840-1902). Auteur du texte` | `Émile Zola` — retirer les dates entre parenthèses, le rôle après le point, puis inverser `Nom, Prénom` |
|
||
| `dc:publisher` | `le Livre de poche (Paris)` | `le Livre de poche` — retirer la ville en fin de chaîne |
|
||
|
||
- **Couvertures OpenLibrary : 502 intermittents.** Mesuré en phase 2 — trois appels consécutifs sur le même ISBN ont donné 200, 200, puis 502. Ne **jamais** valider l'URL par un HEAD avant de l'afficher : on supprimerait au hasard des couvertures existantes. L'URL est émise systématiquement, et **l'interface doit gérer l'image cassée** (`onerror`), le `?default=false` garantissant un 404 franc plutôt qu'un placeholder silencieux.
|
||
- **OpenLibrary : le double appel auteur ne suffit pas toujours.** Cas réel (*Introduction to Algorithms*) : l'édition n'a aucun champ `authors`, seulement `contributions` et `by_statement`. Le repli implémenté passe par `/works/{id}.json` → clés d'auteurs → `/authors/{id}.json`, puis en dernier recours `by_statement`. Sans ce repli, l'auteur serait vide — c'est la même classe de bug que celui de BookLogr, sous une autre forme.
|
||
- **Livres étrangers absents**, comme attendu du dépôt légal français : *Introduction to Algorithms* et *Effective Java* introuvables sous les deux formes d'ISBN. C'est exactement le rôle d'OpenLibrary en second rideau — la cascade est donc bien nécessaire, pas seulement confortable.
|
||
|
||
## Intégration SSO YunoHost
|
||
|
||
**Mécanisme retenu : en-têtes HTTP injectés par SSOwat.** nginx authentifie le visiteur via le portail YunoHost, puis transmet l'identité à l'application dans des en-têtes que l'app se contente de lire :
|
||
|
||
| En-tête | Contenu |
|
||
|---|---|
|
||
| `YNH_USER` | nom d'utilisateur authentifié |
|
||
| `YNH_USER_EMAIL` | email |
|
||
| `YNH_USER_FULLNAME` | nom complet |
|
||
|
||
`YNH_USER_FULLNAME` évite une requête LDAP supplémentaire pour l'affichage.
|
||
|
||
**OIDC a été écarté** (vérifié le 2026-08-17) : la documentation de packaging YunoHost ne documente aucun fournisseur OpenID Connect pour les apps. Les seuls mécanismes officiels sont LDAP direct et ces en-têtes.
|
||
|
||
### Points de vigilance
|
||
|
||
- La doc YunoHost indique que ces en-têtes sont **protégés contre l'injection depuis le client** (SSOwat les écrase). Malgré cela, faire écouter le service .NET **uniquement sur `127.0.0.1`**, jamais sur `0.0.0.0` — défense en profondeur, à traiter comme une contrainte dure dans `systemd.service` et `nginx.conf`. Un service exposé directement sur le réseau permettrait de forger `YNH_USER` et de contourner tout le portail.
|
||
- **Limite connue** : se déconnecter du portail YunoHost ne déconnecte pas des apps, chacune conservant sa propre session/cookie.
|
||
- En développement local, il n'y a pas de SSOwat : prévoir un utilisateur simulé (en-tête forcé ou configuration de dev) plutôt que de désactiver l'auth.
|
||
|
||
## Architecture de déploiement YunoHost
|
||
|
||
### Trois projets .NET, un seul service
|
||
|
||
Le client Blazor WebAssembly n'est **pas** un serveur : compilé, ce ne sont que des fichiers statiques (HTML/CSS/`.wasm`) **servis par l'API**. Un seul processus tourne donc sur le serveur.
|
||
|
||
```
|
||
DÉVELOPPEMENT COMPILATION DÉPLOIEMENT
|
||
MaBibli.Client ─┐
|
||
MaBibli.Shared ─┼──► dotnet publish ──► un dossier ──► un service systemd
|
||
MaBibli.Api ─┘ MaBibli.Api unique sur 127.0.0.1:PORT
|
||
```
|
||
|
||
`MaBibli.Api` référence `MaBibli.Client` ; à la compilation, les fichiers du client atterrissent dans le `wwwroot` de l'API.
|
||
|
||
Commande de publication cible :
|
||
|
||
```
|
||
dotnet publish MaBibli.Api -c Release -r linux-x64 --self-contained
|
||
```
|
||
|
||
Le self-contained embarque le runtime .NET dans le binaire : **aucun `dotnet-runtime` à installer** côté serveur, pas de conflit de versions. C'est le modèle de `radarr_ynh`.
|
||
|
||
### Empreintes WASM désactivées — pourquoi (ex-bloquant du publish, résolu le 2026-08-18)
|
||
|
||
**Ne pas réactiver `OverrideHtmlAssetPlaceholders` ni les empreintes WASM sans relire ce qui suit.**
|
||
|
||
Le `index.html` de `MaBibli.Client` ne contient **ni import map, ni placeholder `#[.{fingerprint}]`** : il référence directement `_framework/blazor.webassembly.js`. C'est délibéré, et `MaBibli.Client.csproj` porte `<WasmFingerprintAssets>false</WasmFingerprintAssets>` pour que les fichiers servis portent bien ces noms stables.
|
||
|
||
#### Le symptôme d'origine
|
||
|
||
`dotnet publish MaBibli.Api -c Release` produisait un `wwwroot/index.html` aux placeholders **non substitués** (`<script type="importmap"></script>` vide, `src="_framework/blazor.webassembly#[.{fingerprint}].js"` littéral), alors que seuls les noms empreintés existaient sur disque. La racine répondait **200**, le script de démarrage **404**, l'application publiée restait **blanche**. `dotnet publish MaBibli.Client` seul, lui, produisait un `index.html` correct.
|
||
|
||
#### La cause réelle (SDK .NET 10.0.300)
|
||
|
||
Elle est dans `Microsoft.NET.Sdk.StaticWebAssets.HtmlAssetPlaceholders.targets`. La cible de **publication** `GenerateHtmlAssetPlaceholdersPublishStaticWebAssets` reçoit la liste des fichiers HTML à réécrire via `HtmlFiles="@(_HtmlStaticWebAssets)"` — or cet item n'est **jamais alimenté par le chemin de publication** : il l'est uniquement par la cible de **build** `ResolveHtmlAssetPlaceholdersBuildConfiguration`.
|
||
|
||
Quand on publie le client seul, le build a tourné dans la même instance MSBuild, l'item est rempli, la réécriture a lieu. Quand c'est l'**API** qui publie, elle demande au projet client ses assets de publication dans une instance où la cible de build n'a **pas** tourné : `@(_HtmlStaticWebAssets)` est vide, la tâche de réécriture ne produit **aucun fichier**, aucun asset HTML calculé n'entre dans `staticwebassets.publish.json` — et c'est alors le fichier source `MaBibli.Client/wwwroot/index.html` qui est recopié tel quel par `ComputeResolvedFilesToPublishList`, placeholders compris. Vérifié : le manifeste de publication de l'API ne contient **aucun** asset `.html`.
|
||
|
||
C'est pour cela que `<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>` sur l'API ne changeait rien : la cible s'exécutait bien, mais sur une liste vide.
|
||
|
||
#### La correction retenue
|
||
|
||
Supprimer le besoin de réécriture plutôt que réparer la réécriture : sans import map ni placeholder, il n'y a plus rien à substituer, et le fichier source recopié tel quel est déjà le bon.
|
||
|
||
- `<WasmFingerprintAssets>false</WasmFingerprintAssets>` sur le client ;
|
||
- **pas** de `OverrideHtmlAssetPlaceholders` (c'est lui qui, à `true`, force `BlazorFingerprintBlazorJs` et empreinte `blazor.webassembly.js` — la tentative précédente n'avait échoué que parce que les deux propriétés étaient combinées) ;
|
||
- `index.html` : `<script src="_framework/blazor.webassembly.js">`, sans `<script type="importmap">` ni `<link rel="preload" id="webassembly">` (deux balises que seule la machinerie de placeholders sait remplir).
|
||
|
||
Le cache-busting reste assuré par le service worker, qui compare les empreintes de `service-worker-assets.js`.
|
||
|
||
**Piège de diagnostic à conserver** : vérifier que `/` renvoie 200 ne prouve rien — c'est ce qui a fait passer le défaut inaperçu à la phase 1. Il faut vérifier les scripts que `index.html` référence **réellement**, ou charger la page dans un navigateur.
|
||
|
||
### Le service worker est le SEUL cache-busting du projet — conséquences
|
||
|
||
Les empreintes étant désactivées, `blazor.webassembly.js` et `dotnet.js` portent des noms
|
||
stables : rien d'autre que le service worker n'empêche de servir éternellement une version
|
||
périmée. Vérifié que la chaîne tient, sans navigateur, en publiant deux fois avec une seule
|
||
ligne de différence dans `app.css` :
|
||
|
||
| | Publication A | Publication B |
|
||
|---|---|---|
|
||
| `service-worker-assets.js` → `version` | `Oc+bE5e+` | `jBwHvKfd` |
|
||
| Première ligne de `service-worker.js` | `/* Manifest version: Oc+bE5e+ */` | `/* Manifest version: jBwHvKfd */` |
|
||
|
||
Le point important est la **seconde ligne** : le SDK écrit la version dans le corps même de
|
||
`service-worker.js`. Le navigateur compare ce fichier **octet à octet** à chaque vérification de
|
||
mise à jour — il n'a donc pas à deviner que `service-worker-assets.js` a changé. Nouveau worker →
|
||
nouveau nom de cache (`offline-cache-{version}`) → tous les assets refetchés. Un changement de
|
||
code C# suffit aussi (empreintes des `.wasm`), constaté : `Oc+bE5e+` → `wCvu+Chi`.
|
||
|
||
**Ce que le mécanisme d'origine ne réglait pas** : un nouveau worker *attend* que **tous** les
|
||
onglets de l'application soient fermés. Sur mobile, un onglet oublié fige la mise à jour sans que
|
||
personne comprenne pourquoi. D'où `wwwroot/js/mise-a-jour.js` :
|
||
|
||
- il **vérifie le support avant d'appeler `navigator.serviceWorker`** — absent en contexte non
|
||
sécurisé (http sur une IP locale), où l'appel direct levait une `TypeError` ;
|
||
- il **journalise un échec d'enregistrement** au lieu de l'avaler (c'est ce qui a permis
|
||
d'élucider le point ci-dessous) ;
|
||
- il appelle `registration.update()` à chaque chargement, et affiche un bandeau « Mettre à jour »
|
||
quand une version est prête ; le clic envoie `SKIP_WAITING` au worker en attente, qui appelle
|
||
`self.skipWaiting()` (ajouté à `service-worker.published.js`), puis `controllerchange` recharge.
|
||
|
||
⚠️ Le rechargement sur `controllerchange` est **conditionné à un clic** : cet événement survient
|
||
aussi à la toute première installation, et recharger à ce moment-là serait un clignotement
|
||
inexplicable. Ne pas retirer le drapeau.
|
||
|
||
### ⚠️ Le service worker ne s'enregistre pas dans le navigateur d'automatisation — c'est l'environnement
|
||
|
||
Symptôme constaté depuis la phase 3, cause établie le 2026-08-18. **Ne pas repartir en chasse au
|
||
bug de configuration PWA** : les quatre observations ci-dessous vont toutes dans le même sens.
|
||
|
||
1. `navigator.serviceWorker.getRegistrations()` renvoie `[]`, et `register()` échoue en
|
||
`TypeError: … An unknown error occurred when fetching the script.`
|
||
2. Un `fetch('/service-worker.js')` **depuis la même page** renvoie `200 text/javascript`,
|
||
3 335 octets. Le fichier est donc bien servi.
|
||
3. **Toutes** les URL échouent identiquement, y compris `/index.html` — or un HTML *récupéré*
|
||
échouerait avec une erreur de type MIME, pas avec « fetching the script ». L'échec est donc
|
||
**avant** la requête.
|
||
4. Décisif : journalisation `Microsoft.AspNetCore` en `Information`, puis un `fetch` et un
|
||
`register` sur la même URL portant chacun un repère distinct. Le serveur journalise
|
||
`Request starting … ?repere=fetchB` et **rien du tout** pour `?repere=registerB`. La requête
|
||
d'enregistrement **ne quitte jamais le navigateur**.
|
||
|
||
Le navigateur en question n'est pas un Chrome ordinaire : `navigator.userAgent` indique
|
||
`Claude/1.30096.1 Chrome/148 Electron/42.7.0` — un hôte Electron, dont la couche d'interception
|
||
réseau ne sert pas les requêtes de script de service worker.
|
||
|
||
**Conséquence à assumer** : le démarrage hors-ligne *complet* (coquille HTML/WASM servie par le
|
||
service worker) n'est **pas vérifiable ici**. Ce qui a été vérifié pour de bon, c'est tout le
|
||
reste — coquille servie par un serveur statique sur la **même origine**, API réellement
|
||
injoignable, et l'application repart de son cache IndexedDB. À confirmer dans un navigateur
|
||
ordinaire : charger l'application, vérifier dans les outils de développement (Application →
|
||
Service Workers) que le worker est `activated`, cocher « Offline », puis recharger.
|
||
|
||
### Deux dépôts distincts
|
||
|
||
| Dépôt | Contenu | Rôle |
|
||
|---|---|---|
|
||
| `mabibli` | Le code C#, les 3 projets | Ce qui est développé |
|
||
| `mabibli_ynh` | `manifest.toml`, scripts, conf nginx/systemd | Comment l'installer sur YunoHost |
|
||
|
||
Le paquet `_ynh` **ne contient aucun code C#** : il porte des instructions d'installation et une URL vers une archive compilée, avec son empreinte SHA256.
|
||
|
||
```
|
||
mabibli_ynh/
|
||
├── manifest.toml ← identité, version, URL du binaire + sha256
|
||
├── conf/
|
||
│ ├── systemd.service ← lancement du service, port
|
||
│ └── nginx.conf ← reverse proxy + intégration SSO
|
||
└── scripts/
|
||
├── install / remove
|
||
├── upgrade
|
||
└── backup / restore
|
||
```
|
||
|
||
### Chaîne de publication
|
||
|
||
Compilation **locale**, puis dépôt manuel de l'archive en release sur le Gitea de l'utilisateur, et mise à jour du `sha256` dans le `manifest.toml`. Prévoir un **script de build** encapsulant ces étapes, écrit pour être réutilisable tel quel dans une CI (Gitea Actions) si l'utilisateur bascule plus tard.
|
||
|
||
**Ne jamais compiler sur le serveur** à l'installation : cela imposerait le SDK .NET complet sur la machine YunoHost, pour une compilation lente — l'inverse exact de ce que permet le self-contained.
|
||
|
||
## Modèle de données
|
||
|
||
Schéma en vigueur depuis la migration `StatutPersonnelEtTableAuteurs` (2026-08-18).
|
||
|
||
```
|
||
Livre
|
||
├── Id
|
||
├── Isbn (index)
|
||
├── Titre
|
||
├── TitreNormalise (index — minuscules sans accents, recalculé à chaque écriture)
|
||
├── Editeur
|
||
├── Format : Physique | Numerique
|
||
├── CoverUrl
|
||
├── DateAjout
|
||
└── AjoutePar (YNH_USER — traçabilité, PAS un cloisonnement)
|
||
⚠ pas de colonne Statut, pas de colonne Auteur
|
||
|
||
Auteur
|
||
├── Id
|
||
├── Nom (affichage : « Émile Zola »)
|
||
├── NomNormalise (index — recherche : « emile zola »)
|
||
└── CleRegroupement (index UNIQUE — mots normalisés triés : « emile zola »)
|
||
|
||
LivreAuteur (n-n : un livre peut avoir plusieurs auteurs)
|
||
├── LivreId ─┐ clé primaire composite
|
||
├── AuteurId ─┘ FK Livre = cascade, FK Auteur = restrict
|
||
└── Position (rang sur la couverture, à partir de 0)
|
||
|
||
StatutLecture (le statut est PERSONNEL)
|
||
├── Id
|
||
├── LivreId (FK vers Livre, cascade)
|
||
├── Utilisateur (YNH_USER)
|
||
├── Statut : ALire | EnCours | Lu
|
||
├── DateMaj
|
||
└── UNIQUE (LivreId, Utilisateur)
|
||
absence de ligne = « non commencé »
|
||
|
||
RapprochementRefuse (mémoire des « non » de l'utilisateur)
|
||
├── Id
|
||
├── AuteurAId ─┐ UNIQUE, couple toujours rangé (petit Id d'abord)
|
||
└── AuteurBId ─┘ pour qu'un refus vaille dans les deux sens
|
||
|
||
Pret
|
||
├── Id
|
||
├── LivreId (FK Livre, cascade — index simple pour l'historique)
|
||
├── Emprunteur (nom, texte libre)
|
||
├── DatePret (UTC)
|
||
├── DateRetour (UTC, nullable — NULL tant que non rendu)
|
||
└── UNIQUE (LivreId) WHERE DateRetour IS NULL
|
||
un seul prêt ouvert par livre ; les prêts clos se répètent librement
|
||
|
||
LivreSouhaite (la liste d'envies est PERSONNELLE — voir sa section)
|
||
├── Id
|
||
├── Utilisateur (YNH_USER — une FRONTIÈRE, contrairement à AjoutePar)
|
||
├── Titre
|
||
├── TitreNormalise (clé d'ŒUVRE : « / » et « : » coupés — CleOeuvre)
|
||
├── Auteur (texte libre — PAS de FK vers Auteur, volontairement)
|
||
├── AuteurNormalise (jamais NULL, sinon l'unicité ci-dessous ne tient pas)
|
||
├── Editeur / Annee / Isbn / CoverUrl / Note
|
||
├── DateAjout
|
||
├── Rang (ordre CHOISI, 0 = le plus désiré — personnel comme le reste)
|
||
└── UNIQUE (Utilisateur, TitreNormalise, AuteurNormalise)
|
||
une œuvre par personne ; deux personnes peuvent souhaiter le même livre
|
||
```
|
||
|
||
Garder `Pret` comme table séparée (pas un champ sur `Livre`) pour conserver l'historique complet des prêts passés, pas juste l'état actuel.
|
||
|
||
**`AjoutePar` est une information, pas une frontière.** La bibliothèque est commune : ne **jamais** filtrer les requêtes de lecture sur ce champ. Il sert à savoir qui a saisi le livre (et implicitement à qui il appartient), pas à restreindre l'accès. Ce choix permet de basculer plus tard vers des bibliothèques cloisonnées sans migration de schéma.
|
||
|
||
**Le statut de lecture n'appartient plus à `Livre`.** Il vit dans une table par utilisateur (`LivreId` + `YNH_USER` + statut, unicité sur le couple). Un livre sans ligne pour l'utilisateur courant est simplement « non commencé ». Ne jamais réintroduire de colonne `Statut` sur `Livre` : elle redeviendrait commune à tout le foyer.
|
||
|
||
**L'auteur n'est plus un champ texte sur `Livre`.** Une table dédiée porte le nom d'affichage et une forme normalisée servant au regroupement et à la recherche. Le regroupement automatique ne s'applique qu'aux variantes **sûres** (casse, accents, initiales, ordre nom/prénom) ; les rapprochements ambigus — « Hamilton » seul vers « Peter F. Hamilton » — doivent être **proposés**, jamais appliqués silencieusement : une fusion erronée est difficile à défaire.
|
||
|
||
### Recherche sans accents : pourquoi des colonnes normalisées
|
||
|
||
**SQLite ne sait pas ignorer les accents.** Sa collation `NOCASE` ne couvre que l'ASCII et il n'existe pas de `unaccent()` intégré. La seule façon d'obtenir « Emile » → « Émile » sans charger tout le catalogue en mémoire est de stocker la forme mise à plat dans une colonne dédiée, et d'y chercher un terme passé par **exactement la même** fonction (`NormalisationTexte`).
|
||
|
||
Conséquence à ne pas oublier : **toute écriture doit recalculer ces colonnes** (`Livre.RecalculerFormes()`, `Auteur.RecalculerFormes()`).
|
||
|
||
Deux formes cohabitent sur `Auteur`, et ce n'est pas une redondance :
|
||
|
||
- `NomNormalise` garde l'ordre de saisie — c'est ce que la recherche parcourt en sous-chaîne ;
|
||
- `CleRegroupement` trie les mots, ce qui rend `Zola, Émile` et `Émile Zola` identiques. C'est elle qui porte l'index **unique**, donc l'invariant « un auteur, une fiche ».
|
||
|
||
La clé ne sait pas rapprocher les **initiales** (`P.F. Hamilton` ↔ `Peter F. Hamilton`) : cela demande d'aligner les mots un à un, ce qui n'a pas d'équivalent SQL. Ce cas est traité en mémoire par `RapprochementAuteurs.Comparer`, sur une table qui compte au plus quelques centaines de lignes.
|
||
|
||
### Reprise des données existantes
|
||
|
||
La migration recopie l'ancien contenu avec les moyens du bord, puis **`ServiceRenormalisation` finit le travail en C# au démarrage** — idempotent, il sert aussi de filet si les règles de normalisation changent un jour.
|
||
|
||
- L'ancien `Livres.Statut`, commun à tout le foyer, est **rattaché à `AjoutePar`**, seule personne que la base associe au livre. Les statuts des livres sans `AjoutePar` sont **perdus** : les attribuer serait une invention.
|
||
- L'ancien `Livres.Auteur` devient une fiche par valeur distincte. SQLite ne regroupant qu'à la casse près, les variantes accentuées ressortent en double — c'est le passage C# qui les réunit, en gardant le nom d'affichage le plus présentable (accents conservés, ni `NOM, Prénom` ni capitales intégrales).
|
||
|
||
**Les prêts ne concernent en pratique que les livres physiques** — les ebooks étant de simples fiches, il n'y a pas d'objet à prêter. `Emprunteur` reste un **texte libre**, sans lien avec les comptes YunoHost : on suit les prêts à des personnes extérieures au foyer, pas les échanges entre utilisateurs de l'app.
|
||
|
||
## Prêts — implémenté le 2026-08-18
|
||
|
||
### Un prêt ouvert par livre, garanti par la base
|
||
|
||
Un exemplaire sorti ne se prête pas une seconde fois. Le service le vérifie et renvoie un message
|
||
nommant celui qui l'a déjà (« Ce livre est déjà prêté à Marie »), mais entre cette vérification et
|
||
l'insertion il reste une fenêtre. Elle est fermée par un **index unique partiel** :
|
||
|
||
```sql
|
||
CREATE UNIQUE INDEX IX_Prets_LivreId_EnCours ON Prets (LivreId) WHERE DateRetour IS NULL;
|
||
```
|
||
|
||
Le filtre est ce qui rend la chose possible : seules les lignes ouvertes sont indexées, donc
|
||
l'historique reste libre d'accumuler autant de prêts clos que nécessaire sur le même livre.
|
||
Vérifié sur base réelle : un second `INSERT` ouvert échoue en `UNIQUE constraint failed`, un
|
||
second prêt **clos** passe.
|
||
|
||
⚠️ Deux index cohabitent sur `Prets.LivreId` et ce n'est pas une redondance : `IX_Prets_LivreId`
|
||
sert l'historique (toutes les lignes), l'index partiel sert la contrainte. **Ils doivent porter un
|
||
nom explicite** — EF Core identifie un index par ses colonnes, et sans nom distinct le second
|
||
déclaré *remplace* purement et simplement le premier dans la migration générée.
|
||
|
||
### Le prêt est commun, contrairement au statut de lecture
|
||
|
||
Symétrique inverse de la décision sur les statuts, et il faut tenir les deux :
|
||
|
||
| | Statut de lecture | Prêt |
|
||
|---|---|---|
|
||
| Portée | **personnel** (par `YNH_USER`) | **commun au foyer** |
|
||
| Pourquoi | deux personnes lisent le même exemplaire à leur rythme | un livre absent l'est pour tout le monde |
|
||
|
||
Aucun point d'entrée des prêts ne reçoit d'identité — le service n'en prend même pas en
|
||
paramètre, pour qu'on ne puisse pas s'en servir par inadvertance. N'importe qui doit pouvoir
|
||
noter le retour d'un livre qu'il a récupéré.
|
||
|
||
`LivreDto.PreteA` / `PreteDepuis` portent l'état courant pour l'étiquette du catalogue ;
|
||
l'historique complet se demande à part (`GET /api/livres/{id}/prets`) pour ne pas alourdir chaque
|
||
liste.
|
||
|
||
### Points d'entrée
|
||
|
||
| Méthode | Route | Rôle |
|
||
|---|---|---|
|
||
| `GET` | `/api/prets/en-cours` | Ce qui n'est pas à la maison, **du prêt le plus ancien au plus récent** |
|
||
| `POST` | `/api/livres/{id}/prets` | Prêter (400 si déjà sorti, numérique, ou emprunteur vide) |
|
||
| `GET` | `/api/livres/{id}/prets` | Historique, du plus récent au plus ancien (404 si livre inconnu, `[]` si jamais prêté) |
|
||
| `POST` | `/api/prets/{id}/retour` | Clore le prêt (400 s'il l'est déjà) |
|
||
|
||
L'ordre croissant des prêts en cours est délibéré : ce qu'on cherche dans cette vue, c'est le
|
||
livre sorti depuis six mois qu'on avait oublié, pas celui prêté hier.
|
||
|
||
### Décisions prises là où CLAUDE.md était muet
|
||
|
||
- **Supprimer un livre emporte son historique de prêts** (cascade, déjà déclarée, désormais
|
||
couverte par un test). Un historique orphelin — « quelqu'un a emprunté quelque chose » — ne se
|
||
lit plus. L'écran de suppression avertit en plus quand le livre est actuellement dehors.
|
||
- **La date de prêt est modifiable, la date de retour non.** On note souvent un prêt après coup
|
||
(« Paul a mon Zola depuis Noël ») ; un retour se constate au moment où il a lieu. Les deux
|
||
refusent une date future, et un retour antérieur à son prêt.
|
||
- **Rendre un prêt déjà clos est refusé** plutôt que silencieusement ignoré : réécrire la date
|
||
remplacerait une information exacte par une approximative.
|
||
- **Toutes les dates sont stockées en UTC**, comme `DateAjout`. Le `<input type="date">` produit
|
||
une date sans fuseau : le client la déclare **locale** avant de la convertir
|
||
(`ServiceLivresApi.EnUtc`). Sans cela le prêt se décalerait d'un jour pour la moitié du globe.
|
||
Vérifié en exécution : saisie du 12/08 → `2026-08-11 22:00` en base (CEST).
|
||
|
||
## Doublons du catalogue — avertir, jamais refuser (2026-08-19)
|
||
|
||
Défaut remonté en usage : rien n'empêchait de rescanner un livre déjà catalogué, et
|
||
`ServiceCatalogue.CreerAsync` ne faisait aucun contrôle.
|
||
|
||
**Décidé : un avertissement confirmable**, pas un index unique. `HasIndex(l => l.Isbn)` reste
|
||
délibérément **non unique**, et il ne faut pas le rendre unique « pour bien faire » :
|
||
|
||
- **posséder deux exemplaires est légitime** — on garde le sien et on prête l'autre. C'est
|
||
précisément le cas qu'un index unique rendrait impossible, sans recours ;
|
||
- **l'ISBN est facultatif** (livres anciens, tirages sans ISBN) : il ne peut pas porter à lui
|
||
seul l'unicité, et SQLite tient deux `NULL` pour distincts de toute façon.
|
||
|
||
⚠️ Le contraste avec la liste d'envies est voulu : `LivreSouhaite` **refuse** le doublon par
|
||
index unique. Une envie décrit une œuvre, dont on ne souhaite pas deux exemplaires ; un livre
|
||
décrit un **objet**, qu'on peut posséder en double. Ne pas uniformiser les deux.
|
||
|
||
### Deux critères, dont aucun n'est une clé
|
||
|
||
| Critère | Attrape | Rate, ou signale à tort |
|
||
|---|---|---|
|
||
| **ISBN identique** | le rescan du même livre, cas visé | rien quand l'ISBN manque |
|
||
| **`CleOeuvre` + un auteur commun** | la saisie manuelle sans ISBN, le sous-titre, l'ordre `Nom, Prénom` | le poche à côté du grand format — deux exemplaires bien réels |
|
||
|
||
L'auteur est **exigé en plus du titre** (au sens de `RapprochementAuteurs`) : sans lui, deux
|
||
recueils « Nouvelles » sans rapport se signaleraient l'un l'autre. Deux fiches **sans aucun**
|
||
auteur concordent, le titre étant alors tout ce dont on dispose.
|
||
|
||
⚠️ **La clé d'œuvre est un préfixe de `TitreNormalise`** — la troncature du sous-titre précède
|
||
la normalisation, qui préserve l'ordre des caractères. C'est ce qui permet de dégrossir en SQL
|
||
sur la colonne indexée (`== cle` ou `StartsWith(cle + " ")`) puis de vérifier l'égalité exacte
|
||
des clés en mémoire. Sans cette seconde passe, « Germinal les années noires » serait signalé
|
||
comme doublon de « Germinal » ; sans la première, il faudrait charger tout le catalogue à
|
||
chaque ajout.
|
||
|
||
### 409, et surtout pas 400
|
||
|
||
`POST /api/livres` répond **409 Conflict** avec `DoublonsLivre` (message + les fiches en cause),
|
||
et `?confirmerDoublon=true` enregistre la même saisie sans plus rien demander.
|
||
|
||
⚠️ **Un doublon n'est pas une erreur**, et le code de statut n'est pas un détail : la saisie est
|
||
valide, c'est le catalogue qui contient déjà quelque chose. D'où un **troisième état** dans
|
||
`ResultatEcriture` et dans `ResultatCreation` côté client, au lieu d'un message logé dans
|
||
`Erreur` — un texte rouge dirait « c'est raté » là où l'écran doit proposer « ajouter quand
|
||
même ». Le 409 traverse donc `EnvoyerAsync` sans passer par le traitement d'erreur générique,
|
||
qui l'aurait aplati en « L'enregistrement a échoué (409) ».
|
||
|
||
### L'écran montre les fiches, il ne les décrit pas
|
||
|
||
`AvertissementDoublon` remplace le formulaire tant que la question n'est pas tranchée (la saisie
|
||
attend dans `_saisie` et revient intacte si l'on renonce), et affiche chaque livre en cause
|
||
**en lien vers sa fiche** : la vraie question est « est-ce bien le même livre ? », et on n'y
|
||
répond qu'en regardant celui qui est déjà là. Le prêt en cours y figure — un exemplaire dehors
|
||
est justement une raison d'en vouloir un second.
|
||
|
||
**Ce qui n'est délibérément pas fait** : le contrôle ne porte que sur la **création**. Une
|
||
édition qui rendrait deux fiches identiques est un geste délibéré sur une fiche existante, pas
|
||
un scan répété par mégarde.
|
||
|
||
## Liste d'envies — implémentée le 2026-08-18
|
||
|
||
### Une table à part, et non un statut de plus
|
||
|
||
`LivreSouhaite` ne dépend pas de `Livre`. Le choix se joue sur un invariant, pas sur le confort :
|
||
un souhait logé dans `Livres` serait entré **mécaniquement** dans le catalogue, dans les
|
||
compteurs, dans les prêts et dans le cache hors-ligne, et il aurait fallu ajouter « et qui n'est
|
||
pas souhaité » à **chaque** lecture. Un invariant qu'on réécrit à chaque requête finit par être
|
||
oublié quelque part. Ici les deux tables ne se croisent pas — vérifié en exécution : une envie
|
||
n'apparaît ni au catalogue, ni dans la recherche, ni dans le compteur de livres d'un auteur.
|
||
|
||
C'est aussi la seule forme qui accepte une envie **sans édition arrêtée** : on souhaite une
|
||
œuvre, on possède un exemplaire. Un souhait n'a donc ni `Format`, ni prêt, et son ISBN est
|
||
facultatif.
|
||
|
||
```
|
||
LivreSouhaite (la liste d'envies est PERSONNELLE)
|
||
├── Id
|
||
├── Utilisateur (YNH_USER — ⚠️ une vraie FRONTIÈRE, voir ci-dessous)
|
||
├── Titre
|
||
├── TitreNormalise (clé d'ŒUVRE : sous-titre coupé — voir CleOeuvre)
|
||
├── Auteur (texte libre, PAS une FK vers Auteur)
|
||
├── AuteurNormalise (jamais NULL : SQLite tient deux NULL pour distincts)
|
||
├── Editeur / Annee / Isbn / CoverUrl / Note
|
||
├── DateAjout
|
||
├── INDEX (Utilisateur)
|
||
└── UNIQUE (Utilisateur, TitreNormalise, AuteurNormalise)
|
||
```
|
||
|
||
### `Utilisateur` est une frontière, contrairement à `Livre.AjoutePar`
|
||
|
||
C'est le point à ne pas confondre, les trois portées du projet coexistant désormais :
|
||
|
||
| Donnée | Portée | Filtre-t-on dessus ? |
|
||
|---|---|---|
|
||
| Catalogue (`AjoutePar`) | **commune** au foyer | **jamais** — ce serait un bug de conception |
|
||
| Prêt | **commun** au foyer | jamais |
|
||
| Statut de lecture | **personnel** | oui, sur l'appelant |
|
||
| **Liste d'envies** | **personnelle** | **oui, toujours, sans exception** |
|
||
|
||
Aucun point d'entrée n'accepte de nom d'utilisateur. Supprimer l'envie d'un autre renvoie **404**,
|
||
pas 403 : l'inexistence et l'appartenance à autrui sont volontairement indiscernables. Le sens
|
||
même de la fonctionnalité est de préparer un cadeau sans que l'autre le voie venir.
|
||
|
||
**L'auteur reste du texte libre**, pas une FK vers `Auteur` : cette table décrit qui est *dans la
|
||
bibliothèque*. Y insérer les auteurs souhaités les ferait apparaître sur `/auteurs` avec
|
||
« 0 livre » — et trahirait la liste d'envies de son propriétaire à tout le foyer.
|
||
|
||
### Export — `.txt` et `.csv`, aucun des deux ne demande de dépendance
|
||
|
||
IDEES.md décrit deux usages qui n'appellent pas le même fichier :
|
||
|
||
| Format | Usage | Forme |
|
||
|---|---|---|
|
||
| `.txt` | l'emporter en librairie | groupé **par auteur**, lisible tel quel sur un téléphone |
|
||
| `.csv` | le partager avant un anniversaire | tableau, pour se répartir les achats dans un tableur |
|
||
|
||
Deux points à ne pas « corriger » :
|
||
|
||
- **Séparateur point-virgule**, pas la virgule. Le CSV n'a qu'un travail — s'ouvrir dans un
|
||
tableur — et Excel en locale française attend `;`. Un fichier à virgules atterrit entièrement
|
||
dans la colonne A. La collection est francophone : c'est ce cas-là qu'il faut servir.
|
||
- **BOM UTF-8 en tête du CSV uniquement.** Sans lui Excel affiche « Émile Zola », soit à peu
|
||
près chaque ligne d'un fonds français. Le `.txt` s'en passe : certains lecteurs simples
|
||
l'affichent comme un caractère parasite.
|
||
|
||
Le fichier est produit **côté serveur** (`Results.File` pose un `Content-Disposition:
|
||
attachment`). ⚠️ Le lien de téléchargement **doit** porter l'attribut `download` : sans lui, le
|
||
routeur Blazor intercepte le clic comme une navigation interne et affiche sa page « introuvable »
|
||
au lieu de télécharger. Vérifié dans le navigateur.
|
||
|
||
### La liste s'ordonne à la main — migration `RangDesEnvies` (2026-08-18)
|
||
|
||
Une liste d'envies se lit « qu'est-ce que je veux le plus ? ». Elle était classée **par auteur
|
||
puis par titre** : l'alphabet répondait à une question que personne ne se pose. La colonne
|
||
`Rang` porte désormais l'ordre choisi, `0` en tête.
|
||
|
||
⚠️ **Le rang est personnel comme toute la table** : la numérotation repart de zéro pour chaque
|
||
`Utilisateur`, sans quoi l'ordre de l'un dépendrait de celui de l'autre.
|
||
|
||
**Une envie nouvelle se pose en fin de liste**, jamais en tête : on vient de la noter, on n'a
|
||
pas dit qu'on la voulait plus que les autres. La poser en tête déclasserait le choix de
|
||
l'utilisateur à chaque ajout.
|
||
|
||
#### La migration ne se réduit pas à `AddColumn`
|
||
|
||
⚠️ À `0` partout, l'ordre serait retombé sur l'`Id`, c'est-à-dire l'ordre d'ajout : **toutes les
|
||
listes existantes se seraient réordonnées** sous les yeux de leur propriétaire, sans qu'il ait
|
||
rien demandé. Le `migrationBuilder.Sql` qui suit l'`AddColumn` **reconduit exactement l'ordre
|
||
affiché jusqu'ici** — auteurs renseignés d'abord, puis par auteur, puis par titre.
|
||
|
||
Il s'appuie sur les **row values** de SQLite (`(a, b) < (c, d)`, disponibles depuis 3.15), qui
|
||
expriment « combien de lignes se classaient avant celle-ci » en une comparaison. L'`Id` ferme
|
||
le tri : sans lui, deux lignes égales recevraient le même rang.
|
||
|
||
⚠️ `ServiceSouhaitsTests` crée son schéma avec `EnsureCreated`, qui **ne joue aucune migration** :
|
||
ce SQL n'y passerait jamais. `MigrationRangDesEnviesTests` migre donc jusqu'à `ListeDEnvies`,
|
||
insère en SQL brut, puis applique `RangDesEnvies` et vérifie les rangs des deux utilisateurs.
|
||
Vérifié aussi en exécution sur la base de développement.
|
||
|
||
#### Un seul point d'entrée : `PUT /api/souhaits/ordre`
|
||
|
||
Il attend la liste **entière** des identifiants, dans l'ordre voulu. C'est ce dont a besoin le
|
||
glisser-déposer, et les flèches monter/descendre s'y ramènent — **une seule opération à
|
||
vérifier** plutôt qu'un « monter » et un « descendre » séparés.
|
||
|
||
Deux protections, toutes deux couvertes par des tests :
|
||
|
||
- **Le service relit la liste de l'appelant et ne se fie pas à ce que le client envoie.** Un
|
||
identifiant appartenant à quelqu'un d'autre est ignoré, jamais renuméroté — le filtre fait
|
||
partie de la clé, comme pour la suppression.
|
||
- **Une envie absente de l'ordre reçu est conservée**, rangée à la suite. La liste du client
|
||
peut être périmée (envie ajoutée depuis un autre appareil) : mal classée vaut infiniment mieux
|
||
que disparue.
|
||
|
||
#### Les flèches ne sont pas un ornement
|
||
|
||
⚠️ **Le glisser-déposer HTML5 ne fonctionne pas au doigt sur mobile.** Sans les flèches
|
||
monter/descendre, réordonner n'existerait que sur PC — c'est-à-dire pas là où l'on consulte sa
|
||
liste. Les deux gestes passent par le même code client (`DeplacerAsync`), la liste étant
|
||
réordonnée **avant** l'aller-retour réseau pour que la flèche réponde tout de suite ; un échec
|
||
recharge l'ordre du serveur plutôt que de laisser croire à un enregistrement.
|
||
|
||
**L'export CSV suit le rang** (utile pour se répartir les achats), le `.txt` reste **groupé par
|
||
auteur** : c'est ce qu'on épelle à un libraire. Les deux gardent leur usage.
|
||
|
||
### L'ajout d'une envie a son propre écran
|
||
|
||
`/souhaits/ajout`, séparé de `/souhaits` comme la fiche livre l'est de son édition : la liste ne
|
||
montre plus que la liste, et l'ajout a la place de proposer une vraie recherche. Trois voies,
|
||
dans l'ordre où le besoin se présente :
|
||
|
||
| Voie | Quand | Source |
|
||
|---|---|---|
|
||
| **Par titre** (défaut) | le cas **normal** — on souhaite un livre qu'on n'a pas en main | `bib.title`, auteur facultatif en `and` |
|
||
| **Par ISBN** | on l'a en main (librairie, chez quelqu'un) | cascade ISBN existante |
|
||
| **À la main** | aucune source ne le connaît | rien |
|
||
|
||
⚠️ **Le filtre de type de document est indispensable ici aussi.** Mesuré le 2026-08-18 :
|
||
`bib.title all "la bête humaine"` croisé avec Zola rend 20 notices dont **12 films** de Renoir,
|
||
un manuscrit et un multimédia. `ParserRecherche` réutilise donc la liste positive de
|
||
`BnfBibliographieParser`.
|
||
|
||
⚠️ **En revanche aucun post-filtre sur l'auteur**, contrairement à la bibliographie : la
|
||
contrainte de titre resserre déjà, l'auteur est facultatif, et surtout **l'utilisateur choisit
|
||
dans la liste en voyant les auteurs de chaque notice**. C'est ce qui laisse passer la BD signée
|
||
Dobbs tirée de *La bête humaine* — une adaptation se souhaite parfaitement, et rien ne trompe
|
||
puisque son auteur est affiché.
|
||
|
||
**`LivreSouhaite.CoverUrl` est enfin alimenté** : la couverture vient d'OpenLibrary dès qu'un
|
||
ISBN est connu, y compris pour les résultats d'une recherche par titre (l'ISBN est repris du
|
||
`dc:identifier` de la notice). **Sans ISBN, pas de couverture et on n'en invente pas** — la
|
||
vignette retombe sur son substitut à initiale. Décidé de **ne pas** offrir de champ « URL de
|
||
couverture » : il inviterait à coller des liens morts pour un gain nul.
|
||
|
||
## Une envie déjà au catalogue est signalée, jamais supprimée (2026-08-19)
|
||
|
||
Demande d'origine : quand un livre souhaité entre au catalogue, retirer l'envie correspondante.
|
||
**Décidé : on signale.** `SouhaitDto.Possede` / `LivreId` portent le rapprochement, et l'écran
|
||
des envies affiche une étiquette « Déjà au catalogue » **cliquable vers la fiche**.
|
||
|
||
Deux raisons, dont la première est structurelle :
|
||
|
||
- **le catalogue est commun, la liste d'envies personnelle.** L'envie n'est pas forcément celle
|
||
de la personne qui a saisi le livre : la supprimer modifierait la liste d'un autre en silence,
|
||
et lui ferait perdre sa note (« demandé à Noël »). L'API ne sait pas — délibérément — écrire
|
||
dans la liste d'autrui, et lui ouvrir cette porte percerait un invariant tenu partout ailleurs ;
|
||
- **le rapprochement est faillible** (`CleOeuvre` : titre retraduit, tome, intégrale). Une
|
||
suppression fondée sur un rapprochement faux est irréversible ; un signalement se corrige d'un
|
||
coup d'œil — d'où l'étiquette qui **mène à la fiche**, pour vérifier avant de retirer.
|
||
|
||
C'est la même règle que le grisage de la bibliographie, dans l'autre sens : on **marque**, on ne
|
||
masque ni ne supprime jamais une ligne.
|
||
|
||
### Le critère est celui des doublons, et c'est voulu
|
||
|
||
ISBN identique (il tranche seul, désignant une édition précise), sinon même clé d'œuvre **et**
|
||
un auteur commun. La convention « deux jeux de noms tous deux vides concordent » vit désormais
|
||
en un seul endroit, `RapprochementAuteurs.PartagentUnAuteur`, partagée avec la détection de
|
||
doublons du catalogue — deux règles voisines qui divergeraient en silence seraient pires qu'une
|
||
règle imparfaite.
|
||
|
||
⚠️ `LivreSouhaite.TitreNormalise` **est déjà** la clé d'œuvre (voir l'entité) : rien à
|
||
recalculer de ce côté, contrairement au catalogue dont `TitreNormalise` garde le titre entier.
|
||
|
||
⚠️ **Le catalogue est chargé en entier** (projection minimale) pour ce rapprochement. Assumé à
|
||
l'échelle d'un foyer, et cohérent avec l'instantané hors-ligne qui l'emporte déjà tout entier
|
||
dans le navigateur. Le jour où la bibliothèque compterait des milliers de fiches, c'est là qu'il
|
||
faudrait dégrossir en SQL — pas renoncer au signalement.
|
||
|
||
⚠️ Piège rencontré, et pris par les tests seuls : `GetValueOrDefault` sur un
|
||
`Dictionary<int, int>` rend **zéro**, pas `null`. Toute envie se disait donc possédée, en
|
||
pointant vers un livre inexistant. Le compilateur ne pouvait rien dire — `0` est un `int`
|
||
parfaitement valide.
|
||
|
||
**Ce qui n'est délibérément pas fait** : les exports `.txt` et `.csv` ne portent pas la mention.
|
||
Ce sont des instantanés qu'on emporte, alors que le signalement appelle une action — vérifier,
|
||
puis retirer — qui a lieu dans l'application.
|
||
|
||
## Bibliographie par auteur — SRU BnF, validé le 2026-08-18
|
||
|
||
Le déclencheur décrit dans IDEES.md : depuis un auteur déjà présent, voir tout ce qu'il a écrit,
|
||
les livres possédés grisés, et marquer les autres comme souhaités.
|
||
|
||
```
|
||
https://catalogue.bnf.fr/api/SRU?version=1.2&operation=searchRetrieve
|
||
&query=bib.author all "{nom}"&recordSchema=dublincore
|
||
&maximumRecords=100&startRecord={1|101}
|
||
```
|
||
|
||
L'index **`bib.author`** fonctionne, sans clé, en ~0,85 s par page. Deux pages de 100 sont
|
||
lancées **en parallèle** (200 notices au plus).
|
||
|
||
### ⚠️ Deux filtres sont indispensables, et le second est critique
|
||
|
||
Sans eux, l'écran ment à l'utilisateur.
|
||
|
||
1. **Type de document.** L'index auteur ne distingue pas un livre d'un livre audio, d'un jeu ou
|
||
d'un spectacle. Sur 200 notices de Werber : 180 imprimés, 13 enregistrements sonores, 3 images
|
||
animées, 2 jeux, 1 spectacle. Liste **positive** (`texte imprimé`, `ressource électronique`) :
|
||
un type inconnu est écarté.
|
||
2. **Auteur réel de la notice.** `all` exige que tous les mots soient présents — mais sur
|
||
l'**ensemble** des auteurs d'une notice. Un livre signé d'un « Émile » quelconque et d'un
|
||
« Zola » quelconque remonte donc aussi. Mesuré sur 200 notices d'« Émile Zola » : **67 écartées**,
|
||
dont toute l'œuvre de sa fille **Denise Le Blond-Zola** (*Émile Zola raconté par sa fille*),
|
||
qui parle de lui sans qu'il l'ait écrite. On exige donc qu'un `dc:creator` nettoyé corresponde
|
||
au nom demandé au sens de `RapprochementAuteurs`.
|
||
|
||
**Coût assumé du second filtre** : les adaptations dont l'auteur n'est pas le scénariste
|
||
disparaissent (les BD tirées de Werber sont signées Corbeyran — 17 notices sur 200). C'est le bon
|
||
compromis : afficher l'œuvre d'un tiers sous le nom de l'auteur est une erreur visible et gênante,
|
||
en omettre une adaptation ne l'est pas.
|
||
|
||
### Ce que ça donne réellement
|
||
|
||
| Auteur | Notices annoncées | Lues | Retenues | **Œuvres distinctes** |
|
||
|---|---|---|---|---|
|
||
| Bernard Werber | 203 | 200 | 166 | **51** |
|
||
| Émile Zola | 2692 | 200 | 118 | **87** |
|
||
| Amélie Nothomb | 277 | 200 | 169 | **63** |
|
||
|
||
Le regroupement des rééditions est ce qui rend l'écran utilisable : sans lui, « Les fourmis »
|
||
occupe onze lignes. Le représentant retenu est la notice **la plus ancienne** (l'édition
|
||
originale situe l'œuvre dans la carrière), mais l'ISBN est pris sur n'importe quelle notice qui
|
||
en porte un — les éditions anciennes n'en ont souvent pas.
|
||
|
||
⚠️ **Le plafond de 200 notices est un extrait pour les auteurs très réédités** (Zola en annonce
|
||
2692). L'interface **doit** le dire (`BibliographieDto.Tronquee`) : une bibliographie partielle
|
||
présentée comme complète ferait croire qu'un livre n'existe pas.
|
||
|
||
### Le rapprochement se fait par TITRE, pas par ISBN — et il rate des choses
|
||
|
||
**Pourquoi pas l'ISBN** : il désigne une *édition*, pas une œuvre. Le poche, le grand format et
|
||
la réédition portent trois ISBN pour le même roman — mesuré : 183 notices de livres de Werber
|
||
pour 51 œuvres, trois éditions par œuvre en moyenne. Comparer les ISBN répondrait « vous ne
|
||
l'avez pas » à propos d'un livre posé sur l'étagère. L'ISBN ne sert donc qu'en **confirmation**.
|
||
|
||
`CleOeuvre` coupe la mention de responsabilité (` / `) puis le sous-titre (` : `) et met à plat.
|
||
C'est ce qui réunit « Les thanatonautes », « Les thanatonautes : roman » et
|
||
« Les thanatonautes / Bernard Werber ». **Ce qu'elle rate**, et qu'il ne faut pas prétendre
|
||
autrement :
|
||
|
||
- un titre **retraduit ou changé** n'est pas rapproché du tout ;
|
||
- une œuvre possédée **à l'intérieur d'une intégrale** n'est pas repérée ;
|
||
- un **tome** n'est pas rapproché de son recueil (`Troisième humanité` ≠ `Troisième humanité. Tome 1`) ;
|
||
- deux œuvres partageant leur **titre principal** sont confondues (risque borné : la comparaison
|
||
n'a lieu qu'**à auteur donné**, jamais sur tout le catalogue) ;
|
||
- la **ligature `œ`** n'est pas réduite à `oe` — *L'Œuvre* de Zola échappe au rapprochement
|
||
(limite héritée de `NormalisationTexte`, voir IDEES.md).
|
||
|
||
Conséquence de conception : l'écran **grise** ce qu'il reconnaît et ne **masque jamais** de ligne.
|
||
Un faux négatif se voit et se corrige d'un coup d'œil ; une ligne masquée à tort serait invisible.
|
||
L'interface énonce cette limite en bas de liste plutôt que de laisser l'utilisateur la découvrir.
|
||
|
||
### Une source muette n'est pas une bibliographie vide — corrigé le 2026-08-19
|
||
|
||
Symptôme remonté : sur « Robert A. Harper », l'écran affichait
|
||
`BnF injoignable (TaskCanceledException)` **puis**, juste en dessous, « La BnF ne connaît aucun
|
||
livre de cet auteur ». Deux phrases contradictoires, dont la seconde était **fausse** : on n'avait
|
||
pas pu poser la question, donc on ne savait rien.
|
||
|
||
C'est la pire des deux erreurs possibles ici — elle **ferme** la question au lieu de la laisser
|
||
ouverte, et avec une explication rassurante qui rend l'absence crédible.
|
||
|
||
`BibliographieDto.Etat` (`EtatSourceBibliographie`) distingue désormais quatre issues, et
|
||
`SourceMuette` est ce que l'interface doit consulter **avant** d'expliquer une absence de
|
||
résultats :
|
||
|
||
| État | Sens | Ce que fait l'écran |
|
||
|---|---|---|
|
||
| `Ok` | la BnF a répondu | affiche la liste, **même vide** — et explique alors l'absence |
|
||
| `DelaiDepasse` | pas de réponse à temps | dit la panne, propose **Réessayer** |
|
||
| `Injoignable` | pas jointe, ou erreur HTTP | idem |
|
||
| `ReponseIllisible` | réponse inexploitable | idem |
|
||
|
||
⚠️ **Les deux branches s'excluent dans le balisage.** « La BnF ne connaît aucun livre de cet
|
||
auteur » n'est rendu que sous `!SourceMuette`. Ne jamais les remettre en séquence.
|
||
|
||
⚠️ `DelaiDepasse` se distingue de `Injoignable` parce qu'il n'appelle pas la même chose : un
|
||
délai dépassé se retente **tout de suite**, et c'est le cas le plus fréquent. `HttpClient`
|
||
l'annonce par une `TaskCanceledException` dont l'exception *interne* est un `TimeoutException` —
|
||
sans ce test, il se confondrait avec une annulation de l'appelant.
|
||
|
||
⚠️ **Les motifs sont écrits pour un humain.** `BnfClient.MotifDeSourceMuette` est **public** à
|
||
dessein : ce texte atteint l'utilisateur, c'est un contrat, et des tests vérifient qu'il ne
|
||
contient ni « Exception », ni « Task », ni « Http », que les trois motifs diffèrent, et que celui
|
||
du délai invite à réessayer.
|
||
|
||
Un bouton **Réessayer** existe aussi sur l'échec de l'appel à *notre* serveur (délai du
|
||
navigateur) : sans lui, il fallait recharger la page entière.
|
||
|
||
#### ⚠️ Le diagnostic d'IDEES.md était faux sur un point — vérifié en exécution
|
||
|
||
IDEES.md tenait « Robert A. Harper » pour **deux** problèmes : un délai dépassé, **et** un auteur
|
||
qui n'aurait « effectivement aucune notice à son nom » dans l'index BnF, faute d'être un auteur
|
||
francophone. Le second n'existe pas.
|
||
|
||
Mesuré le 2026-08-19 sur la base de développement : la BnF annonce **85 notices** pour cet
|
||
auteur, 78 sont écartées (autre support, ou signées de quelqu'un d'autre), et il reste
|
||
**7 œuvres** — dont *La thérapie émotivo-rationnelle*, correctement reconnue comme déjà possédée.
|
||
Le premier chargement expirait, le second aboutissait : c'est **le même défaut observé deux
|
||
fois**, pris pour deux.
|
||
|
||
La leçon vaut au-delà du cas : une source intermittente produit exactement les symptômes d'une
|
||
source lacunaire. C'est précisément pourquoi il faut distinguer les deux en machine plutôt qu'à
|
||
l'œil.
|
||
|
||
**Conséquence sur OpenLibrary** : la justification qui la rendait urgente tombe. Elle reste une
|
||
piste, avec sa limite connue — les œuvres remontent en langue originale et ne se rapprochent pas
|
||
du catalogue par `CleOeuvre`.
|
||
|
||
## Interface — décisions actées le 2026-08-18
|
||
|
||
Retours d'usage d'`IDEES.md`, appliqués et donc retirés de ce fichier-là.
|
||
|
||
### La fiche d'un livre a deux modes, et le mode est dans l'URL
|
||
|
||
`/livres/{id}` consulte, `/livres/{id}/edition` modifie. Ouvrir une fiche depuis le catalogue ne
|
||
doit rien pouvoir casser — sur mobile on ouvre une fiche pour *regarder*, et l'ancienne page
|
||
plaçait un formulaire vivant sous le pouce.
|
||
|
||
Le mode n'est **pas** un booléen interne : le mettre dans l'adresse rend l'écran d'édition
|
||
partageable et fait du bouton « retour » du navigateur une sortie d'édition naturelle. Vérifié.
|
||
|
||
⚠️ **Piège Blazor à connaître.** Les deux `@page` visent le même composant avec le même `Id` :
|
||
passer de l'un à l'autre ne change **aucun paramètre**, et le routeur ne déclenche alors **aucun
|
||
nouveau rendu**. Constaté en exécution : l'URL passait à `/edition`, l'écran restait en
|
||
consultation (alors qu'un chargement direct de l'URL fonctionnait, ce qui masque le défaut).
|
||
Le composant s'abonne donc à `NavigationManager.LocationChanged` pour se redessiner lui-même.
|
||
|
||
### Ce qui reste manipulable sans entrer en édition
|
||
|
||
Deux exceptions assumées, parce que ce ne sont pas des modifications de la *fiche* :
|
||
|
||
| Action | Où | Pourquoi hors « Éditer » |
|
||
|---|---|---|
|
||
| Statut de lecture | bloc dédié sur la fiche en consultation | Action la plus fréquente de l'application, et elle ne touche qu'à **ma** lecture, pas à la fiche commune. L'enfouir derrière « Éditer » serait un recul |
|
||
| Prêter / marquer rendu | bloc « Prêt » sur la fiche en consultation | Gestes du quotidien, et le prêt est une donnée **du foyer** : n'importe qui doit pouvoir noter un retour sans ouvrir un formulaire d'édition |
|
||
|
||
Corollaire : le statut modifié depuis la consultation doit **rafraîchir la saisie du formulaire
|
||
d'édition**, sinon un passage ultérieur en édition réécrirait l'ancienne valeur.
|
||
|
||
La **suppression**, elle, n'est offerte que dans le mode édition : c'est le seul écran où l'on
|
||
vient pour changer quelque chose.
|
||
|
||
### La liste ne se manipule pas
|
||
|
||
Dans le catalogue, le statut de lecture est une **pastille en lecture seule**, sur la même ligne
|
||
que les autres étiquettes. Les trois boutons *À lire / En cours / Lu* directement actionnables
|
||
depuis chaque carte ont été retirés : trop faciles à déclencher par erreur en faisant défiler.
|
||
|
||
Pas de pastille = « non commencé », cohérent avec l'absence de ligne en base.
|
||
|
||
### Le format ne s'affiche que pour les ebooks
|
||
|
||
Un livre est **physique par défaut** : c'est le cas majoritaire, et l'étiquette sur chaque ligne
|
||
n'apprenait rien. Seul « Numérique » apparaît, dans la liste comme sur la fiche.
|
||
|
||
⚠️ Conséquence à ne pas casser : le formulaire d'ajout **doit** présélectionner *Physique*,
|
||
sinon on créerait des fiches sans format en croyant que « rien d'affiché = physique ». C'est
|
||
acquis sans code particulier (`Format.Physique` vaut 0, valeur par défaut de l'énumération) —
|
||
mais si l'énumération change d'ordre un jour, ce comportement tombe.
|
||
|
||
### La navigation tient dans quatre onglets, en bas de l'écran
|
||
|
||
`MainLayout` porte une barre fixe **Catalogue / Auteurs / Prêts / Envies**. Elle remplace les
|
||
listes de liens que chaque page traînait dans sa barre d'actions : cinq boutons y passaient sur
|
||
deux ou trois lignes sur un téléphone, et « Envies » ne figurait pas partout.
|
||
|
||
Conséquence tenue partout : **`.actions-flottantes` ne porte plus que des *actions***. Tout lien
|
||
qui doublonnait exactement une destination d'onglet a été retiré — « Retour au catalogue »,
|
||
« Retour aux auteurs », « Ma liste d'envies ». Ce qui reste est ce qu'aucun onglet ne sait faire
|
||
(« Ses livres chez vous », qui est un catalogue *restreint*).
|
||
|
||
⚠️ **En bas, pas sous le bandeau** : le pouce atteint le bas de l'écran. Les deux barres fixes
|
||
coexistent grâce à `--mb-onglets-hauteur`, partagée entre `MainLayout.razor.css` et `app.css` —
|
||
sans elle, la barre d'actions recouvrirait les onglets. Vérifié à 375 px : onglets 770-812,
|
||
actions 706-770, aucun chevauchement.
|
||
|
||
⚠️ `NavLinkMatch.All` sur « Catalogue » est **obligatoire** : son `href` est la racine, et sans
|
||
cela l'onglet resterait allumé sur les quatre écrans.
|
||
|
||
Les onglets **restent actifs hors-ligne** : les quatre écrans se consultent depuis leurs
|
||
instantanés. Ce sont les écritures qui se désactivent, jamais la navigation.
|
||
|
||
### Les filtres du catalogue se replient derrière un bouton
|
||
|
||
Deux rangées de segments occupaient en permanence le haut de l'écran pour un réglage qu'on
|
||
change rarement. Elles tiennent maintenant derrière « Filtrer », près du titre.
|
||
|
||
⚠️ **Le compteur sur le bouton (« Filtrer (1) ») est ce qui rend le repli sans danger** : un
|
||
filtre actif et invisible ferait conclure que des livres ont disparu. Il ne compte que ce qui
|
||
serait invisible une fois replié — ni la recherche, ni la restriction à un auteur, toutes deux
|
||
déjà visibles à l'écran.
|
||
|
||
**La ligne « Tous / Physique / Numérique » disparaît quand le fonds n'a qu'un seul format**,
|
||
dans le prolongement de « le format ne s'affiche que pour les ebooks ». Elle réapparaît d'
|
||
elle-même au premier ebook — vérifié en exécution.
|
||
|
||
⚠️ Les formats présents ne se déduisent que d'un chargement **sans aucun critère**
|
||
(`EstSansCritere`). Les tirer d'une liste filtrée escamoterait la ligne dès qu'une recherche ne
|
||
remonte qu'un format, et l'utilisateur ne pourrait plus l'élargir. Dans le doute, la ligne reste
|
||
affichée : un filtre inutile est bénin, un filtre escamoté ne l'est pas.
|
||
|
||
### Une seule entrée d'ajout, désactivée hors-ligne — pas masquée
|
||
|
||
« Saisie manuelle » quitte le catalogue : elle reste accessible un écran plus loin, depuis
|
||
« Ajouter un livre », qui la propose dès qu'un ISBN ne donne rien.
|
||
|
||
⚠️ **Décision confirmée le 2026-08-18** : IDEES.md proposait de *masquer* les entrées d'ajout
|
||
hors-ligne. C'est refusé, et c'est le maintien de la règle actée. Un bouton disparu est
|
||
indiscernable d'une fonction supprimée : l'utilisateur conclurait à une régression au lieu
|
||
d'apprendre que c'est le réseau — même raisonnement que « pas dans les données enregistrées sur
|
||
cet appareil » plutôt que « n'existe plus ». **Le désencombrement passe par le regroupement**
|
||
(deux boutons en un), jamais par l'effacement.
|
||
|
||
Vérifié serveur arrêté : un seul bouton, `disabled`, portant son motif ; les quatre onglets
|
||
intacts ; les livres servis par l'instantané.
|
||
|
||
### Couverture agrandissable au clic
|
||
|
||
Vignette et fiche : cliquer l'image l'affiche en grand sur fond sombre, fermeture au clic
|
||
extérieur ou par Échap. Le calque est focalisé à l'ouverture (`FocusAsync`) — sans focus, aucun
|
||
`keydown` ne lui parviendrait et Échap resterait sans effet.
|
||
|
||
**Le substitut à initiale n'est jamais cliquable** : le bouton déclencheur n'est rendu que
|
||
lorsqu'une URL de couverture existe. Sans couverture, il n'y a rien à agrandir.
|
||
|
||
## Installation YunoHost — éprouvée en production le 2026-08-18
|
||
|
||
Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
|
||
mis à jour et sauvegardé sur un vrai serveur ; ce qui suit est ce que l'exercice a
|
||
appris, pas ce qu'on en attendait.
|
||
|
||
### La chaîne, telle qu'elle tourne
|
||
|
||
```
|
||
publier-release.sh → archive tar.gz (69 Mo, self-contained) → release Gitea
|
||
→ manifest.toml (amd64.url + amd64.sha256)
|
||
→ yunohost app install <url du dépôt _ynh>
|
||
```
|
||
|
||
Le script est la seule source des trois valeurs qui doivent rester cohérentes :
|
||
version, URL, sha256. ⚠️ Il ne réécrit **que** `manifest.toml` — les autres mentions
|
||
de l'URL (`README.md`, `Documentation=` de l'unité systemd) sont à traiter à la main.
|
||
|
||
⚠️ **Le dépôt du code doit être public.** `ynh_setup_source` télécharge sans jeton.
|
||
Gitea répond **404 et non 403** à un anonyme sur un dépôt privé : le symptôme est
|
||
rigoureusement identique à « la release n'existe pas », ce qui envoie chercher au
|
||
mauvais endroit. Contrôle qui tranche, hors session authentifiée :
|
||
`curl -fsSLI "<url du manifeste>"`.
|
||
|
||
⚠️ **YunoHost lit le manifeste depuis Gitea**, jamais la copie locale. Une correction
|
||
non poussée est une correction qui n'existe pas — constaté deux fois.
|
||
|
||
### Deux pièges systemd, tous deux invisibles hors d'un vrai serveur
|
||
|
||
Ni l'un ni l'autre ne peut sortir d'un `dotnet run` ou d'un lancement du publish à la
|
||
main : ils tiennent au gestionnaire de services, pas à l'application. C'est ce qui
|
||
justifie de tester l'installation réelle plutôt que le seul binaire.
|
||
|
||
| Piège | Symptôme | Correction |
|
||
|---|---|---|
|
||
| `Environment=` **découpe sur les espaces** | `ArgumentException: Format of the initialization string … at index 0` | guillemeter **toute** la ligne : `Environment="ConnectionStrings__MaBibli=Data Source=…"` |
|
||
| `ProtectHome=yes` masque `/home` | `SQLite Error 14: unable to open database file` | `ProtectHome=tmpfs` + `BindPaths=__DATA_DIR__` |
|
||
|
||
Le premier ne définissait pas une variable mais **deux** : `…__MaBibli=Data` et un
|
||
`Source=…` parasite. Le second est contre-intuitif parce que le `data_dir` de
|
||
YunoHost vit sous `/home/yunohost.app/<app>` : `ReadWritePaths=` **ne perce pas**
|
||
`ProtectHome` — mesuré sur une unité de test, `yes` + `ReadWritePaths` échoue,
|
||
`tmpfs` + `BindPaths` réussit. `tmpfs` garde l'essentiel du bénéfice : les répertoires
|
||
personnels du serveur restent invisibles au service.
|
||
|
||
### `install_dir` n'appartient pas à root, contrairement au manifeste
|
||
|
||
Le manifeste déclare `owner = "root:rwx"` pour que le service ne puisse pas réécrire
|
||
ses propres binaires. En pratique le helper `_ynh_apply_default_permissions` repasse
|
||
derrière avec `chown -R <app>:<app>`. Observé : `/var/www/mabibli` appartient à
|
||
`mabibli`.
|
||
|
||
**L'intention tient quand même**, mais par un seul mécanisme au lieu de deux :
|
||
`ProtectSystem=strict` met tout le système en lecture seule dans le namespace du
|
||
service, `ReadWritePaths=` ne listant que le `data_dir`. ⚠️ Ne pas retirer
|
||
`ProtectSystem=strict` en croyant que la propriété des fichiers protège encore.
|
||
|
||
### Ce qui a été vérifié en exécution
|
||
|
||
- **Installation** : archive téléchargée, sha256 contrôlé, migrations EF Core
|
||
appliquées au premier démarrage (`Application started` en 5 s, timeout de 60 s
|
||
largement suffisant), base créée dans `data_dir`.
|
||
- **Écoute sur `127.0.0.1` uniquement** — la contrainte dure de « Intégration SSO »
|
||
est tenue en production, vérifiée par `ss -tlnp`.
|
||
- **Mode WAL confirmé sur le serveur** (`PRAGMA journal_mode` → `wal`) : la
|
||
justification du `.backup` de `scripts/backup` n'est pas théorique. Les fichiers
|
||
`-wal`/`-shm` n'existent pas au repos (SQLite fait un checkpoint à la fermeture de
|
||
la dernière connexion) — leur absence dans un listing ne veut pas dire que le mode
|
||
a changé.
|
||
- **Sauvegarde** : l'archive contient bien `mabibli-instantane.db` **à côté** de la
|
||
base vivante et de ses `-wal`/`-shm`. C'est la restauration qui écarte ces derniers.
|
||
- **Mise à jour** : la sauvegarde de sécurité pré-upgrade **saute `data_dir`**
|
||
(`BACKUP_CORE_ONLY`), comme prévu, et la bibliothèque survit au remplacement
|
||
intégral d'`install_dir`.
|
||
- **Désinstallation** : `remove` et `remove --purge` fonctionnent tous deux. Sans
|
||
`--purge`, `data_dir` survit — donc **réinstaller après un `remove` retrouve
|
||
l'ancienne base**. Ce n'est pas une installation vierge, même si tout le reste est
|
||
neuf : piège à connaître en phase d'essai.
|
||
|
||
**La marche à suivre — première mise en production, montée de version, retour arrière,
|
||
désinstallation — est écrite pas à pas dans `mabibli_ynh/PUBLICATION.md`.** Elle n'est
|
||
pas recopiée ici : ce sont des gestes, pas des décisions.
|
||
|
||
### Ce que l'installation impose et qui ne se contourne pas
|
||
|
||
**Un domaine entier** (`mabibli.mondomaine.tld`), pas un sous-chemin : le
|
||
`<base href="/">` et les empreintes de `service-worker-assets.js` sont figés à la
|
||
compilation, et rien n'est recompilé sur le serveur. Le manifeste déclare donc
|
||
l'application en `full_domain`.
|
||
|
||
## Historique du projet (pourquoi ces choix)
|
||
|
||
L'utilisateur a testé deux solutions existantes avant de se lancer dans un projet custom :
|
||
|
||
- **uBiblio** (Docker, Python) — gère prêts + scan ISBN, mais dépend de Google Books (clé API requise) pour l'autofill, ce qui ne convient pas à l'utilisateur qui veut éviter cette dépendance
|
||
- **BookLogr** (Docker, Python) — utilise OpenLibrary nativement mais a le bug décrit ci-dessus (auteur non récupéré)
|
||
|
||
Conclusion : aucune des deux solutions existantes ne coche toutes les cases (prêts + pas de dépendance Google + autofill fiable) → développement d'une solution sur mesure.
|
||
|
||
## Prochaines étapes suggérées
|
||
|
||
1. Scaffolder le projet Blazor WebAssembly PWA (`dotnet new blazorwasm --pwa`) — **commande vérifiée valide en .NET 10**, l'option `--pwa` existe toujours
|
||
2. Ajouter le **projet API ASP.NET Core** : le client WASM tourne dans le navigateur, SQLite vit côté serveur — une API est indispensable, elle n'était pas explicitée dans la version initiale de ce document
|
||
3. Mettre en place le modèle EF Core + SQLite + migrations
|
||
4. Implémenter le service de lookup ISBN (OpenLibrary avec le double-appel titre+auteur)
|
||
5. CRUD livres (physique/numérique, statuts de lecture)
|
||
6. Gestion des prêts
|
||
7. Intégration scan caméra (**ZXing.Net** + interop caméra minimal)
|
||
8. ~~Cache hors-ligne pour la consultation~~ — fait le 2026-08-18 (voir « Stratégie hors-ligne »)
|
||
9. ~~Packaging YunoHost~~ — fait le 2026-08-18, installé et mis à jour sur un serveur réel (voir « Installation YunoHost »)
|
||
|
||
## Questions ouvertes
|
||
|
||
**Tous les choix structurants ont été tranchés le 2026-08-17** — voir le tableau des décisions. Le cadrage est clos, le développement peut commencer.
|
||
|
||
Points à réévaluer en cours de route, sans blocage :
|
||
|
||
- **AOT WASM** : mesurer le scan sur un vrai téléphone une fois fonctionnel. Activer `RunAOTCompilation` seulement si la fluidité est insuffisante.
|
||
- **Runner Gitea Actions** : à vérifier le jour où l'utilisateur voudra automatiser les releases.
|
||
- **Wikidata en 3ᵉ source ISBN** : uniquement si la cascade BnF → OpenLibrary montre ses limites en usage réel.
|
||
- **Démarrage hors-ligne par le service worker** : à confirmer dans un navigateur ordinaire, l'environnement d'automatisation ne permettant pas d'enregistrer un service worker (voir la section dédiée). Le reste du hors-ligne, lui, est vérifié.
|
||
- **Taille de l'instantané** : 10 livres pèsent ~2,4 Ko de JSON. Rien à optimiser avant plusieurs milliers de fiches ; si le jour vient, découper par pages plutôt que renoncer à l'instantané complet, qui est ce qui rend la recherche hors-ligne possible.
|