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
etcdkDropListExited
- 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:
pathExistsInBookmarks
computed signal détecte l'existence- Bouton affiché conditionnellement
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:
- Client charge le fichier → stocke
currentRev
- Client modifie → calcule
newRev
- Client sauvegarde avec header
If-Match: currentRev
- 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 unnumber
title
doit être unstring
(si présent)path
requis pourfile
,folder
items
requis (array) pourgroup
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
-
Basename fallback
- Créer un bookmark sans
title
- Vérifier que seul le nom de fichier s'affiche
- Créer un bookmark sans
-
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
-
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
-
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
-
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é
-
Persistance
- Faire une modification
- Recharger la page
- Vérifier que la modification est présente
-
Conflit externe
- Modifier
.obsidian/bookmarks.json
manuellement - Faire une modification dans l'app
- Vérifier que le modal de conflit apparaît
- Modifier
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"
etrole="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:
dragDisabled
esttrue
(vérifiersearchTerm
)- IDs de drop lists invalides
- 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:
- Repository en mode
read-only
oudisconnected
- Erreur d'écriture non catchée
- 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:
- Modifications simultanées (Obsidian + ObsiViewer)
- Rev non actualisé après load
- 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