feat: add Obsidian bookmarks integration with toggle UI

- Integrated BookmarksService to display and filter bookmarked notes in quick links and paginated list
- Added bookmark toggle button in note viewer with visual feedback (filled/outline icon states)
- Implemented accordion behavior in mobile sidebar where opening one section closes others
- Updated responsive breakpoints to treat tablet as mobile (desktop layout starts at 1024px)
This commit is contained in:
Bruno Charest 2025-11-04 11:18:06 -05:00
parent 59d8a9f83a
commit 7331077ffa
9 changed files with 414 additions and 46 deletions

View File

@ -9,6 +9,7 @@ import { TagFilterStore } from '../../core/stores/tag-filter.store';
import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-list-state.service'; import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-list-state.service';
import { FilterService } from '../../services/filter.service'; import { FilterService } from '../../services/filter.service';
import { NoteContextMenuService } from '../../services/note-context-menu.service'; import { NoteContextMenuService } from '../../services/note-context-menu.service';
import { BookmarksService } from '../../services/bookmarks.service';
import { EditorStateService } from '../../../services/editor-state.service'; import { EditorStateService } from '../../../services/editor-state.service';
import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component'; import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component';
import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.service'; import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.service';
@ -346,6 +347,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
readonly contextMenu = inject(NoteContextMenuService); readonly contextMenu = inject(NoteContextMenuService);
private editorState = inject(EditorStateService); private editorState = inject(EditorStateService);
private keyboard = inject(KeyboardShortcutsService); private keyboard = inject(KeyboardShortcutsService);
private bookmarksService = inject(BookmarksService);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
private preservedOffset: number | null = null; private preservedOffset: number | null = null;
private useUnifiedSync = true; private useUnifiedSync = true;
@ -367,7 +369,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
folderFilter = input<string | null>(null); folderFilter = input<string | null>(null);
query = input<string>(''); query = input<string>('');
tagFilter = input<string | null>(null); tagFilter = input<string | null>(null);
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null); quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | 'bookmarks' | null>(null);
selectedId = input<string | null>(null); selectedId = input<string | null>(null);
kindFilter = input<'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all' | null>(null); kindFilter = input<'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all' | null>(null);
@ -561,6 +563,11 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
if (quickKey2) { if (quickKey2) {
items = items.filter(n => { items = items.filter(n => {
const full = byId.get(n.id); const full = byId.get(n.id);
// Special handling for bookmarks: check BookmarksService
if (quickKey2 === 'bookmarks') {
return this.bookmarksService.isBookmarked(n.filePath) && this.matchesKind(n.filePath, 'markdown');
}
// Standard quick links: check frontmatter
const fm = full?.frontmatter || {}; const fm = full?.frontmatter || {};
return fm[quickKey2] === true && this.matchesKind(n.filePath, 'markdown'); return fm[quickKey2] === true && this.matchesKind(n.filePath, 'markdown');
}); });
@ -927,7 +934,8 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
'template': { icon: '📑', name: 'Template' }, 'template': { icon: '📑', name: 'Template' },
'task': { icon: '🗒️', name: 'Task' }, 'task': { icon: '🗒️', name: 'Task' },
'private': { icon: '🔒', name: 'Private' }, 'private': { icon: '🔒', name: 'Private' },
'archive': { icon: '🗃️', name: 'Archive' } 'archive': { icon: '🗃️', name: 'Archive' },
'bookmarks': { icon: '🔖', name: 'Obsidian Bookmarks' }
}; };
return displays[quickLink] || null; return displays[quickLink] || null;
} }

View File

