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:
parent
59d8a9f83a
commit
7331077ffa
@ -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<void>();
|
||||
private preservedOffset: number | null = null;
|
||||
private useUnifiedSync = true;
|
||||
@ -367,7 +369,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
folderFilter = input<string | null>(null);
|
||||
query = input<string>('');
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
<app-badge-count class="ml-auto" [count]="counts().all" color="slate"></app-badge-count>
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
@ -75,6 +82,7 @@ interface QuickLinkCountsUi {
|
||||
})
|
||||
export class QuickLinksComponent {
|
||||
private vault = inject(VaultService);
|
||||
readonly bookmarks = inject(BookmarksService);
|
||||
@Output() quickLinkSelected = new EventEmitter<string>();
|
||||
|
||||
counts = () => this.vault.counts() as QuickLinkCountsUi;
|
||||
|
||||
@ -36,7 +36,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
|
||||
<!-- Section Tests (dev-only) -->
|
||||
<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"
|
||||
(click)="open.tests = !open.tests">
|
||||
(click)="toggleSection('tests')">
|
||||
<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>
|
||||
</button>
|
||||
@ -62,7 +62,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
|
||||
<!-- Quick Links accordion -->
|
||||
<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"
|
||||
(click)="open.quick = !open.quick">
|
||||
(click)="toggleSection('quick')">
|
||||
<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>
|
||||
</button>
|
||||
@ -74,7 +74,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
|
||||
<!-- Folders accordion -->
|
||||
<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"
|
||||
(click)="open.folders = !open.folders">
|
||||
(click)="toggleSection('folders')">
|
||||
<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>
|
||||
</button>
|
||||
@ -86,7 +86,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
|
||||
<!-- Tags accordion -->
|
||||
<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"
|
||||
(click)="open.tags = !open.tags">
|
||||
(click)="toggleSection('tags')">
|
||||
<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>
|
||||
</button>
|
||||
@ -106,7 +106,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
|
||||
<!-- AI Tools accordion -->
|
||||
<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"
|
||||
(click)="open.ai = !open.ai">
|
||||
(click)="toggleSection('ai')">
|
||||
<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>
|
||||
</button>
|
||||
@ -131,7 +131,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
|
||||
<!-- Trash accordion -->
|
||||
<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"
|
||||
(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="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.trash">{{ open.trash ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
@ -201,7 +201,7 @@ export class AppSidebarDrawerComponent {
|
||||
@Output() aboutSelected = new EventEmitter<void>();
|
||||
@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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
276
src/app/services/bookmarks.service.ts
Normal file
276
src/app/services/bookmarks.service.ts
Normal 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 || []);
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,11 @@ export class ResponsiveService {
|
||||
isDesktop = signal<boolean>(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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
<!-- Row 2: state icons (toggle buttons) -->
|
||||
<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')) {
|
||||
<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')) {
|
||||
@ -396,6 +414,7 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly vault = inject(VaultService);
|
||||
private readonly editorState = inject(EditorStateService);
|
||||
private readonly bookmarks = inject(BookmarksService);
|
||||
private readonly tagPaletteSize = 12;
|
||||
private readonly tagColorCache = new Map<string, number>();
|
||||
private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
|
||||
@ -450,6 +469,20 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
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> {
|
||||
const currentNote = this.note();
|
||||
if (!currentNote?.id) return;
|
||||
|
||||
33
vault/.obsidian/bookmarks.json
vendored
33
vault/.obsidian/bookmarks.json
vendored
@ -10,21 +10,32 @@
|
||||
"ctime": 1759433952208,
|
||||
"path": "HOME.md",
|
||||
"title": "HOME"
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"ctime": 1759677937745,
|
||||
"path": "folder1/test2.md",
|
||||
"title": "test2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"ctime": 1759434060575,
|
||||
"path": "test.md",
|
||||
"title": "Page de test Markdown"
|
||||
"path": "folder-4/test-add-properties.md",
|
||||
"title": "test-add-properties.md",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
5
vault/.obsidian/workspace.json
vendored
5
vault/.obsidian/workspace.json
vendored
@ -74,7 +74,8 @@
|
||||
"title": "Bookmarks"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"currentTab": 2
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
@ -178,7 +179,7 @@
|
||||
"obsidian-excalidraw-plugin:New drawing": false
|
||||
}
|
||||
},
|
||||
"active": "2e9abbba0bbc33e1",
|
||||
"active": "aaf62e01f34df49b",
|
||||
"lastOpenFiles": [
|
||||
"big/note_500.md",
|
||||
"big/note_499.md",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user