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:
+178
-43
@@ -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
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// /api), the Vite dev server proxies it too — so no base URL, no CORS.
|
||||
import type { Recipe } from '../shared/types';
|
||||
import { shrinkForUpload, resizedJpeg } from './engine/imageOps';
|
||||
import { forgetStale, hold, keepStale, serverAnswered, serverGone, stale } from './pwa/offline';
|
||||
import { t } from './i18n/I18nProvider';
|
||||
|
||||
// Upload caps. The API + nginx both refuse oversized bodies, so shrink in the
|
||||
// browser first; the server then sniffs the bytes and requires the declared
|
||||
@@ -157,14 +159,38 @@ export interface Stats {
|
||||
}
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
|
||||
...init,
|
||||
});
|
||||
// A read is answered from what the server last said. A write is not answered
|
||||
// at all when no server is there — pretending it landed is a lie the screen
|
||||
// would act on — but it is not lost either: it is held, in order, and sent
|
||||
// when one answers. See `hold`/`flush` in pwa/offline.ts.
|
||||
const read = !init?.method || init.method === 'GET';
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api${path}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
|
||||
...init,
|
||||
});
|
||||
} catch (err) {
|
||||
// No answer at all: the server is gone. Serve the last one it gave for this
|
||||
// read, or fail as before — the caller has its own empty state.
|
||||
serverGone();
|
||||
// A write goes on the queue instead. The one exception is the sole call that
|
||||
// is not about the server's own data: `open-explorer` asks it to pop a
|
||||
// folder on this machine, and popping it minutes later is not popping it.
|
||||
if (!read && path !== '/open-explorer') {
|
||||
hold(path, init?.method ?? 'POST', typeof init?.body === 'string' ? init.body : null);
|
||||
throw new Error(t('offline.queued'));
|
||||
}
|
||||
const staleOne = read ? await stale<T>(path) : null;
|
||||
if (staleOne !== null) return staleOne;
|
||||
throw err;
|
||||
}
|
||||
serverAnswered();
|
||||
if (res.status === 204) return undefined as T;
|
||||
const body = await readJson(res);
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
if (read) keepStale(path, body);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
@@ -222,10 +248,14 @@ export const api = {
|
||||
// null user = signed out; the API answers 200 either way.
|
||||
me: () => call<{ user: User | null }>('/auth/me'),
|
||||
signup: (email: string, password: string) =>
|
||||
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) })
|
||||
// The answers held offline are the previous account's and the next one must
|
||||
// not be shown them; this browser is not an account until it has one.
|
||||
.finally(forgetStale),
|
||||
login: (email: string, password: string) =>
|
||||
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
logout: () => call<void>('/auth/logout', { method: 'POST' }),
|
||||
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) })
|
||||
.finally(forgetStale),
|
||||
logout: () => call<void>('/auth/logout', { method: 'POST' }).finally(forgetStale),
|
||||
// Mail the verification link to the signed-in address again. Works while
|
||||
// unverified (that is the whole point); 429 once the hourly cap is spent.
|
||||
resendVerification: () => call<{ ok: boolean; verified?: boolean }>('/auth/resend-verification', { method: 'POST' }),
|
||||
|
||||
@@ -23,7 +23,8 @@ const FOLDERS = 'folders';
|
||||
const PHOTOS = 'photos';
|
||||
const THUMBS = 'thumbs';
|
||||
const EDITS = 'edits';
|
||||
const DB_VERSION = 2;
|
||||
const DIRS = 'dirs';
|
||||
const DB_VERSION = 3;
|
||||
|
||||
// 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.
|
||||
@@ -38,6 +39,15 @@ 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;
|
||||
// A batch that only goes down when it is full is a wall that stands empty for the
|
||||
// whole of a reading and then fills in one go: the reader's folder of 26 RAWs is
|
||||
// under one batch, so nothing of it reached the screen until the reading was over.
|
||||
// The clock is the other end of the batch — whatever is in hand goes down once this
|
||||
// long has passed since the last time, so the count climbs and the first tiles are
|
||||
// drawn while the roll is still being read.
|
||||
// ponytail: 400ms of the reading's own clock, no timer to start or stop; a roll of
|
||||
// twenty thousand frames is fifty extra transactions on a reading of minutes.
|
||||
const FLUSH_MS = 400;
|
||||
// Frames read at once. Reading a frame is nearly all waiting — the bytes come off
|
||||
// the disk, the decode runs on a thread of its own — and a wait that is not spent
|
||||
// on the next frame is time the roll does not get back. Past a few, the disk and
|
||||
@@ -236,6 +246,7 @@ function openDb(): Promise<IDBDatabase> {
|
||||
db.createObjectStore(THUMBS, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' });
|
||||
if (!db.objectStoreNames.contains(DIRS)) db.createObjectStore(DIRS, { keyPath: 'folder' });
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
blockedSink?.(false);
|
||||
@@ -413,9 +424,11 @@ export async function removeFolder(name: string): Promise<void> {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const hasThumbs = db.objectStoreNames.contains(THUMBS);
|
||||
const stores = hasThumbs ? [FOLDERS, PHOTOS, THUMBS, EDITS] : [FOLDERS, PHOTOS, EDITS];
|
||||
const hasDirs = db.objectStoreNames.contains(DIRS);
|
||||
const stores = [FOLDERS, PHOTOS, EDITS, ...(hasThumbs ? [THUMBS] : []), ...(hasDirs ? [DIRS] : [])];
|
||||
const tx = db.transaction(stores, 'readwrite');
|
||||
tx.objectStore(FOLDERS).delete(name);
|
||||
if (hasDirs) tx.objectStore(DIRS).delete(name);
|
||||
const photos = tx.objectStore(PHOTOS);
|
||||
const thumbs = hasThumbs ? tx.objectStore(THUMBS) : null;
|
||||
const edits = tx.objectStore(EDITS);
|
||||
@@ -433,6 +446,192 @@ export async function removeFolder(name: string): Promise<void> {
|
||||
await clearWalk(name);
|
||||
}
|
||||
|
||||
// The frames a reading met are the frames the disk holds. The catalogue keeps a
|
||||
// row for every frame it has ever seen and nothing ever took one out: a frame
|
||||
// taken off the disk kept its row, and a folder with no reading of its own falls
|
||||
// back on the catalogue for a number — a number that could only climb. And the
|
||||
// row is not only a number: it is a tile the wall draws and a frame the strip
|
||||
// offers, one whose file is not there. A reading that listed the whole of what
|
||||
// it was pointed at is the one thing that knows the disk, so what it did not
|
||||
// meet is dropped here — the row, its tile, and the marks on it, because a star
|
||||
// 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.
|
||||
async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
|
||||
try {
|
||||
const root = normPath(folder);
|
||||
const under = (from ? `${root}/${normPath(from)}` : root).toLowerCase();
|
||||
const rows = (await listPhotos(folder)).filter((p) => {
|
||||
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 (!rows.length) return;
|
||||
const gone = new Set(rows.map((p) => p.id));
|
||||
if (cached) cached = cached.filter((p) => !gone.has(p.id));
|
||||
const db = await openDb();
|
||||
const hasThumbs = db.objectStoreNames.contains(THUMBS);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction([PHOTOS, EDITS, ...(hasThumbs ? [THUMBS] : [])], 'readwrite');
|
||||
const photos = tx.objectStore(PHOTOS);
|
||||
const edits = tx.objectStore(EDITS);
|
||||
const thumbs = hasThumbs ? tx.objectStore(THUMBS) : null;
|
||||
for (const p of rows) {
|
||||
photos.delete(p.id);
|
||||
edits.delete(p.id);
|
||||
thumbs?.delete(p.id);
|
||||
thumbCache.delete(p.id);
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} catch {
|
||||
// A repair that failed leaves the reading's own word standing: the counts
|
||||
// are written either way, and the rows it did not meet are the next
|
||||
// reading's to drop.
|
||||
}
|
||||
}
|
||||
|
||||
// --- what the walk found ----------------------------------------------------
|
||||
|
||||
// The folders a reading walked into, and how many frames it met under each, kept
|
||||
// where the catalogue is kept. The column is drawn from this list, and a list
|
||||
// that lived only for the length of the reading took the rows with it: the reader
|
||||
// came back to a roll missing every folder that held no frame the catalogue had —
|
||||
// a folder of frames the reading never reached, and an empty folder alike — and
|
||||
// had to walk their parent a second time to get the names back. The numbers come
|
||||
// from the walk for the same reason: a folder the reading never reached is a
|
||||
// folder whose size the catalogue cannot say, and the walk is the only thing that
|
||||
// can.
|
||||
//
|
||||
// `counts` is the reading's own map, folded to one spelling per path, which is
|
||||
// how the screen looks a number up.
|
||||
export interface WalkedDirs {
|
||||
// The picked folder, by name.
|
||||
folder: string;
|
||||
// Every folder the walk has been into, `rel` spelled the way the walk spells
|
||||
// it: `''` for the roll itself is not a row, so it is not one here.
|
||||
dirs: LibraryDir[];
|
||||
// Folder path in the reading's own spelling, lower-cased on the way in, to the
|
||||
// number of frames the walk met under it.
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function listWalkedDirs(): Promise<WalkedDirs[]> {
|
||||
try {
|
||||
const rows = await ask<WalkedDirs[]>(DIRS, 'readonly', (s) => s.getAll());
|
||||
return rows.map((r) => ({ folder: r.folder, dirs: r.dirs ?? [], counts: r.counts ?? {} }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// What the walk found, written down where the screen can pick it up. A reading
|
||||
// kept to one folder knows the folders under that one and nothing of the rest of
|
||||
// the roll, so it is folded into what is already there; a reading of the whole
|
||||
// roll is the roll, and what it did not find is not there any more.
|
||||
export async function saveWalkedDirs(
|
||||
folder: string,
|
||||
dirs: LibraryDir[],
|
||||
counts: Record<string, number> | null,
|
||||
replace: boolean,
|
||||
from = '',
|
||||
// The folders the reading asked to open and could not, spelled the way the walk
|
||||
// spells them. A reading has no word on these, and a nought beside the name of
|
||||
// one of them is a word it does not have.
|
||||
unlisted?: Iterable<string>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const root = normPath(folder).toLowerCase();
|
||||
// One spelling to a path: the reading's own map holds every key twice, as the
|
||||
// walk spelled it and in lower case, and a branch counted off both is twice
|
||||
// its own size. Folded here once, and the record keeps the one spelling the
|
||||
// screen looks a number up by.
|
||||
const fold = (m: Record<string, number>): Record<string, number> => {
|
||||
const out: Record<string, number> = {};
|
||||
for (const [k, n] of Object.entries(m)) out[k.replace(/\\/g, '/').toLowerCase()] = n;
|
||||
return out;
|
||||
};
|
||||
// The folders this reading was in, before another visit's are folded in.
|
||||
const walked = dirs.slice();
|
||||
if (!replace) {
|
||||
const prev = (await ask<WalkedDirs | undefined>(DIRS, 'readonly', (s) => s.get(folder))) ?? null;
|
||||
const byId = new Map((prev?.dirs ?? []).map((d) => [d.id, d]));
|
||||
for (const d of dirs) byId.set(d.id, d);
|
||||
dirs = [...byId.values()];
|
||||
if (!counts) {
|
||||
counts = fold(prev?.counts ?? {});
|
||||
} else {
|
||||
const was = fold(prev?.counts ?? {});
|
||||
const now = fold(counts);
|
||||
// A reading kept to one folder counts the frames under it and says
|
||||
// nothing of the rest of the roll — but the rows above it hold those
|
||||
// frames too. Written in as they came, the branch would move and its
|
||||
// parents would not, a folder with more under it than the folder it sits
|
||||
// in and a roll under its own tree. So the difference the branch came by
|
||||
// is the difference each row above it comes by, and the reading's numbers
|
||||
// take the place of the ones the last reading left under it — a branch
|
||||
// the reader emptied is a branch this reading met nothing in.
|
||||
const sub = from ? `${root}/${normPath(from).toLowerCase()}` : root;
|
||||
const under = (k: string) => k === sub || k.startsWith(`${sub}/`);
|
||||
const sum = (m: Record<string, number>) => {
|
||||
let n = 0;
|
||||
for (const [k, v] of Object.entries(m)) if (under(k)) n += v;
|
||||
return n;
|
||||
};
|
||||
const delta = sum(now) - sum(was);
|
||||
const merged: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(was)) if (!under(k)) merged[k] = v;
|
||||
Object.assign(merged, now);
|
||||
for (let anc = sub; delta && anc.length > root.length; ) {
|
||||
anc = anc.slice(0, anc.lastIndexOf('/'));
|
||||
merged[anc] = (merged[anc] ?? 0) + delta;
|
||||
}
|
||||
counts = merged;
|
||||
}
|
||||
}
|
||||
if (counts) {
|
||||
// A folder the reading was in and met no frame under is a folder with
|
||||
// nothing under it: left out of the record, the row falls back on the
|
||||
// catalogue, and a frame taken off the disk keeps its number for good.
|
||||
const key = (rel: string) => (rel ? `${root}/${normPath(rel)}` : root).toLowerCase();
|
||||
// A folder the reading could not open is a folder it has no word on, and
|
||||
// nothing — not even nothing — is what a reading with no word on a folder
|
||||
// may write beside its name. Written in as a nought it takes the place of
|
||||
// the number the catalogue holds, and a column drawn off a drive that
|
||||
// hiccupped once reads noughts over folders full of frames until the reader
|
||||
// rescans the roll. Left out, the row falls back on the catalogue, which is
|
||||
// what the last reading that could open it found.
|
||||
const unsaid = new Set([...(unlisted ?? [])].map((r) => normPath(r).toLowerCase()));
|
||||
for (const d of walked) {
|
||||
if (unsaid.has(normPath(d.rel).toLowerCase())) continue;
|
||||
counts[key(d.rel)] ??= 0;
|
||||
}
|
||||
if (replace) counts[root] ??= 0;
|
||||
counts = fold(counts);
|
||||
}
|
||||
await ask(DIRS, 'readwrite', (s) => s.put({ folder, dirs, counts: counts ?? {} } satisfies WalkedDirs));
|
||||
} catch {
|
||||
// A catalogue that will not keep them is a column drawn from the catalogue, as
|
||||
// it was before.
|
||||
}
|
||||
}
|
||||
|
||||
// Every record of what a walk found, dropped: the catalogue that was just put
|
||||
// back is the one the reader asked for, and a number read off a disk the backup
|
||||
// does not describe is not a number to draw under it. Everything is forgotten
|
||||
// because a restore replaces the whole catalogue; what is left is a roll whose
|
||||
// next reading names it again.
|
||||
export async function forgetWalkedDirs(): Promise<void> {
|
||||
try {
|
||||
await ask(DIRS, 'readwrite', (s) => s.clear());
|
||||
} catch {
|
||||
// Nothing to forget.
|
||||
}
|
||||
}
|
||||
|
||||
// A handle kept in IndexedDB comes back without its permission: the browser
|
||||
// dropped it when the tab closed, and only the visitor can hand it back. Asking
|
||||
// costs one quiet prompt, which is why this runs at start-up rather than the
|
||||
@@ -628,6 +827,11 @@ interface WalkSaved {
|
||||
// The frames the walk has found and the reading has not got to. The front of
|
||||
// this is the frame a reloaded reading picks up at.
|
||||
frames: string[];
|
||||
// Written by the builds that kept only the front of the queue: the count of
|
||||
// frames in hand when the position went down. `frames` was a slice of them, so
|
||||
// a position with more in hand than it wrote cannot say where the rest were —
|
||||
// see `loadWalk`. No reading of this build writes it.
|
||||
unread?: number;
|
||||
// How many of the front of `frames` were already counted as read by the reading
|
||||
// that wrote this down: the rows are in hand and not in the catalogue yet, so
|
||||
// the next reading reads them again — and counts none of them twice.
|
||||
@@ -646,13 +850,22 @@ async function loadWalk(folder: string): Promise<WalkSaved | null> {
|
||||
const fh = await walkFile(folder, false);
|
||||
if (!fh) return null;
|
||||
const saved = JSON.parse(await (await fh.getFile()).text()) as WalkSaved;
|
||||
return Array.isArray(saved?.walked) &&
|
||||
const ok = Array.isArray(saved?.walked) &&
|
||||
Array.isArray(saved?.pending) &&
|
||||
Array.isArray(saved?.frames) &&
|
||||
Number.isInteger(saved?.restored) &&
|
||||
saved.progress
|
||||
? saved
|
||||
: null;
|
||||
saved.progress;
|
||||
if (!ok) return null;
|
||||
// The queue was cut off when this was written down by an older build: it knew
|
||||
// where the first few hundred unread frames were and nothing of the rest, and
|
||||
// a walk that takes it up walks the folders it lists as already walked — an
|
||||
// update over a roll with a few thousand frames left in it reads the queue out
|
||||
// and calls the roll read, never reaching the frames past the cut. A position
|
||||
// that cannot say where the reading had got to is no position: the reading
|
||||
// starts from the top and skips the frames it knows on their size and their
|
||||
// time. A position this build wrote holds the whole queue and is always taken.
|
||||
if (saved.unread !== undefined && saved.unread > saved.frames.length) return null;
|
||||
return saved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -744,6 +957,12 @@ export async function scanFolder(
|
||||
}
|
||||
}
|
||||
const tree: Walk = { root: folder.handle, pending: [], walked: treeWalked };
|
||||
// Whether this reading took up a position, read here and not after the walk:
|
||||
// `tree.walked` is this same set and the walk fills it as it goes, so a reading
|
||||
// that started from the top and one that started from a position look alike
|
||||
// once either has been through the roll — and the difference is exactly what a
|
||||
// reading may say about folders it never listed. See `prunePhotos`.
|
||||
const resumed = treeWalked.size > 0;
|
||||
// A queue comes back as paths, so the folders are asked for again; a reading
|
||||
// with nothing written down starts at the folder it was pointed at — the
|
||||
// picked one, or the one the reader right-clicked inside it.
|
||||
@@ -791,47 +1010,61 @@ export async function scanFolder(
|
||||
progress.at ??= '';
|
||||
const found = new Set(progress.dirs.map((d) => d.id));
|
||||
const normRoot = normPath(folder.name);
|
||||
// The rows are counted from what the catalogue already holds, and the reading
|
||||
// only adds to them. A number that started at nothing would sit under the
|
||||
// catalogue's own for as long as the reading took to pass it, and the screen
|
||||
// shows the larger of the two — so an update, where the reading is mostly
|
||||
// frames the catalogue has, is a number that stands still and then jumps. What
|
||||
// the reading adds is the frames it found that the catalogue does not hold yet:
|
||||
// the number climbs for news alone, and ends on the true total.
|
||||
const counts: Record<string, number> = {};
|
||||
// The rows are counted from what the walk meets, and from nothing else. The
|
||||
// catalogue used to be counted in first, so an update over a roll it already
|
||||
// held wore the catalogue's own numbers for the whole of the reading — and a
|
||||
// catalogue only ever grows: a frame taken off the disk is a row nothing takes
|
||||
// out, so a folder the reader emptied kept the number it had, and every scan
|
||||
// after that drew a count the disk no longer had. The walk meets every frame of
|
||||
// the roll and is the only thing that knows what is there now; what it counted
|
||||
// is what the column says. A reading that takes up a position starts from the
|
||||
// numbers that position was written with: the folders it walked before this
|
||||
// visit are folders it does not walk again, and a number that started at
|
||||
// nothing would leave their frames out of the count for good.
|
||||
const counts: Record<string, number> = { ...(saved?.progress.counts ?? {}) };
|
||||
// A reading kept to one folder says nothing about the rest of the roll: its
|
||||
// numbers drawn over the column would count a roll down to a branch of itself,
|
||||
// and the branch below the folder it was pointed at is the whole of what it has
|
||||
// walked. The row of the roll itself falls back on what the roll's own reading
|
||||
// found, which is a number this one has no business standing under.
|
||||
const fromPath = bounded ? normPath(from) : '';
|
||||
const under = (path: string) => !bounded || path === fromPath || path.startsWith(`${fromPath}/`);
|
||||
const count = (rel: string, n = 1) => {
|
||||
const relNorm = rel.replace(/\\/g, '/');
|
||||
const cut = relNorm.lastIndexOf('/');
|
||||
counts[normRoot] = (counts[normRoot] ?? 0) + n;
|
||||
counts[normRoot.toLowerCase()] = counts[normRoot];
|
||||
if (!bounded) {
|
||||
counts[normRoot] = (counts[normRoot] ?? 0) + n;
|
||||
counts[normRoot.toLowerCase()] = counts[normRoot];
|
||||
}
|
||||
let path = '';
|
||||
for (const part of (cut < 0 ? '' : relNorm.slice(0, cut)).split('/')) {
|
||||
if (!part) continue;
|
||||
path = path ? `${path}/${part}` : part;
|
||||
if (!under(path)) continue;
|
||||
const key = `${normRoot}/${path}`;
|
||||
counts[key] = (counts[key] ?? 0) + n;
|
||||
counts[key.toLowerCase()] = counts[key];
|
||||
}
|
||||
};
|
||||
const relOfId = (id: string) => {
|
||||
const normId = normPath(id);
|
||||
const prefix = `${normRoot}/`.toLowerCase();
|
||||
if (normId.toLowerCase().startsWith(prefix)) {
|
||||
return normId.slice(normRoot.length + 1);
|
||||
}
|
||||
return normId;
|
||||
};
|
||||
for (const id of known.keys()) count(relOfId(id));
|
||||
const knownPaths = new Set(
|
||||
[...known.keys()].map((id) => relOfId(id))
|
||||
);
|
||||
// A frame the walk meets once is one frame, whether it is one the catalogue
|
||||
// holds or one it has never seen: the walk lists every folder it walks into, so
|
||||
// a name met twice is two entries for one frame — and both are counted once.
|
||||
const countedDiscovered = new Set<string>();
|
||||
// The folders this reading could not open, and so has no word on: what they
|
||||
// hold was never listed, and a reading that would repair the catalogue to the
|
||||
// disk must not read silence for absence.
|
||||
const failed = new Set<string>();
|
||||
const countDiscovered = (rel: string) => {
|
||||
const relClean = rel.replace(/\\/g, '/');
|
||||
if (knownPaths.has(relClean) || countedDiscovered.has(relClean)) return;
|
||||
countedDiscovered.add(relClean);
|
||||
const key = relClean.toLowerCase();
|
||||
if (countedDiscovered.has(key)) return;
|
||||
countedDiscovered.add(key);
|
||||
count(relClean);
|
||||
};
|
||||
// The frames a reading that was cut off had already counted come back with its
|
||||
// position: the walk it takes up is the rest of the roll, and a frame it does
|
||||
// not walk again is a frame it must not count again either.
|
||||
for (const rel of saved?.frames ?? []) countedDiscovered.add(rel.replace(/\\/g, '/').toLowerCase());
|
||||
for (const e of entries) countDiscovered(e.rel);
|
||||
// The frames the catalogue already holds of the folder being read, which is
|
||||
// what the reading is measured against: a roll of a hundred thousand that has
|
||||
@@ -840,8 +1073,8 @@ export async function scanFolder(
|
||||
// measured against the frames under that one alone.
|
||||
const heldPrefix = `${folder.name}/${from}`.toLowerCase();
|
||||
const held = bounded
|
||||
? [...known.keys()].filter((id) => id.toLowerCase().startsWith(heldPrefix)).length
|
||||
: known.size;
|
||||
? rawKnown.filter((p) => p.id.toLowerCase().startsWith(heldPrefix)).length
|
||||
: rawKnown.length;
|
||||
// What the screen is told, and how often. The column is redrawn out of the
|
||||
// count and the count moves with every frame, so telling the screen a frame at
|
||||
// a time is a whole tree rebuilt a frame at a time — on a roll of a hundred
|
||||
@@ -866,6 +1099,9 @@ export async function scanFolder(
|
||||
// hands the list on with the rest of its position.
|
||||
const dirs: LibraryDir[] = progress.dirs;
|
||||
let batch: LibraryPhoto[] = [];
|
||||
// When the batch last went down, for the clock above: a reading of a long roll is
|
||||
// told its rows have landed at a beat the reader can watch, not in one lump.
|
||||
let flushedAt = Date.now();
|
||||
// The frames this reading found for the first time, waiting to be handed on
|
||||
// with the batch they landed in. A frame the catalogue already had is not one
|
||||
// of these, however it came back — it is on the strip already, and a second
|
||||
@@ -884,18 +1120,55 @@ export async function scanFolder(
|
||||
// string per batch, which is the page held still for the whole roll. A position
|
||||
// a step behind is a frame or two read again on the next visit, and a frame read
|
||||
// again is skipped on its size and its time.
|
||||
//
|
||||
// The whole queue and not the front of it: a position that kept a few hundred
|
||||
// paths and dropped the rest could not say where the reading had got to, and a
|
||||
// reload in the middle of a long roll either took that position up and called
|
||||
// the roll read with thousands of frames still unread, or — as the guard in
|
||||
// `loadWalk` had it — threw it away and left the roll unread with nothing to
|
||||
// pick it up. A megabyte a second of string on the main thread is what the
|
||||
// position costs; the alternative is the reading.
|
||||
let wroteAt = 0;
|
||||
const write = async () => {
|
||||
const write = async (force = false) => {
|
||||
// A reading kept to one folder has no position to write: the file it would
|
||||
// go in is the whole roll's, and a fraction of the tree written there is a
|
||||
// roll that comes back with the rest of itself missing — the next update
|
||||
// walks that one branch and calls the roll read.
|
||||
if (bounded) return;
|
||||
const at = Date.now();
|
||||
if (wroteAt !== 0 && at - wroteAt < WALK_MS) return;
|
||||
// The clock is for the batches, which land faster than a position is worth
|
||||
// writing. The two writes that bracket a pass are the reading's own shape and
|
||||
// are not a matter of the clock: the one before it says the roll has not been
|
||||
// listed yet, and a pass that is through in under `WALK_MS` — every roll of a
|
||||
// few hundred folders — would leave that first one standing in for a walk that
|
||||
// is over, so the next visit listed the whole tree again to learn what was
|
||||
// already written down.
|
||||
if (!force && wroteAt !== 0 && at - wroteAt < WALK_MS) return;
|
||||
wroteAt = at;
|
||||
// The numbers go down with the position. `publish` is what the screen reads
|
||||
// and it writes on a clock of its own; the count of a folder is only in
|
||||
// `counts` and reaches `progress` through it, so a position written between
|
||||
// two tells would say the reading had walked a folder and hold the number of
|
||||
// it from before that folder — and a reading that takes the position up skips
|
||||
// the folders it lists as walked, counting none of them again. The number a
|
||||
// reloaded reading has for a folder it never walks again is this one.
|
||||
progress.counts = { ...counts };
|
||||
const held = [...inHand, ...batch.map(pathOf)];
|
||||
const savedEntries = entries.slice(0, 500).map((e) => e.rel);
|
||||
// A folder this reading could not open is a folder it has no word on. Left
|
||||
// among the walked, the position would say it had been read and the next
|
||||
// reading — an update, which takes this position up — would skip it and never
|
||||
// look inside it again: a number that went to nothing stays at nothing
|
||||
// however many updates follow, and only a rescan, which throws the position
|
||||
// away, brings it back. It goes back in the queue instead, where the next
|
||||
// reading opens it and reads it as the folder it never managed to be.
|
||||
const unsaid = failed.size ? new Set([...failed].map(normPath)) : null;
|
||||
const walkedSaved = unsaid ? [...tree.walked].filter((r) => !unsaid.has(normPath(r))) : [...tree.walked];
|
||||
const rest = tree.pending.map((p) => p.rel);
|
||||
await saveWalk(
|
||||
folder.name,
|
||||
[...tree.walked],
|
||||
tree.pending.map((p) => p.rel),
|
||||
[...held, ...savedEntries],
|
||||
walkedSaved,
|
||||
unsaid ? [...unsaid, ...rest] : rest,
|
||||
[...held, ...entries.map((e) => e.rel)],
|
||||
held.length,
|
||||
progress
|
||||
);
|
||||
@@ -909,6 +1182,7 @@ export async function scanFolder(
|
||||
if (!batch.length) return;
|
||||
const rows = batch;
|
||||
batch = [];
|
||||
flushedAt = Date.now();
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const hasThumbs = db.objectStoreNames.contains(THUMBS);
|
||||
@@ -935,6 +1209,11 @@ export async function scanFolder(
|
||||
}
|
||||
await write();
|
||||
};
|
||||
// Whether what is in hand goes down now: the batch is full, or the clock has come
|
||||
// round. The frames of a roll smaller than `BATCH` never fill one, and waiting for
|
||||
// the end of the reading to hand them over is the reading's length of an empty
|
||||
// screen.
|
||||
const due = () => batch.length > 0 && (batch.length >= BATCH || Date.now() - flushedAt >= FLUSH_MS);
|
||||
|
||||
const readOne = async (handle: FileSystemFileHandle, rel: string): Promise<void> => {
|
||||
const file = await handle.getFile();
|
||||
@@ -943,7 +1222,7 @@ export async function scanFolder(
|
||||
if (seen && seen.size === file.size && seen.mtime === file.lastModified) {
|
||||
if (!seen.handle) {
|
||||
batch.push({ ...seen, handle });
|
||||
if (batch.length >= BATCH) await flush();
|
||||
if (due()) await flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -981,13 +1260,14 @@ export async function scanFolder(
|
||||
batch.push(row);
|
||||
if (!seen) {
|
||||
added.push(row);
|
||||
// A frame the catalogue did not hold is news the rows have to show: it is
|
||||
// the one thing an update adds to a number the catalogue already set.
|
||||
count(rel);
|
||||
// The row is not counted here. The walk counted every frame it found,
|
||||
// this one included, the moment it found it — a second count on the way
|
||||
// to the catalogue is the same frame twice, and a number that climbs past
|
||||
// the roll and falls back to it when the reading ends.
|
||||
}
|
||||
progress.added++;
|
||||
inHand = inHand.filter((r) => r !== rel);
|
||||
if (batch.length >= BATCH) await flush();
|
||||
if (due()) await flush();
|
||||
};
|
||||
|
||||
// The frames in hand, read a few at a time: reading a frame is almost all
|
||||
@@ -1042,6 +1322,12 @@ export async function scanFolder(
|
||||
|
||||
let stop = false;
|
||||
let walked = false;
|
||||
// The position before a single folder has been listed: a roll whose first pass
|
||||
// takes a minute to get through the folders at its top is a roll with nothing
|
||||
// written down for the whole of that minute, and a reload in it found nothing
|
||||
// to pick up and left the reading where it was — started again by hand. What
|
||||
// this writes down is the folder the reading was pointed at and no frame of it.
|
||||
await write();
|
||||
// One bounded pass at a time, and the pass yields between frames, so a folder
|
||||
// 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
|
||||
@@ -1069,6 +1355,9 @@ export async function scanFolder(
|
||||
},
|
||||
(file) => {
|
||||
countDiscovered(file.rel);
|
||||
},
|
||||
(rel) => {
|
||||
failed.add(rel);
|
||||
}
|
||||
);
|
||||
// The names come in before the frames they hold: the column grows one pass
|
||||
@@ -1087,8 +1376,9 @@ export async function scanFolder(
|
||||
// The position before a frame of this pass is looked at: a reload here
|
||||
// reads the pass's frames again, and a frame already in the catalogue is
|
||||
// skipped anyway — where the other order would leave the last pass's
|
||||
// frames out of the catalogue until the next visit.
|
||||
await write();
|
||||
// frames out of the catalogue until the next visit. Written whatever the
|
||||
// clock says: it is the pass that is over, not a batch.
|
||||
await write(true);
|
||||
// A pass is worth telling the screen about whether the clock says so or
|
||||
// not: the names of a whole layer have just arrived, and the column is
|
||||
// built from them.
|
||||
@@ -1103,7 +1393,27 @@ export async function scanFolder(
|
||||
// walks the roll from the top and skips what has not moved. A reading kept to
|
||||
// one folder never wrote one down, and the file it would clear is the whole
|
||||
// roll's — a position another reading is in the middle of.
|
||||
if (!stop) await clearWalk(folder.name);
|
||||
// The roll's own position, and only the reading of the whole roll has one to
|
||||
// clear: a reading kept to a subfolder never wrote it, and clearing it here
|
||||
// throws away the rest of a roll an earlier reading stopped in the middle of.
|
||||
if (!stop && !bounded) await clearWalk(folder.name);
|
||||
// The catalogue brought to the disk. What the reading met is what is there, but
|
||||
// only for a reading that met the whole of what it was pointed at: one that was
|
||||
// stopped, one that took up a position — the folders that position had already
|
||||
// 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);
|
||||
}
|
||||
// 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
|
||||
// reading of the whole roll that reached its end is the roll and replaces what
|
||||
// was there — a folder it did not meet is a folder that is gone, and its number
|
||||
// with it. A reading kept to one folder knows one branch of the roll and is
|
||||
// folded in; a reading that was stopped keeps its numbers for the folders it
|
||||
// walked and none for the rest, which is what it counted. See `saveWalkedDirs`.
|
||||
await saveWalkedDirs(folder.name, dirs, stop ? null : counts, !stop && !bounded, bounded ? from : '', failed);
|
||||
// The last word, whatever the clock says: the frames the reading got to are
|
||||
// the frames the column is about to be told about one last time.
|
||||
publish(true);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import type { LibraryEdit, LibraryFolder } from './library';
|
||||
import {
|
||||
ensurePermission,
|
||||
forgetWalkedDirs,
|
||||
getPhotoThumb,
|
||||
listEdits,
|
||||
listFolders,
|
||||
@@ -396,6 +397,10 @@ export async function restoreNow(onProgress?: (p: BackupProgress) => void): Prom
|
||||
|
||||
// Instant catalog metadata & edits restoration (< 100ms)
|
||||
const photos = await restoreCatalogue(photoList, editList);
|
||||
// And the walks are forgotten with it: the numbers a walk wrote down are off a
|
||||
// disk this backup does not describe, and a column drawn under them would say a
|
||||
// folder holds what it held the last time the reader looked at it.
|
||||
await forgetWalkedDirs();
|
||||
|
||||
// Activate backup tile fetcher for lazy rendering on screen
|
||||
setBackupTileFetcher(readBackupTile);
|
||||
|
||||
@@ -567,21 +567,57 @@ function getCamToSrgbMatrix(cd: any, brand?: CameraBrand): number[][] {
|
||||
}
|
||||
}
|
||||
|
||||
export async function developRaw(bytes: Uint8Array, fileName?: string): Promise<Uint8Array> {
|
||||
// 1. Fast extraction of the embedded JPEG preview.
|
||||
// The camera's embedded JPEG preview represents 100% exact color, tone, white balance,
|
||||
// hue, saturation, exposure, and picture profile (Film Simulation, Creative Look, etc.)
|
||||
// generated directly by the camera hardware (Fujifilm, Sony, Olympus, Nikon, Canon,
|
||||
// Ricoh, Lumix, Leica, iPhone ProRAW, DNG).
|
||||
const preview = extractEmbeddedJpeg(bytes);
|
||||
if (preview && preview.length > 10000) {
|
||||
return preview;
|
||||
// The size a JPEG declares for itself, off its SOF segment — a header read, no
|
||||
// decoder. Null when the bytes carry no readable frame header.
|
||||
export function jpegFrameSize(jpeg: Uint8Array): { width: number; height: number } | null {
|
||||
for (let i = 2; i + 9 < jpeg.length; ) {
|
||||
if (jpeg[i] !== 0xff) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const marker = jpeg[i + 1];
|
||||
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||||
return { height: (jpeg[i + 5] << 8) | jpeg[i + 6], width: (jpeg[i + 7] << 8) | jpeg[i + 8] };
|
||||
}
|
||||
i += 2 + ((jpeg[i + 2] << 8) | jpeg[i + 3]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Is the embedded preview the frame itself? Bodies that write one at the frame
|
||||
// size (Nikon NEF 6016x4016 out of 6032x4032, Sony ARW, an iPhone ProRAW DNG)
|
||||
// differ from the sensor by a margin row or two; a thumbnail out of the same
|
||||
// sensor is 1920x1280, a third of the long side, so 90% divides them cleanly.
|
||||
// Unreadable sizes keep the old behaviour and trust the preview.
|
||||
function previewIsFrame(preview: Uint8Array, meta: any): boolean {
|
||||
const size = jpegFrameSize(preview);
|
||||
const frame = Math.max(
|
||||
Number(meta?.iwidth) || 0,
|
||||
Number(meta?.iheight) || 0,
|
||||
Number(meta?.width) || 0,
|
||||
Number(meta?.height) || 0
|
||||
);
|
||||
if (!size || !frame) return true;
|
||||
return Math.max(size.width, size.height) >= frame * 0.9;
|
||||
}
|
||||
|
||||
export async function developRaw(bytes: Uint8Array, fileName?: string): Promise<Uint8Array> {
|
||||
// The embedded JPEG is the camera's own rendering — exact colour, tone, white
|
||||
// balance and picture profile, generated by the body itself. It is the frame
|
||||
// only when it is the size of the frame; on most bodies it is a thumbnail
|
||||
// (1920x1280 out of a 24MP sensor, measured on a Fuji RAF and a Lumix RW2), so
|
||||
// handing it back is what opened a 24MP file as a 1920 wide one. Whether it is
|
||||
// the frame is known from the sensor size, which costs one LibRaw open (91ms
|
||||
// measured, metadata only — no demosaic) against seconds for the develop.
|
||||
const preview = extractEmbeddedJpeg(bytes);
|
||||
|
||||
const raw = new LibRaw();
|
||||
try {
|
||||
await raw.open(bytes.slice() as unknown as BufferSource, SETTINGS);
|
||||
const meta = await raw.metadata(true);
|
||||
if (preview && preview.length > 10000 && previewIsFrame(preview, meta)) {
|
||||
return preview;
|
||||
}
|
||||
const img = await raw.imageData();
|
||||
const cd = meta?.color_data;
|
||||
if (!img || (!cd?.cam_mul && !cd?.pre_mul) || (!cd.rgb_cam && !cd.cam_xyz)) {
|
||||
@@ -657,7 +693,7 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise<
|
||||
if (!first) throw new Error('no surface for the develop');
|
||||
|
||||
let finalShot = first;
|
||||
let refJpeg = extractEmbeddedJpeg(bytes);
|
||||
let refJpeg = preview;
|
||||
if (!refJpeg) {
|
||||
refJpeg = await cameraPreview(raw).catch(() => null);
|
||||
}
|
||||
@@ -691,6 +727,9 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise<
|
||||
if (!jpeg?.length) throw new Error('develop produced no bytes');
|
||||
return jpeg;
|
||||
} catch (err) {
|
||||
// The preview is also the fallback, as it always was: a file whose sensor
|
||||
// will not decode still opens as the camera rendered it.
|
||||
if (preview && preview.length > 10000) return preview;
|
||||
const fallback = await rawThumbnail(bytes).catch(() => null);
|
||||
if (fallback) return fallback;
|
||||
throw err;
|
||||
|
||||
@@ -89,7 +89,8 @@ export async function walkPass(
|
||||
keep: (name: string) => boolean,
|
||||
jump: () => string | null,
|
||||
onDir?: (rel: string) => void,
|
||||
onFile?: (file: WalkFile) => void
|
||||
onFile?: (file: WalkFile) => void,
|
||||
onFail?: (rel: string) => void
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < DIRS_PER_PASS && walk.pending.length; i++) {
|
||||
await jumpQueue(walk, jump());
|
||||
@@ -115,6 +116,10 @@ export async function walkPass(
|
||||
}
|
||||
} catch {
|
||||
// A subfolder that will not open is skipped; the rest of the roll stands.
|
||||
// It is also a folder this walk has no word on — what it holds was never
|
||||
// listed, so a caller about to take the walk's word for what the disk
|
||||
// holds is told here that this folder is not part of it.
|
||||
onFail?.(here.rel);
|
||||
}
|
||||
}
|
||||
return walk.pending.length === 0;
|
||||
|
||||
@@ -25,6 +25,21 @@ interface I18nValue {
|
||||
|
||||
const Ctx = createContext<I18nValue | null>(null);
|
||||
|
||||
function translate(lang: Lang, key: MsgKey, params?: Record<string, string | number>): string {
|
||||
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
|
||||
if (params) {
|
||||
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// The same lookup, for the code outside React that has to write a line the
|
||||
// screen will show — the held write in api.ts, which throws its sentence out of
|
||||
// a catch block. It reads the language the way the provider does.
|
||||
export function t(key: MsgKey, params?: Record<string, string | number>): string {
|
||||
return translate(readLang(), key, params);
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>(readLang);
|
||||
|
||||
@@ -40,13 +55,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const value = useMemo<I18nValue>(() => ({
|
||||
lang,
|
||||
setLang,
|
||||
t: (key, params) => {
|
||||
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
|
||||
if (params) {
|
||||
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
t: (key, params) => translate(lang, key, params),
|
||||
}), [lang, setLang]);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
||||
@@ -249,6 +249,8 @@ export const en: Dict = {
|
||||
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
|
||||
'lib.update': 'UPDATE...',
|
||||
'lib.rescan': 'RESCAN FROM THE TOP',
|
||||
'lib.resume': 'KEEP READING {folder}',
|
||||
'lib.resumeHint': 'The last reading of this folder was cut off. Press to read it on from where it stopped.',
|
||||
'lib.remove': 'REMOVE',
|
||||
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
||||
'lib.nodeHint': 'Right-click to rename, update, rescan, collapse or remove the folder.',
|
||||
@@ -322,6 +324,13 @@ export const en: Dict = {
|
||||
'lib.restoreNothing': 'There is no catalogue of its own in {folder} — pick the folder a backup was written into.',
|
||||
'lib.restoreFailed': 'The backup folder could not be read.',
|
||||
|
||||
// The repair (Library.tsx, admin only): every roll read back to the disk, so
|
||||
// the catalogue and the disk say the same thing again.
|
||||
'lib.repair': 'REPAIR DATA',
|
||||
'lib.repairConfirm': 'Read all {n} folders from the top so the library matches the disk? Frames that are gone will be dropped from the catalogue.',
|
||||
'lib.repairRun': 'Repairing the library ({n} folders)…',
|
||||
'lib.repairDone': 'Library repaired: {n} folders read back.',
|
||||
|
||||
'nav.admin': 'Admin',
|
||||
'nav.photos': 'My photos',
|
||||
'nav.profile': 'Profile',
|
||||
@@ -435,6 +444,14 @@ export const en: Dict = {
|
||||
'err.generic': 'Something went wrong: {msg}',
|
||||
'err.offline': 'Could not reach the server.',
|
||||
|
||||
// The offline bar (src/ui/OfflineBar.tsx).
|
||||
'offline.notice': 'No server — showing the copy saved on this machine.',
|
||||
// A write the server never got. It is held, not lost; the sentence rides
|
||||
// `err.generic`'s `{msg}`, so it reads as the second half of that one, and the
|
||||
// count is the bar's own line for the queue (`offline.pending`).
|
||||
'offline.queued': 'the server is out of reach, so this change is saved here and sent when it is back.',
|
||||
'offline.pending': 'Not sent yet: {n}',
|
||||
|
||||
'meta.exported': 'Downloaded {name} ({kb}KB, 300 ppi)',
|
||||
};
|
||||
|
||||
|
||||
@@ -261,6 +261,8 @@ export const vi = {
|
||||
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
|
||||
'lib.update': 'CẬP NHẬT...',
|
||||
'lib.rescan': 'QUÉT LẠI TỪ ĐẦU',
|
||||
'lib.resume': 'ĐỌC TIẾP {folder}',
|
||||
'lib.resumeHint': 'Lượt đọc thư mục này bị cắt ngang. Bấm để đọc tiếp từ chỗ đã dừng.',
|
||||
'lib.remove': 'BỎ',
|
||||
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
||||
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, thu gọn hay bỏ thư mục.',
|
||||
@@ -335,6 +337,13 @@ export const vi = {
|
||||
'lib.restoreNothing': 'Thư mục {folder} không có danh mục sao lưu nào — hãy chọn đúng thư mục đã ghi sao lưu.',
|
||||
'lib.restoreFailed': 'Không đọc được thư mục sao lưu.',
|
||||
|
||||
// Sửa dữ liệu (Library.tsx, chỉ quản trị): đọc lại từng cuộn ảnh về đúng đĩa,
|
||||
// để danh mục và đĩa nói cùng một điều.
|
||||
'lib.repair': 'SỬA DỮ LIỆU',
|
||||
'lib.repairConfirm': 'Đọc lại cả {n} thư mục từ đầu để thư viện khớp với đĩa? Ảnh đã mất sẽ bị gỡ khỏi danh mục.',
|
||||
'lib.repairRun': 'Đang sửa dữ liệu thư viện ({n} thư mục)…',
|
||||
'lib.repairDone': 'Đã sửa dữ liệu thư viện: đọc lại {n} thư mục.',
|
||||
|
||||
'adm.title': 'Quản trị dải phim',
|
||||
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
|
||||
'adm.upload': 'THÊM ẢNH',
|
||||
@@ -445,6 +454,12 @@ export const vi = {
|
||||
'err.generic': 'Có lỗi xảy ra: {msg}',
|
||||
'err.offline': 'Không gọi được máy chủ.',
|
||||
|
||||
// The offline bar (src/ui/OfflineBar.tsx). Says both halves: the server is
|
||||
// gone, and what is on the screen is the copy this machine kept.
|
||||
'offline.notice': 'Mất kết nối máy chủ — đang xem bản đã lưu trên máy.',
|
||||
'offline.queued': 'máy chủ không tới được, nên thay đổi này đã lưu trên máy và sẽ gửi khi kết nối lại.',
|
||||
'offline.pending': 'Chưa gửi được: {n}',
|
||||
|
||||
'meta.exported': 'Đã tải {name} ({kb}KB, 300 ppi)',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Library } from './Library';
|
||||
import { scanSession } from './engine/library';
|
||||
import { go } from './nav';
|
||||
import { InstallPrompt } from './ui/InstallPrompt';
|
||||
import { OfflineBar } from './ui/OfflineBar';
|
||||
import { installTracking } from './track';
|
||||
|
||||
// Six routes, no router: the landing page, the workspace, the strip moderation
|
||||
@@ -105,6 +106,7 @@ createRoot(root).render(
|
||||
<I18nProvider>
|
||||
<Shell />
|
||||
<InstallPrompt />
|
||||
<OfflineBar />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// Whether the server can be reached, and what it last said.
|
||||
//
|
||||
// Two things the offline story needs and no more: a flag the screen can watch
|
||||
// (the notice in ui/OfflineBar.tsx), and the last good answer to each read, so a
|
||||
// reload with no server comes back on the data it was already showing instead of
|
||||
// an empty page.
|
||||
//
|
||||
// The flag is set by the API layer, the one place that finds out: a fetch that
|
||||
// never got an answer is a server that is gone, and the next one that answers is
|
||||
// the server back. `navigator.onLine` is the browser's guess and is wrong exactly
|
||||
// where it matters — a server down behind a working connection — so it only sets
|
||||
// the flag one way, and never clears it.
|
||||
//
|
||||
// The last good answers live in a store of their own in IndexedDB, keyed by the
|
||||
// route that gave them: the strip's listing is a few hundred kilobytes and
|
||||
// localStorage's few megabytes hold the theme, the language and the library's
|
||||
// view besides. They are this browser's, not this account's, so they are dropped
|
||||
// whenever the account changes (see `forgetStale`, called from the auth calls in
|
||||
// api.ts).
|
||||
//
|
||||
// The writes that found no server are held here too, in order, and sent the
|
||||
// moment one answers — same storage, same account, same clearing. See `hold`.
|
||||
import { keep, recall } from '../remember';
|
||||
|
||||
// The writes waiting for a server. A write the API layer carries is always a
|
||||
// JSON string (the two that carry bytes — a photo, an avatar — never reach here,
|
||||
// see `hold`), so this is a handful of small bodies in the same small
|
||||
// localStorage the theme and the language already live in. A queue long enough
|
||||
// to matter is a queue the visitor has walked away from, and a reload keeps it
|
||||
// anyway.
|
||||
const PENDING = 'rc.pending';
|
||||
// A read is kept whole, whatever its size: the strip's listing carries thousands
|
||||
// of rows and it is exactly the read an offline visit wants an answer to. The
|
||||
// ceiling is only there so a body that is not a listing at all — a few megabytes
|
||||
// of image someone sent through a JSON route — is not written into the store.
|
||||
const CAP = 2_000_000;
|
||||
|
||||
let reachable = true;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
function set(next: boolean): void {
|
||||
if (reachable === next) return;
|
||||
reachable = next;
|
||||
notify();
|
||||
}
|
||||
|
||||
// The shape `useEffect` already has: an unsubscribe.
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function isReachable(): boolean {
|
||||
return reachable;
|
||||
}
|
||||
|
||||
// A request that never got an answer.
|
||||
export function serverGone(): void {
|
||||
set(false);
|
||||
}
|
||||
|
||||
// Any answer at all — a 401 included: a server that refuses is a server that is
|
||||
// there, and what the screen shows for it is its own business.
|
||||
export function serverAnswered(): void {
|
||||
set(true);
|
||||
flush();
|
||||
}
|
||||
|
||||
// One held write: the route, the verb, and the JSON body `call()` was handed.
|
||||
// `body: null` is a write that carries none (`DELETE`).
|
||||
export interface Held {
|
||||
method: string;
|
||||
path: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
function queue(): Held[] {
|
||||
const raw = recall(PENDING);
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw) as Held[];
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
// A half-written queue is no queue.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setQueue(list: Held[]): void {
|
||||
keep(PENDING, JSON.stringify(list));
|
||||
notify();
|
||||
}
|
||||
|
||||
// How many writes the server has not seen yet — the count the bar shows.
|
||||
export function heldCount(): number {
|
||||
return queue().length;
|
||||
}
|
||||
|
||||
// Hold a write the server did not answer. The same route twice is the later
|
||||
// write in the earlier one's place: two edits of one recipe offline are one
|
||||
// recipe to save, not two.
|
||||
//
|
||||
// Only what the API layer carries is held, and a file is only ever carried
|
||||
// round it (a photo upload, an avatar) — those are not held at all: there is no
|
||||
// trustworthy place for a few megabytes in this browser, and the uploader's own
|
||||
// retry is the honest answer for a file.
|
||||
export function hold(path: string, method: string, body: string | null): void {
|
||||
const list = queue();
|
||||
const at = list.findIndex((item) => item.path === path && item.method === method);
|
||||
const one: Held = { method, path, body };
|
||||
if (at === -1) list.push(one);
|
||||
else list[at] = one;
|
||||
setQueue(list);
|
||||
}
|
||||
|
||||
let sending = false;
|
||||
|
||||
// A server answered: the writes it never got go now, oldest first. A refusal
|
||||
// (4xx) is that server's own answer, so the entry goes — asking the same
|
||||
// question forever is not a queue, it is a loop. No answer at all, or a server
|
||||
// that broke (5xx, 429), stops the run and leaves the rest for the next answer.
|
||||
export function flush(): void {
|
||||
if (sending || !reachable) return;
|
||||
const list = queue();
|
||||
if (list.length === 0) return;
|
||||
sending = true;
|
||||
void (async () => {
|
||||
for (const item of list) {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api${item.path}`, {
|
||||
method: item.method,
|
||||
credentials: 'same-origin',
|
||||
headers: item.body === null ? undefined : { 'content-type': 'application/json' },
|
||||
body: item.body ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
// Gone again mid-run: what is left stays right where it is, in order.
|
||||
serverGone();
|
||||
break;
|
||||
}
|
||||
if (res.status >= 500 || res.status === 429) break;
|
||||
setQueue(queue().filter((it) => !(it.path === item.path && it.method === item.method)));
|
||||
}
|
||||
sending = false;
|
||||
})();
|
||||
}
|
||||
|
||||
// The reads are a database of their own, not a store added to the library's: a
|
||||
// tab left open holds the library at the version it opened it with, and a new
|
||||
// store there would have every visit wait on that tab. Their own key is the
|
||||
// route, their value the JSON string it answered with.
|
||||
const DB_NAME = 'recipescam-offline';
|
||||
const READS = 'reads';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(READS);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// One request per call, the shape the library's own `ask` has: a keyed get, put
|
||||
// or clear is one transaction and nothing more.
|
||||
function ask(mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<unknown> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = run(db.transaction(READS, mode).objectStore(READS));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// What this route answered the last time a server was there, or null. Answered
|
||||
// out of the store, so it is a promise where the old localStorage read was not;
|
||||
// the one caller already awaits.
|
||||
export async function stale<T>(path: string): Promise<T | null> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await ask('readonly', (s) => s.get(path));
|
||||
} catch {
|
||||
// No store to read, no answer to give — the caller's own empty state.
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
// A half-written value is no value.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Written in the background: the answer on screen is not waiting on the disk.
|
||||
export function keepStale(path: string, body: unknown): void {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = JSON.stringify(body);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (raw.length > CAP) return;
|
||||
void ask('readwrite', (s) => s.put(raw, path)).catch(() => {});
|
||||
}
|
||||
|
||||
// The account changed: the answers held are the last account's, and the next one
|
||||
// must not be shown them. Neither must it send its writes under the new name —
|
||||
// they were made by whoever was signed in when they landed.
|
||||
export function forgetStale(): void {
|
||||
void ask('readwrite', (s) => s.clear()).catch(() => {});
|
||||
try {
|
||||
localStorage.removeItem(PENDING);
|
||||
} catch {
|
||||
// Private mode: there was nothing to drop.
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
// The browser going offline is the flag arriving early, and no more: it is never
|
||||
// used to clear it, because a connection says nothing about the server behind it.
|
||||
window.addEventListener('offline', serverGone);
|
||||
@@ -2128,3 +2128,29 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track-
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: background 0.3s ease, outline 0.3s ease;
|
||||
}
|
||||
|
||||
/* --- the offline bar ------------------------------------------------------
|
||||
ui/OfflineBar.tsx, over every route. A pill at the foot of the screen, out of
|
||||
the way of the header the studio is read across and of nothing else: the
|
||||
workspace's own bottom bar on a phone is the one thing there, and the pill
|
||||
clears it (44px pill + the bar's 16px of padding, and a hair). */
|
||||
.offline-bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 55; /* under a dialog (60), over the page */
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--accent-line, var(--border));
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elev);
|
||||
color: var(--accent);
|
||||
box-shadow: var(--shadow);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.offline-bar { bottom: calc(72px + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { heldCount, isReachable, subscribe } from '../pwa/offline';
|
||||
|
||||
// The server cannot be reached. A bar, not a dialog: nothing is being asked of
|
||||
// the visitor, and the answer — the saved copy of what the screen was holding —
|
||||
// is already on the screen. It goes away by itself the moment a call answers.
|
||||
//
|
||||
// It also stays up while writes are still held: the server being back is not the
|
||||
// same thing as the server having them, and a queue that drained quietly would
|
||||
// leave the visitor wondering whether the last change landed. See `hold`/`flush`
|
||||
// in pwa/offline.ts.
|
||||
export function OfflineBar() {
|
||||
const { t } = useI18n();
|
||||
const [gone, setGone] = useState(() => !isReachable());
|
||||
const [held, setHeld] = useState(heldCount);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe(() => {
|
||||
setGone(!isReachable());
|
||||
setHeld(heldCount());
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!gone && held === 0) return null;
|
||||
const lines = [gone ? t('offline.notice') : '', held > 0 ? t('offline.pending', { n: held }) : ''];
|
||||
return (
|
||||
<div className="offline-bar" role="status" data-key="offline-bar">
|
||||
{lines.filter(Boolean).join(' ')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user