@ -1,6 +1,7 @@
import { Component, EventEmitter, Output, inject } from '@angular/core'; import { Component, EventEmitter, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { VaultService } from '../../../services/vault.service'; import { VaultService } from '../../../services/vault.service';
import { BookmarksService } from '../../services/bookmarks.service';
import { BadgeCountComponent } from '../../shared/ui/badge-count.component'; import { BadgeCountComponent } from '../../shared/ui/badge-count.component';
interface QuickLinkCountsUi { interface QuickLinkCountsUi {
@ -27,6 +28,12 @@ interface QuickLinkCountsUi {
<app-badge-count class="ml-auto" [count]="counts().all" color="slate"></app-badge-count> <app-badge-count class="ml-auto" [count]="counts().all" color="slate"></app-badge-count>
</button> </button>
</li> </li>
<li>
<button (click)="select('bookmarks')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left">
<span class="flex items-center gap-2"><span>🔖</span> <span>Obsidian Bookmarks</span></span>
<app-badge-count class="ml-auto" [count]="bookmarks.count()" color="blue"></app-badge-count>
</button>
</li>
<li> <li>
<button (click)="select('favorites')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left"> <button (click)="select('favorites')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left">
<span class="flex items-center gap-2"><span></span> <span>Favoris</span></span> <span class="flex items-center gap-2"><span></span> <span>Favoris</span></span>
@ -75,6 +82,7 @@ interface QuickLinkCountsUi {
}) })
export class QuickLinksComponent { export class QuickLinksComponent {
private vault = inject(VaultService); private vault = inject(VaultService);
readonly bookmarks = inject(BookmarksService);
@Output() quickLinkSelected = new EventEmitter<string>(); @Output() quickLinkSelected = new EventEmitter<string>();
counts = () => this.vault.counts() as QuickLinkCountsUi; counts = () => this.vault.counts() as QuickLinkCountsUi;

View File

@ -36,7 +36,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
<!-- Section Tests (dev-only) --> <!-- Section Tests (dev-only) -->
<section *ngIf="env.features.showTestSection" class="border-b border-border dark:border-gray-800"> <section *ngIf="env.features.showTestSection" class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors" <button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.tests = !open.tests"> (click)="toggleSection('tests')">
<span class="flex items-center gap-2">🧪 <span>Section Tests</span></span> <span class="flex items-center gap-2">🧪 <span>Section Tests</span></span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.tests">{{ open.tests ? '▾' : '▸' }}</span> <span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.tests">{{ open.tests ? '▾' : '▸' }}</span>
</button> </button>
@ -62,7 +62,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
<!-- Quick Links accordion --> <!-- Quick Links accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors" <button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.quick = !open.quick"> (click)="toggleSection('quick')">
<span class="flex items-center gap-2"> <span>Quick Links</span></span> <span class="flex items-center gap-2"> <span>Quick Links</span></span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.quick">{{ open.quick ? '▾' : '▸' }}</span> <span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.quick">{{ open.quick ? '▾' : '▸' }}</span>
</button> </button>
@ -74,7 +74,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
<!-- Folders accordion --> <!-- Folders accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors" <button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.folders = !open.folders"> (click)="toggleSection('folders')">
<span class="flex items-center gap-2">📁 <span>Folders</span></span> <span class="flex items-center gap-2">📁 <span>Folders</span></span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.folders">{{ open.folders ? '▾' : '▸' }}</span> <span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.folders">{{ open.folders ? '▾' : '▸' }}</span>
</button> </button>
@ -86,7 +86,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
<!-- Tags accordion --> <!-- Tags accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors" <button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.tags = !open.tags"> (click)="toggleSection('tags')">
<span class="flex items-center gap-2">🏷 <span>Tags</span></span> <span class="flex items-center gap-2">🏷 <span>Tags</span></span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.tags">{{ open.tags ? '▾' : '▸' }}</span> <span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.tags">{{ open.tags ? '▾' : '▸' }}</span>
</button> </button>
@ -106,7 +106,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
<!-- AI Tools accordion --> <!-- AI Tools accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors" <button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.ai = !open.ai"> (click)="toggleSection('ai')">
<span class="flex items-center gap-2">🤖 <span>AI Tools</span></span> <span class="flex items-center gap-2">🤖 <span>AI Tools</span></span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.ai">{{ open.ai ? '▾' : '▸' }}</span> <span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.ai">{{ open.ai ? '▾' : '▸' }}</span>
</button> </button>
@ -131,7 +131,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
<!-- Trash accordion --> <!-- Trash accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors" <button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.trash = !open.trash; onFolder('.trash')"> (click)="toggleSection('trash'); onFolder('.trash')">
<span class="flex items-center gap-2">🗑 <span>Trash</span></span> <span class="flex items-center gap-2">🗑 <span>Trash</span></span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.trash">{{ open.trash ? '▾' : '▸' }}</span> <span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.trash">{{ open.trash ? '▾' : '▸' }}</span>
</button> </button>
@ -201,7 +201,7 @@ export class AppSidebarDrawerComponent {
@Output() aboutSelected = new EventEmitter<void>(); @Output() aboutSelected = new EventEmitter<void>();
@Output() aiToolSelected = new EventEmitter<string>(); @Output() aiToolSelected = new EventEmitter<string>();
open = { quick: true, folders: true, tags: false, ai: false, trash: false, tests: true }; open = { quick: true, folders: false, tags: false, ai: false, trash: false, tests: false };
onSelect(id: string) { onSelect(id: string) {
this.noteSelected.emit(id); this.noteSelected.emit(id);
@ -259,4 +259,13 @@ export class AppSidebarDrawerComponent {
this.aiToolSelected.emit(tool.id); this.aiToolSelected.emit(tool.id);
this.mobileNav.sidebarOpen.set(false); this.mobileNav.sidebarOpen.set(false);
} }
/**
* Toggle section: open requested section, close all others
* Mimics desktop sidebar accordion behavior
*/
toggleSection(which: 'quick' | 'folders' | 'tags' | 'ai' | 'trash' | 'tests'): void {
this.open = { quick: false, folders: false, tags: false, ai: false, trash: false, tests: false };
(this.open as any)[which] = true;
}
} }

View File

@ -21,6 +21,7 @@ import { TestExcalidrawPageComponent } from '../../features/tests/test-excalidra
import { ParametersPage } from '../../features/parameters/parameters.page'; import { ParametersPage } from '../../features/parameters/parameters.page';
import { AboutPanelComponent } from '../../features/about/about-panel.component'; import { AboutPanelComponent } from '../../features/about/about-panel.component';
import { UrlStateService } from '../../services/url-state.service'; import { UrlStateService } from '../../services/url-state.service';
import { BookmarksService } from '../../services/bookmarks.service';
import { FilterService } from '../../services/filter.service'; import { FilterService } from '../../services/filter.service';
import { NoteInfoModalComponent } from '../../features/note-info/note-info-modal.component'; import { NoteInfoModalComponent } from '../../features/note-info/note-info-modal.component';
import { NoteInfoModalService } from '../../services/note-info-modal.service'; import { NoteInfoModalService } from '../../services/note-info-modal.service';
@ -375,6 +376,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
responsive = inject(ResponsiveService); responsive = inject(ResponsiveService);
mobileNav = inject(MobileNavService); mobileNav = inject(MobileNavService);
urlState = inject(UrlStateService); urlState = inject(UrlStateService);
bookmarks = inject(BookmarksService);
filters = inject(FilterService); filters = inject(FilterService);
noteInfo = inject(NoteInfoModalService); noteInfo = inject(NoteInfoModalService);
inPageSearch = inject(InPageSearchService); inPageSearch = inject(InPageSearchService);
@ -426,7 +428,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
hoveredFlyout: 'quick' | 'folders' | 'tags' | 'ai' | 'trash' | 'help' | 'about' | 'tests' | 'playground' | null = null; hoveredFlyout: 'quick' | 'folders' | 'tags' | 'ai' | 'trash' | 'help' | 'about' | 'tests' | 'playground' | null = null;
private flyoutCloseTimer: any = null; private flyoutCloseTimer: any = null;
tagFilter: string | null = null; tagFilter: string | null = null;
quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null = null; quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | 'bookmarks' | null = null;
private suppressNextNoteSelection = false; private suppressNextNoteSelection = false;
// --- URL State <-> Layout sync --- // --- URL State <-> Layout sync ---
@ -459,6 +461,11 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
return 'private'; return 'private';
case 'archive': case 'archive':
return 'archive'; return 'archive';
case 'bookmarks':
case 'obsidian bookmarks':
case 'obsidian-bookmarks':
case '🔖 obsidian bookmarks':
return 'bookmarks';
default: default:
return null; return null;
} }
@ -473,6 +480,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
case 'task': return 'Tâches'; case 'task': return 'Tâches';
case 'private': return 'Privé'; case 'private': return 'Privé';
case 'archive': return 'Archive'; case 'archive': return 'Archive';
case 'bookmarks': return 'Bookmarks';
default: return null; default: return null;
} }
} }
@ -544,16 +552,11 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.quickLinkFilter = internal; this.quickLinkFilter = internal;
this.folderFilter = null; this.folderFilter = null;
this.tagFilter = null; this.tagFilter = null;
if (internal === 'favoris') {
this.suppressNextNoteSelection = true;
}
if (!hasNote && !this.suppressNextNoteSelection) {
this.autoSelectFirstNote(); this.autoSelectFirstNote();
}
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
} else if (!hasNote && !this.suppressNextNoteSelection) { } else {
this.autoSelectFirstNote(); this.autoSelectFirstNote();
} }
// Auto-open quick flyout when quick filter is active // Auto-open quick flyout when quick filter is active
@ -568,7 +571,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.folderFilter = null; this.folderFilter = null;
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = null; this.quickLinkFilter = null;
if (!hasNote) this.autoSelectFirstNote(); this.autoSelectFirstNote();
} }
this.suppressNextNoteSelection = false; this.suppressNextNoteSelection = false;
// Close any open flyout when no filters // Close any open flyout when no filters
@ -630,11 +633,16 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
// Apply Quick Link filter // Apply Quick Link filter
if (quickLink) { if (quickLink) {
if (quickLink === 'bookmarks') {
// Only markdown files and only those present in BookmarksService
list = list.filter(n => this.bookmarks.isBookmarked(n.filePath) && /\.md$/i.test(n.filePath || ''));
} else {
list = list.filter(n => { list = list.filter(n => {
const frontmatter = n.frontmatter || {}; const frontmatter = n.frontmatter || {} as any;
return frontmatter[quickLink] === true; return frontmatter[quickLink as keyof typeof frontmatter] === true;
}); });
} }
}
// Apply query if present // Apply query if present
if (q) { if (q) {
@ -677,10 +685,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
onQueryChange(query: string) { onQueryChange(query: string) {
this.listQuery = query; this.listQuery = query;
// Only auto-select when query is cleared; while typing keep focus in search (handled by notes-list) // Auto-select first note on any list change including search updates
if (!query) {
this.autoSelectFirstNote(); this.autoSelectFirstNote();
}
// Sync URL search term // Sync URL search term
this.urlState.updateSearch(query); this.urlState.updateSearch(query);
} }
@ -836,8 +842,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
} }
onQuickLink(_id: string) { onQuickLink(_id: string) {
const suppressAutoSelect = _id === 'all' || _id === 'favorites'; this.suppressNextNoteSelection = false;
this.suppressNextNoteSelection = suppressAutoSelect;
if (_id === 'all') { if (_id === 'all') {
// Show all pages: clear filters and focus list // Show all pages: clear filters and focus list
@ -965,11 +970,25 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
if (label) { if (label) {
this.urlState.setQuickWithMarkdown(label); this.urlState.setQuickWithMarkdown(label);
} }
} else if (_id === 'bookmarks') {
// Filter by bookmarks (Obsidian bookmarks.json)
this.folderFilter = null;
this.tagFilter = null;
this.quickLinkFilter = 'bookmarks';
this.listQuery = '';
try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {}
if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list');
}
this.scheduleCloseFlyout(150);
const label = this.mapInternalQuickToUrl('bookmarks');
if (label) {
this.urlState.setQuickWithMarkdown(label);
}
} }
// Auto-select first note after filter changes // Auto-select first note after filter changes
if (!this.suppressNextNoteSelection) {
this.autoSelectFirstNote(); this.autoSelectFirstNote();
}
this.suppressNextNoteSelection = false; this.suppressNextNoteSelection = false;
} }

