diff --git a/src/app/features/list/paginated-notes-list.component.ts b/src/app/features/list/paginated-notes-list.component.ts index f894b53..deb8b5a 100644 --- a/src/app/features/list/paginated-notes-list.component.ts +++ b/src/app/features/list/paginated-notes-list.component.ts @@ -9,6 +9,7 @@ import { TagFilterStore } from '../../core/stores/tag-filter.store'; import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-list-state.service'; import { FilterService } from '../../services/filter.service'; import { NoteContextMenuService } from '../../services/note-context-menu.service'; +import { BookmarksService } from '../../services/bookmarks.service'; import { EditorStateService } from '../../../services/editor-state.service'; import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component'; import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.service'; @@ -346,6 +347,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy { readonly contextMenu = inject(NoteContextMenuService); private editorState = inject(EditorStateService); private keyboard = inject(KeyboardShortcutsService); + private bookmarksService = inject(BookmarksService); private destroy$ = new Subject(); private preservedOffset: number | null = null; private useUnifiedSync = true; @@ -367,7 +369,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy { folderFilter = input(null); query = input(''); tagFilter = input(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(null); kindFilter = input<'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all' | null>(null); @@ -561,6 +563,11 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy { if (quickKey2) { items = items.filter(n => { 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 || {}; return fm[quickKey2] === true && this.matchesKind(n.filePath, 'markdown'); }); @@ -927,7 +934,8 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy { 'template': { icon: '📑', name: 'Template' }, 'task': { icon: '🗒️', name: 'Task' }, 'private': { icon: '🔒', name: 'Private' }, - 'archive': { icon: '🗃️', name: 'Archive' } + 'archive': { icon: '🗃️', name: 'Archive' }, + 'bookmarks': { icon: '🔖', name: 'Obsidian Bookmarks' } }; return displays[quickLink] || null; } diff --git a/src/app/features/quick-links/quick-links.component.ts b/src/app/features/quick-links/quick-links.component.ts index 5ae5644..af2a0d2 100644 --- a/src/app/features/quick-links/quick-links.component.ts +++ b/src/app/features/quick-links/quick-links.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Output, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { VaultService } from '../../../services/vault.service'; +import { BookmarksService } from '../../services/bookmarks.service'; import { BadgeCountComponent } from '../../shared/ui/badge-count.component'; interface QuickLinkCountsUi { @@ -27,6 +28,12 @@ interface QuickLinkCountsUi { +
  • + +
  • @@ -62,7 +62,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
    @@ -74,7 +74,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
    @@ -86,7 +86,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
    @@ -106,7 +106,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
    @@ -131,7 +131,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
    @@ -201,7 +201,7 @@ export class AppSidebarDrawerComponent { @Output() aboutSelected = new EventEmitter(); @Output() aiToolSelected = new EventEmitter(); - 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) { this.noteSelected.emit(id); @@ -259,4 +259,13 @@ export class AppSidebarDrawerComponent { this.aiToolSelected.emit(tool.id); 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; + } } diff --git a/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts index 0c7987e..7bef1c3 100644 --- a/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts +++ b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts @@ -21,6 +21,7 @@ import { TestExcalidrawPageComponent } from '../../features/tests/test-excalidra import { ParametersPage } from '../../features/parameters/parameters.page'; import { AboutPanelComponent } from '../../features/about/about-panel.component'; import { UrlStateService } from '../../services/url-state.service'; +import { BookmarksService } from '../../services/bookmarks.service'; import { FilterService } from '../../services/filter.service'; import { NoteInfoModalComponent } from '../../features/note-info/note-info-modal.component'; import { NoteInfoModalService } from '../../services/note-info-modal.service'; @@ -375,6 +376,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { responsive = inject(ResponsiveService); mobileNav = inject(MobileNavService); urlState = inject(UrlStateService); + bookmarks = inject(BookmarksService); filters = inject(FilterService); noteInfo = inject(NoteInfoModalService); inPageSearch = inject(InPageSearchService); @@ -426,7 +428,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { hoveredFlyout: 'quick' | 'folders' | 'tags' | 'ai' | 'trash' | 'help' | 'about' | 'tests' | 'playground' | null = null; private flyoutCloseTimer: any = 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; // --- URL State <-> Layout sync --- @@ -459,6 +461,11 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { return 'private'; case 'archive': return 'archive'; + case 'bookmarks': + case 'obsidian bookmarks': + case 'obsidian-bookmarks': + case '🔖 obsidian bookmarks': + return 'bookmarks'; default: return null; } @@ -473,6 +480,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { case 'task': return 'Tâches'; case 'private': return 'Privé'; case 'archive': return 'Archive'; + case 'bookmarks': return 'Bookmarks'; default: return null; } } @@ -544,16 +552,11 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { this.quickLinkFilter = internal; this.folderFilter = null; this.tagFilter = null; - if (internal === 'favoris') { - this.suppressNextNoteSelection = true; - } - if (!hasNote && !this.suppressNextNoteSelection) { - this.autoSelectFirstNote(); - } + this.autoSelectFirstNote(); if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } - } else if (!hasNote && !this.suppressNextNoteSelection) { + } else { this.autoSelectFirstNote(); } // Auto-open quick flyout when quick filter is active @@ -568,7 +571,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = null; - if (!hasNote) this.autoSelectFirstNote(); + this.autoSelectFirstNote(); } this.suppressNextNoteSelection = false; // Close any open flyout when no filters @@ -630,10 +633,15 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { // Apply Quick Link filter if (quickLink) { - list = list.filter(n => { - const frontmatter = n.frontmatter || {}; - return frontmatter[quickLink] === true; - }); + 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 => { + const frontmatter = n.frontmatter || {} as any; + return frontmatter[quickLink as keyof typeof frontmatter] === true; + }); + } } // Apply query if present @@ -677,10 +685,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { onQueryChange(query: string) { this.listQuery = query; - // Only auto-select when query is cleared; while typing keep focus in search (handled by notes-list) - if (!query) { - this.autoSelectFirstNote(); - } + // Auto-select first note on any list change including search updates + this.autoSelectFirstNote(); // Sync URL search term this.urlState.updateSearch(query); } @@ -836,8 +842,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { } onQuickLink(_id: string) { - const suppressAutoSelect = _id === 'all' || _id === 'favorites'; - this.suppressNextNoteSelection = suppressAutoSelect; + this.suppressNextNoteSelection = false; if (_id === 'all') { // Show all pages: clear filters and focus list @@ -965,11 +970,25 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { if (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 - if (!this.suppressNextNoteSelection) { - this.autoSelectFirstNote(); - } + this.autoSelectFirstNote(); this.suppressNextNoteSelection = false; } diff --git a/src/app/services/bookmarks.service.ts b/src/app/services/bookmarks.service.ts new file mode 100644 index 0000000..ac9c528 --- /dev/null +++ b/src/app/services/bookmarks.service.ts @@ -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({ items: [] }); + private loading = signal(false); + private lastRev = signal(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 { + this.loading.set(true); + try { + const response = await firstValueFrom( + this.http.get('/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 { + 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 { + 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 { + this.bookmarksDoc.set({ items: [] }); + await this.saveBookmarks(); + this.toast.success('All bookmarks cleared'); + } + + /** + * Refresh bookmarks from server + */ + async refresh(): Promise { + 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 { + 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(); + 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 || []); + } +} diff --git a/src/app/shared/services/responsive.service.ts b/src/app/shared/services/responsive.service.ts index dfa1539..4ba3eda 100644 --- a/src/app/shared/services/responsive.service.ts +++ b/src/app/shared/services/responsive.service.ts @@ -10,8 +10,11 @@ export class ResponsiveService { isDesktop = signal(false); constructor() { - this.breakpointObserver.observe('(max-width: 767px)').subscribe(r => this.isMobile.set(r.matches)); - this.breakpointObserver.observe('(min-width: 768px) and (max-width: 1023px)').subscribe(r => this.isTablet.set(r.matches)); + // Mobile layout: up to 1023px + 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)); } } diff --git a/src/components/tags-view/note-viewer/note-viewer.component.ts b/src/components/tags-view/note-viewer/note-viewer.component.ts index 9a664d1..cbc6b78 100644 --- a/src/components/tags-view/note-viewer/note-viewer.component.ts +++ b/src/components/tags-view/note-viewer/note-viewer.component.ts @@ -23,6 +23,7 @@ import { EditorStateService } from '../../../services/editor-state.service'; import { ClipboardService } from '../../../app/shared/services/clipboard.service'; import { ToastService } from '../../../app/shared/toast/toast.service'; import { VaultService } from '../../../services/vault.service'; +import { BookmarksService } from '../../../app/services/bookmarks.service'; import { Subscription } from 'rxjs'; import mermaid from 'mermaid'; @@ -234,6 +235,23 @@ export interface WikiLinkActivation {
    + + @if (hasState('favoris')) {