chore: update Angular cache and TypeScript build info
This commit is contained in:
@@ -9,6 +9,15 @@
|
||||
(toggleWrap)="toggleRawWrap()"
|
||||
></app-raw-view-overlay>
|
||||
}
|
||||
|
||||
@if (showAddBookmarkModal()) {
|
||||
<app-add-bookmark-modal
|
||||
[notePath]="selectedNote()?.filePath || ''"
|
||||
[noteTitle]="selectedNote()?.title || ''"
|
||||
(close)="closeBookmarkModal()"
|
||||
(save)="onBookmarkSave($event)"
|
||||
></app-add-bookmark-modal>
|
||||
}
|
||||
<!-- Navigation latérale desktop -->
|
||||
<nav class="hidden w-14 flex-col items-center gap-4 border-r border-border bg-bg-main py-4 lg:flex">
|
||||
<button
|
||||
@@ -51,6 +60,14 @@
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('bookmarks')"
|
||||
class="btn btn-sm btn-icon"
|
||||
[ngClass]="activeView() === 'bookmarks' ? 'btn-primary' : 'btn-ghost'"
|
||||
aria-label="Afficher les favoris"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (isDesktop() || isSidebarOpen()) {
|
||||
@@ -94,7 +111,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-border/70 bg-card/85 px-3 py-3 shadow-subtle">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
@@ -132,6 +149,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>Agenda</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('bookmarks')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
[ngClass]="{ 'bg-bg-muted/80 text-text-main shadow-subtle': activeView() === 'bookmarks' }"
|
||||
[attr.aria-pressed]="activeView() === 'bookmarks'"
|
||||
aria-label="Afficher les favoris"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
|
||||
<span>Favoris</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,6 +288,11 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('bookmarks') {
|
||||
<div class="flex h-full flex-col">
|
||||
<app-bookmarks-panel (bookmarkClick)="onBookmarkNavigate($event)"></app-bookmarks-panel>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,6 +385,24 @@
|
||||
</svg>
|
||||
<span class="sr-only">Alt+D</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-icon btn-ghost disabled:opacity-40 disabled:pointer-events-none"
|
||||
(click)="toggleBookmarkModal()"
|
||||
[disabled]="!selectedNote()"
|
||||
aria-label="Ajouter aux favoris"
|
||||
title="Ajouter aux favoris"
|
||||
>
|
||||
@if (isCurrentNoteBookmarked()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5 text-blue-600 dark:text-blue-400">
|
||||
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="btn btn-icon btn-ghost"
|
||||
|
||||
+105
-2
@@ -15,6 +15,9 @@ import { GraphViewComponent } from './components/graph-view/graph-view.component
|
||||
import { TagsViewComponent } from './components/tags-view/tags-view.component';
|
||||
import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component';
|
||||
import { RawViewOverlayComponent } from './shared/overlays/raw-view-overlay.component';
|
||||
import { BookmarksPanelComponent } from './components/bookmarks-panel/bookmarks-panel.component';
|
||||
import { AddBookmarkModalComponent, type BookmarkFormData } from './components/add-bookmark-modal/add-bookmark-modal.component';
|
||||
import { BookmarksService } from './core/bookmarks/bookmarks.service';
|
||||
|
||||
// Types
|
||||
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
||||
@@ -36,6 +39,8 @@ interface TocEntry {
|
||||
TagsViewComponent,
|
||||
MarkdownCalendarComponent,
|
||||
RawViewOverlayComponent,
|
||||
BookmarksPanelComponent,
|
||||
AddBookmarkModalComponent,
|
||||
],
|
||||
templateUrl: './app.component.simple.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
@@ -47,12 +52,13 @@ export class AppComponent implements OnDestroy {
|
||||
private markdownViewerService = inject(MarkdownViewerService);
|
||||
private downloadService = inject(DownloadService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
// --- State Signals ---
|
||||
isSidebarOpen = signal<boolean>(true);
|
||||
isOutlineOpen = signal<boolean>(true);
|
||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar'>('files');
|
||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'>('files');
|
||||
selectedNoteId = signal<string>('');
|
||||
sidebarSearchTerm = signal<string>('');
|
||||
tableOfContents = signal<TocEntry[]>([]);
|
||||
@@ -60,6 +66,7 @@ export class AppComponent implements OnDestroy {
|
||||
rightSidebarWidth = signal<number>(288);
|
||||
isRawViewOpen = signal<boolean>(false);
|
||||
isRawViewWrapped = signal<boolean>(true);
|
||||
showAddBookmarkModal = signal<boolean>(false);
|
||||
readonly LEFT_MIN_WIDTH = 220;
|
||||
readonly LEFT_MAX_WIDTH = 520;
|
||||
readonly RIGHT_MIN_WIDTH = 220;
|
||||
@@ -91,6 +98,32 @@ export class AppComponent implements OnDestroy {
|
||||
|
||||
readonly isDarkMode = this.themeService.isDark;
|
||||
|
||||
// Bookmark state
|
||||
readonly isCurrentNoteBookmarked = computed(() => {
|
||||
const noteId = this.selectedNoteId();
|
||||
if (!noteId) return false;
|
||||
|
||||
const note = this.selectedNote();
|
||||
if (!note) return false;
|
||||
|
||||
const doc = this.bookmarksService.doc();
|
||||
const notePath = note.filePath;
|
||||
|
||||
const findBookmark = (items: any[]): boolean => {
|
||||
for (const item of items) {
|
||||
if (item.type === 'file' && item.path === notePath) {
|
||||
return true;
|
||||
}
|
||||
if (item.type === 'group' && item.items) {
|
||||
if (findBookmark(item.items)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return findBookmark(doc.items);
|
||||
});
|
||||
|
||||
// --- Data Signals ---
|
||||
fileTree = this.vaultService.fileTree;
|
||||
graphData = this.vaultService.graphData;
|
||||
@@ -365,6 +398,17 @@ export class AppComponent implements OnDestroy {
|
||||
this.activeView.set('search');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current calendar search results and related UI state.
|
||||
* Used by the "Effacer" button in the search panel.
|
||||
*/
|
||||
clearCalendarResults(): void {
|
||||
this.calendarResults.set([]);
|
||||
this.calendarSearchState.set('idle');
|
||||
this.calendarSearchError.set(null);
|
||||
this.calendarSelectionLabel.set(null);
|
||||
}
|
||||
|
||||
startLeftResize(event: PointerEvent): void {
|
||||
if (!this.isSidebarOpen()) {
|
||||
this.isSidebarOpen.set(true);
|
||||
@@ -435,7 +479,7 @@ export class AppComponent implements OnDestroy {
|
||||
handle?.addEventListener('lostpointercapture', cleanup);
|
||||
}
|
||||
|
||||
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar'): void {
|
||||
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'): void {
|
||||
this.activeView.set(view);
|
||||
this.sidebarSearchTerm.set('');
|
||||
}
|
||||
@@ -521,6 +565,65 @@ export class AppComponent implements OnDestroy {
|
||||
this.downloadService.downloadText(content, filename);
|
||||
}
|
||||
|
||||
toggleBookmarkModal(): void {
|
||||
this.showAddBookmarkModal.update(v => !v);
|
||||
}
|
||||
|
||||
closeBookmarkModal(): void {
|
||||
this.showAddBookmarkModal.set(false);
|
||||
}
|
||||
|
||||
onBookmarkSave(data: BookmarkFormData): void {
|
||||
const note = this.selectedNote();
|
||||
if (!note) return;
|
||||
|
||||
// Check if bookmark already exists
|
||||
const doc = this.bookmarksService.doc();
|
||||
let existingCtime: number | null = null;
|
||||
|
||||
const findExisting = (items: any[]): number | null => {
|
||||
for (const item of items) {
|
||||
if (item.type === 'file' && item.path === note.filePath) {
|
||||
return item.ctime;
|
||||
}
|
||||
if (item.type === 'group' && item.items) {
|
||||
const found = findExisting(item.items);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
existingCtime = findExisting(doc.items);
|
||||
|
||||
if (existingCtime) {
|
||||
// Update existing bookmark
|
||||
this.bookmarksService.updateBookmark(existingCtime, {
|
||||
title: data.title,
|
||||
});
|
||||
|
||||
// If group changed, move it
|
||||
if (data.groupCtime !== null) {
|
||||
this.bookmarksService.moveBookmark(existingCtime, data.groupCtime, 0);
|
||||
}
|
||||
} else {
|
||||
// Create new bookmark
|
||||
this.bookmarksService.createFileBookmark(data.path, data.title, data.groupCtime);
|
||||
}
|
||||
|
||||
this.closeBookmarkModal();
|
||||
}
|
||||
|
||||
onBookmarkNavigate(bookmark: any): void {
|
||||
if (bookmark.type === 'file' && bookmark.path) {
|
||||
// Find note by matching filePath
|
||||
const note = this.vaultService.allNotes().find(n => n.filePath === bookmark.path);
|
||||
if (note) {
|
||||
this.selectNote(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleGlobalKeydown(event: KeyboardEvent): void {
|
||||
if (!event.altKey || event.repeat) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" (click)="onBackdropClick($event)">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full p-6" (click)="$event.stopPropagation()">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ isEditMode() ? 'Edit bookmark' : 'Add bookmark' }}
|
||||
</h2>
|
||||
<button
|
||||
(click)="onCancel()"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="space-y-4">
|
||||
<!-- Path -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="path()"
|
||||
(input)="onPathChange($any($event.target).value)"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. Tests/Allo note.md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="title()"
|
||||
(input)="onTitleChange($any($event.target).value)"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Display name (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bookmark group -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Bookmark group
|
||||
</label>
|
||||
<select
|
||||
[value]="selectedGroupCtime() || ''"
|
||||
(change)="onGroupChange($any($event.target).value)"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Root (no group)</option>
|
||||
@for (group of groups(); track group.ctime) {
|
||||
<option [value]="group.ctime">{{ group.title }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-3 mt-6">
|
||||
<button
|
||||
(click)="onCancel()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="onSave()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
:host-context(.dark) select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Add Bookmark Modal Component
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BookmarksService } from '../../core/bookmarks/bookmarks.service';
|
||||
import type { BookmarkGroup } from '../../core/bookmarks/types';
|
||||
|
||||
export interface BookmarkFormData {
|
||||
path: string;
|
||||
title: string;
|
||||
groupCtime: number | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-bookmark-modal',
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './add-bookmark-modal.component.html',
|
||||
styleUrls: ['./add-bookmark-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AddBookmarkModalComponent {
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
|
||||
@Input() notePath: string = '';
|
||||
@Input() noteTitle: string = '';
|
||||
@Input() existingBookmark: { ctime: number; title?: string; groupCtime?: number } | null = null;
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() save = new EventEmitter<BookmarkFormData>();
|
||||
|
||||
readonly path = signal('');
|
||||
readonly title = signal('');
|
||||
readonly selectedGroupCtime = signal<number | null>(null);
|
||||
|
||||
readonly groups = computed(() => {
|
||||
const doc = this.bookmarksService.doc();
|
||||
const result: Array<{ ctime: number; title: string }> = [];
|
||||
|
||||
const collectGroups = (items: any[], level = 0) => {
|
||||
for (const item of items) {
|
||||
if (item.type === 'group') {
|
||||
result.push({
|
||||
ctime: item.ctime,
|
||||
title: ' '.repeat(level) + (item.title || 'Untitled Group'),
|
||||
});
|
||||
if (item.items) {
|
||||
collectGroups(item.items, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collectGroups(doc.items);
|
||||
return result;
|
||||
});
|
||||
|
||||
readonly isEditMode = computed(() => this.existingBookmark !== null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.path.set(this.notePath);
|
||||
this.title.set(this.noteTitle);
|
||||
|
||||
if (this.existingBookmark) {
|
||||
this.title.set(this.existingBookmark.title || this.noteTitle);
|
||||
this.selectedGroupCtime.set(this.existingBookmark.groupCtime || null);
|
||||
}
|
||||
}
|
||||
|
||||
onPathChange(value: string): void {
|
||||
this.path.set(value);
|
||||
}
|
||||
|
||||
onTitleChange(value: string): void {
|
||||
this.title.set(value);
|
||||
}
|
||||
|
||||
onGroupChange(value: string): void {
|
||||
const ctime = value ? parseInt(value, 10) : null;
|
||||
this.selectedGroupCtime.set(ctime);
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
const pathValue = this.path().trim();
|
||||
const titleValue = this.title().trim();
|
||||
|
||||
if (!pathValue) {
|
||||
alert('Path is required');
|
||||
return;
|
||||
}
|
||||
|
||||
this.save.emit({
|
||||
path: pathValue,
|
||||
title: titleValue || pathValue,
|
||||
groupCtime: this.selectedGroupCtime(),
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
onBackdropClick(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget) {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<div class="bookmark-node">
|
||||
<div
|
||||
class="relative flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md cursor-pointer transition-colors"
|
||||
[style.padding-left]="indentStyle"
|
||||
(click)="onClick($event)"
|
||||
(contextmenu)="onContextMenu($event)"
|
||||
[class.bg-gray-50]="isGroup"
|
||||
[class.dark:bg-gray-800/50]="isGroup">
|
||||
|
||||
<!-- Icon -->
|
||||
<span class="text-base select-none" [class.cursor-pointer]="isGroup">
|
||||
{{ icon }}
|
||||
</span>
|
||||
|
||||
<!-- Text -->
|
||||
<span
|
||||
class="flex-1 text-sm text-gray-900 dark:text-gray-100 truncate"
|
||||
[title]="displayText">
|
||||
{{ displayText }}
|
||||
</span>
|
||||
|
||||
@if (isGroup) {
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
title="Ajouter un favori dans ce groupe"
|
||||
(click)="onAddBookmark($event)">
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||
title="Supprimer ce groupe"
|
||||
(click)="onDelete($event)">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Badge for group count -->
|
||||
@if (isGroup && hasChildren) {
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 bg-gray-200 dark:bg-gray-700 px-2 py-0.5 rounded-full">
|
||||
{{ children.length }}
|
||||
</span>
|
||||
}
|
||||
|
||||
<!-- Context Menu Button -->
|
||||
<button
|
||||
class="opacity-0 hover:opacity-100 focus:opacity-100 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded transition-opacity"
|
||||
(click)="onContextMenu($event); $event.stopPropagation()"
|
||||
title="More options">
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<!-- Context Menu -->
|
||||
@if (showMenu()) {
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 z-10 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg"
|
||||
(click)="$event.stopPropagation()">
|
||||
@if (isGroup) {
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
(click)="onAddBookmark()">
|
||||
Add Bookmark Here
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
(click)="onAddGroup()">
|
||||
Add Subgroup
|
||||
</button>
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
(click)="onDelete()">
|
||||
Delete Group
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
(click)="onEdit()">
|
||||
Edit
|
||||
</button>
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
(click)="onDelete()">
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Children -->
|
||||
@if (isGroup && isExpanded()) {
|
||||
<div
|
||||
class="ml-6 border-l border-gray-200 dark:border-gray-700 pl-2 min-h-[40px]"
|
||||
cdkDropList
|
||||
[cdkDropListData]="children"
|
||||
[cdkDropListConnectedTo]="getDropListConnections()"
|
||||
[cdkDropListDisabled]="dragDisabled"
|
||||
[cdkDropListSortingDisabled]="false"
|
||||
[cdkDropListId]="dropListId"
|
||||
cdkDropListOrientation="vertical"
|
||||
(cdkDropListDropped)="onChildDrop($event)">
|
||||
@for (child of children; track trackByCtime($index, child)) {
|
||||
<app-bookmark-item
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="dragDisabled"
|
||||
[cdkDragData]="{ ctime: child.ctime, parentCtime: bookmark.ctime }"
|
||||
[node]="child"
|
||||
[level]="level + 1"
|
||||
[dragDisabled]="dragDisabled"
|
||||
[dropListIds]="dropListIds"
|
||||
(bookmarkClick)="bookmarkClick.emit($event)"
|
||||
class="mt-1" />
|
||||
}
|
||||
@if (children.length === 0) {
|
||||
<div class="py-2 px-3 text-xs text-gray-400 dark:text-gray-500 italic">
|
||||
Drop items here
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close menu -->
|
||||
@if (showMenu()) {
|
||||
<div
|
||||
class="fixed inset-0 z-0"
|
||||
(click)="closeMenu()">
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.bookmark-item {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
|
||||
&:hover .context-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-item:active {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
:host-context(.dark) .bookmark-item:active {
|
||||
background-color: #374151;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Bookmark Item Component - Displays a single bookmark in the tree
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
inject,
|
||||
signal,
|
||||
forwardRef,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
import type { BookmarkNode, BookmarkGroup } from '../../core/bookmarks/types';
|
||||
import { BookmarksService } from '../../core/bookmarks/bookmarks.service';
|
||||
import { BookmarksPanelComponent } from '../bookmarks-panel/bookmarks-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmark-item',
|
||||
imports: [CommonModule, DragDropModule, forwardRef(() => BookmarkItemComponent)],
|
||||
templateUrl: './bookmark-item.component.html',
|
||||
styleUrls: ['./bookmark-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BookmarkItemComponent {
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
private readonly panel = inject(BookmarksPanelComponent, { optional: true });
|
||||
|
||||
@Input({ required: true }) node!: BookmarkNode;
|
||||
@Input() level = 0;
|
||||
@Input() dragDisabled = false;
|
||||
@Input() dropListIds: string[] = [];
|
||||
|
||||
@Output() bookmarkClick = new EventEmitter<BookmarkNode>();
|
||||
|
||||
readonly showMenu = signal(false);
|
||||
readonly isExpanded = signal(true);
|
||||
|
||||
get bookmark(): BookmarkNode {
|
||||
return this.node;
|
||||
}
|
||||
|
||||
get indentStyle(): string {
|
||||
return `${this.level * 20}px`;
|
||||
}
|
||||
|
||||
get icon(): string {
|
||||
switch (this.bookmark.type) {
|
||||
case 'group':
|
||||
return this.isExpanded() ? '📂' : '📁';
|
||||
case 'file':
|
||||
return '📄';
|
||||
case 'folder':
|
||||
return '📁';
|
||||
case 'search':
|
||||
return '🔍';
|
||||
case 'heading':
|
||||
return '📌';
|
||||
case 'block':
|
||||
return '🔗';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
get displayText(): string {
|
||||
const node = this.bookmark;
|
||||
if (node.title) {
|
||||
return node.title;
|
||||
}
|
||||
|
||||
if (node.type === 'file' || node.type === 'folder') {
|
||||
return node.path;
|
||||
}
|
||||
|
||||
if (node.type === 'search') {
|
||||
return node.query;
|
||||
}
|
||||
|
||||
if (node.type === 'heading' || node.type === 'block') {
|
||||
return `${node.path} > ${node.subpath}`;
|
||||
}
|
||||
|
||||
return 'Untitled';
|
||||
}
|
||||
|
||||
get isGroup(): boolean {
|
||||
return this.bookmark.type === 'group';
|
||||
}
|
||||
|
||||
get hasChildren(): boolean {
|
||||
return this.isGroup && (this.bookmark as BookmarkGroup).items?.length > 0;
|
||||
}
|
||||
|
||||
get children(): BookmarkNode[] {
|
||||
if (!this.isGroup) {
|
||||
return [];
|
||||
}
|
||||
return (this.bookmark as BookmarkGroup).items;
|
||||
}
|
||||
|
||||
trackByCtime(index: number, item: BookmarkNode): number {
|
||||
return item.ctime ?? index;
|
||||
}
|
||||
|
||||
get dropListId(): string {
|
||||
return `group-${this.bookmark.ctime}`;
|
||||
}
|
||||
|
||||
getDropListConnections(): string[] {
|
||||
return this.dropListIds.filter(id => id !== this.dropListId);
|
||||
}
|
||||
|
||||
onChildDrop(event: CdkDragDrop<BookmarkNode[]>): void {
|
||||
if (this.dragDisabled || !this.isGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.item.data as { ctime: number; parentCtime: number | null } | undefined;
|
||||
if (!data || typeof data.ctime !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.ctime === this.bookmark.ctime) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookmarksService.moveBookmark(data.ctime, this.bookmark.ctime, event.currentIndex);
|
||||
}
|
||||
|
||||
toggleExpand(): void {
|
||||
if (this.isGroup) {
|
||||
this.isExpanded.update(v => !v);
|
||||
}
|
||||
}
|
||||
|
||||
onClick(event: MouseEvent): void {
|
||||
if (this.isGroup) {
|
||||
this.toggleExpand();
|
||||
} else {
|
||||
this.bookmarkClick.emit(this.bookmark);
|
||||
}
|
||||
}
|
||||
|
||||
onContextMenu(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.showMenu.set(true);
|
||||
}
|
||||
|
||||
closeMenu(): void {
|
||||
this.showMenu.set(false);
|
||||
}
|
||||
|
||||
onEdit(): void {
|
||||
this.closeMenu();
|
||||
// TODO: Open edit modal
|
||||
console.log('Edit bookmark:', this.bookmark);
|
||||
}
|
||||
|
||||
onDelete(event?: MouseEvent): void {
|
||||
event?.stopPropagation();
|
||||
this.closeMenu();
|
||||
if (this.isGroup) {
|
||||
this.deleteGroup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Delete "${this.displayText}"?`)) {
|
||||
this.bookmarksService.deleteBookmark(this.bookmark.ctime);
|
||||
}
|
||||
}
|
||||
|
||||
onAddGroup(event?: MouseEvent): void {
|
||||
event?.stopPropagation();
|
||||
if (!this.isGroup) return;
|
||||
this.closeMenu();
|
||||
this.panel?.createGroup(this.bookmark.ctime);
|
||||
}
|
||||
|
||||
onAddBookmark(event?: MouseEvent): void {
|
||||
event?.stopPropagation();
|
||||
if (!this.isGroup) return;
|
||||
this.closeMenu();
|
||||
if (this.panel) {
|
||||
this.panel.startCreateBookmarkInGroup(this.bookmark.ctime);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteGroup(): void {
|
||||
if (!this.isGroup) return;
|
||||
if (this.panel) {
|
||||
this.panel.deleteGroup(this.bookmark.ctime);
|
||||
} else if (confirm(`Delete group "${this.displayText}" and all its items?`)) {
|
||||
this.bookmarksService.deleteBookmark(this.bookmark.ctime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<div class="bookmarks-panel flex flex-col h-full bg-white dark:bg-gray-900">
|
||||
<!-- Header -->
|
||||
<div class="bookmarks-header flex-shrink-0 p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Bookmarks</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
(click)="createGroup()"
|
||||
title="Créer un groupe">
|
||||
+ Group
|
||||
</button>
|
||||
@if (saving()) {
|
||||
<span class="text-xs text-blue-600 dark:text-blue-400 animate-pulse">Saving...</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
[value]="searchTerm()"
|
||||
(input)="onSearchChange($any($event.target).value)"
|
||||
placeholder="Search bookmarks..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
@if (searchTerm()) {
|
||||
<button
|
||||
(click)="onSearchChange('')"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Clear search">
|
||||
✕
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="bookmarks-body flex-1 overflow-y-auto p-4">
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-red-600 dark:text-red-400 font-semibold">Error:</span>
|
||||
<span class="text-sm text-red-700 dark:text-red-300 flex-1">{{ error() }}</span>
|
||||
<button
|
||||
(click)="clearError()"
|
||||
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
||||
title="Dismiss">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (isEmpty()) {
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p class="mb-4">No bookmarks yet</p>
|
||||
<p class="text-sm">Use the bookmark icon in the note toolbar to add one.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bookmarks-tree mt-4 border-2 border-dashed border-blue-500/60 dark:border-blue-400/60 bg-blue-500/10 dark:bg-blue-400/10 text-blue-600 dark:text-blue-300 rounded-md min-h-[80px] flex items-center justify-center"
|
||||
cdkDropList
|
||||
[cdkDropListData]="displayItems()"
|
||||
cdkDropListId="root"
|
||||
[cdkDropListConnectedTo]="getDropListConnections('root')"
|
||||
cdkDropListOrientation="vertical"
|
||||
[cdkDropListDisabled]="dragDisabled"
|
||||
(cdkDropListDropped)="handleRootDrop($event)">
|
||||
<span class="text-sm font-medium">Drop items here</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="bookmarks-tree"
|
||||
cdkDropList
|
||||
[cdkDropListData]="displayItems()"
|
||||
cdkDropListId="root"
|
||||
[cdkDropListConnectedTo]="getDropListConnections('root')"
|
||||
cdkDropListOrientation="vertical"
|
||||
[cdkDropListDisabled]="dragDisabled"
|
||||
(cdkDropListDropped)="handleRootDrop($event)">
|
||||
@if (!dragDisabled) {
|
||||
<div class="mb-2 rounded-md border border-dashed border-blue-500/40 dark:border-blue-400/40 bg-blue-500/5 dark:bg-blue-400/5 px-3 py-2 text-xs font-medium text-blue-600 dark:text-blue-300 text-center">
|
||||
Drop here to move to root
|
||||
</div>
|
||||
}
|
||||
@for (node of displayItems(); track node.ctime) {
|
||||
<app-bookmark-item
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="dragDisabled"
|
||||
[cdkDragData]="{ ctime: node.ctime, parentCtime: null }"
|
||||
[node]="node"
|
||||
[level]="0"
|
||||
[dragDisabled]="dragDisabled"
|
||||
[dropListIds]="dropListIds()"
|
||||
(bookmarkClick)="onBookmarkClick($event)"
|
||||
class="mb-1" />
|
||||
}
|
||||
@if (displayItems().length === 0) {
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 py-4 text-center">No bookmarks to display.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Conflict Modal -->
|
||||
@if (conflictInfo()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Conflict Detected</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
The bookmarks file has been modified externally. What would you like to do?
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
(click)="resolveConflictReload()"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors">
|
||||
Reload from file
|
||||
</button>
|
||||
<button
|
||||
(click)="resolveConflictOverwrite()"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors">
|
||||
Overwrite file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Modals placeholders - will be implemented separately -->
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
.bookmarks-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bookmarks-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
.bookmarks-body {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark mode */
|
||||
.bookmarks-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.bookmarks-body::-webkit-scrollbar-track {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
:host-context(.dark) .bookmarks-body::-webkit-scrollbar-track {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.bookmarks-body::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
:host-context(.dark) .bookmarks-body::-webkit-scrollbar-thumb {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.bookmarks-body::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
:host-context(.dark) .bookmarks-body::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #6b7280;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Bookmarks Panel Component - Main UI for managing bookmarks
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
Output,
|
||||
EventEmitter,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
import type { BookmarkNode } from '../../core/bookmarks/types';
|
||||
import { BookmarksService } from '../../core/bookmarks/bookmarks.service';
|
||||
import { BookmarkItemComponent } from '../bookmark-item/bookmark-item.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks-panel',
|
||||
imports: [CommonModule, FormsModule, DragDropModule, BookmarkItemComponent],
|
||||
templateUrl: './bookmarks-panel.component.html',
|
||||
styleUrls: ['./bookmarks-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BookmarksPanelComponent {
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
|
||||
@Output() bookmarkClick = new EventEmitter<BookmarkNode>();
|
||||
|
||||
// Local state
|
||||
readonly searchTerm = signal('');
|
||||
|
||||
// Service state
|
||||
readonly doc = this.bookmarksService.doc;
|
||||
readonly filteredDoc = this.bookmarksService.filteredDoc;
|
||||
readonly saving = this.bookmarksService.saving;
|
||||
readonly loading = this.bookmarksService.loading;
|
||||
readonly error = this.bookmarksService.error;
|
||||
readonly conflictInfo = this.bookmarksService.conflictInfo;
|
||||
|
||||
readonly displayDoc = computed(() =>
|
||||
this.searchTerm().trim() ? this.filteredDoc() : this.doc()
|
||||
);
|
||||
|
||||
readonly displayItems = computed(() => this.displayDoc().items ?? []);
|
||||
|
||||
readonly dragDisabledSignal = computed(() => this.searchTerm().trim().length > 0);
|
||||
readonly isEmpty = computed(() => this.displayItems().length === 0);
|
||||
|
||||
readonly 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collect(this.displayItems());
|
||||
return ids;
|
||||
});
|
||||
|
||||
readonly trackNode = (index: number, node: BookmarkNode) => node.ctime ?? index;
|
||||
onSearchChange(term: string): void {
|
||||
this.searchTerm.set(term);
|
||||
this.bookmarksService.setFilterTerm(term);
|
||||
}
|
||||
|
||||
clearError(): void {
|
||||
this.bookmarksService.clearError();
|
||||
}
|
||||
|
||||
get dragDisabled(): boolean {
|
||||
return this.dragDisabledSignal();
|
||||
}
|
||||
|
||||
getDropListConnections(id: string): string[] {
|
||||
return this.dropListIds().filter(existingId => existingId !== id);
|
||||
}
|
||||
|
||||
createGroup(parentCtime: number | null = null): void {
|
||||
const title = window.prompt('Nom du groupe');
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookmarksService.createGroup(title.trim(), parentCtime ?? null);
|
||||
}
|
||||
|
||||
deleteGroup(ctime: number): void {
|
||||
if (!window.confirm('Supprimer ce groupe et tous ses favoris ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookmarksService.deleteBookmark(ctime);
|
||||
}
|
||||
|
||||
startCreateBookmarkInGroup(parentCtime: number): void {
|
||||
const path = window.prompt('Chemin du fichier (ex: Notes/note.md)');
|
||||
if (!path || !path.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = window.prompt('Titre (optionnel)')?.trim();
|
||||
this.bookmarksService.createFileBookmark(path.trim(), title || undefined, parentCtime);
|
||||
}
|
||||
|
||||
onBookmarkClick(bookmark: BookmarkNode): void {
|
||||
this.bookmarkClick.emit(bookmark);
|
||||
}
|
||||
|
||||
handleDrop(event: CdkDragDrop<BookmarkNode[]>, parentCtime: number | null): void {
|
||||
if (this.dragDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.item.data as { ctime: number; parentCtime: number | null } | undefined;
|
||||
if (!data || typeof data.ctime !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if dropping into itself
|
||||
if (parentCtime === data.ctime) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookmarksService.moveBookmark(data.ctime, parentCtime, event.currentIndex);
|
||||
}
|
||||
|
||||
handleRootDrop(event: CdkDragDrop<BookmarkNode[]>): void {
|
||||
this.handleDrop(event, null);
|
||||
}
|
||||
|
||||
async resolveConflictReload(): Promise<void> {
|
||||
await this.bookmarksService.resolveConflictReload();
|
||||
}
|
||||
|
||||
async resolveConflictOverwrite(): Promise<void> {
|
||||
await this.bookmarksService.resolveConflictOverwrite();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Bookmarks Repository - Persistence layer for bookmarks.json
|
||||
* Provides two adapters: File System Access API and Server Bridge
|
||||
*/
|
||||
|
||||
import type { BookmarksDoc, AccessStatus } from './types';
|
||||
import { calculateRev, formatBookmarksJSON, parseBookmarksJSON } from './bookmarks.utils';
|
||||
|
||||
/**
|
||||
* Repository interface - single source of truth: <vault>/.obsidian/bookmarks.json
|
||||
*/
|
||||
export interface IBookmarksRepository {
|
||||
load(): Promise<BookmarksDoc>;
|
||||
save(doc: BookmarksDoc): Promise<{ rev: string }>;
|
||||
getAccessStatus(): Promise<AccessStatus>;
|
||||
getCurrentRev(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter A: File System Access API (preferred for browser/PWA)
|
||||
*/
|
||||
export class FsAccessRepository implements IBookmarksRepository {
|
||||
private directoryHandle: FileSystemDirectoryHandle | null = null;
|
||||
private currentRev: string | null = null;
|
||||
private readonly BOOKMARKS_PATH = '.obsidian/bookmarks.json';
|
||||
private readonly STORAGE_KEY = 'obsiwatcher.vault.directoryHandle';
|
||||
|
||||
async connectVault(): Promise<void> {
|
||||
if (typeof window === 'undefined' || !('showDirectoryPicker' in window)) {
|
||||
throw new Error('File System Access API not available');
|
||||
}
|
||||
|
||||
try {
|
||||
// Request directory access
|
||||
const handle = await (window as any).showDirectoryPicker({
|
||||
mode: 'readwrite',
|
||||
});
|
||||
|
||||
this.directoryHandle = handle;
|
||||
|
||||
// Store handle for persistence (if supported)
|
||||
try {
|
||||
const idb = await this.getIDB();
|
||||
if (idb) {
|
||||
await idb.put('handles', handle, 'vaultDirectory');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to persist directory handle:', err);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('User cancelled vault selection or access denied');
|
||||
}
|
||||
}
|
||||
|
||||
async reconnectVault(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
const idb = await this.getIDB();
|
||||
if (!idb) return false;
|
||||
|
||||
const handle = await idb.get('handles', 'vaultDirectory');
|
||||
if (!handle) return false;
|
||||
|
||||
// Request permission again (if available)
|
||||
if (typeof (handle as any).requestPermission === 'function') {
|
||||
const permission = await (handle as any).requestPermission({ mode: 'readwrite' });
|
||||
if (permission !== 'granted') return false;
|
||||
}
|
||||
|
||||
this.directoryHandle = handle;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to reconnect vault:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<BookmarksDoc> {
|
||||
if (!this.directoryHandle) {
|
||||
throw new Error('Vault not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to .obsidian/bookmarks.json
|
||||
const obsidianDir = await this.directoryHandle.getDirectoryHandle('.obsidian', { create: true });
|
||||
let fileHandle: FileSystemFileHandle;
|
||||
|
||||
try {
|
||||
fileHandle = await obsidianDir.getFileHandle('bookmarks.json');
|
||||
} catch {
|
||||
// File doesn't exist, create it
|
||||
fileHandle = await obsidianDir.getFileHandle('bookmarks.json', { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
const emptyDoc: BookmarksDoc = { items: [] };
|
||||
await writable.write(formatBookmarksJSON(emptyDoc));
|
||||
await writable.close();
|
||||
this.currentRev = calculateRev(emptyDoc);
|
||||
return emptyDoc;
|
||||
}
|
||||
|
||||
// Read file
|
||||
const file = await fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
|
||||
if (!text.trim()) {
|
||||
const emptyDoc: BookmarksDoc = { items: [] };
|
||||
this.currentRev = calculateRev(emptyDoc);
|
||||
return emptyDoc;
|
||||
}
|
||||
|
||||
const doc = parseBookmarksJSON(text);
|
||||
if (!doc) {
|
||||
throw new Error('Invalid bookmarks.json format');
|
||||
}
|
||||
|
||||
this.currentRev = calculateRev(doc);
|
||||
return doc;
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async save(doc: BookmarksDoc): Promise<{ rev: string }> {
|
||||
if (!this.directoryHandle) {
|
||||
throw new Error('Vault not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const obsidianDir = await this.directoryHandle.getDirectoryHandle('.obsidian', { create: true });
|
||||
const fileHandle = await obsidianDir.getFileHandle('bookmarks.json', { create: true });
|
||||
|
||||
// Atomic write: write to temp, then replace
|
||||
const writable = await fileHandle.createWritable();
|
||||
const content = formatBookmarksJSON(doc);
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
|
||||
const newRev = calculateRev(doc);
|
||||
this.currentRev = newRev;
|
||||
|
||||
return { rev: newRev };
|
||||
} catch (error) {
|
||||
console.error('Failed to save bookmarks:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAccessStatus(): Promise<AccessStatus> {
|
||||
if (!this.directoryHandle) {
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if we can still access the handle
|
||||
if (typeof (this.directoryHandle as any).requestPermission === 'function') {
|
||||
const permission = await (this.directoryHandle as any).requestPermission({ mode: 'readwrite' });
|
||||
if (permission === 'granted') {
|
||||
return 'connected';
|
||||
}
|
||||
return 'read-only';
|
||||
}
|
||||
return 'connected';
|
||||
} catch {
|
||||
return 'disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentRev(): string | null {
|
||||
return this.currentRev;
|
||||
}
|
||||
|
||||
getVaultName(): string | null {
|
||||
return this.directoryHandle?.name ?? null;
|
||||
}
|
||||
|
||||
private async getIDB(): Promise<any> {
|
||||
if (typeof window === 'undefined' || !window.indexedDB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('ObsiViewerDB', 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event: any) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains('handles')) {
|
||||
db.createObjectStore('handles');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter B: Server Bridge (for apps with backend)
|
||||
*/
|
||||
export class ServerBridgeRepository implements IBookmarksRepository {
|
||||
private currentRev: string | null = null;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(baseUrl = '/api/vault') {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async load(): Promise<BookmarksDoc> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/bookmarks`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
// File doesn't exist, return empty
|
||||
const emptyDoc: BookmarksDoc = { items: [] };
|
||||
this.currentRev = calculateRev(emptyDoc);
|
||||
return emptyDoc;
|
||||
}
|
||||
throw new Error(`Server error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const doc = data as BookmarksDoc;
|
||||
|
||||
this.currentRev = data.rev || calculateRev(doc);
|
||||
return doc;
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks from server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async save(doc: BookmarksDoc): Promise<{ rev: string }> {
|
||||
try {
|
||||
const newRev = calculateRev(doc);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/bookmarks`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'If-Match': this.currentRev || '',
|
||||
},
|
||||
body: formatBookmarksJSON(doc),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 409) {
|
||||
throw new Error('Conflict: File modified externally');
|
||||
}
|
||||
throw new Error(`Server error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.currentRev = result.rev || newRev;
|
||||
|
||||
return { rev: this.currentRev };
|
||||
} catch (error) {
|
||||
console.error('Failed to save bookmarks to server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAccessStatus(): Promise<AccessStatus> {
|
||||
return 'connected';
|
||||
}
|
||||
|
||||
getCurrentRev(): string | null {
|
||||
return this.currentRev;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory repository (temporary fallback, read-only demo mode)
|
||||
*/
|
||||
export class InMemoryRepository implements IBookmarksRepository {
|
||||
private doc: BookmarksDoc = { items: [] };
|
||||
private currentRev: string | null = null;
|
||||
|
||||
async load(): Promise<BookmarksDoc> {
|
||||
return this.doc;
|
||||
}
|
||||
|
||||
async save(doc: BookmarksDoc): Promise<{ rev: string }> {
|
||||
console.warn('InMemoryRepository: Changes not persisted. Connect a vault to save.');
|
||||
this.doc = doc;
|
||||
const rev = calculateRev(doc);
|
||||
this.currentRev = rev;
|
||||
return { rev };
|
||||
}
|
||||
|
||||
async getAccessStatus(): Promise<AccessStatus> {
|
||||
return 'read-only';
|
||||
}
|
||||
|
||||
getCurrentRev(): string | null {
|
||||
return this.currentRev;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository factory - automatically selects best adapter
|
||||
*/
|
||||
export function createRepository(): IBookmarksRepository {
|
||||
// Default to server bridge repository (backend-managed bookmarks)
|
||||
return new ServerBridgeRepository('/api/vault');
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Bookmarks Service - Unit Tests
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { BookmarksService } from './bookmarks.service';
|
||||
import { InMemoryRepository } from './bookmarks.repository';
|
||||
import type { BookmarksDoc, BookmarkGroup, BookmarkFile } from './types';
|
||||
|
||||
describe('BookmarksService', () => {
|
||||
let service: BookmarksService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [BookmarksService],
|
||||
});
|
||||
service = TestBed.inject(BookmarksService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize with empty bookmarks', () => {
|
||||
const doc = service.doc();
|
||||
expect(doc.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create a group', () => {
|
||||
service.createGroup('Test Group');
|
||||
const doc = service.doc();
|
||||
|
||||
expect(doc.items.length).toBe(1);
|
||||
expect(doc.items[0].type).toBe('group');
|
||||
expect(doc.items[0].title).toBe('Test Group');
|
||||
});
|
||||
|
||||
it('should create a file bookmark', () => {
|
||||
service.createFileBookmark('test.md', 'Test File');
|
||||
const doc = service.doc();
|
||||
|
||||
expect(doc.items.length).toBe(1);
|
||||
expect(doc.items[0].type).toBe('file');
|
||||
expect((doc.items[0] as BookmarkFile).path).toBe('test.md');
|
||||
expect(doc.items[0].title).toBe('Test File');
|
||||
});
|
||||
|
||||
it('should delete a bookmark', () => {
|
||||
service.createFileBookmark('test.md');
|
||||
let doc = service.doc();
|
||||
const ctime = doc.items[0].ctime;
|
||||
|
||||
service.deleteBookmark(ctime);
|
||||
doc = service.doc();
|
||||
|
||||
expect(doc.items.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should update a bookmark', () => {
|
||||
service.createGroup('Old Title');
|
||||
const ctime = service.doc().items[0].ctime;
|
||||
|
||||
service.updateBookmark(ctime, { title: 'New Title' });
|
||||
const doc = service.doc();
|
||||
|
||||
expect(doc.items[0].title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('should mark as dirty when modified', () => {
|
||||
expect(service.isDirty()).toBe(false);
|
||||
|
||||
service.createGroup('Test');
|
||||
|
||||
expect(service.isDirty()).toBe(true);
|
||||
});
|
||||
|
||||
it('should calculate stats correctly', () => {
|
||||
service.createGroup('Group 1');
|
||||
service.createFileBookmark('file1.md');
|
||||
service.createFileBookmark('file2.md');
|
||||
|
||||
const stats = service.stats();
|
||||
|
||||
expect(stats.total).toBe(3);
|
||||
expect(stats.groups).toBe(1);
|
||||
expect(stats.items).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter bookmarks by search term', () => {
|
||||
service.createGroup('Important Group');
|
||||
service.createFileBookmark('test.md', 'Test File');
|
||||
service.createFileBookmark('important.md', 'Important File');
|
||||
|
||||
service.setFilterTerm('important');
|
||||
const filtered = service.filteredDoc();
|
||||
|
||||
expect(filtered.items.length).toBe(2); // Group + File
|
||||
});
|
||||
|
||||
it('should generate unique ctimes', () => {
|
||||
service.createGroup('Group 1');
|
||||
service.createGroup('Group 2');
|
||||
service.createGroup('Group 3');
|
||||
|
||||
const doc = service.doc();
|
||||
const ctimes = doc.items.map(item => item.ctime);
|
||||
const uniqueCtimes = new Set(ctimes);
|
||||
|
||||
expect(uniqueCtimes.size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Bookmarks Service - State management with Signals
|
||||
*/
|
||||
|
||||
import { Injectable, signal, computed, effect } from '@angular/core';
|
||||
import type {
|
||||
BookmarksDoc,
|
||||
BookmarkNode,
|
||||
BookmarkGroup,
|
||||
BookmarkFile,
|
||||
AccessStatus,
|
||||
ConflictInfo,
|
||||
} from './types';
|
||||
import type { IBookmarksRepository } from './bookmarks.repository';
|
||||
import { createRepository } from './bookmarks.repository';
|
||||
import {
|
||||
cloneBookmarksDoc,
|
||||
ensureUniqueCTimes,
|
||||
validateBookmarksDoc,
|
||||
findNodeByCtime,
|
||||
removeNode,
|
||||
updateNode,
|
||||
addNode,
|
||||
moveNode,
|
||||
filterTree,
|
||||
countNodes,
|
||||
calculateRev,
|
||||
generateCtime,
|
||||
flattenTree,
|
||||
} from './bookmarks.utils';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BookmarksService {
|
||||
private repository: IBookmarksRepository = createRepository();
|
||||
private saveTimeoutId: any = null;
|
||||
private readonly SAVE_DEBOUNCE_MS = 800;
|
||||
|
||||
// State Signals
|
||||
private readonly _doc = signal<BookmarksDoc>({ items: [] });
|
||||
private readonly _selectedNodeCtime = signal<number | null>(null);
|
||||
private readonly _filterTerm = signal<string>('');
|
||||
private readonly _isDirty = signal<boolean>(false);
|
||||
private readonly _saving = signal<boolean>(false);
|
||||
private readonly _loading = signal<boolean>(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
private readonly _accessStatus = signal<AccessStatus>('disconnected');
|
||||
private readonly _lastSaved = signal<Date | null>(null);
|
||||
private readonly _conflictInfo = signal<ConflictInfo | null>(null);
|
||||
|
||||
// Public Computed Signals
|
||||
readonly doc = computed(() => this._doc());
|
||||
readonly selectedNodeCtime = computed(() => this._selectedNodeCtime());
|
||||
readonly filterTerm = computed(() => this._filterTerm());
|
||||
readonly isDirty = computed(() => this._isDirty());
|
||||
readonly saving = computed(() => this._saving());
|
||||
readonly loading = computed(() => this._loading());
|
||||
readonly error = computed(() => this._error());
|
||||
readonly accessStatus = computed(() => this._accessStatus());
|
||||
readonly lastSaved = computed(() => this._lastSaved());
|
||||
readonly conflictInfo = computed(() => this._conflictInfo());
|
||||
|
||||
readonly filteredDoc = computed(() => {
|
||||
const term = this._filterTerm();
|
||||
const doc = this._doc();
|
||||
if (!term.trim()) return doc;
|
||||
return filterTree(doc, term);
|
||||
});
|
||||
|
||||
readonly flatTree = computed(() => flattenTree(this.filteredDoc()));
|
||||
|
||||
readonly stats = computed(() => countNodes(this._doc()));
|
||||
|
||||
readonly selectedNode = computed(() => {
|
||||
const ctime = this._selectedNodeCtime();
|
||||
if (ctime === null) return null;
|
||||
const found = findNodeByCtime(this._doc(), ctime);
|
||||
return found ? found.node : null;
|
||||
});
|
||||
|
||||
readonly isConnected = computed(() => this._accessStatus() === 'connected');
|
||||
readonly isReadOnly = computed(() => this._accessStatus() === 'read-only');
|
||||
|
||||
constructor() {
|
||||
// Auto-save effect
|
||||
effect(() => {
|
||||
const isDirty = this._isDirty();
|
||||
const status = this._accessStatus();
|
||||
|
||||
if (isDirty && status === 'connected') {
|
||||
this.debounceSave();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load from backend
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.loadFromRepository();
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks on init:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bookmarks from repository
|
||||
*/
|
||||
async loadFromRepository(): Promise<void> {
|
||||
try {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
const doc = await this.repository.load();
|
||||
const validated = ensureUniqueCTimes(doc);
|
||||
|
||||
this._doc.set(validated);
|
||||
this._isDirty.set(false);
|
||||
this._accessStatus.set(await this.repository.getAccessStatus());
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to load bookmarks';
|
||||
this._error.set(message);
|
||||
this._accessStatus.set('disconnected');
|
||||
throw error;
|
||||
} finally {
|
||||
this._loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save bookmarks to repository (immediate)
|
||||
*/
|
||||
async saveNow(): Promise<void> {
|
||||
try {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
const doc = this._doc();
|
||||
const result = await this.repository.save(doc);
|
||||
|
||||
this._isDirty.set(false);
|
||||
this._lastSaved.set(new Date());
|
||||
this._conflictInfo.set(null);
|
||||
this._accessStatus.set('connected');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to save bookmarks';
|
||||
this._error.set(message);
|
||||
this._accessStatus.set('disconnected');
|
||||
|
||||
// Check for conflict
|
||||
if (message.includes('Conflict') || message.includes('conflict')) {
|
||||
await this.detectConflict();
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this._saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced save
|
||||
*/
|
||||
private debounceSave(): void {
|
||||
if (this.saveTimeoutId) {
|
||||
clearTimeout(this.saveTimeoutId);
|
||||
}
|
||||
|
||||
this.saveTimeoutId = setTimeout(() => {
|
||||
this.saveNow().catch(err => {
|
||||
console.error('Auto-save failed:', err);
|
||||
});
|
||||
}, this.SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect external changes
|
||||
*/
|
||||
private async detectConflict(): Promise<void> {
|
||||
try {
|
||||
const remoteDoc = await this.repository.load();
|
||||
const localRev = calculateRev(this._doc());
|
||||
const remoteRev = calculateRev(remoteDoc);
|
||||
|
||||
if (localRev !== remoteRev) {
|
||||
this._conflictInfo.set({
|
||||
localRev,
|
||||
remoteRev,
|
||||
remoteContent: remoteDoc,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to detect conflict:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conflict: reload from file (discard local)
|
||||
*/
|
||||
async resolveConflictReload(): Promise<void> {
|
||||
await this.loadFromRepository();
|
||||
this._conflictInfo.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conflict: overwrite file with local
|
||||
*/
|
||||
async resolveConflictOverwrite(): Promise<void> {
|
||||
await this.saveNow();
|
||||
this._conflictInfo.set(null);
|
||||
}
|
||||
|
||||
// === CRUD Operations ===
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
createGroup(title: string, parentCtime: number | null = null, index?: number): void {
|
||||
const newGroup: BookmarkGroup = {
|
||||
type: 'group',
|
||||
ctime: generateCtime(),
|
||||
title,
|
||||
items: [],
|
||||
};
|
||||
|
||||
const updated = addNode(this._doc(), newGroup, parentCtime, index);
|
||||
this._doc.set(updated);
|
||||
this._isDirty.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new file bookmark
|
||||
*/
|
||||
createFileBookmark(path: string, title?: string, parentCtime: number | null = null, index?: number): void {
|
||||
const newBookmark: BookmarkFile = {
|
||||
type: 'file',
|
||||
ctime: generateCtime(),
|
||||
path,
|
||||
...(title && { title }),
|
||||
};
|
||||
|
||||
const updated = addNode(this._doc(), newBookmark, parentCtime, index);
|
||||
this._doc.set(updated);
|
||||
this._isDirty.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a bookmark
|
||||
*/
|
||||
updateBookmark(ctime: number, updates: Partial<BookmarkNode>): void {
|
||||
const updated = updateNode(this._doc(), ctime, updates);
|
||||
this._doc.set(updated);
|
||||
this._isDirty.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a bookmark
|
||||
*/
|
||||
deleteBookmark(ctime: number): void {
|
||||
const updated = removeNode(this._doc(), ctime);
|
||||
this._doc.set(updated);
|
||||
this._isDirty.set(true);
|
||||
|
||||
if (this._selectedNodeCtime() === ctime) {
|
||||
this._selectedNodeCtime.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a bookmark
|
||||
*/
|
||||
moveBookmark(nodeCtime: number, newParentCtime: number | null, newIndex: number): void {
|
||||
const updated = moveNode(this._doc(), nodeCtime, newParentCtime, newIndex);
|
||||
this._doc.set(updated);
|
||||
this._isDirty.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a bookmark
|
||||
*/
|
||||
selectBookmark(ctime: number | null): void {
|
||||
this._selectedNodeCtime.set(ctime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter term
|
||||
*/
|
||||
setFilterTerm(term: string): void {
|
||||
this._filterTerm.set(term);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error
|
||||
*/
|
||||
clearError(): void {
|
||||
this._error.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import bookmarks from JSON
|
||||
*/
|
||||
async importBookmarks(json: string, mode: 'merge' | 'replace'): Promise<void> {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
const validation = validateBookmarksDoc(parsed);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid bookmarks format: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
const imported = ensureUniqueCTimes(parsed as BookmarksDoc);
|
||||
|
||||
if (mode === 'replace') {
|
||||
this._doc.set(imported);
|
||||
} else {
|
||||
// Merge: append to root
|
||||
const current = this._doc();
|
||||
const merged: BookmarksDoc = {
|
||||
items: [...current.items, ...imported.items],
|
||||
};
|
||||
this._doc.set(ensureUniqueCTimes(merged));
|
||||
}
|
||||
|
||||
this._isDirty.set(true);
|
||||
await this.saveNow();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to import bookmarks';
|
||||
this._error.set(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export bookmarks as JSON
|
||||
*/
|
||||
exportBookmarks(): string {
|
||||
return JSON.stringify(this._doc(), null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access status
|
||||
*/
|
||||
async refreshAccessStatus(): Promise<void> {
|
||||
const status = await this.repository.getAccessStatus();
|
||||
this._accessStatus.set(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Bookmarks Utils - Unit Tests
|
||||
*/
|
||||
|
||||
import {
|
||||
validateBookmarksDoc,
|
||||
ensureUniqueCTimes,
|
||||
findNodeByCtime,
|
||||
addNode,
|
||||
removeNode,
|
||||
moveNode,
|
||||
countNodes,
|
||||
filterTree,
|
||||
calculateRev,
|
||||
} from './bookmarks.utils';
|
||||
import type { BookmarksDoc, BookmarkGroup, BookmarkFile } from './types';
|
||||
|
||||
describe('Bookmarks Utils', () => {
|
||||
describe('validateBookmarksDoc', () => {
|
||||
it('should validate a correct document', () => {
|
||||
const doc: BookmarksDoc = {
|
||||
items: [
|
||||
{
|
||||
type: 'group',
|
||||
ctime: 1000,
|
||||
title: 'Group 1',
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateBookmarksDoc(doc);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject invalid document', () => {
|
||||
const doc = { invalid: true };
|
||||
const result = validateBookmarksDoc(doc);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject missing type', () => {
|
||||
const doc = {
|
||||
items: [{ ctime: 1000 }],
|
||||
};
|
||||
|
||||
const result = validateBookmarksDoc(doc);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureUniqueCTimes', () => {
|
||||
it('should generate unique ctimes for duplicate values', () => {
|
||||
const doc: BookmarksDoc = {
|
||||
items: [
|
||||
{ type: 'file', ctime: 1000, path: 'a.md' },
|
||||
{ type: 'file', ctime: 1000, path: 'b.md' },
|
||||
{ type: 'file', ctime: 1000, path: 'c.md' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = ensureUniqueCTimes(doc);
|
||||
const ctimes = result.items.map(item => item.ctime);
|
||||
const uniqueCtimes = new Set(ctimes);
|
||||
|
||||
expect(uniqueCtimes.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNodeByCtime', () => {
|
||||
it('should find a root node', () => {
|
||||
const doc: BookmarksDoc = {
|
||||
items: [
|
||||
{ type: 'file', ctime: 1000, path: 'test.md' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = findNodeByCtime(doc, 1000);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.node.ctime).toBe(1000);
|
||||
expect(result?.parent).toBe(null);
|
||||
});
|
||||
|
||||
it('should find a nested node', () => {
|
||||
const doc: BookmarksDoc = {
|
||||
items: [
|
||||
{
|
||||
type: 'group',
|
||||
ctime: 1000,
|
||||
title: 'Group',
|
||||
items: [
|
||||
{ type: 'file', ctime: 2000, path: 'nested.md' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = findNodeByCtime(doc, 2000);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.node.ctime).toBe(2000);
|
||||
expect(result?.parent?.ctime).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addNode', () => {
|
||||
it('should add node to root', () => {
|
||||
const doc: BookmarksDoc = { items: [] };
|
||||
const newNode: BookmarkFile = {
|
||||
type: 'file',
|
||||
ctime: 1000,
|
||||
path: 'test.md',
|
||||
};
|
||||
|
||||
const result = addNode(doc, newNode, null);
|
||||
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].ctime).toBe(1000);
|
||||
});
|
||||
|
||||
it('should add node to group', () => {
|
||||
const doc: BookmarksDoc = {
|
||||
items: [
|
||||
{
|
||||
type: 'group',
|
||||
ctime: 1000,
|
||||
title: 'Group',
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newNode: BookmarkFile = {
|
||||
type: 'file',
|
||||
ctime: 2000,
|
||||
path: 'test.md',
|
||||
};
|
||||
|
||||
const result = addNode(doc, newNode, 1000);
|
||||
const group = result.items[0] as BookmarkGroup;
|
||||
|
||||
expect(group.items.length).toBe(1);
|
||||
expect(group.items[0].ctime).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeNode', () => {
|
||||
it('should remove a node', () => {
|
||||
const doc: BookmarksDoc = {
|
||||
items: [
|
||||
{ type: 'file', ctime: 1000, path: 'test.md' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = removeNode(doc, 1000);
|
||||
|
||||
expect(result.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countNodes', () => {
|
||||
it('should count nodes correctly', () => {
|
||||
const doc: BookmarksDoc = {
|
||||
items: [
|
||||
{
|
||||
type: 'group',
|
||||
ctime: 1000,
|
||||
title: 'Group',
|
||||
items: [
|
||||
{ type: 'file', ctime: 2000, path: 'a.md' },
|
||||
{ type: 'file', ctime: 3000, path: 'b.md' },
|
||||
],
|
||||
},
|
||||
{ type: 'file', ctime: 4000, path: 'c.md' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = countNodes(doc);
|
||||
|
||||
expect(result.total).toBe(4);
|
||||
expect(result.groups).toBe(1);
|
||||
expect(result.items).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterTree', () => {
|
||||
it('should filter nodes by search term', () => {
|
||||
const doc: BookmarksDoc = {
|
||||
items: [
|
||||
{ type: 'file', ctime: 1000, path: 'important.md', title: 'Important' },
|
||||
{ type: 'file', ctime: 2000, path: 'test.md', title: 'Test' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = filterTree(doc, 'important');
|
||||
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].ctime).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateRev', () => {
|
||||
it('should calculate consistent rev for same content', () => {
|
||||
const doc: BookmarksDoc = {
|
||||
items: [
|
||||
{ type: 'file', ctime: 1000, path: 'test.md' },
|
||||
],
|
||||
};
|
||||
|
||||
const rev1 = calculateRev(doc);
|
||||
const rev2 = calculateRev(doc);
|
||||
|
||||
expect(rev1).toBe(rev2);
|
||||
});
|
||||
|
||||
it('should calculate different rev for different content', () => {
|
||||
const doc1: BookmarksDoc = {
|
||||
items: [
|
||||
{ type: 'file', ctime: 1000, path: 'a.md' },
|
||||
],
|
||||
};
|
||||
|
||||
const doc2: BookmarksDoc = {
|
||||
items: [
|
||||
{ type: 'file', ctime: 1000, path: 'b.md' },
|
||||
],
|
||||
};
|
||||
|
||||
const rev1 = calculateRev(doc1);
|
||||
const rev2 = calculateRev(doc2);
|
||||
|
||||
expect(rev1).not.toBe(rev2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Bookmark Utilities - Tree operations, validation, and JSON handling
|
||||
*/
|
||||
|
||||
import type { BookmarkNode, BookmarkGroup, BookmarksDoc, BookmarkTreeNode } from './types';
|
||||
|
||||
/**
|
||||
* Deep clone a bookmarks document
|
||||
*/
|
||||
export function cloneBookmarksDoc(doc: BookmarksDoc): BookmarksDoc {
|
||||
return JSON.parse(JSON.stringify(doc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone a bookmark node
|
||||
*/
|
||||
export function cloneNode(node: BookmarkNode): BookmarkNode {
|
||||
return JSON.parse(JSON.stringify(node));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ctime (milliseconds timestamp)
|
||||
*/
|
||||
export function generateCtime(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all nodes have unique ctimes
|
||||
*/
|
||||
export function ensureUniqueCTimes(doc: BookmarksDoc): BookmarksDoc {
|
||||
const usedCtimes = new Set<number>();
|
||||
const cloned = cloneBookmarksDoc(doc);
|
||||
|
||||
function processNode(node: BookmarkNode): void {
|
||||
// If ctime is missing or already used, generate new one
|
||||
if (!node.ctime || usedCtimes.has(node.ctime)) {
|
||||
let newCtime = generateCtime();
|
||||
while (usedCtimes.has(newCtime)) {
|
||||
newCtime++;
|
||||
}
|
||||
node.ctime = newCtime;
|
||||
}
|
||||
usedCtimes.add(node.ctime);
|
||||
|
||||
// Process children if group
|
||||
if (node.type === 'group') {
|
||||
for (const child of node.items) {
|
||||
processNode(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of cloned.items) {
|
||||
processNode(item);
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a bookmarks document structure
|
||||
*/
|
||||
export function validateBookmarksDoc(data: unknown): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
errors.push('Document must be an object');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
const doc = data as any;
|
||||
|
||||
if (!Array.isArray(doc.items)) {
|
||||
errors.push('Document must have an "items" array');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
function validateNode(node: any, path: string): void {
|
||||
if (!node || typeof node !== 'object') {
|
||||
errors.push(`${path}: Node must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.type || typeof node.type !== 'string') {
|
||||
errors.push(`${path}: Node must have a "type" string`);
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ['group', 'file', 'search', 'folder', 'heading', 'block'];
|
||||
if (!validTypes.includes(node.type)) {
|
||||
errors.push(`${path}: Invalid type "${node.type}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.ctime !== undefined && typeof node.ctime !== 'number') {
|
||||
errors.push(`${path}: ctime must be a number`);
|
||||
}
|
||||
|
||||
if (node.title !== undefined && typeof node.title !== 'string') {
|
||||
errors.push(`${path}: title must be a string`);
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
if (node.type === 'group') {
|
||||
if (!Array.isArray(node.items)) {
|
||||
errors.push(`${path}: Group must have an "items" array`);
|
||||
} else {
|
||||
node.items.forEach((child: any, index: number) => {
|
||||
validateNode(child, `${path}.items[${index}]`);
|
||||
});
|
||||
}
|
||||
} else if (node.type === 'file' || node.type === 'folder') {
|
||||
if (!node.path || typeof node.path !== 'string') {
|
||||
errors.push(`${path}: ${node.type} must have a "path" string`);
|
||||
}
|
||||
} else if (node.type === 'search') {
|
||||
if (!node.query || typeof node.query !== 'string') {
|
||||
errors.push(`${path}: search must have a "query" string`);
|
||||
}
|
||||
} else if (node.type === 'heading' || node.type === 'block') {
|
||||
if (!node.path || typeof node.path !== 'string') {
|
||||
errors.push(`${path}: ${node.type} must have a "path" string`);
|
||||
}
|
||||
if (!node.subpath || typeof node.subpath !== 'string') {
|
||||
errors.push(`${path}: ${node.type} must have a "subpath" string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.items.forEach((item: any, index: number) => {
|
||||
validateNode(item, `items[${index}]`);
|
||||
});
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a node by ctime (unique identifier)
|
||||
*/
|
||||
export function findNodeByCtime(
|
||||
doc: BookmarksDoc,
|
||||
ctime: number
|
||||
): { node: BookmarkNode; parent: BookmarkGroup | null; index: number } | null {
|
||||
function search(
|
||||
items: BookmarkNode[],
|
||||
parent: BookmarkGroup | null
|
||||
): { node: BookmarkNode; parent: BookmarkGroup | null; index: number } | null {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const node = items[i];
|
||||
if (node.ctime === ctime) {
|
||||
return { node, parent, index: i };
|
||||
}
|
||||
if (node.type === 'group') {
|
||||
const found = search(node.items, node);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return search(doc.items, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node by ctime
|
||||
*/
|
||||
export function removeNode(doc: BookmarksDoc, ctime: number): BookmarksDoc {
|
||||
const cloned = cloneBookmarksDoc(doc);
|
||||
|
||||
function remove(items: BookmarkNode[]): boolean {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const node = items[i];
|
||||
if (node.ctime === ctime) {
|
||||
items.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'group' && remove(node.items)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
remove(cloned.items);
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a node by ctime
|
||||
*/
|
||||
export function updateNode(doc: BookmarksDoc, ctime: number, updates: Partial<BookmarkNode>): BookmarksDoc {
|
||||
const cloned = cloneBookmarksDoc(doc);
|
||||
|
||||
function update(items: BookmarkNode[]): boolean {
|
||||
for (const node of items) {
|
||||
if (node.ctime === ctime) {
|
||||
Object.assign(node, updates);
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'group' && update(node.items)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
update(cloned.items);
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a node to a parent group (or root if parentCtime is null)
|
||||
*/
|
||||
export function addNode(
|
||||
doc: BookmarksDoc,
|
||||
node: BookmarkNode,
|
||||
parentCtime: number | null,
|
||||
index?: number
|
||||
): BookmarksDoc {
|
||||
const cloned = cloneBookmarksDoc(doc);
|
||||
|
||||
if (parentCtime === null) {
|
||||
// Add to root
|
||||
if (index !== undefined && index >= 0 && index <= cloned.items.length) {
|
||||
cloned.items.splice(index, 0, node);
|
||||
} else {
|
||||
cloned.items.push(node);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function add(items: BookmarkNode[]): boolean {
|
||||
for (const item of items) {
|
||||
if (item.ctime === parentCtime && item.type === 'group') {
|
||||
if (index !== undefined && index >= 0 && index <= item.items.length) {
|
||||
item.items.splice(index, 0, node);
|
||||
} else {
|
||||
item.items.push(node);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (item.type === 'group' && add(item.items)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
add(cloned.items);
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a node to a new parent and index
|
||||
*/
|
||||
export function moveNode(
|
||||
doc: BookmarksDoc,
|
||||
nodeCtime: number,
|
||||
newParentCtime: number | null,
|
||||
newIndex: number
|
||||
): BookmarksDoc {
|
||||
const found = findNodeByCtime(doc, nodeCtime);
|
||||
if (!found) return doc;
|
||||
|
||||
// Can't move a node into itself or its descendants
|
||||
if (newParentCtime !== null && isDescendant(found.node, newParentCtime)) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
const nodeClone = cloneNode(found.node);
|
||||
let updated = removeNode(doc, nodeCtime);
|
||||
updated = addNode(updated, nodeClone, newParentCtime, newIndex);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a target ctime is a descendant of a node
|
||||
*/
|
||||
function isDescendant(node: BookmarkNode, targetCtime: number): boolean {
|
||||
if (node.ctime === targetCtime) return true;
|
||||
if (node.type === 'group') {
|
||||
return node.items.some(child => isDescendant(child, targetCtime));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten tree for display
|
||||
*/
|
||||
export function flattenTree(doc: BookmarksDoc): BookmarkTreeNode[] {
|
||||
const result: BookmarkTreeNode[] = [];
|
||||
|
||||
function traverse(items: BookmarkNode[], parent: BookmarkTreeNode | null, level: number): void {
|
||||
items.forEach((node, index) => {
|
||||
const treeNode: BookmarkTreeNode = { bookmark: node, parent, level, index };
|
||||
result.push(treeNode);
|
||||
if (node.type === 'group') {
|
||||
traverse(node.items, treeNode, level + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
traverse(doc.items, null, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tree by search term
|
||||
*/
|
||||
export function filterTree(doc: BookmarksDoc, searchTerm: string): BookmarksDoc {
|
||||
if (!searchTerm.trim()) return doc;
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
const cloned = cloneBookmarksDoc(doc);
|
||||
|
||||
function matchesSearch(node: BookmarkNode): boolean {
|
||||
if (node.title?.toLowerCase().includes(term)) return true;
|
||||
if (node.type === 'file' || node.type === 'folder') {
|
||||
if (node.path.toLowerCase().includes(term)) return true;
|
||||
}
|
||||
if (node.type === 'search') {
|
||||
if (node.query.toLowerCase().includes(term)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function filter(items: BookmarkNode[]): BookmarkNode[] {
|
||||
const filtered: BookmarkNode[] = [];
|
||||
for (const node of items) {
|
||||
if (node.type === 'group') {
|
||||
const filteredChildren = filter(node.items);
|
||||
if (filteredChildren.length > 0 || matchesSearch(node)) {
|
||||
filtered.push({ ...node, items: filteredChildren });
|
||||
}
|
||||
} else if (matchesSearch(node)) {
|
||||
filtered.push(node);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
cloned.items = filter(cloned.items);
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count nodes recursively
|
||||
*/
|
||||
export function countNodes(doc: BookmarksDoc): { total: number; groups: number; items: number } {
|
||||
let total = 0;
|
||||
let groups = 0;
|
||||
let items = 0;
|
||||
|
||||
function count(nodes: BookmarkNode[]): void {
|
||||
for (const node of nodes) {
|
||||
total++;
|
||||
if (node.type === 'group') {
|
||||
groups++;
|
||||
count(node.items);
|
||||
} else {
|
||||
items++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count(doc.items);
|
||||
return { total, groups, items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a simple hash/rev for conflict detection
|
||||
*/
|
||||
export function calculateRev(doc: BookmarksDoc): string {
|
||||
const content = JSON.stringify(doc.items);
|
||||
// Simple hash implementation
|
||||
let hash = 0;
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const char = content.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36) + '-' + content.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format JSON for writing (preserve readability)
|
||||
*/
|
||||
export function formatBookmarksJSON(doc: BookmarksDoc): string {
|
||||
return JSON.stringify(doc, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON safely
|
||||
*/
|
||||
export function parseBookmarksJSON(json: string): BookmarksDoc | null {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
const validation = validateBookmarksDoc(parsed);
|
||||
if (!validation.valid) {
|
||||
console.error('Invalid bookmarks JSON:', validation.errors);
|
||||
return null;
|
||||
}
|
||||
return parsed as BookmarksDoc;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse bookmarks JSON:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Bookmarks Module - Public API
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Service
|
||||
export { BookmarksService } from './bookmarks.service';
|
||||
|
||||
// Repository
|
||||
export {
|
||||
type IBookmarksRepository,
|
||||
FsAccessRepository,
|
||||
ServerBridgeRepository,
|
||||
InMemoryRepository,
|
||||
createRepository,
|
||||
} from './bookmarks.repository';
|
||||
|
||||
// Utilities
|
||||
export * from './bookmarks.utils';
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Bookmark Types - 100% compatible with Obsidian's .obsidian/bookmarks.json
|
||||
*/
|
||||
|
||||
export type BookmarkType = 'group' | 'file' | 'search' | 'folder' | 'heading' | 'block';
|
||||
|
||||
export interface BookmarkBase {
|
||||
type: BookmarkType;
|
||||
ctime: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface BookmarkGroup extends BookmarkBase {
|
||||
type: 'group';
|
||||
items: BookmarkNode[];
|
||||
}
|
||||
|
||||
export interface BookmarkFile extends BookmarkBase {
|
||||
type: 'file';
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface BookmarkSearch extends BookmarkBase {
|
||||
type: 'search';
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface BookmarkFolder extends BookmarkBase {
|
||||
type: 'folder';
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface BookmarkHeading extends BookmarkBase {
|
||||
type: 'heading';
|
||||
path: string;
|
||||
subpath: string;
|
||||
}
|
||||
|
||||
export interface BookmarkBlock extends BookmarkBase {
|
||||
type: 'block';
|
||||
path: string;
|
||||
subpath: string;
|
||||
}
|
||||
|
||||
export type BookmarkNode =
|
||||
| BookmarkGroup
|
||||
| BookmarkFile
|
||||
| BookmarkSearch
|
||||
| BookmarkFolder
|
||||
| BookmarkHeading
|
||||
| BookmarkBlock;
|
||||
|
||||
export interface BookmarksDoc {
|
||||
items: BookmarkNode[];
|
||||
rev?: string; // For conflict detection
|
||||
}
|
||||
|
||||
export interface BookmarkTreeNode {
|
||||
bookmark: BookmarkNode;
|
||||
parent: BookmarkTreeNode | null;
|
||||
level: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export type AccessStatus = 'connected' | 'disconnected' | 'read-only';
|
||||
|
||||
export interface ConflictInfo {
|
||||
localRev: string;
|
||||
remoteRev: string;
|
||||
remoteContent: BookmarksDoc;
|
||||
}
|
||||
@@ -162,6 +162,26 @@
|
||||
--tw-ring-offset-color: var(--bg-main);
|
||||
}
|
||||
|
||||
/* Dark theme: strengthen input readability */
|
||||
:root.dark .input,
|
||||
:root.dark input[type="text"],
|
||||
:root.dark input[type="search"],
|
||||
:root.dark textarea,
|
||||
:root.dark select {
|
||||
color: var(--text-main) !important;
|
||||
caret-color: var(--text-main);
|
||||
background-color: var(--card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:root.dark .input::placeholder,
|
||||
:root.dark input[type="text"]::placeholder,
|
||||
:root.dark input[type="search"]::placeholder,
|
||||
:root.dark textarea::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
.textarea:disabled,
|
||||
.select:disabled {
|
||||
|
||||
Reference in New Issue
Block a user