fix(library): land a reading's rows as they are read, and keep the copy the reader reads offline

- scan: rows go down on a clock as well as on a full batch. The reader's folder
  of 26 RAWs is under one batch of 50, so nothing of it reached the screen until
  the reading was over — a wall that stood empty for the whole scan and then
  filled in one go. Frames now reach the screen every 400ms while the roll is
  still being read, so the count climbs and the first tiles are drawn as the
  frames land.
- tiles: a HEIC or a RAW has its tile written the first time it is drawn, so
  every later visit reads the tile back instead of the file.
- resume: a reading a reload cut off is offered by the next visit with a chip
  instead of being run behind the reader's back.
- offline listing: the desk's listing is kept whole in IndexedDB under
  `recipescam-offline`/`reads`, past the 200 000 characters localStorage held,
  so an offline visit opens on the listing it kept instead of on nothing.
- offline writes: a vote or a view cast with no server is held and goes out when
  one answers again, with a bar that says how many are waiting.
- sw: the shell precaches its own bundle, stylesheet and icon, read out of the
  document — a worker that precached only the document answered an offline
  launch with a page whose JavaScript the machine did not have.
- checks in docker/frontend/scripts: tree-scan-check (counts while reading,
  resume after a reload, tiles of a long roll), offline-read-check,
  offline-queue-check.
