ObsiViewer/src/core/bookmarks/bookmarks.service.ts

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);
}
}