373 lines
9.7 KiB
TypeScript
373 lines
9.7 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<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);
|
|
}
|
|
}
|