fix(library): one row per frame of a drive root, however a build spelled its id

This commit is contained in:
2026-10-13 14:57:49 +07:00
parent c506cdf645
commit a3171dd40e
3 changed files with 579 additions and 40 deletions
+120 -40
View File
@@ -650,17 +650,42 @@ export async function removeFolder(name: string): Promise<void> {
// or a rotation belongs to a frame that is there.
//
// `from` is the folder the reading was pointed at, '' for the whole roll; `met`
// is every frame it found, spelled relative to the roll.
export async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
// is every frame it found, spelled relative to the roll. `missing` says whether
// this reading has a word on the frames it did not meet — a reading that took up
// a position, or one that met a folder it could not open, has none and only
// clears what is said below about the frames it did meet.
export async function prunePhotos(folder: string, from: string, met: Set<string>, missing = true): Promise<void> {
try {
const root = normPath(folder);
const under = (from ? `${root}/${normPath(from)}` : root).toLowerCase();
const rows = (await listPhotos(folder)).filter((p) => {
// One frame, one row. A row this reading did not meet is a frame gone from the
// disk and goes with it — but a second row for a frame it did meet is the same
// frame filed under an id this build no longer computes, and the reader who
// asked for the disk is looking at one frame and not two: the first of the two
// rows stands, the rest go with their tiles and their marks.
const rows: LibraryPhoto[] = [];
const kept = new Map<string, LibraryPhoto>();
for (const p of await listPhotos(folder)) {
const rel = (p.dir ? `${normPath(p.dir)}/${p.name}` : p.name).toLowerCase();
const key = `${root.toLowerCase()}/${rel}`;
if (from && key !== under && !key.startsWith(`${under}/`)) return false;
return !met.has(rel);
});
if (from && key !== under && !key.startsWith(`${under}/`)) continue;
if (!met.has(rel)) {
if (missing) rows.push(p);
continue;
}
const at = kept.get(key);
if (!at) {
kept.set(key, p);
continue;
}
// The row filed under the path this build computes is the one that stands;
// the other is the same frame under a spelling nothing reads, and it is
// this reading that has the frame in hand — so it goes, with its tile and
// its marks.
const stale = at.id.toLowerCase() === key ? p : at;
if (stale === at) kept.set(key, p);
rows.push(stale);
}
if (!rows.length) return;
const gone = new Set(rows.map((p) => p.id));
if (cached) cached = cached.filter((p) => !gone.has(p.id));
@@ -1188,9 +1213,22 @@ export async function scanFolder(
if (!handle) throw new Error('no-folder-handle');
const rawKnown = await listPhotos(folder.name);
const known = new Map<string, LibraryPhoto>();
// The frame is its path, and the path is what two spellings of it have in
// common: a catalogue written by a build that filed a drive root's frames under
// `\/2017\a.RAF` — the raw name, before `photoId` folded the separators — holds
// this frame already, and a reading that looked it up by the id it computes
// would find nothing and file a second row and a second tile beside the first.
const knownPath = new Map<string, LibraryPhoto>();
const pathIn = (p: LibraryPhoto) =>
`${normPath(p.folder)}${p.dir ? `/${normPath(p.dir)}` : ''}/${p.name}`.toLowerCase();
for (const p of rawKnown) {
known.set(p.id, p);
known.set(p.id.toLowerCase(), p);
const path = pathIn(p);
// The row filed under the id this build computes is the one the frame is
// written back to; the other spelling is the same frame and stands only
// where it is the only row there is.
if (!knownPath.has(path) || p.id.toLowerCase() === path) knownPath.set(path, p);
}
// A reading kept to one folder keeps no position of its own. The position file
// belongs to the reading that walks the whole roll, and a fraction of the tree
@@ -1474,7 +1512,13 @@ export async function scanFolder(
}
const file = await withTimeout(handle.getFile(), READ_MS);
const id = photoId(folder.name, rel);
const seen = known.get(id);
// The row the catalogue already holds for this frame, found by the id this
// reading computes and, failing that, by the path both spellings share — and
// the frame is written back under the id it is filed under: one frame is one
// row, one tile and one recipe, whatever a build before this one spelled it.
const held = known.get(id) ?? knownPath.get(`${normPath(folder.name)}/${normPath(rel)}`.toLowerCase());
const filed = held?.id ?? id;
const seen = held;
if (seen && seen.size === file.size && seen.mtime === file.lastModified) return;
// Where the frame was taken, off the few hundred kilobytes of its head that
// its own EXIF lives in — where the 32MB a camera's RAW used to be read for
@@ -1495,7 +1539,7 @@ export async function scanFolder(
const relNorm = rel.replace(/\\/g, '/');
const cut = relNorm.lastIndexOf('/');
const row: LibraryPhoto = {
id,
id: filed,
folder: normPath(folder.name),
dir: cut < 0 ? '' : normPath(relNorm.slice(0, cut)),
name: handle.name,
@@ -1661,8 +1705,11 @@ export async function scanFolder(
// walked are folders this reading never listed — and one that met a folder it
// could not open are all readings with no word on the frames they did not
// reach, and what they did not reach is not theirs to drop. See `prunePhotos`.
if (!stop && !failed.size && (bounded || !resumed)) {
await prunePhotos(folder.name, bounded ? from : '', countedDiscovered);
// The frame met twice under one path is the other half of that and is dropped
// whatever the reading is: a row is said about here by the frame it is, and
// that frame this reading has just had in hand.
if (!stop) {
await prunePhotos(folder.name, bounded ? from : '', countedDiscovered, !failed.size && (bounded || !resumed));
}
// What the walk found, kept where the catalogue is kept, so the column has it
// on the next visit whether the reading ran to its end or was cut off. A
@@ -2171,9 +2218,24 @@ async function writeThumb(id: string, blob: Blob, tell: boolean): Promise<void>
if (tell) for (const listener of thumbListeners) listener(id, blob);
try {
const db = await openDb();
if (!db.objectStoreNames.contains(THUMBS)) return;
const tx = db.transaction(THUMBS, 'readwrite');
tx.objectStore(THUMBS).put({ id, thumb: blob });
if (!db.objectStoreNames.contains(THUMBS) || !db.objectStoreNames.contains(PHOTOS)) return;
// A tile belongs to a frame the catalogue holds: a frame the wall asked for
// the tile of is one it drew off a row — and a reading that runs behind the
// wall is a reading that may drop that row while the tile is being made. The
// tile would then be kept for a row nothing has, a store with one more tile
// in it than there are frames, which is `makeThumbs` reading the folder as
// short of a tile it will never be drawn out of. The read of the row and the
// write of the tile are one transaction, so the reading cannot step between
// them.
await new Promise<void>((resolve, reject) => {
const tx = db.transaction([PHOTOS, THUMBS], 'readwrite');
const req = tx.objectStore(PHOTOS).get(id);
req.onsuccess = () => {
if (req.result) tx.objectStore(THUMBS).put({ id, thumb: blob });
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
// The tile is in hand either way; where it is not kept it is made again.
}
@@ -2466,38 +2528,56 @@ export async function readPhotos(
// one folder's rows are the run of keys between the folder and its own name:
// the rows the index handed back, asked of the store, a chunk at a time.
const root = folder === undefined ? null : normPath(folder);
const at = (last: string | null): IDBKeyRange =>
root === null
? IDBKeyRange.lowerBound(last ?? '', last !== null)
: IDBKeyRange.bound(last ?? `${root}/`, `${root}/\uffff`, last !== null, false);
// A folder whose name carries a separator is a folder with two spellings: the
// picker hands a drive root `\` where `normPath` writes `''`, and the build
// that filed its frames before `photoId` folded the separators filed them all
// under the raw one — `\/2017\a.RAF` where this build writes `/2017/a.RAF`.
// A row in the other spelling is a row nothing reads: the reading of the disk
// meets the same frame again under the id it computes, finds nothing, and
// files a second row and a second tile beside the row it cannot see — and the
// repair that would drop it reads the folder the same way, so the two stay.
// The raw range is read with the computed one, and its rows come back under
// the ids they are filed under, for `scanFolder` to keep their frame from
// being filed twice and `prunePhotos` to drop against the spelling that
// stands. See both.
const raw = folder !== undefined && folder !== root ? folder : null;
const rows: LibraryPhoto[] = [];
let last: string | null = null;
let emittedFirst = false;
for (;;) {
const chunk = await new Promise<LibraryPhoto[]>((resolve, reject) => {
const req = db.transaction(PHOTOS, 'readonly').objectStore(PHOTOS).getAll(at(last), READ_CHUNK);
req.onsuccess = () => resolve(req.result as LibraryPhoto[]);
req.onerror = () => reject(req.error);
});
if (!chunk.length) break;
last = chunk[chunk.length - 1].id;
for (const row of chunk) {
// Neither is read again: the file a frame is opened from is the folder's
// business — see `fileHandleFor` — and the tile is the store's own.
delete (row as { handle?: unknown }).handle;
delete (row as { thumb?: unknown }).thumb;
}
rows.push(...chunk);
const sweep = async (base: string): Promise<void> => {
let last: string | null = null;
for (;;) {
const chunk = await new Promise<LibraryPhoto[]>((resolve, reject) => {
const range =
root === null
? IDBKeyRange.lowerBound(last ?? '', last !== null)
: IDBKeyRange.bound(last ?? `${base}/`, `${base}/\uffff`, last !== null, false);
const req = db.transaction(PHOTOS, 'readonly').objectStore(PHOTOS).getAll(range, READ_CHUNK);
req.onsuccess = () => resolve(req.result as LibraryPhoto[]);
req.onerror = () => reject(req.error);
});
if (!chunk.length) break;
last = chunk[chunk.length - 1].id;
for (const row of chunk) {
// Neither is read again: the file a frame is opened from is the folder's
// business — see `fileHandleFor` — and the tile is the store's own.
delete (row as { handle?: unknown }).handle;
delete (row as { thumb?: unknown }).thumb;
}
rows.push(...chunk);
if (onBatch && !emittedFirst && rows.length >= 400) {
emittedFirst = true;
const initial = [...rows].sort((a, b) => b.taken - a.taken);
onBatch(initial);
}
if (onBatch && !emittedFirst && rows.length >= 400) {
emittedFirst = true;
const initial = [...rows].sort((a, b) => b.taken - a.taken);
onBatch(initial);
}
if (chunk.length < READ_CHUNK) break;
}
if (chunk.length < READ_CHUNK) break;
}
};
await sweep(root ?? '');
if (raw) await sweep(raw);
rows.sort((a, b) => b.taken - a.taken);
if (!folder) {
cached = rows;