430 lines
11 KiB
TypeScript
430 lines
11 KiB
TypeScript
/**
|
|
* 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<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)) {
|
|
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;
|
|
}
|
|
}
|