ObsiViewer/docs/BOOKMARKS_TECHNICAL.md

16 KiB

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)

{
  "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

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).

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.

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
<div 
  cdkDropList
  cdkDropListId="root"
  (cdkDropListDropped)="handleRootDrop($event)"
  (cdkDropListEntered)="onDragEnterRoot()"
  (cdkDropListExited)="onDragExitRoot()"
  [class.bg-blue-500/20]="isDraggingOverRoot()">
  Drop here to move to root
</div>

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
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.

const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close(); // Commit atomique

Côté serveur (ServerBridgeRepository)

Stratégie write-to-temp + rename:

// 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.

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é:

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:

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:

getDropListConnections(id: string): string[] {
  return this.dropListIds().filter(existingId => existingId !== id);
}

Données de drag

Chaque item draggable transporte son ctime et parentCtime:

<app-bookmark-item
  cdkDrag
  [cdkDragData]="{ ctime: node.ctime, parentCtime: null }"
  ... />

Gestion du drop

handleDrop(event: CdkDragDrop<BookmarkNode[]>, 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:

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::

<div class="bg-white dark:bg-gray-900">
  <span class="text-gray-900 dark:text-gray-100">...</span>
</div>

Basculement automatique via ThemeService.

Feedback visuel

Pendant le drag

<div [class.bg-blue-500/20]="isDraggingOver()">
  <!-- Highlight zone active -->
</div>

Pendant la sauvegarde

@if (saving()) {
  <span class="animate-pulse">Saving...</span>
}

Erreurs

@if (error()) {
  <div class="bg-red-50 dark:bg-red-900/20 ...">
    {{ error() }}
  </div>
}

États vides

@if (isEmpty()) {
  <div class="text-center py-8 text-gray-500">
    <p>No bookmarks yet</p>
    <p class="text-sm">Use the bookmark icon to add one.</p>
  </div>
}

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:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
})

Les signals déclenchent automatiquement la détection uniquement quand nécessaire.

trackBy

Pour les listes:

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:

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:

{
  "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:

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:

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:

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