UNPKG

roam-research-mcp

Version:
156 lines (155 loc) • 6.43 kB
/** * Diff Computation * * Computes the minimal set of batch actions needed to transform * existing blocks into the desired new block structure. */ import { flattenExistingBlocks } from './parser.js'; import { matchBlocks, normalizeText } from './matcher.js'; /** * Compute the diff between existing blocks and desired new blocks. * * This function: * 1. Matches new blocks to existing blocks by content similarity * 2. For matched blocks: generates update/move actions as needed * 3. For unmatched new blocks: generates create actions * 4. For unmatched existing blocks: generates delete actions * * @param existing - Array of existing block trees * @param newBlocks - Flat array of new blocks (desired state) * @param parentUid - UID of the parent page/block * @returns DiffResult with categorized actions */ export function diffBlockTrees(existing, newBlocks, parentUid) { const result = { creates: [], updates: [], moves: [], deletes: [], preservedUids: new Set(), }; // Flatten existing blocks for matching const existingFlat = flattenExistingBlocks(existing); // Step 1: Match blocks by content const matches = matchBlocks(existingFlat, newBlocks); // Build uid -> ExistingBlock mapping const existingByUid = new Map(existingFlat.map((eb) => [eb.uid, eb])); /** * Resolve the desired parent UID for a new block. * If the parent was matched to an existing block, use that UID. */ function desiredParentUid(newBlock) { if (!newBlock.parentRef) return parentUid; const parentNewUid = newBlock.parentRef.blockUid; if (parentNewUid === parentUid) return parentUid; // If parent is matched, target the existing parent UID return matches.get(parentNewUid) ?? parentNewUid; } // Build desired structure: map each new block to its desired parent // and group siblings by their desired parent const newUidToDesiredParent = new Map(); const siblingsByDesiredParent = new Map(); for (const newBlock of newBlocks) { const newUid = newBlock.ref.blockUid; const targetUid = matches.get(newUid) ?? newUid; const dParent = desiredParentUid(newBlock); newUidToDesiredParent.set(newUid, dParent); if (!siblingsByDesiredParent.has(dParent)) { siblingsByDesiredParent.set(dParent, []); } siblingsByDesiredParent.get(dParent).push(targetUid); } // Step 2: Process each new block for (const newBlock of newBlocks) { const newUid = newBlock.ref.blockUid; if (matches.has(newUid)) { // Block matched to an existing block const existUid = matches.get(newUid); const existBlock = existingByUid.get(existUid); const currentParent = existBlock.parentUid ?? parentUid; const dParent = newUidToDesiredParent.get(newUid); result.preservedUids.add(existUid); // Check for text/heading changes -> update-block let needsUpdate = false; const updateAction = { action: 'update-block', block: { uid: existUid }, }; if (normalizeText(newBlock.text) !== normalizeText(existBlock.text)) { updateAction.block.string = newBlock.text; needsUpdate = true; } if (newBlock.heading !== existBlock.heading) { if (newBlock.heading !== null) { updateAction.block.heading = newBlock.heading; needsUpdate = true; } else if (existBlock.heading !== null) { // Remove heading by setting to 0 updateAction.block.heading = 0; needsUpdate = true; } } if (needsUpdate) { result.updates.push(updateAction); } // Check for parent/order changes -> move-block const desiredSiblings = siblingsByDesiredParent.get(dParent) ?? []; const desiredOrder = desiredSiblings.indexOf(existUid); if (currentParent !== dParent || existBlock.order !== desiredOrder) { result.moves.push({ action: 'move-block', block: { uid: existUid }, location: { 'parent-uid': dParent, order: desiredOrder }, }); } } else { // No match -> create-block const dParent = newUidToDesiredParent.get(newUid); const desiredSiblings = siblingsByDesiredParent.get(dParent) ?? []; const desiredOrder = desiredSiblings.indexOf(newUid); const createAction = { action: 'create-block', location: { 'parent-uid': dParent, order: desiredOrder >= 0 ? desiredOrder : 'last', }, block: { uid: newBlock.ref.blockUid, string: newBlock.text, }, }; if (newBlock.heading !== null) { createAction.block.heading = newBlock.heading; } if (newBlock.open !== undefined) { createAction.block.open = newBlock.open; } result.creates.push(createAction); } } // Step 3: Find unmatched existing blocks -> delete const matchedExistingUids = new Set(matches.values()); for (const existBlock of existingFlat) { if (!matchedExistingUids.has(existBlock.uid)) { result.deletes.push({ action: 'delete-block', block: { uid: existBlock.uid }, }); } } return result; } /** * Diff blocks at a specific level (for hierarchical diffing). * Used internally for recursive tree comparison. */ export function diffBlockLevel(existing, newBlocks, parentUid) { // Filter to only blocks at this level (direct children of parentUid) const existingAtLevel = existing.filter((e) => e.parentUid === parentUid || e.parentUid === null); const newAtLevel = newBlocks.filter((n) => n.parentRef?.blockUid === parentUid || (!n.parentRef && parentUid === parentUid)); return diffBlockTrees(existingAtLevel, newAtLevel, parentUid); }