646 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			646 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
# 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
 | 
						|
<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
 | 
						|
 | 
						|
```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
 | 
						|
<app-bookmark-item
 | 
						|
  cdkDrag
 | 
						|
  [cdkDragData]="{ ctime: node.ctime, parentCtime: null }"
 | 
						|
  ... />
 | 
						|
```
 | 
						|
 | 
						|
### Gestion du drop
 | 
						|
 | 
						|
```typescript
 | 
						|
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`:
 | 
						|
 | 
						|
```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
 | 
						|
<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
 | 
						|
 | 
						|
```html
 | 
						|
<div [class.bg-blue-500/20]="isDraggingOver()">
 | 
						|
  <!-- Highlight zone active -->
 | 
						|
</div>
 | 
						|
```
 | 
						|
 | 
						|
#### Pendant la sauvegarde
 | 
						|
 | 
						|
```html
 | 
						|
@if (saving()) {
 | 
						|
  <span class="animate-pulse">Saving...</span>
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
#### Erreurs
 | 
						|
 | 
						|
```html
 | 
						|
@if (error()) {
 | 
						|
  <div class="bg-red-50 dark:bg-red-900/20 ...">
 | 
						|
    {{ error() }}
 | 
						|
  </div>
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
### États vides
 | 
						|
 | 
						|
```html
 | 
						|
@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:
 | 
						|
 | 
						|
```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
 |