library: the reading is copied into a folder of the reader's own, so a cleared profile gets it back without a frame being read twice

This commit is contained in:
2026-09-30 16:41:11 +07:00
parent 325d5c0470
commit 3e3045d912
6 changed files with 902 additions and 1 deletions
+91 -1
View File
@@ -572,7 +572,17 @@ export async function scanFolder(
// exactly as the catalogue already holds it. This is what makes a second scan
// of the same folder cheap, and what finishes a roll whose first scan was cut
// short — the frames it got through are skipped, the rest are read.
if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) return;
if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) {
// A row restored from a backup has no handle: a handle is not a thing that
// can be written to a file, so the row comes back with everything but the
// one thing that opens the frame, and the walk is where it gets it back.
// The row is put again — the bytes of the frame are still never read.
if (!seen.handle) {
batch.push({ ...seen, handle });
if (batch.length >= BATCH) await flush();
}
return;
}
// A RAW is read whole because that is the only way LibRaw can seek to the
// preview the camera wrote inside it; a JPEG is handed to the decoder as it
// is, and only its header is read for the date. Both tiles come off what the
@@ -966,3 +976,83 @@ export async function listEditedIds(): Promise<Set<string>> {
return new Set();
}
}
export async function listEdits(): Promise<LibraryEdit[]> {
try {
return await ask<LibraryEdit[]>(EDITS, 'readonly', (s) => s.getAll());
} catch {
return [];
}
}
// --- restore ---------------------------------------------------------------
// The catalogue as it travels: the rows without the two things that cannot be
// written to a file — a handle, which only the visitor's picker can hand out, and
// the tile itself, which is a blob and would be a second copy of the backup
// folder. Everything else is here, `mtime` and `size` and `star` included, which
// is what lets the walk that follows match every frame and read none of them.
export type CatalogueRow = Omit<LibraryPhoto, 'handle' | 'thumb'>;
// The rows a set of ids stands for, gaps left as gaps — one transaction for the
// lot, where `getPhoto` would be one per frame.
async function rowsAt(ids: string[]): Promise<(LibraryPhoto | undefined)[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(PHOTOS, 'readonly');
const store = tx.objectStore(PHOTOS);
const out: (LibraryPhoto | undefined)[] = [];
ids.forEach((id, i) => {
const req = store.get(id);
req.onsuccess = () => {
out[i] = req.result as LibraryPhoto | undefined;
};
});
tx.oncomplete = () => resolve(out);
tx.onerror = () => reject(tx.error);
});
}
// Put a catalogue back. A frame the catalogue already holds and can open — one
// with a handle — is left exactly as it is: the backup's copy of it would come
// back without one, which is a working catalogue traded for a worse one. Every
// other row goes in as it came out. `thumb` fetches the tile that was backed up
// beside the row, and a tile that will not read leaves the row without one: the
// grid paints a blank cell until the next scan reads the frame.
export async function restoreCatalogue(
photos: CatalogueRow[],
edits: LibraryEdit[],
thumb?: (id: string) => Promise<Blob | null>
): Promise<number> {
const db = await openDb();
const size = 200;
let put = 0;
for (let at = 0; at < photos.length; at += size) {
const slice = photos.slice(at, at + size);
const had = await rowsAt(slice.map((r) => r.id));
const want = slice.filter((_, i) => !had[i]?.handle);
if (!want.length) continue;
const rows = await Promise.all(
want.map(async (row) => ({ ...row, thumb: thumb ? await thumb(row.id) : null }) as unknown as LibraryPhoto)
);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(PHOTOS, 'readwrite');
const store = tx.objectStore(PHOTOS);
for (const row of rows) store.put(row);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
put += rows.length;
}
for (let at = 0; at < edits.length; at += size) {
const slice = edits.slice(at, at + size);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(EDITS, 'readwrite');
const store = tx.objectStore(EDITS);
for (const row of slice) store.put(row);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
return put;
}