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
|