153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
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<Set<RelativePath>> {
|
|
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<RelativePath>();
|
|
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<RelativePath, string>();
|
|
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;
|
|
}
|