|
|
|
@@ -20,7 +20,9 @@ import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
|
|
|
|
const DB_NAME = 'recipescam-library';
|
|
|
|
|
const FOLDERS = 'folders';
|
|
|
|
|
const PHOTOS = 'photos';
|
|
|
|
|
const THUMBS = 'thumbs';
|
|
|
|
|
const EDITS = 'edits';
|
|
|
|
|
const DB_VERSION = 2;
|
|
|
|
|
|
|
|
|
|
// The tile the catalogue keeps is the same tile the grid cell and the strip both
|
|
|
|
|
// paint, and there is one of them per frame of a roll that reaches six figures.
|
|
|
|
@@ -30,8 +32,8 @@ const EDITS = 'edits';
|
|
|
|
|
// lower quality put the same picture in the same cell: the tile is now ~12KB, and
|
|
|
|
|
// the roll a quarter of the disk it was. The quality is the one Lightroom keeps
|
|
|
|
|
// its own grid previews at, and at this size the eye cannot tell it from 0.75.
|
|
|
|
|
const THUMB_MAX = 256;
|
|
|
|
|
const THUMB_QUALITY = 0.6;
|
|
|
|
|
const THUMB_MAX = 640;
|
|
|
|
|
const THUMB_QUALITY = 0.8;
|
|
|
|
|
// Frames go down in batches, so a 3000-file folder is 60 transactions rather
|
|
|
|
|
// than 3000 of them.
|
|
|
|
|
const BATCH = 50;
|
|
|
|
@@ -138,16 +140,33 @@ export function canBrowseFolders(): boolean {
|
|
|
|
|
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const THUMB_CACHE_MAX = 500;
|
|
|
|
|
const thumbCache = new Map<string, Blob>();
|
|
|
|
|
|
|
|
|
|
export function cacheThumb(id: string, blob: Blob): void {
|
|
|
|
|
if (thumbCache.has(id)) thumbCache.delete(id);
|
|
|
|
|
else if (thumbCache.size >= THUMB_CACHE_MAX) {
|
|
|
|
|
const first = thumbCache.keys().next().value;
|
|
|
|
|
if (first) thumbCache.delete(first);
|
|
|
|
|
}
|
|
|
|
|
thumbCache.set(id, blob);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getCachedThumb(id: string): Blob | null {
|
|
|
|
|
const blob = thumbCache.get(id);
|
|
|
|
|
if (blob) {
|
|
|
|
|
thumbCache.delete(id);
|
|
|
|
|
thumbCache.set(id, blob);
|
|
|
|
|
return blob;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
|
|
|
|
|
|
|
|
|
function openDb(): Promise<IDBDatabase> {
|
|
|
|
|
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
|
|
|
|
// No version on purpose. A version the browser has to reach for is a version
|
|
|
|
|
// another tab can block: a tab still running the previous build holds the
|
|
|
|
|
// catalogue open, the upgrade waits on it, and this one never gets an answer
|
|
|
|
|
// — the screen comes up empty on a catalogue that is all there. Opening
|
|
|
|
|
// without one takes the catalogue as it is and creates it when it is missing.
|
|
|
|
|
const req = indexedDB.open(DB_NAME);
|
|
|
|
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
|
req.onupgradeneeded = () => {
|
|
|
|
|
const db = req.result;
|
|
|
|
|
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
|
|
|
|
@@ -156,6 +175,9 @@ function openDb(): Promise<IDBDatabase> {
|
|
|
|
|
photos.createIndex('folder', 'folder');
|
|
|
|
|
photos.createIndex('taken', 'taken');
|
|
|
|
|
}
|
|
|
|
|
if (!db.objectStoreNames.contains(THUMBS)) {
|
|
|
|
|
db.createObjectStore(THUMBS, { keyPath: 'id' });
|
|
|
|
|
}
|
|
|
|
|
if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' });
|
|
|
|
|
};
|
|
|
|
|
req.onsuccess = () => resolve(req.result);
|
|
|
|
@@ -213,12 +235,18 @@ export async function removeFolder(name: string): Promise<void> {
|
|
|
|
|
const ids = (await listPhotos(name)).map((p) => p.id);
|
|
|
|
|
const db = await openDb();
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
const tx = db.transaction([FOLDERS, PHOTOS, EDITS], 'readwrite');
|
|
|
|
|
const hasThumbs = db.objectStoreNames.contains(THUMBS);
|
|
|
|
|
const stores = hasThumbs ? [FOLDERS, PHOTOS, THUMBS, EDITS] : [FOLDERS, PHOTOS, EDITS];
|
|
|
|
|
const tx = db.transaction(stores, 'readwrite');
|
|
|
|
|
tx.objectStore(FOLDERS).delete(name);
|
|
|
|
|
const photos = tx.objectStore(PHOTOS);
|
|
|
|
|
const thumbs = hasThumbs ? tx.objectStore(THUMBS) : null;
|
|
|
|
|
const edits = tx.objectStore(EDITS);
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
photos.delete(id);
|
|
|
|
|
tx.objectStore(EDITS).delete(id);
|
|
|
|
|
thumbs?.delete(id);
|
|
|
|
|
edits.delete(id);
|
|
|
|
|
thumbCache.delete(id);
|
|
|
|
|
}
|
|
|
|
|
tx.oncomplete = () => resolve();
|
|
|
|
|
tx.onerror = () => reject(tx.error);
|
|
|
|
@@ -239,12 +267,13 @@ export async function ensurePermission(handle: FileSystemHandle, write = false):
|
|
|
|
|
// against, and the two values are the whole type.
|
|
|
|
|
type PermissionMode = 'read' | 'readwrite';
|
|
|
|
|
const mode: PermissionMode = write ? 'readwrite' : 'read';
|
|
|
|
|
if (!handle) return false;
|
|
|
|
|
const askable = handle as FileSystemHandle & {
|
|
|
|
|
queryPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
|
|
|
|
|
requestPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
if (!askable.queryPermission) return false;
|
|
|
|
|
if (!askable || !askable.queryPermission) return false;
|
|
|
|
|
if ((await askable.queryPermission({ mode })) === 'granted') return true;
|
|
|
|
|
return (await askable.requestPermission?.({ mode })) === 'granted';
|
|
|
|
|
} catch {
|
|
|
|
@@ -322,7 +351,10 @@ async function tile(blob: Blob, size?: { width: number; height: number } | null)
|
|
|
|
|
const ctx = canvas.getContext('2d', { alpha: false });
|
|
|
|
|
if (!ctx) return null;
|
|
|
|
|
ctx.drawImage(bitmap, 0, 0);
|
|
|
|
|
return await new Promise<Blob | null>((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY));
|
|
|
|
|
const res = await new Promise<Blob | null>((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY));
|
|
|
|
|
canvas.width = 0;
|
|
|
|
|
canvas.height = 0;
|
|
|
|
|
return res;
|
|
|
|
|
} finally {
|
|
|
|
|
bitmap.close();
|
|
|
|
|
}
|
|
|
|
@@ -670,18 +702,23 @@ export async function scanFolder(
|
|
|
|
|
batch = [];
|
|
|
|
|
const db = await openDb();
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
const tx = db.transaction(PHOTOS, 'readwrite');
|
|
|
|
|
const photos = tx.objectStore(PHOTOS);
|
|
|
|
|
for (const row of rows) photos.put(row);
|
|
|
|
|
const hasThumbs = db.objectStoreNames.contains(THUMBS);
|
|
|
|
|
const stores = hasThumbs ? [PHOTOS, THUMBS] : [PHOTOS];
|
|
|
|
|
const tx = db.transaction(stores, 'readwrite');
|
|
|
|
|
const photoStore = tx.objectStore(PHOTOS);
|
|
|
|
|
const thumbStore = hasThumbs ? tx.objectStore(THUMBS) : null;
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
const { thumb, ...meta } = row;
|
|
|
|
|
photoStore.put(meta);
|
|
|
|
|
if (thumb) {
|
|
|
|
|
cacheThumb(row.id, thumb);
|
|
|
|
|
thumbStore?.put({ id: row.id, thumb });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
tx.oncomplete = () => resolve();
|
|
|
|
|
tx.onerror = () => reject(tx.error);
|
|
|
|
|
});
|
|
|
|
|
progress.written += rows.length;
|
|
|
|
|
// Out of the batch and onto the strip in one move, before the position is
|
|
|
|
|
// written: the frames are in the catalogue now, and the screen that draws
|
|
|
|
|
// them needs no read to find them. Ordered by nothing — the order they were
|
|
|
|
|
// read in is the order the roll was shot in, near enough, and the sort that
|
|
|
|
|
// settles it comes when the reading is through.
|
|
|
|
|
if (added.length) {
|
|
|
|
|
const fresh = added;
|
|
|
|
|
added = [];
|
|
|
|
@@ -689,41 +726,23 @@ export async function scanFolder(
|
|
|
|
|
}
|
|
|
|
|
await write();
|
|
|
|
|
};
|
|
|
|
|
// One frame, end to end: its bytes, its tile, its shutter time, and the row the
|
|
|
|
|
// catalogue keeps. A frame that has not moved is dropped on its size and its
|
|
|
|
|
// time before a byte of it is read, which is what makes a second scan of the
|
|
|
|
|
// same folder cheap; a frame that will not read at all is skipped by the lane
|
|
|
|
|
// that ran it — one bad file in a folder is not a failed folder.
|
|
|
|
|
|
|
|
|
|
const readOne = async (handle: FileSystemFileHandle, rel: string): Promise<void> => {
|
|
|
|
|
const file = await handle.getFile();
|
|
|
|
|
const id = photoId(folder.name, rel);
|
|
|
|
|
const seen = known.get(id);
|
|
|
|
|
// Unchanged means the same size and the same write time: the frame is left
|
|
|
|
|
// 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) {
|
|
|
|
|
// 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 && seen.size === file.size && seen.mtime === file.lastModified) {
|
|
|
|
|
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
|
|
|
|
|
// camera wrote — a RAW's preview is a copy, where a develop is every pixel.
|
|
|
|
|
const raw = isRawName(file.name);
|
|
|
|
|
const bytes = new Uint8Array(await (raw ? file.arrayBuffer() : file.slice(0, HEAD_BYTES).arrayBuffer()));
|
|
|
|
|
const RAW_HEADER_BYTES = 4 * 1024 * 1024;
|
|
|
|
|
const sliceBuffer = await file.slice(0, raw ? RAW_HEADER_BYTES : HEAD_BYTES).arrayBuffer();
|
|
|
|
|
const bytes = new Uint8Array(sliceBuffer);
|
|
|
|
|
const preview = raw ? await rawThumbnail(bytes) : null;
|
|
|
|
|
// The tile is the preview the camera wrote inside a RAW, or the frame itself.
|
|
|
|
|
// Both carry their own size in their first few hundred bytes — the same bytes
|
|
|
|
|
// the date comes out of — so the decoder is handed the size instead of being
|
|
|
|
|
// asked with a second decode of the whole frame to learn which edge is long.
|
|
|
|
|
const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file;
|
|
|
|
|
const size = preview ? jpegSize(preview) : raw ? null : jpegSize(bytes);
|
|
|
|
|
const thumb = src ? await tile(src, size) : null;
|
|
|
|
@@ -739,16 +758,10 @@ export async function scanFolder(
|
|
|
|
|
taken,
|
|
|
|
|
size: file.size,
|
|
|
|
|
mtime: file.lastModified,
|
|
|
|
|
// The reader's own score is not the file's: a frame that is read again
|
|
|
|
|
// keeps the stars it was given.
|
|
|
|
|
star: seen?.star,
|
|
|
|
|
addedAt: seen?.addedAt ?? Date.now(),
|
|
|
|
|
};
|
|
|
|
|
batch.push(row);
|
|
|
|
|
// A frame the catalogue already held is on the strip already: its row takes
|
|
|
|
|
// the place of the one there, at the end of the reading. Only the frame the
|
|
|
|
|
// reading met for the first time is handed over as it lands — that is the
|
|
|
|
|
// one the reader is waiting to see.
|
|
|
|
|
if (!seen) added.push(row);
|
|
|
|
|
progress.added++;
|
|
|
|
|
inHand = inHand.filter((r) => r !== rel);
|
|
|
|
@@ -764,6 +777,7 @@ export async function scanFolder(
|
|
|
|
|
// no more than one batch.
|
|
|
|
|
const drain = async () => {
|
|
|
|
|
const lanes = new Set<Promise<void>>();
|
|
|
|
|
let processed = 0;
|
|
|
|
|
while (entries.length) {
|
|
|
|
|
if (shouldStop?.()) {
|
|
|
|
|
stop = true;
|
|
|
|
@@ -793,6 +807,12 @@ export async function scanFolder(
|
|
|
|
|
lanes.add(lane);
|
|
|
|
|
if (lanes.size >= LANES) await Promise.race(lanes);
|
|
|
|
|
publish();
|
|
|
|
|
|
|
|
|
|
// Micro-yield to the browser main thread event loop so scanning never blocks UI rendering or user interactions
|
|
|
|
|
processed++;
|
|
|
|
|
if (processed % 12 === 0) {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await Promise.all(lanes);
|
|
|
|
|
};
|
|
|
|
@@ -846,6 +866,8 @@ export async function scanFolder(
|
|
|
|
|
publish(true);
|
|
|
|
|
}
|
|
|
|
|
await drain();
|
|
|
|
|
// Yield between pass iterations to maintain UI fluidness
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
|
} while (!walked && !stop);
|
|
|
|
|
await flush();
|
|
|
|
|
// A reading that came to its end has no position worth keeping: the next one
|
|
|
|
@@ -1223,16 +1245,99 @@ export function cachedPhotos(): LibraryPhoto[] {
|
|
|
|
|
return cached ?? [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearLibraryCache(): void {
|
|
|
|
|
cached = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
// the read is the thing that gives way first, that is every time.
|
|
|
|
|
export async function getPhotoThumb(id: string): Promise<Blob | null> {
|
|
|
|
|
const cached = getCachedThumb(id);
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>> {
|
|
|
|
|
const res = new Map<string, Blob>();
|
|
|
|
|
const missing: string[] = [];
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const cached = getCachedThumb(id);
|
|
|
|
|
if (cached) {
|
|
|
|
|
res.set(id, cached);
|
|
|
|
|
} else {
|
|
|
|
|
missing.push(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!missing.length) return res;
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
tx.oncomplete = () => resolve();
|
|
|
|
|
tx.onerror = () => reject(tx.error);
|
|
|
|
|
});
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
|
|
|
|
|
try {
|
|
|
|
|
const db = await openDb();
|
|
|
|
|
const rows = await ask<LibraryPhoto[]>(PHOTOS, 'readonly', (s) =>
|
|
|
|
|
folder ? s.index('folder').getAll(folder) : s.getAll()
|
|
|
|
|
);
|
|
|
|
|
// Newest shutter first — a folder of stills reads in the order it was shot.
|
|
|
|
|
if (db.objectStoreNames.contains(THUMBS)) {
|
|
|
|
|
const legacyPhotosToUpdate: LibraryPhoto[] = [];
|
|
|
|
|
const legacyThumbsToPut: { id: string; thumb: Blob }[] = [];
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
if (row.thumb) {
|
|
|
|
|
const t = row.thumb;
|
|
|
|
|
cacheThumb(row.id, t);
|
|
|
|
|
legacyThumbsToPut.push({ id: row.id, thumb: t });
|
|
|
|
|
const { thumb, ...meta } = row;
|
|
|
|
|
legacyPhotosToUpdate.push(meta as LibraryPhoto);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (legacyThumbsToPut.length > 0) {
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
|
|
|
|
const tx = db.transaction([PHOTOS, THUMBS], 'readwrite');
|
|
|
|
|
const pStore = tx.objectStore(PHOTOS);
|
|
|
|
|
const tStore = tx.objectStore(THUMBS);
|
|
|
|
|
for (const item of legacyThumbsToPut) tStore.put(item);
|
|
|
|
|
for (const item of legacyPhotosToUpdate) pStore.put(item);
|
|
|
|
|
} catch {}
|
|
|
|
|
})();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
|
|
|
if (rows[i].thumb) delete (rows[i] as { thumb?: Blob | null }).thumb;
|
|
|
|
|
}
|
|
|
|
|
rows.sort((a, b) => b.taken - a.taken);
|
|
|
|
|
if (!folder) cached = rows;
|
|
|
|
|
return rows;
|
|
|
|
@@ -1247,7 +1352,12 @@ export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
|
|
|
|
|
|
|
|
|
|
export async function getPhoto(id: string): Promise<LibraryPhoto | null> {
|
|
|
|
|
try {
|
|
|
|
|
return (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;
|
|
|
|
|
const photo = (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;
|
|
|
|
|
if (photo && photo.thumb) {
|
|
|
|
|
cacheThumb(id, photo.thumb);
|
|
|
|
|
delete (photo as { thumb?: Blob | null }).thumb;
|
|
|
|
|
}
|
|
|
|
|
return photo;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
@@ -1350,22 +1460,35 @@ export async function restoreCatalogue(
|
|
|
|
|
const db = await openDb();
|
|
|
|
|
const size = 200;
|
|
|
|
|
let put = 0;
|
|
|
|
|
const hasThumbs = db.objectStoreNames.contains(THUMBS);
|
|
|
|
|
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)
|
|
|
|
|
const items = await Promise.all(
|
|
|
|
|
want.map(async (row) => {
|
|
|
|
|
const blob = thumb ? await thumb(row.id) : null;
|
|
|
|
|
return { row, blob };
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
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);
|
|
|
|
|
const stores = hasThumbs ? [PHOTOS, THUMBS] : [PHOTOS];
|
|
|
|
|
const tx = db.transaction(stores, 'readwrite');
|
|
|
|
|
const photoStore = tx.objectStore(PHOTOS);
|
|
|
|
|
const thumbStore = hasThumbs ? tx.objectStore(THUMBS) : null;
|
|
|
|
|
for (const { row, blob } of items) {
|
|
|
|
|
const { thumb: _, ...meta } = row as any;
|
|
|
|
|
photoStore.put(meta);
|
|
|
|
|
if (blob) {
|
|
|
|
|
cacheThumb(row.id, blob);
|
|
|
|
|
thumbStore?.put({ id: row.id, thumb: blob });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
tx.oncomplete = () => resolve();
|
|
|
|
|
tx.onerror = () => reject(tx.error);
|
|
|
|
|
});
|
|
|
|
|
put += rows.length;
|
|
|
|
|
put += items.length;
|
|
|
|
|
}
|
|
|
|
|
for (let at = 0; at < edits.length; at += size) {
|
|
|
|
|
const slice = edits.slice(at, at + size);
|
|
|
|
|