import { v4 as uuid } from "uuid"; import type { FileOperations } from "../file-operations/file-operations"; import type { Database, DocumentId, RelativePath } from "../persistence/database"; import type { VaultSnapshot } from "../services/types/VaultSnapshot"; import { EMPTY_HASH, hash } from "../utils/hash"; import { arePathAliases, findPathWithSameSpelling } from "../utils/portable-path"; import { applyLocalChange, unappliedChanges, type LocalChange } from "./local-changes"; export type { LocalChange } from "./local-changes"; interface ScanOptions { database: Database; guard: () => void; changes: LocalChange[]; files: FileOperations; ignored: (path: RelativePath) => boolean; oversized: (size: number) => boolean; } /** Scans are complete or fail. Missing unmaterialized/ignored entries are not deletions. */ export async function scanLocalFiles( { database, guard, changes: queuedChanges, files, ignored, oversized }: ScanOptions, initial?: VaultSnapshot ): Promise> { const next = structuredClone(database.state); // Leave the prefix queued until its resulting state is saved. // A persisted change ID makes replay safe after an uncertain save. const changes = [...queuedChanges]; const unsyncablePaths = new Set(); for (const change of unappliedChanges(next, changes)) { applyLocalChange(next, change, ignored, initial); next.lastAppliedLocalChangeId = change.changeId; } const paths: RelativePath[] = []; const reportedPaths = await files.listFilesRecursively(); for (const path of reportedPaths) { // Some filesystems may report a different Unicode spelling after a // rename. Keep an already-accessible spelling when possible. const alias = findPathWithSameSpelling(Object.values(next.local), path); paths.push( alias && !reportedPaths.includes(alias) && (await files.fs.exists(alias)) ? alias : path ); } const present = new Set(paths); const hashes = new Map(); for (const path of paths) { if (ignored(path)) { unsyncablePaths.add(path); continue; } const info = await files.fs.stat(path); if (!info || info.kind !== "file") throw new Error(`File changed during scan: ${path}`); if (oversized(info.size)) { unsyncablePaths.add(path); continue; } const snapshot = await files.fs.read(path); if (!snapshot) throw new Error(`File changed during scan: ${path}`); if (!oversized(snapshot.content.length)) { hashes.set(path, await hash(snapshot.content)); } else { unsyncablePaths.add(path); } } const missing = new Set( Object.keys(next.local).filter( (id) => next.documents[id]?.observedHash !== undefined && !ignored(next.local[id]) && !present.has(next.local[id]) ) ); const knownPaths = new Set(Object.values(next.local)); const unknown = [...hashes.keys()].filter((path) => !knownPaths.has(path)); for (const path of unknown) { const digest = hashes.get(path)!; const matches = [...missing].filter( (id) => next.documents[id]?.observedHash === digest ); const sameHash = unknown.filter( (other) => hashes.get(other) === digest ); let id: DocumentId | undefined; if ( digest !== EMPTY_HASH && matches.length === 1 && sameHash.length === 1 ) { id = matches[0]; missing.delete(id); } else if (initial) { id = Object.keys(initial.fileManifest.entries).find( (candidate) => !next.local[candidate] && arePathAliases( initial.fileManifest.entries[candidate], path ) ); } id ??= uuid(); next.local[id] = path; next.documents[id] ??= { observedHash: "" }; } for (const id of missing) delete next.local[id]; for (const [id, path] of Object.entries(next.local)) { if (unsyncablePaths.has(path) || ignored(path)) { next.excluded ??= {}; if (next.excluded[id] === undefined) next.excluded[id] = next.fileManifest.entries[id] ?? null; } const digest = hashes.get(path); if (digest !== undefined) { const doc = (next.documents[id] ??= {}); // The old history no longer contains this base. Preserve any edit // made during recovery as a new file instead of overwriting it. if (doc.base?.vaultUpdateId === null && doc.base.hash !== digest) { const recoveredId = uuid(); next.local[recoveredId] = path; next.documents[recoveredId] = { observedHash: digest }; delete next.local[id]; } else doc.observedHash = digest; } } guard(); await database.commit(next); queuedChanges.splice(0, changes.length); guard(); return unsyncablePaths; }