Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105import type { DetectedChange, MoveCandidate } from './types';import { stringSimilarity } from './utils/string-similarity';
const DEFAULT_THRESHOLD = 0.7;const LARGE_FILE_BYTES = 4096;const SAMPLE_WINDOW = 100;
export interface MoveDetectionResult { moves: MoveCandidate[]; remainingChanges: DetectedChange[];}
/** * Detect renames/moves by pairing local deletions with local additions * that have sufficiently similar content (Sørensen–Dice ≥ threshold). * * @param changes - All locally detected changes. * @param getContent - Async getter for current FS content. Returns null for * unreadable / binary files (those are skipped as non-movable). * @param threshold - Minimum similarity score. Default 0.7. */export async function detectMoves( changes: DetectedChange[], getContent: (path: string) => Promise<string | null>, threshold = DEFAULT_THRESHOLD,): Promise<MoveDetectionResult> { const deletions = changes.filter( (c) => c.type === 'delete' && !c.isDirectory, ); const additions = changes.filter((c) => c.type === 'add' && !c.isDirectory); const others = changes.filter( (c) => (c.type !== 'delete' && c.type !== 'add') || c.isDirectory, );
const deleteContents = new Map<string, string | null>(); const addContents = new Map<string, string | null>();
await Promise.all([ ...deletions.map(async (c) => { deleteContents.set(c.path, await getContent(c.path).catch(() => null)); }), ...additions.map(async (c) => { addContents.set(c.path, await getContent(c.path).catch(() => null)); }), ]);
const claimed = new Set<string>(); const moves: MoveCandidate[] = []; const matchedDeletions = new Set<string>();
for (const deletion of deletions) { const delContent = deleteContents.get(deletion.path); if (delContent === null || delContent === undefined) continue;
let bestScore = 0; let bestAddition: DetectedChange | null = null;
for (const addition of additions) { if (claimed.has(addition.path)) continue; const addContent = addContents.get(addition.path); if (addContent === null || addContent === undefined) continue;
const score = computeSimilarity(delContent, addContent); if (score > bestScore) { bestScore = score; bestAddition = addition; } }
if (bestScore >= threshold && bestAddition) { moves.push({ oldPath: deletion.path, newPath: bestAddition.path, similarity: bestScore, }); claimed.add(bestAddition.path); matchedDeletions.add(deletion.path); } }
const remainingChanges: DetectedChange[] = [ ...others, ...deletions.filter((d) => !matchedDeletions.has(d.path)), ...additions.filter((a) => !claimed.has(a.path)), ];
return { moves, remainingChanges };}
function computeSimilarity(a: string, b: string): number { if (a.length > LARGE_FILE_BYTES || b.length > LARGE_FILE_BYTES) { const len = Math.min(a.length, b.length); const scores = [0.25, 0.5, 0.75].map((pos) => { const start = Math.floor(pos * len); const end = Math.min(start + SAMPLE_WINDOW, len); const wa = a.slice(start, end); const wb = b.slice(start, end); if (wa.length < 2 || wb.length < 2) return 0; return stringSimilarity(wa, wb); }); return scores.reduce((sum, s) => sum + s, 0) / scores.length; } return stringSimilarity(a, b);}