View File

@ -0,0 +1,276 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { ToastService } from '../shared/toast/toast.service';
import { VaultService } from '../../services/vault.service';
export interface BookmarkItem {
type: 'file' | 'folder' | 'group' | 'search';
path?: string;
title?: string;
ctime?: number;
items?: BookmarkItem[];
}
export interface BookmarksDocument {
items: BookmarkItem[];
rev?: string;
}
@Injectable({
providedIn: 'root'
})
export class BookmarksService {
private http = inject(HttpClient);
private toast = inject(ToastService);
private vault = inject(VaultService);
// State
private bookmarksDoc = signal<BookmarksDocument>({ items: [] });
private loading = signal(false);
private lastRev = signal<string | undefined>(undefined);
// Computed
readonly items = computed(() => this.bookmarksDoc().items);
readonly isLoading = computed(() => this.loading());
/**
* Count all bookmarked file paths (recursively)
*/
readonly count = computed(() => {
return this.countFiles(this.items());
});
/**
* Get all bookmarked file paths (flattened)
*/
readonly bookmarkedPaths = computed(() => {
return this.extractFilePaths(this.items());
});
constructor() {
// Load bookmarks on startup
this.loadBookmarks();
}
/**
* Load bookmarks from server
*/
async loadBookmarks(): Promise<void> {
this.loading.set(true);
try {
const response = await firstValueFrom(
this.http.get<BookmarksDocument>('/api/vault/bookmarks')
);
this.bookmarksDoc.set(response);
this.lastRev.set(response.rev);
// Validate and auto-repair on load (no-op if already valid)
try {
await this.validateAndRepair();
} catch (e) {
console.warn('[BookmarksService] Validation skipped:', e);
}
} catch (error) {
console.error('[BookmarksService] Failed to load bookmarks:', error);
this.toast.error('Failed to load bookmarks');
} finally {
this.loading.set(false);
}
}
/**
* Save bookmarks to server
*/
private async saveBookmarks(): Promise<void> {
this.loading.set(true);
try {
let headers = new HttpHeaders();
if (this.lastRev()) {
headers = headers.set('If-Match', this.lastRev()!);
}
const response = await firstValueFrom(
this.http.put<{ rev: string }>('/api/vault/bookmarks', { items: this.bookmarksDoc().items }, { headers })
);
this.lastRev.set(response.rev);
} catch (error) {
console.error('[BookmarksService] Failed to save bookmarks:', error);
this.toast.error('Failed to save bookmarks');
throw error;
} finally {
this.loading.set(false);
}
}
/**
* Check if a file is bookmarked
*/
isBookmarked(filePath: string): boolean {
return this.bookmarkedPaths().includes(filePath);
}
/**
* Toggle bookmark for a file
*/
async toggleBookmark(filePath: string, title?: string): Promise<void> {
const currentItems = [...this.items()];
const isCurrentlyBookmarked = this.isBookmarked(filePath);
if (isCurrentlyBookmarked) {
// Remove bookmark
const filtered = this.removeFileFromItems(currentItems, filePath);
this.bookmarksDoc.update(doc => ({ ...doc, items: filtered }));
this.toast.success('Removed from bookmarks');
} else {
// Add bookmark
const newItem: BookmarkItem = {
type: 'file',
path: filePath,
title: title || filePath,
ctime: Date.now()
};
currentItems.push(newItem);
this.bookmarksDoc.update(doc => ({ ...doc, items: currentItems }));
this.toast.success('Added to bookmarks');
}
// Save to server
await this.saveBookmarks();
}
/**
* Recursively count file items
*/
private countFiles(items: BookmarkItem[]): number {
let count = 0;
for (const item of items) {
if (item.type === 'file') {
count++;
} else if (item.items) {
count += this.countFiles(item.items);
}
}
return count;
}
/**
* Recursively extract all file paths
*/
private extractFilePaths(items: BookmarkItem[]): string[] {
const paths: string[] = [];
for (const item of items) {
if (item.type === 'file' && item.path) {
paths.push(item.path);
} else if (item.items) {
paths.push(...this.extractFilePaths(item.items));
}
}
return paths;
}
/**
* Recursively remove a file from items
*/
private removeFileFromItems(items: BookmarkItem[], filePath: string): BookmarkItem[] {
const result: BookmarkItem[] = [];
for (const item of items) {
if (item.type === 'file' && item.path === filePath) {
// Skip this item (remove it)
continue;
}
if (item.items) {
// Recursively filter nested items
const filteredChildren = this.removeFileFromItems(item.items, filePath);
result.push({ ...item, items: filteredChildren });
} else {
result.push(item);
}
}
return result;
}
/**
* Clear all bookmarks
*/
async clearAll(): Promise<void> {
this.bookmarksDoc.set({ items: [] });
await this.saveBookmarks();
this.toast.success('All bookmarks cleared');
}
/**
* Refresh bookmarks from server
*/
async refresh(): Promise<void> {
await this.loadBookmarks();
}
/**
* Validate and repair bookmarks against current vault metadata.
* - Normalizes paths (slashes, trim leading/trailing)
* - Removes entries for files that do not exist
* - Fixes case/extension mismatches to canonical file paths when possible
* Saves to server only when changes are detected.
*/
async validateAndRepair(): Promise<void> {
const original = this.bookmarksDoc();
const repairedItems = this.repairItems(original.items);
const changed = JSON.stringify(original.items) !== JSON.stringify(repairedItems);
if (changed) {
this.bookmarksDoc.set({ items: repairedItems, rev: original.rev });
await this.saveBookmarks();
this.toast.success('Bookmarks validated and repaired');
}
}
// --- Helpers ---
private normalizePath(p?: string): string {
const s = String(p || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
return s;
}
private repairItems(items: BookmarkItem[]): BookmarkItem[] {
// Build a lookup of canonical paths from vault metadata
const meta = (() => { try { return this.vault.allFilesMetadata() || []; } catch { return []; } })();
const canonicalByLc = new Map<string, string>();
for (const m of meta) {
const path = this.normalizePath((m as any).path || (m as any).filePath || '');
if (path) canonicalByLc.set(path.toLowerCase(), path);
}
const fix = (arr: BookmarkItem[]): BookmarkItem[] => {
const out: BookmarkItem[] = [];
for (const item of arr || []) {
if (!item) continue;
if (item.type === 'file') {
const raw = this.normalizePath(item.path);
if (!raw) continue; // drop empty
// try exact
let canonical = canonicalByLc.get(raw.toLowerCase());
// try with .md if missing
if (!canonical && !/\.[^\/]+$/.test(raw)) {
canonical = canonicalByLc.get((raw + '.md').toLowerCase());
}
if (!canonical) {
// file not found -> drop
continue;
}
out.push({ ...item, path: canonical });
} else if (Array.isArray(item.items) && item.items.length > 0) {
const repairedChildren = fix(item.items);
// keep group/folder/search only if it still has children (or if not a container)
if (repairedChildren.length > 0) {
out.push({ ...item, items: repairedChildren });
}
} else {
// keep non-file items without children as-is
out.push(item);
}
}
return out;
};
return fix(items || []);
}
}

View File

@ -10,8 +10,11 @@ export class ResponsiveService {
isDesktop = signal<boolean>(false); isDesktop = signal<boolean>(false);
constructor() { constructor() {
this.breakpointObserver.observe('(max-width: 767px)').subscribe(r => this.isMobile.set(r.matches)); // Mobile layout: up to 1023px
this.breakpointObserver.observe('(min-width: 768px) and (max-width: 1023px)').subscribe(r => this.isTablet.set(r.matches)); this.breakpointObserver.observe('(max-width: 1023px)').subscribe(r => this.isMobile.set(r.matches));
// Tablet breakpoint disabled - go directly from desktop to mobile
this.breakpointObserver.observe('(min-width: 99999px)').subscribe(r => this.isTablet.set(r.matches));
// Desktop layout: 1024px and above
this.breakpointObserver.observe('(min-width: 1024px)').subscribe(r => this.isDesktop.set(r.matches)); this.breakpointObserver.observe('(min-width: 1024px)').subscribe(r => this.isDesktop.set(r.matches));
} }
} }

