# Bookmarks Technical Documentation ## Vue d'ensemble La fonctionnalité Bookmarks d'ObsiViewer permet de gérer des favoris compatibles à 100% avec Obsidian, en lisant et écrivant dans `.obsidian/bookmarks.json`. ## Architecture ### Couches ``` ┌─────────────────────────────────────┐ │ UI Components │ │ - BookmarksPanelComponent │ │ - BookmarkItemComponent │ │ - AddBookmarkModalComponent │ └─────────────┬───────────────────────┘ │ ┌─────────────▼───────────────────────┐ │ BookmarksService (Angular) │ │ - State management (Signals) │ │ - Business logic │ └─────────────┬───────────────────────┘ │ ┌─────────────▼───────────────────────┐ │ IBookmarksRepository │ │ ├─ FsAccessRepository (browser) │ │ ├─ ServerBridgeRepository (API) │ │ └─ InMemoryRepository (fallback) │ └─────────────┬───────────────────────┘ │ ┌─────────────▼───────────────────────┐ │ .obsidian/bookmarks.json │ └─────────────────────────────────────┘ ``` ## Structure de données ### Format JSON (Compatible Obsidian) ```json { "items": [ { "type": "file", "ctime": 1759241377289, "path": "notes/document.md", "title": "Mon Document" }, { "type": "group", "ctime": 1759202283361, "title": "Mes Projets", "items": [ { "type": "file", "ctime": 1759202288985, "path": "projets/projet-a.md" } ] } ], "rev": "abc123-456" } ``` ### Types TypeScript ```typescript type BookmarkType = 'group' | 'file' | 'search' | 'folder' | 'heading' | 'block'; interface BookmarkBase { type: BookmarkType; ctime: number; // Timestamp unique (ID) title?: string; // Titre optionnel } interface BookmarkFile extends BookmarkBase { type: 'file'; path: string; // Chemin relatif dans la vault } interface BookmarkGroup extends BookmarkBase { type: 'group'; items: BookmarkNode[]; // Enfants récursifs } type BookmarkNode = BookmarkFile | BookmarkGroup | ...; interface BookmarksDoc { items: BookmarkNode[]; rev?: string; // Pour détection de conflits } ``` ## Règles métier ### 1. Affichage des titres **Règle**: Si `title` manque, afficher le **basename** (nom de fichier sans dossier). ```typescript displayTitle = bookmark.title ?? basename(bookmark.path); // Exemple: "notes/projet/doc.md" → "doc.md" ``` **Implémentation**: `BookmarkItemComponent.displayText` getter. ### 2. Identifiants uniques **Règle**: Utiliser `ctime` (timestamp en millisecondes) comme ID unique. **Garantie d'unicité**: La fonction `ensureUniqueCTimes()` détecte et corrige les doublons. ### 3. Hiérarchie et drag & drop #### Opérations autorisées - ✅ Racine → Groupe (déposer un item dans un groupe) - ✅ Groupe → Racine (extraire un item d'un groupe) - ✅ Groupe A → Groupe B (déplacer entre groupes) - ✅ Réordonnancement au sein d'un conteneur #### Détection de cycles **Problème**: Empêcher de déposer un groupe dans lui-même ou ses descendants. **Solution**: La méthode `isDescendantOf()` vérifie récursivement la hiérarchie avant chaque déplacement. ```typescript private isDescendantOf(ancestorCtime: number): boolean { // Trouve l'ancêtre potentiel const ancestorNode = findNodeByCtime(doc.items, ancestorCtime); if (!ancestorNode) return false; // Vérifie si this.bookmark est dans ses descendants return checkDescendants(ancestorNode, this.bookmark.ctime); } ``` **Appel**: Dans `BookmarkItemComponent.onChildDrop()` avant `moveBookmark()`. ### 4. Zone "Drop here to move to root" **Problème initial**: La zone ne réagissait pas aux drops. **Solution**: - Ajout d'événements `cdkDropListEntered` et `cdkDropListExited` - Signal `isDraggingOverRoot` pour feedback visuel - Classes CSS dynamiques pour mettre en évidence la zone active ```html
Drop here to move to root
``` ### 5. Suppression d'un bookmark **Fonctionnalité**: Bouton "Supprimer" dans `AddBookmarkModalComponent` si le path existe déjà. **Implémentation**: 1. `pathExistsInBookmarks` computed signal détecte l'existence 2. Bouton affiché conditionnellement 3. `removePathEverywhere()` retire toutes les occurrences du path ```typescript removePathEverywhere(path: string): void { const removeByPath = (items: BookmarkNode[]): BookmarkNode[] => { return items.filter(item => { if (item.type === 'file' && item.path === path) { return false; // Supprime } if (item.type === 'group') { item.items = removeByPath(item.items); // Récursif } return true; }); }; const updated = { ...doc, items: removeByPath([...doc.items]) }; this._doc.set(updated); } ``` ## Persistence et intégrité ### Sauvegarde atomique #### Côté browser (FsAccessRepository) Utilise `FileSystemFileHandle.createWritable()` qui est atomique par nature. ```typescript const writable = await fileHandle.createWritable(); await writable.write(content); await writable.close(); // Commit atomique ``` #### Côté serveur (ServerBridgeRepository) Stratégie **write-to-temp + rename**: ```javascript // 1. Créer backup fs.copyFileSync(bookmarksPath, bookmarksPath + '.bak'); // 2. Écrire dans fichier temporaire fs.writeFileSync(tempPath, content, 'utf-8'); // 3. Rename atomique (opération système) fs.renameSync(tempPath, bookmarksPath); ``` **Avantages**: - Aucune corruption si crash pendant l'écriture - Backup automatique (`.bak`) - Respect de l'ordre d'origine (pas de réordonnancement involontaire) ### Détection de conflits **Mécanisme**: Hash `rev` calculé sur le contenu. ```typescript function calculateRev(doc: BookmarksDoc): string { const content = JSON.stringify(doc.items); let hash = 0; for (let i = 0; i < content.length; i++) { const char = content.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return Math.abs(hash).toString(36) + '-' + content.length; } ``` **Flow**: 1. Client charge le fichier → stocke `currentRev` 2. Client modifie → calcule `newRev` 3. Client sauvegarde avec header `If-Match: currentRev` 4. Serveur compare avec son `currentRev` - ✅ Match → Sauvegarde - ❌ Mismatch → HTTP 409 Conflict **Résolution**: - **Reload**: Recharger depuis le fichier (perdre les modifications locales) - **Overwrite**: Forcer l'écriture (écraser les modifications externes) ### Validation JSON Avant toute écriture, le schéma est validé: ```typescript function validateBookmarksDoc(data: unknown): { valid: boolean; errors: string[] } { const errors: string[] = []; // Vérifie structure racine if (!data || typeof data !== 'object') { errors.push('Document must be an object'); } if (!Array.isArray(data.items)) { errors.push('Document must have an "items" array'); } // Vérifie chaque node récursivement validateNode(item, path); return { valid: errors.length === 0, errors }; } ``` **Champs validés**: - `type` ∈ `['group', 'file', 'search', 'folder', 'heading', 'block']` - `ctime` doit être un `number` - `title` doit être un `string` (si présent) - `path` requis pour `file`, `folder` - `items` requis (array) pour `group` ## Drag & Drop avec Angular CDK ### Configuration des drop lists Chaque conteneur (racine ou groupe) a un ID unique: ```typescript const dropListIds = computed(() => { const ids: string[] = ['root']; const collect = (items: BookmarkNode[]) => { for (const item of items) { if (item.type === 'group') { ids.push(`group-${item.ctime}`); if (item.items?.length) { collect(item.items); // Récursif } } } }; collect(displayItems()); return ids; }); ``` ### Connexions entre listes Chaque drop list peut recevoir des items de toutes les autres: ```typescript getDropListConnections(id: string): string[] { return this.dropListIds().filter(existingId => existingId !== id); } ``` ### Données de drag Chaque item draggable transporte son `ctime` et `parentCtime`: ```html ``` ### Gestion du drop ```typescript handleDrop(event: CdkDragDrop, parentCtime: number | null): void { const data = event.item.data; // 1. Validation if (!data || typeof data.ctime !== 'number') return; if (parentCtime === data.ctime) return; // Drop into itself // 2. Détection de cycles (pour les groupes) if (parentCtime && isDescendantOf(data.ctime, parentCtime)) { console.warn('Cannot move a parent into its own descendant'); return; } // 3. Déplacement this.bookmarksService.moveBookmark( data.ctime, // Item à déplacer parentCtime, // Nouveau parent (null = racine) event.currentIndex // Nouvelle position ); } ``` ### Algorithme de déplacement Dans `bookmarks.utils.ts`: ```typescript export function moveNode( doc: BookmarksDoc, nodeCtime: number, newParentCtime: number | null, newIndex: number ): BookmarksDoc { // 1. Trouver le node const found = findNodeByCtime(doc, nodeCtime); if (!found) return doc; // 2. Vérifier cycles if (newParentCtime !== null && isDescendant(found.node, newParentCtime)) { return doc; } // 3. Cloner le node const nodeClone = cloneNode(found.node); // 4. Retirer de l'ancienne position let updated = removeNode(doc, nodeCtime); // 5. Insérer à la nouvelle position updated = addNode(updated, nodeClone, newParentCtime, newIndex); return updated; } ``` **Opérations immutables**: Chaque fonction retourne un nouveau document, jamais de mutation directe. ## UI/UX ### Responsive design - **Desktop**: Panel latéral fixe (320-400px) - **Mobile**: Drawer plein écran ### Thèmes (dark/light) Classes Tailwind avec préfixe `dark:`: ```html
...
``` Basculement automatique via `ThemeService`. ### Feedback visuel #### Pendant le drag ```html
``` #### Pendant la sauvegarde ```html @if (saving()) { Saving... } ``` #### Erreurs ```html @if (error()) {
{{ error() }}
} ``` ### États vides ```html @if (isEmpty()) {

No bookmarks yet

Use the bookmark icon to add one.

} ``` ## Tests ### Scénarios critiques 1. **Basename fallback** - Créer un bookmark sans `title` - Vérifier que seul le nom de fichier s'affiche 2. **Drag vers racine** - Créer un groupe avec un item - Drag l'item vers la zone "Drop here to move to root" - Vérifier qu'il apparaît à la racine 3. **Drag entre groupes** - Créer 2 groupes (A et B) - Ajouter un item dans A - Drag l'item de A vers B - Vérifier qu'il est maintenant dans B 4. **Détection de cycles** - Créer groupe A contenant groupe B - Tenter de drag A dans B - Vérifier que l'opération est bloquée 5. **Suppression via modal** - Ajouter un document aux bookmarks - Rouvrir la modal d'ajout pour ce document - Vérifier que le bouton "Delete" est présent - Cliquer sur "Delete" - Vérifier que le bookmark est supprimé 6. **Persistance** - Faire une modification - Recharger la page - Vérifier que la modification est présente 7. **Conflit externe** - Modifier `.obsidian/bookmarks.json` manuellement - Faire une modification dans l'app - Vérifier que le modal de conflit apparaît ## Performance ### Change detection Utilisation de `OnPush` + Signals: ```typescript @Component({ changeDetection: ChangeDetectionStrategy.OnPush, }) ``` Les signals déclenchent automatiquement la détection uniquement quand nécessaire. ### trackBy Pour les listes: ```typescript readonly trackNode = (index: number, node: BookmarkNode) => node.ctime ?? index; ``` Évite le re-render complet à chaque modification. ### Computed signals Les valeurs dérivées sont memoïzées: ```typescript readonly displayItems = computed(() => this.displayDoc().items ?? []); ``` Recalculé uniquement si `displayDoc()` change. ## Accessibilité ### États actuels - ✅ Rôles ARIA basiques (buttons, inputs) - ✅ Focus states visibles - ✅ Contraste colors (WCAG AA) ### Améliorations futures - ⏳ `role="tree"` et `role="treeitem"` pour la hiérarchie - ⏳ Navigation clavier (Arrow keys, Enter, Space) - ⏳ Screen reader announcements (ARIA live regions) - ⏳ Drag & drop au clavier ## Compatibilité Obsidian ### Champs conservés L'app préserve tous les champs Obsidian: ```json { "type": "file", "ctime": 1759241377289, "path": "...", "title": "...", "subpath": "...", // Pour heading/block "color": "...", // Extension Obsidian "icon": "..." // Extension Obsidian } ``` Même si l'app n'utilise pas `color` ou `icon`, ils sont préservés lors de l'écriture. ### Ordre préservé L'ordre des items dans `items[]` est strictement conservé (pas de tri automatique). ### Format JSON Indentation 2 espaces, comme Obsidian: ```typescript JSON.stringify(doc, null, 2); ``` ## Dépannage ### Drag & drop ne fonctionne pas **Symptôme**: Les items ne se déplacent pas. **Causes possibles**: 1. `dragDisabled` est `true` (vérifier `searchTerm`) 2. IDs de drop lists invalides 3. Données de drag manquantes ou mal typées **Debug**: ```typescript console.log('dragDisabled:', this.dragDisabled); console.log('dropListIds:', this.dropListIds()); console.log('cdkDragData:', event.item.data); ``` ### Sauvegarde ne persiste pas **Symptôme**: Les modifications disparaissent au reload. **Causes possibles**: 1. Repository en mode `read-only` ou `disconnected` 2. Erreur d'écriture non catchée 3. Auto-save débounce trop long **Debug**: ```typescript console.log('accessStatus:', this.bookmarksService.accessStatus()); console.log('isDirty:', this.bookmarksService.isDirty()); console.log('saving:', this.bookmarksService.saving()); ``` ### Conflits fréquents **Symptôme**: Modal de conflit apparaît souvent. **Causes possibles**: 1. Modifications simultanées (Obsidian + ObsiViewer) 2. Rev non actualisé après load 3. Auto-save trop agressif **Solution**: Augmenter `SAVE_DEBOUNCE_MS` dans le service. ## Évolutions futures ### Court terme - [ ] Ajout de tests unitaires E2E (Playwright) - [ ] Support du drag & drop au clavier - [ ] Preview au survol d'un bookmark file - [ ] Multi-sélection pour opérations en masse ### Moyen terme - [ ] Support des autres types (search, folder, heading, block) - [ ] Sélecteur d'icônes custom - [ ] Colorisation des groupes - [ ] Import/Export avec preview ### Long terme - [ ] Synchronisation temps réel (WebSockets) - [ ] Recherche full-text dans les bookmarks - [ ] Smart bookmarks (filtres dynamiques) - [ ] Partage de bookmarks entre utilisateurs --- **Dernière mise à jour**: 2025-01-30 **Version**: 2.0.0 **Auteur**: ObsiViewer Team