web: name a roll's folders before reading their frames
This commit is contained in:
@@ -15,12 +15,15 @@
|
||||
import type { Recipe } from '../../shared/types';
|
||||
import { readCapturedAt } from './imageOps';
|
||||
import { isRawName, rawThumbnail } from './rawDevelop';
|
||||
import { walkPass, type WalkFile } from './rollWalk';
|
||||
import { walkPass, type Walk, type WalkFile } from './rollWalk';
|
||||
|
||||
const DB_NAME = 'recipescam-library';
|
||||
const FOLDERS = 'folders';
|
||||
const PHOTOS = 'photos';
|
||||
const EDITS = 'edits';
|
||||
// The folders a scan has walked into. They are names before they are frames: the
|
||||
// column draws the shape of a roll while the frames are still being read.
|
||||
const DIRS = 'dirs';
|
||||
|
||||
// The grid cell is ~220px wide and a retina display doubles it: 512 on the long
|
||||
// edge is the largest a tile ever shows, and it costs ~30KB per frame.
|
||||
@@ -56,6 +59,16 @@ export interface LibraryPhoto {
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
// A folder the walk has found, whether or not a frame has been read out of it
|
||||
// yet: the column draws a roll from these before its frames arrive.
|
||||
export interface LibraryDir {
|
||||
// `${folder}/${rel}` — the tree key, the same way a frame id is spelled.
|
||||
id: string;
|
||||
folder: string;
|
||||
// The path under the picked folder, no leading or trailing slash.
|
||||
rel: string;
|
||||
}
|
||||
|
||||
export interface LibraryEdit {
|
||||
photoId: string;
|
||||
recipe: Recipe;
|
||||
@@ -83,14 +96,19 @@ let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
const req = indexedDB.open(DB_NAME, 2);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
db.createObjectStore(FOLDERS, { keyPath: 'name' });
|
||||
const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' });
|
||||
photos.createIndex('folder', 'folder');
|
||||
photos.createIndex('taken', 'taken');
|
||||
db.createObjectStore(EDITS, { keyPath: 'photoId' });
|
||||
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
|
||||
if (!db.objectStoreNames.contains(PHOTOS)) {
|
||||
const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' });
|
||||
photos.createIndex('folder', 'folder');
|
||||
photos.createIndex('taken', 'taken');
|
||||
}
|
||||
if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' });
|
||||
if (!db.objectStoreNames.contains(DIRS)) {
|
||||
db.createObjectStore(DIRS, { keyPath: 'id' }).createIndex('folder', 'folder');
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
@@ -145,15 +163,18 @@ export async function renameFolder(folder: LibraryFolder, label: string): Promis
|
||||
|
||||
export async function removeFolder(name: string): Promise<void> {
|
||||
const ids = (await listPhotos(name)).map((p) => p.id);
|
||||
const dirs = await listDirs(name);
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction([FOLDERS, PHOTOS, EDITS], 'readwrite');
|
||||
const tx = db.transaction([FOLDERS, PHOTOS, EDITS, DIRS], 'readwrite');
|
||||
tx.objectStore(FOLDERS).delete(name);
|
||||
const photos = tx.objectStore(PHOTOS);
|
||||
for (const id of ids) {
|
||||
photos.delete(id);
|
||||
tx.objectStore(EDITS).delete(id);
|
||||
}
|
||||
const store = tx.objectStore(DIRS);
|
||||
for (const dir of dirs) store.delete(dir.id);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
@@ -254,21 +275,31 @@ export async function scanFolder(
|
||||
jump?: () => string | null
|
||||
): Promise<ScanProgress> {
|
||||
const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p]));
|
||||
const pending = [{ dir: folder.handle, rel: '' }];
|
||||
const tree: Walk = { root: folder.handle, pending: [{ dir: folder.handle, rel: '' }], walked: new Set() };
|
||||
const entries: WalkFile[] = [];
|
||||
// What the passes found: the folders this scan walked into, and the names they
|
||||
// put in the column. `names` is cleared a pass at a time; `found` keeps the
|
||||
// whole scan so a folder that has gone from the disk goes from the column too.
|
||||
const names: string[] = [];
|
||||
const found = new Set<string>();
|
||||
const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0 };
|
||||
let batch: LibraryPhoto[] = [];
|
||||
let dirs: LibraryDir[] = [];
|
||||
// One transaction per batch, a put per row: a store with `keyPath: 'id'` takes
|
||||
// a record, not an array of them.
|
||||
const flush = async () => {
|
||||
if (!batch.length) return;
|
||||
if (!batch.length && !dirs.length) return;
|
||||
const rows = batch;
|
||||
const folders = dirs;
|
||||
batch = [];
|
||||
dirs = [];
|
||||
const db = await openDb();
|
||||
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 tx = db.transaction([PHOTOS, DIRS], 'readwrite');
|
||||
const photos = tx.objectStore(PHOTOS);
|
||||
for (const row of rows) photos.put(row);
|
||||
const store = tx.objectStore(DIRS);
|
||||
for (const dir of folders) store.put(dir);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
@@ -276,12 +307,22 @@ export async function scanFolder(
|
||||
let stop = false;
|
||||
let walked = false;
|
||||
// One bounded pass at a time, and the pass yields between frames, so a folder
|
||||
// clicked while the scan runs is walked on the next jump rather than after the
|
||||
// whole tree. Nothing clicked: parents first, then each layer below, deepest
|
||||
// last — the order `pending` is filled in.
|
||||
// clicked while the scan runs is read on the next jump rather than after the
|
||||
// whole tree. Nothing clicked: the picked folder and its frames, then each
|
||||
// layer below with theirs, deepest last — the order `pending` is filled in.
|
||||
do {
|
||||
const from = entries.length;
|
||||
walked = await walkPass(pending, entries, isSupportedPhoto, jump ?? (() => null));
|
||||
names.length = 0;
|
||||
walked = await walkPass(tree, entries, names, isSupportedPhoto, jump ?? (() => null));
|
||||
// The names come in before the frames they hold: the column grows one pass
|
||||
// ahead of the strip, which is the whole point of reading layer by layer.
|
||||
for (const rel of names) {
|
||||
const id = `${folder.name}/${rel.slice(0, -1)}`;
|
||||
if (found.has(id)) continue;
|
||||
found.add(id);
|
||||
dirs.push({ id, folder: folder.name, rel: rel.slice(0, -1) });
|
||||
}
|
||||
if (dirs.length >= BATCH) await flush();
|
||||
progress.total += entries.length - from;
|
||||
for (const { handle, rel } of entries.slice(from)) {
|
||||
if (shouldStop?.()) {
|
||||
@@ -321,9 +362,27 @@ export async function scanFolder(
|
||||
}
|
||||
} while (!walked && !stop);
|
||||
await flush();
|
||||
// A stopped scan has not seen the whole roll, so only a walk that reached the
|
||||
// end may say which folders are gone.
|
||||
if (walked) await pruneDirs(folder.name, found);
|
||||
return progress;
|
||||
}
|
||||
|
||||
// The folders that are no longer under the picked one: a rescan of a roll that
|
||||
// has been tidied on the disk takes the tidied rows out of the column with it.
|
||||
async function pruneDirs(folder: string, keep: Set<string>): Promise<void> {
|
||||
const gone = (await listDirs(folder)).filter((d) => !keep.has(d.id));
|
||||
if (!gone.length) return;
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(DIRS, 'readwrite');
|
||||
const store = tx.objectStore(DIRS);
|
||||
for (const dir of gone) store.delete(dir.id);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
// --- reading ---------------------------------------------------------------
|
||||
|
||||
export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
|
||||
@@ -338,6 +397,16 @@ export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// The folders a scan has walked into, whether or not a frame has been read out
|
||||
// of them yet — the column draws these as soon as the parent pass named them.
|
||||
export async function listDirs(folder?: string): Promise<LibraryDir[]> {
|
||||
try {
|
||||
return await ask<LibraryDir[]>(DIRS, 'readonly', (s) => (folder ? s.index('folder').getAll(folder) : s.getAll()));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPhoto(id: string): Promise<LibraryPhoto | null> {
|
||||
try {
|
||||
return (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;
|
||||
|
||||
Reference in New Issue
Block a user