This commit is contained in:
2026-10-09 12:02:05 +07:00
parent db7ce04613
commit 8e5a3e3b74
17 changed files with 2054 additions and 122 deletions
+178 -43
View File
@@ -19,6 +19,7 @@ import {
jumpScan,
listEditedIds,
listFolders,
listWalkedDirs,
makeTile,
normPath,
onLibraryBlocked,
@@ -260,6 +261,11 @@ export function Library() {
const [state, setState] = useState<'loading' | 'guest' | 'ready'>(
() => (recall(ACCOUNT_KEY) as 'guest' | 'ready' | null) ?? 'loading'
);
// Whether the account on this browser may repair the catalogue: the API is the
// one that decides, and this only drives the button. Read from `me` below, and
// false until it answers — a control that is not there for a moment is not a
// control that was offered and taken back.
const [admin, setAdmin] = useState(false);
useEffect(() => {
let alive = true;
@@ -270,6 +276,7 @@ export function Library() {
const next = me?.user ? 'ready' : 'guest';
keep(ACCOUNT_KEY, next);
setState(next);
setAdmin(Boolean(me?.user?.admin));
})
// No answer is treated like no account: the API being away is not a
// reason to open the shelves to whoever is on the page. It is also not an
@@ -293,10 +300,10 @@ export function Library() {
</a>
</p>
);
return <Catalogue />;
return <Catalogue admin={admin} />;
}
function Catalogue() {
function Catalogue({ admin }: { admin: boolean }) {
const { t } = useI18n();
const [folders, setFolders] = useState<LibraryFolder[]>(() => cachedFolders());
const [ready, setReady] = useState<Set<string>>(new Set());
@@ -304,11 +311,13 @@ function Catalogue() {
// studio is a screen opening, not a page loading, and the frames the reader was
// looking at are already in hand. The read below replaces them a moment later.
const [photos, setPhotos] = useState<LibraryPhoto[]>(() => cachedPhotos());
// 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.
// The folders a reading has walked into, kept with the catalogue: a roll's own
// shape is on screen the moment the catalogue is, and a reading that was cut
// off leaves the names of what it found behind it. `walked` is what those names
// counted — the frames under each, off the disk as the walk last saw it, which
// is the one number that can go down.
const [dirs, setDirs] = useState<LibraryDir[]>([]);
const [walked, setWalked] = useState<Record<string, number>>({});
const [edited, setEdited] = useState<Set<string>>(new Set());
// Which tree node is open, which frame is up, and which of the two middle
// views (the frame, or every thumbnail of the open node) is on. The open node
@@ -420,12 +429,37 @@ function Catalogue() {
const [backBusy, setBackBusy] = useState(false);
const reload = useCallback(async () => {
const [rows, edits] = await Promise.all([readPhotos(), listEditedIds()]);
const [rows, edits, walkedRows] = await Promise.all([readPhotos(), listEditedIds(), listWalkedDirs()]);
// A read that came back with nothing is a read that failed, not a catalogue
// that emptied — and the frames it would have cleared are frames the reader
// was working their way through. The strip keeps them.
if (rows) setPhotos(rows);
setEdited(edits);
// What the readings have walked into, for the column. Lower-cased on the way
// in because that is how a row looks a number up, and the record holds both
// spellings of every key.
const counts: Record<string, number> = {};
for (const row of walkedRows) {
for (const [key, n] of Object.entries(row.counts)) counts[key.toLowerCase()] = n;
}
setWalked(counts);
// Folded into whatever the screen already has rather than put in its place: a
// folder the reader picked inside a roll is on the column from the click, and
// a reading in flight has named folders the record has not been written with
// yet. Both are rows, and neither is one to take away.
setDirs((prev) => {
const map = new Map(prev.map((d) => [d.id, d]));
let changed = false;
for (const row of walkedRows) {
for (const d of row.dirs) {
if (!map.has(d.id)) {
map.set(d.id, d);
changed = true;
}
}
}
return changed ? [...map.values()] : prev;
});
}, []);
// --- the catalogue's own copy ---------------------------------------------
@@ -786,7 +820,10 @@ function Catalogue() {
// for, and a request called off before it ran says nothing at all.
const scan = useCallback(
async (folder: LibraryFolder, quiet = false, from = '', mode: ScanMode = 'update') => {
setNote(null);
// A quiet reading is one nobody asked to hear about — the repair's own,
// whose note says the whole of what it is doing — so it leaves the note
// where it found it. A loud one clears it: its word is the one that lands.
if (!quiet) setNote(null);
try {
const result = await scanAsked(folder, from, mode);
if (!result) return;
@@ -804,18 +841,47 @@ function Catalogue() {
[reload, t]
);
// The folder this screen is on is read back to the disk on the way in, and again
// every time the tab is raised — but only when the last reading of it never
// finished. A reading that ran to its end has nothing left to find, and the
// reader who opens the library to choose a frame is not a reader who asked for
// their disk walked behind their back: on the folder they are opening, a walk
// is a hundred thousand names read off the disk and the band and the memory
// that goes with them, which is the whole of what this screen must not cost.
// A folder the reader wants looked at again says so itself, from the menu on
// its row; what is picked up here is the roll whose reading ended when the app
// did, and nothing else. No note either way: the ring on the row and the
// progress line are the report.
// The repair: every roll in the catalogue read back to the disk, one after the
// other, from the top. A reading is the only thing that knows what a folder
// holds now — it drops the rows the disk has nothing for, with their tiles and
// their marks, and draws every number off its own walk — so the repair of a
// catalogue that has drifted from the disk is exactly a rescan of everything in
// it. Frames that have not moved are skipped on their size and their time, so
// the cost is the disk's own listing and not a develop a frame.
const repair = useCallback(async () => {
if (!folders.length) return;
if (!window.confirm(t('lib.repairConfirm', { n: folders.length }))) return;
setNote(t('lib.repairRun', { n: folders.length }));
let done = 0;
for (const folder of folders) {
// One roll's failure is one roll's: a handle the browser will not hand
// back, a drive that is not there — the rest of the repair still runs.
try {
await scan(folder, true, '', 'rescan');
done++;
} catch {
// The roll keeps what the last reading that could open it left.
}
}
await reload();
setNote(t('lib.repairDone', { n: done }));
}, [folders, reload, scan, t]);
// A reading that ran to its end has nothing left to find, and the reader who
// opens the library to choose a frame is not a reader who asked for their disk
// walked behind their back: a walk of the folder they are opening is a hundred
// thousand names read off the disk and the band and the memory that goes with
// them, which is the whole of what this screen must not cost. So nothing walks
// on the way in. What this screen does instead is go looking — on the way in,
// and again every time the tab is raised — for the one roll whose reading ended
// when the app did, and offer it: the chip by the progress line names the folder
// and one press reads it on from where it stopped. A reading the reader wants of
// a folder whose last one ran to its end is still the menu on that folder's row;
// what the chip picks up is only the roll that was cut off mid-walk.
const rootKey = node ? node.split('/')[0] : null;
// The roll the screen found unfinished, named on the chip beside the progress
// line. It is what stands where the walk it used to run by itself used to be.
const [resume, setResume] = useState<string | null>(null);
// When the folder was last walked for being raised. It stands outside the
// effect because the effect is rebuilt for the folder the reader moves to, and
// the walk it guards is of the folder on the disk, where nothing has moved
@@ -831,11 +897,13 @@ function Catalogue() {
if (Date.now() - synced.current < CATCHUP_MS) return;
synced.current = Date.now();
void scanBusy().then(async (busy) => {
if (busy || scanSession()) return;
// A roll already offered is a roll already looked for: asking the disk
// again every time the tab is raised is the walk this screen just gave up.
if (busy || scanSession() || resume) return;
for (const folder of folders) {
if (!ready.has(folder.name)) continue;
if (await unfinished(folder.name)) {
void scan(folder, false);
setResume(folder.name);
break;
}
}
@@ -847,7 +915,7 @@ function Catalogue() {
};
document.addEventListener('visibilitychange', raised);
return () => document.removeEventListener('visibilitychange', raised);
}, [loaded, folders, ready, scan]);
}, [loaded, folders, ready, scan, resume]);
const add = useCallback(async () => {
setNote(null);
@@ -963,10 +1031,10 @@ function Catalogue() {
const rootFolder = useMemo(() => {
if (!node) return folders[0] ?? null;
const normNode = (node || '').replace(/\\/g, '/');
const normNode = normPath(node);
return (
folders.find((f) => {
const normF = (f.name || '').replace(/\\/g, '/');
const normF = normPath(f.name);
return normNode === normF || normNode.startsWith(`${normF}/`);
}) ?? folders[0] ?? null
);
@@ -1007,8 +1075,13 @@ function Catalogue() {
const hits = new Map<string, number>();
const foldersUnder = new Map<string, Set<string>>();
const add = (folder: string, rel: string, counted: boolean) => {
const normF = (folder || '').replace(/\\/g, '/');
const normRel = (rel || '').replace(/\\/g, '/');
// `normPath`, not a bare backslash swap: a picked drive root is named `\`,
// and `normPath` folds that to the empty root the walk files its frames and
// writes its counts under. Swapped only, the root would spell `/` here and
// `//sub` on the row keys below — two spellings of a folder the walk has
// never heard of, and a number every row would read as nought.
const normF = normPath(folder);
const normRel = normPath(rel);
const lowerF = normF.toLowerCase();
const set = foldersUnder.get(lowerF) ?? new Set<string>();
foldersUnder.set(lowerF, set);
@@ -1038,16 +1111,24 @@ function Catalogue() {
const nodes = useMemo<Node[]>(() => {
const { hits, foldersUnder } = baseTreeInfo;
// What a row says: the reading in flight, then what the reading that walked
// this roll found, then the catalogue. The reading first because it is the
// newest thing there is; the walk second because the catalogue is not a
// record of the disk — a frame taken off it is a row nothing takes out — and a
// number read off it is one that can only ever climb; the catalogue last,
// which is all a roll that has never been walked has.
const counted = (key: string) => {
const normKey = (key || '').replace(/\\/g, '/');
const normKey = normPath(key);
const lowerKey = normKey.toLowerCase();
const hitVal = hits.get(lowerKey) ?? 0;
const readVal = read?.[normKey] ?? read?.[lowerKey] ?? read?.[key] ?? 0;
return Math.max(hitVal, readVal);
const readVal = read?.[normKey] ?? read?.[lowerKey] ?? read?.[key];
if (readVal !== undefined) return readVal;
const walkVal = walked[lowerKey];
if (walkVal !== undefined) return walkVal;
return hits.get(lowerKey) ?? 0;
};
const out: Node[] = [];
for (const folder of folders) {
const normF = (folder.name || '').replace(/\\/g, '/');
const normF = normPath(folder.name);
const lowerF = normF.toLowerCase();
out.push({ key: normF, label: folder.label || folder.name, depth: 0, count: counted(normF) });
// Path order puts a folder before its own children, which is the order the
@@ -1064,7 +1145,7 @@ function Catalogue() {
}
}
return out;
}, [baseTreeInfo, folders, read]);
}, [baseTreeInfo, folders, read, walked]);
// A row with anything under it is the one that folds: its key is the prefix of
// its children's, in the path order the rows come in.
@@ -1159,11 +1240,20 @@ function Catalogue() {
const below = dir === ''
? (p: LibraryPhoto) => normPath(p.dir ?? '') !== ''
: (p: LibraryPhoto) => normPath(p.dir ?? '').startsWith(`${dir}/`);
return photos.filter((p) => {
const pFolder = normPath(p.folder ?? '');
const own: LibraryPhoto[] = [];
const both: LibraryPhoto[] = [];
for (const p of photos) {
if (normPath(p.folder ?? '') !== normRoot) continue;
const pDir = normPath(p.dir ?? '');
return pFolder === normRoot && (pDir === dir || (deep && below(p)));
});
const isOwn = pDir === dir;
if (isOwn) own.push(p);
if (isOwn || below(p)) both.push(p);
}
// A row counts what sits at or under its folder, so a folder whose frames all
// lie one level down would open on an empty screen under a number that says
// otherwise. The branch is drawn then, and only then: a folder holding frames
// of its own opens on those, and the toggle keeps the last word.
return deep || !own.length ? both : own;
}, [deep, node, photos, rootName]);
// What the thumbnail view draws, out of what the open node holds. A frame's
@@ -1670,6 +1760,23 @@ function Catalogue() {
>
{t('lib.restore')}
</button>
{/* The repair, for the account the API calls an admin: the catalogue
this screen draws is the browser's own, and a catalogue that has
drifted — counts under folders the disk emptied, rows for frames
that are gone — is put back by reading every roll to the disk
again. It is the one control here that is a long job by design,
so it asks first and it says how far it got. */}
{admin ? (
<button
type="button"
className="btn"
data-key="lib-repair"
disabled={backBusy || Boolean(live) || scanQueue().length > 0}
onClick={() => void repair()}
>
{t('lib.repair')}
</button>
) : null}
{/* The folder is a control, not a caption: it is the whole of what
tells one backup from another, and pressing it is how a folder
is picked, a name given, a permission handed back. */}
@@ -1698,11 +1805,28 @@ function Catalogue() {
roll of nothing over nothing is the line talking before the
first pass has come back, which is a scan of a hundred
thousand frames announced as `0/0`. */}
<span className="hint" data-key="lib-progress">
{live && live.progress.total
? t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added })
: ''}
</span>
{live && live.progress.total ? (
<span className="hint" data-key="lib-progress">
{t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added })}
</span>
) : resume && !live ? (
// The reading that was cut off when the app closed, kept in the
// reader's own time: one press picks it up where it stopped. It
// says nothing while a reading runs — the line above is the report.
<button
type="button"
className="chip"
data-key="lib-resume"
title={t('lib.resumeHint')}
onClick={() => {
const folder = folders.find((f) => f.name === resume);
setResume(null);
if (folder) void scan(folder, false);
}}
>
<span className="chip-label">{t('lib.resume', { folder: resume })}</span>
</button>
) : null}
<span className="adm-spacer" />
{folders.length > 0 ? (
<>
@@ -2625,6 +2749,17 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
if (idsToFetch.length === 0) return;
// The strip draws one square whatever the frame behind it, so the preview a RAW
// or a HEIC hands back — a whole develop, seconds of it — is shrunk to the
// wall's size the one time it is paid for and then kept: every later visit
// reads the tile back instead of the file.
const tileOf = async (id: string, bytes: Uint8Array) => {
const preview = new Blob([bytes as BlobPart], { type: 'image/jpeg' });
const made = (await makeTile(preview)) ?? preview;
void putPhotoThumb(id, made);
return made;
};
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
if (!alive) return;
const updates: Record<string, string> = {};
@@ -2639,12 +2774,12 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
const fullBytes = new Uint8Array(await file.arrayBuffer());
let prevBytes = await heicThumbnail(fullBytes);
if (!prevBytes) prevBytes = await heicToJpeg(fullBytes, 320);
if (prevBytes) blob = new Blob([prevBytes as BlobPart], { type: 'image/jpeg' });
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
} else if (isRawName(name) || /\.(tiff?)$/i.test(name)) {
const fullBytes = new Uint8Array(await file.arrayBuffer());
let prevBytes = await rawThumbnail(fullBytes, name);
if (!prevBytes && /\.(tiff?)$/i.test(name)) prevBytes = tiffThumbnail(fullBytes);
if (prevBytes) blob = new Blob([prevBytes as BlobPart], { type: 'image/jpeg' });
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
} else {
// A frame the wall can decode itself: the tile is made here, at the
// size the wall paints, because this is the first time anyone has