View File

@ -23,6 +23,7 @@ import { EditorStateService } from '../../../services/editor-state.service';
import { ClipboardService } from '../../../app/shared/services/clipboard.service'; import { ClipboardService } from '../../../app/shared/services/clipboard.service';
import { ToastService } from '../../../app/shared/toast/toast.service'; import { ToastService } from '../../../app/shared/toast/toast.service';
import { VaultService } from '../../../services/vault.service'; import { VaultService } from '../../../services/vault.service';
import { BookmarksService } from '../../../app/services/bookmarks.service';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import mermaid from 'mermaid'; import mermaid from 'mermaid';
@ -234,6 +235,23 @@ export interface WikiLinkActivation {
<!-- Row 2: state icons (toggle buttons) --> <!-- Row 2: state icons (toggle buttons) -->
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<!-- Bookmarks button -->
<button type="button"
class="inline-flex items-center gap-1 transition-colors focus:outline-none"
[ngClass]="isBookmarked() ? 'text-blue-500' : 'text-muted'"
[title]="isBookmarked() ? 'Bookmarked' : 'Add to bookmarks'"
[attr.aria-label]="isBookmarked() ? 'Bookmarked' : 'Add to bookmarks'"
(click)="toggleBookmark()">
@if (isBookmarked()) {
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
}
</button>
@if (hasState('favoris')) { @if (hasState('favoris')) {
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('favoris') ? 'text-rose-500' : 'text-muted'" title="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" aria-label="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" (click)="toggleState('favoris')"> <button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('favoris') ? 'text-rose-500' : 'text-muted'" title="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" aria-label="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" (click)="toggleState('favoris')">
@if (state('favoris')) { @if (state('favoris')) {
@ -396,6 +414,7 @@ export class NoteViewerComponent implements OnDestroy {
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly vault = inject(VaultService); private readonly vault = inject(VaultService);
private readonly editorState = inject(EditorStateService); private readonly editorState = inject(EditorStateService);
private readonly bookmarks = inject(BookmarksService);
private readonly tagPaletteSize = 12; private readonly tagPaletteSize = 12;
private readonly tagColorCache = new Map<string, number>(); private readonly tagColorCache = new Map<string, number>();
private readonly copyFeedbackTimers = new Map<HTMLElement, number>(); private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
@ -450,6 +469,20 @@ export class NoteViewerComponent implements OnDestroy {
return []; return [];
}); });
isBookmarked = computed(() => {
const currentNote = this.note();
if (!currentNote?.filePath) return false;
return this.bookmarks.isBookmarked(currentNote.filePath);
});
async toggleBookmark(): Promise<void> {
const currentNote = this.note();
if (!currentNote?.filePath) return;
const title = String(currentNote.frontmatter?.title || currentNote.fileName || currentNote.filePath);
await this.bookmarks.toggleBookmark(currentNote.filePath, title);
}
async toggleState(key: 'publish' | 'favoris' | 'archive' | 'draft' | 'private' | 'template' | 'task'): Promise<void> { async toggleState(key: 'publish' | 'favoris' | 'archive' | 'draft' | 'private' | 'template' | 'task'): Promise<void> {
const currentNote = this.note(); const currentNote = this.note();
if (!currentNote?.id) return; if (!currentNote?.id) return;

View File

@ -10,21 +10,32 @@
"ctime": 1759433952208, "ctime": 1759433952208,
"path": "HOME.md", "path": "HOME.md",
"title": "HOME" "title": "HOME"
},
{
"type": "file",
"ctime": 1759677937745,
"path": "folder1/test2.md",
"title": "test2"
} }
] ]
}, },
{ {
"type": "file", "type": "file",
"ctime": 1759434060575, "path": "folder-4/test-add-properties.md",
"path": "test.md", "title": "test-add-properties.md",
"title": "Page de test Markdown" "ctime": 1762268601102
},
{
"type": "file",
"path": "Allo-3/Nouvelle note 13.md",
"title": "Nouvelle note 13.md",
"ctime": 1762268909461
},
{
"type": "file",
"path": "Allo-3/Nouveau-markdown.md",
"title": "Nouveau-markdown.md",
"ctime": 1762268911797
},
{
"type": "file",
"path": "tata/Les Compléments Alimentaires Un Guide Général.md",
"title": "Les Compléments Alimentaires Un Guide Général.md",
"ctime": 1762268914159
} }
], ]
"rev": "pu1hkm-417"
} }

View File

@ -74,7 +74,8 @@
"title": "Bookmarks" "title": "Bookmarks"
} }
} }
] ],
"currentTab": 2
} }
], ],
"direction": "horizontal", "direction": "horizontal",
@ -178,7 +179,7 @@
"obsidian-excalidraw-plugin:New drawing": false "obsidian-excalidraw-plugin:New drawing": false
} }
}, },
"active": "2e9abbba0bbc33e1", "active": "aaf62e01f34df49b",
"lastOpenFiles": [ "lastOpenFiles": [
"big/note_500.md", "big/note_500.md",
"big/note_499.md", "big/note_499.md",