web: open the catalogue without a version another tab can block
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
getPhoto,
|
||||
listEditedIds,
|
||||
listFolders,
|
||||
listDirs,
|
||||
listPhotos,
|
||||
pickFolder,
|
||||
readPhotoFile,
|
||||
@@ -69,8 +68,10 @@ export function Library() {
|
||||
const [folders, setFolders] = useState<LibraryFolder[]>([]);
|
||||
const [ready, setReady] = useState<Set<string>>(new Set());
|
||||
const [photos, setPhotos] = useState<LibraryPhoto[]>([]);
|
||||
// The folders a scan has walked into. A roll's own shape is on screen from the
|
||||
// first pass, before the frames under its folders have been read.
|
||||
// The folders the scan in flight has walked into: a roll's own shape is on
|
||||
// screen from the first pass, before the frames under its folders are read.
|
||||
// What a scan found is not filed away — the next visit draws the folders that
|
||||
// hold frames, and the next scan names the rest again.
|
||||
const [dirs, setDirs] = useState<LibraryDir[]>([]);
|
||||
const [edited, setEdited] = useState<Set<string>>(new Set());
|
||||
// Which tree node is open, which frame is up, and which of the two middle
|
||||
@@ -111,10 +112,9 @@ export function Library() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [rows, edits, dirs] = await Promise.all([listPhotos(), listEditedIds(), listDirs()]);
|
||||
const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]);
|
||||
setPhotos(rows);
|
||||
setEdited(edits);
|
||||
setDirs(dirs);
|
||||
}, []);
|
||||
|
||||
// The catalogue outlives the tab; the permission to read those folders does
|
||||
@@ -189,6 +189,7 @@ export function Library() {
|
||||
folder,
|
||||
(p) => {
|
||||
setProgress(p);
|
||||
setDirs(p.dirs);
|
||||
if (p.done === 0 || Date.now() - last > 700) {
|
||||
last = Date.now();
|
||||
void reload();
|
||||
@@ -198,6 +199,7 @@ export function Library() {
|
||||
() => jumpRef.current
|
||||
);
|
||||
setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total }));
|
||||
setDirs(result.dirs);
|
||||
await reload();
|
||||
} catch {
|
||||
setNote(t('lib.failed'));
|
||||
@@ -257,6 +259,7 @@ export function Library() {
|
||||
await removeFolder(folder.name);
|
||||
const rest = folders.filter((f) => f.name !== folder.name);
|
||||
setFolders(rest);
|
||||
setDirs((prev) => prev.filter((d) => d.folder !== folder.name));
|
||||
setNode(rest[0]?.name ?? null);
|
||||
await reload();
|
||||
},
|
||||
|
||||
@@ -21,9 +21,6 @@ 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.
|
||||
@@ -59,8 +56,10 @@ 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.
|
||||
// A folder a scan has walked into, whether or not a frame has been read out of
|
||||
// it yet: the column draws a roll from these before its frames arrive. They are
|
||||
// the scan's own report and live no longer than it does — a reload draws the
|
||||
// folders that hold frames, and the next scan names the rest again.
|
||||
export interface LibraryDir {
|
||||
// `${folder}/${rel}` — the tree key, the same way a frame id is spelled.
|
||||
id: string;
|
||||
@@ -96,7 +95,12 @@ let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 2);
|
||||
// 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);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
|
||||
@@ -106,9 +110,6 @@ function openDb(): Promise<IDBDatabase> {
|
||||
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);
|
||||
@@ -163,18 +164,15 @@ 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, DIRS], 'readwrite');
|
||||
const tx = db.transaction([FOLDERS, PHOTOS, EDITS], '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);
|
||||
});
|
||||
@@ -255,6 +253,10 @@ export interface ScanProgress {
|
||||
total: number;
|
||||
done: number;
|
||||
added: number;
|
||||
// Every folder the walk has been into so far, a fresh list each time: the
|
||||
// column draws them while the frames under them are still being read. Not
|
||||
// filed away — a folder name is worth nothing once the scan it came from ends.
|
||||
dirs: LibraryDir[];
|
||||
}
|
||||
|
||||
// Walk the folder, keep what is new or changed, and leave the rest alone: a
|
||||
@@ -282,24 +284,22 @@ export async function scanFolder(
|
||||
// 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 };
|
||||
const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0, dirs: [] };
|
||||
// The folders this scan has walked into, kept for the whole of it: the column
|
||||
// is redrawn from this list, so it only ever grows.
|
||||
const dirs: LibraryDir[] = [];
|
||||
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 && !dirs.length) return;
|
||||
if (!batch.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, DIRS], 'readwrite');
|
||||
const tx = db.transaction(PHOTOS, '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);
|
||||
});
|
||||
@@ -322,7 +322,10 @@ export async function scanFolder(
|
||||
found.add(id);
|
||||
dirs.push({ id, folder: folder.name, rel: rel.slice(0, -1) });
|
||||
}
|
||||
if (dirs.length >= BATCH) await flush();
|
||||
// The column is handed the names the moment the pass is through, before a
|
||||
// single frame under them has been read. A copy, so the redraw has something
|
||||
// new to look at rather than the list growing under it.
|
||||
progress.dirs = dirs.slice();
|
||||
progress.total += entries.length - from;
|
||||
for (const { handle, rel } of entries.slice(from)) {
|
||||
if (shouldStop?.()) {
|
||||
@@ -362,27 +365,9 @@ 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[]> {
|
||||
@@ -397,16 +382,6 @@ 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