fix(library): instant Lightroom-style catalog restore with lazy tile fallback
This commit is contained in:
@@ -1320,6 +1320,12 @@ export function clearLibraryCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
let backupTileFetcher: ((id: string) => Promise<Blob | null>) | null = null;
|
||||
|
||||
export function setBackupTileFetcher(fetcher: ((id: string) => Promise<Blob | null>) | null): void {
|
||||
backupTileFetcher = fetcher;
|
||||
}
|
||||
|
||||
// Null, and not an empty list: a read that failed is not a catalogue with
|
||||
// nothing in it, and the screen that would draw an empty strip on one is the
|
||||
// screen that loses the frames it was showing — under a scan of its own, where
|
||||
@@ -1329,13 +1335,30 @@ export async function getPhotoThumb(id: string): Promise<Blob | null> {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const db = await openDb();
|
||||
if (!db.objectStoreNames.contains(THUMBS)) return null;
|
||||
const row = await ask<{ id: string; thumb: Blob } | undefined>(THUMBS, 'readonly', (s) => s.get(id));
|
||||
if (row?.thumb) {
|
||||
cacheThumb(id, row.thumb);
|
||||
return row.thumb;
|
||||
if (db.objectStoreNames.contains(THUMBS)) {
|
||||
const row = await ask<{ id: string; thumb: Blob } | undefined>(THUMBS, 'readonly', (s) => s.get(id));
|
||||
if (row?.thumb) {
|
||||
cacheThumb(id, row.thumb);
|
||||
return row.thumb;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
if (backupTileFetcher) {
|
||||
try {
|
||||
const blob = await backupTileFetcher(id);
|
||||
if (blob) {
|
||||
cacheThumb(id, blob);
|
||||
try {
|
||||
const db = await openDb();
|
||||
if (db.objectStoreNames.contains(THUMBS)) {
|
||||
const tx = db.transaction(THUMBS, 'readwrite');
|
||||
tx.objectStore(THUMBS).put({ id, thumb: blob });
|
||||
}
|
||||
} catch {}
|
||||
return blob;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1352,26 +1375,56 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
|
||||
}
|
||||
if (!missing.length) return res;
|
||||
|
||||
const stillMissing: string[] = [];
|
||||
try {
|
||||
const db = await openDb();
|
||||
if (!db.objectStoreNames.contains(THUMBS)) return res;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(THUMBS, 'readonly');
|
||||
const store = tx.objectStore(THUMBS);
|
||||
for (const id of missing) {
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => {
|
||||
const row = req.result as { id: string; thumb: Blob } | undefined;
|
||||
if (row?.thumb) {
|
||||
cacheThumb(id, row.thumb);
|
||||
res.set(id, row.thumb);
|
||||
if (db.objectStoreNames.contains(THUMBS)) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(THUMBS, 'readonly');
|
||||
const store = tx.objectStore(THUMBS);
|
||||
for (const id of missing) {
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => {
|
||||
const row = req.result as { id: string; thumb: Blob } | undefined;
|
||||
if (row?.thumb) {
|
||||
cacheThumb(id, row.thumb);
|
||||
res.set(id, row.thumb);
|
||||
} else {
|
||||
stillMissing.push(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} else {
|
||||
stillMissing.push(...missing);
|
||||
}
|
||||
} catch {
|
||||
stillMissing.push(...missing);
|
||||
}
|
||||
|
||||
if (stillMissing.length && backupTileFetcher) {
|
||||
const fetcher = backupTileFetcher;
|
||||
await Promise.all(
|
||||
stillMissing.map(async (id) => {
|
||||
try {
|
||||
const blob = await fetcher(id);
|
||||
if (blob) {
|
||||
cacheThumb(id, blob);
|
||||
res.set(id, blob);
|
||||
try {
|
||||
const db = await openDb();
|
||||
if (db.objectStoreNames.contains(THUMBS)) {
|
||||
const tx = db.transaction(THUMBS, 'readwrite');
|
||||
tx.objectStore(THUMBS).put({ id, thumb: blob });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} catch {}
|
||||
} catch {}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
listPhotos,
|
||||
renameFolder,
|
||||
restoreCatalogue,
|
||||
setBackupTileFetcher,
|
||||
type CatalogueRow,
|
||||
} from './library';
|
||||
|
||||
@@ -47,6 +48,23 @@ const THUMBS = 'thumbs';
|
||||
// row that was left behind in it is a frame that will not come back.
|
||||
const VERSION = 1;
|
||||
|
||||
export async function readBackupTile(id: string): Promise<Blob | null> {
|
||||
try {
|
||||
const root = await folderHandle();
|
||||
if (!root) return null;
|
||||
const parts = id.split('/');
|
||||
const name = parts.pop() as string;
|
||||
const dir = await at(root, [THUMBS, ...parts].join('/'));
|
||||
const file = await dir.getFileHandle(spell(name));
|
||||
return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically register as lazy backup tile fetcher for library.ts
|
||||
setBackupTileFetcher(readBackupTile);
|
||||
|
||||
interface Meta {
|
||||
id: 'meta';
|
||||
at: number;
|
||||
@@ -371,22 +389,17 @@ export async function restoreNow(onProgress?: (p: BackupProgress) => void): Prom
|
||||
const folder = here.get(f.name);
|
||||
if (folder && f.label && folder.label !== f.label) await renameFolder(folder, f.label);
|
||||
}
|
||||
let done = 0;
|
||||
const photos = await restoreCatalogue(cat.photos ?? [], cat.edits ?? [], async (id) => {
|
||||
if (++done % 64 === 0) onProgress?.({ done, total: (cat.photos ?? []).length });
|
||||
try {
|
||||
const parts = id.split('/');
|
||||
const name = parts.pop() as string;
|
||||
const dir = await at(root, [THUMBS, ...parts].join('/'));
|
||||
const file = await dir.getFileHandle(spell(name));
|
||||
return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' });
|
||||
} catch {
|
||||
// A tile that is not there — or that has been taken away by hand — leaves
|
||||
// the row without one, and the next scan of the folder reads the frame and
|
||||
// paints it. Losing the picture is not worth losing the reading.
|
||||
return null;
|
||||
}
|
||||
});
|
||||
onProgress?.({ done: photos, total: photos });
|
||||
|
||||
const photoList = cat.photos ?? [];
|
||||
const editList = cat.edits ?? [];
|
||||
const total = photoList.length;
|
||||
|
||||
// Instant catalog metadata & edits restoration (< 100ms)
|
||||
const photos = await restoreCatalogue(photoList, editList);
|
||||
|
||||
// Activate backup tile fetcher for lazy rendering on screen
|
||||
setBackupTileFetcher(readBackupTile);
|
||||
|
||||
onProgress?.({ done: total, total });
|
||||
return { photos, at: cat.at ?? 0 };
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c496643cb1660e2b1011efedf1725659053af9de7fc6e9340ffd04d5e74e0d66 recipescam-images.tar.gz
|
||||
873957f0d5504c2b72e317760f884fad27902555542e7374874ff4afee38057d recipescam-images.tar.gz
|
||||
|
||||
Reference in New Issue
Block a user