/** * 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) */ let lastGeneratedCtime = 0; export function generateCtime(): number { const now = Date.now(); if (now <= lastGeneratedCtime) { lastGeneratedCtime += 1; return lastGeneratedCtime; } lastGeneratedCtime = now; return lastGeneratedCtime; } /** * Ensure all nodes have unique ctimes */ export function ensureUniqueCTimes(doc: BookmarksDoc): BookmarksDoc { const usedCtimes = new Set(); 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): 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)) { console.warn('Cannot move a node into its own descendant'); return doc; } const nodeClone = cloneNode(found.node); const oldParentCtime = found.parent ? found.parent.ctime : null; const oldIndex = found.index; // If moving within the same parent, adjust index let adjustedIndex = newIndex; if (oldParentCtime === newParentCtime && oldIndex < newIndex) { // When removing from earlier position, indices shift down adjustedIndex = newIndex - 1; } let updated = removeNode(doc, nodeCtime); updated = addNode(updated, nodeClone, newParentCtime, adjustedIndex); 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; } }