/** * 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({ items: [] }); private readonly _selectedNodeCtime = signal(null); private readonly _filterTerm = signal(''); private readonly _isDirty = signal(false); private readonly _saving = signal(false); private readonly _loading = signal(false); private readonly _error = signal(null); private readonly _accessStatus = signal('disconnected'); private readonly _lastSaved = signal(null); private readonly _conflictInfo = signal(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 { try { await this.loadFromRepository(); } catch (error) { console.error('Failed to load bookmarks on init:', error); } } /** * Load bookmarks from repository */ async loadFromRepository(): Promise { 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 { 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 { 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 { await this.loadFromRepository(); this._conflictInfo.set(null); } /** * Resolve conflict: overwrite file with local */ async resolveConflictOverwrite(): Promise { 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): 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); } } /** * Remove all bookmarks with a specific path (useful for file bookmarks) */ removePathEverywhere(path: string): void { const doc = this._doc(); const removeByPath = (items: BookmarkNode[]): BookmarkNode[] => { return items.filter(item => { if (item.type === 'file' && item.path === path) { return false; // Remove this item } if (item.type === 'group') { // Recursively filter children item.items = removeByPath(item.items); return true; // Keep the group } return true; // Keep other items }); }; const updated = { ...doc, items: removeByPath([...doc.items]) }; this._doc.set(updated); this._isDirty.set(true); } /** * 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 { 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 { const status = await this.repository.getAccessStatus(); this._accessStatus.set(status); } }