chore: update Angular cache and TypeScript build info

This commit is contained in:
2025-09-29 23:22:47 -04:00
parent b4a706930b
commit 67407f68f0
85 changed files with 10400 additions and 492 deletions
+308
View File
@@ -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);
});
});
+347
View File
@@ -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);
}
}
+239
View File
@@ -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);
});
});
});
+409
View File
@@ -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;
}
}
+21
View File
@@ -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';
+71
View File
@@ -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;
}