diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js index be4eb50..4f6cbc3 100644 --- a/docker/frontend/public/sw.js +++ b/docker/frontend/public/sw.js @@ -10,11 +10,14 @@ // hashed bundle with it, which the STATIC rule below then served cache-first // forever. Every navigation here now goes past the browser's own cache, so the // build a visitor gets is the one the server has. -const VERSION = 'recipescam-v11'; +const VERSION = 'recipescam-v12'; -// nginx answers all three with the same index.html (SPA fallback), so they are one -// document under three keys: an offline navigation finds it whichever key it asks. -const SHELL = ['/', '/app', '/library']; +// nginx answers all three routes with the same index.html (SPA fallback), so they +// are one document under three keys: an offline navigation finds it whichever key +// it asks. The manifest is not that document and is precached with them for the +// same reason — it is what makes the installed app an app, and offline it is +// asked for on every launch. +const SHELL = ['/', '/app', '/library', '/manifest.json']; // Cache-first paths: vite hashes every filename in its own bundle, and the wasm, // the models and the icons never change under a given build. @@ -47,12 +50,27 @@ const offlineShell = async (request) => (await caches.match('/')) ?? Response.error(); +// The files the shell is built out of, read out of the shell itself: the hashed +// bundle, the stylesheet, the icon. A worker that precached only the document +// would answer an offline launch with a page whose JavaScript this machine does +// not have — and it never would, because the visit that first loads the app is +// the visit this worker is not yet controlling, so its own STATIC rule below +// never sees those requests. The shell is the one place their names are written. +const shellFiles = (html) => [...html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)].map((m) => m[1]); + self.addEventListener('install', (event) => { - // Best effort, one route at a time: a deploy caught mid-flight must not leave the - // worker uninstalled, so each route fails on its own. + // Best effort, one file at a time: a deploy caught mid-flight must not leave the + // worker uninstalled, so each one fails on its own. event.waitUntil( caches.open(VERSION) - .then((cache) => Promise.all(SHELL.map((url) => cache.add(shellRequest(url)).catch(() => {})))) + .then(async (cache) => { + await Promise.all(SHELL.map((url) => cache.add(shellRequest(url)).catch(() => {}))); + const shell = await cache.match('/app') ?? await cache.match('/'); + if (!shell) return; + for (const url of shellFiles(await shell.text())) { + await cache.add(shellRequest(url)).catch(() => {}); + } + }) .then(() => self.skipWaiting()), ); }); diff --git a/docker/frontend/scripts/offline-queue-check.mjs b/docker/frontend/scripts/offline-queue-check.mjs new file mode 100644 index 0000000..e8c579b --- /dev/null +++ b/docker/frontend/scripts/offline-queue-check.mjs @@ -0,0 +1,152 @@ +// The offline queue, checked in a browser: a write made with no server is held +// rather than lost, a second write to the same route replaces the first, the +// reload keeps it, and a server answering sends it on its own. +// +// The API is stubbed on the page's own origin the way the container's nginx +// serves it, and "no server" is a route that fails the request — which is +// exactly what a dead server looks like from `fetch`. +// +// npm run build && node scripts/offline-queue-check.mjs +// APP_PORT=4885 PLAYWRIGHT_CORE= \ +// node scripts/offline-queue-check.mjs +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); +const APP_PORT = Number(process.env.APP_PORT ?? 4186); +// The key pwa/offline.ts holds the queue under. +const KEY = 'rc.pending'; + +let failures = 0; +function step(name, ok, detail = '') { + if (!ok) failures++; + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); + return ok; +} + +const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); +if (!playwright) { + console.log('SKIP playwright-core is not installed'); + process.exit(0); +} + +const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: FRONTEND, + stdio: 'ignore', +}); +const base = `http://127.0.0.1:${APP_PORT}`; +let up = false; +for (let i = 0; i < 60 && !up; i++) { + up = await fetch(`${base}/`).then((r) => r.ok).catch(() => false); + if (!up) await new Promise((r) => setTimeout(r, 500)); +} +if (!up) { + app.kill('SIGTERM'); + console.log(`FAIL no preview on ${base} — run \`npm run build\` first`); + process.exit(1); +} + +const browser = await playwright.chromium.launch({ + executablePath: process.env.CHROME || undefined, + args: ['--no-sandbox', '--enable-unsafe-swiftshader'], +}); +const context = await browser.newContext(); + +let gone = false; // whether the page's server is there +const sent = []; // every write the stub let through +const PHOTO = { + id: 1, + createdAt: '2026-01-01T00:00:00Z', + slots: ['strip'], + tag: '#CHECK', + title: 'Check frame', + meta: '#1', + consent: true, +}; + +await context.route('**/api/**', (route) => { + if (gone) return route.abort('failed'); + const req = route.request(); + const url = req.url(); + const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + if (req.method() !== 'GET') { + sent.push({ method: req.method(), path: new URL(url).pathname, body: req.postData() }); + return json({ key: 'photo:1', rating: { avg: 5, n: 1, mine: 5 } }); + } + if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com' } }); + if (url.includes('/photos')) return json({ photos: [PHOTO] }); + if (url.includes('/ratings')) return json({ ratings: {} }); + if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } }); + if (url.includes('/recipes')) return json({ recipes: [] }); + return json({}); +}); + +// The bar's own words are asserted, so the page starts in Vietnamese. +await context.addInitScript(() => localStorage.setItem('rc.lang', 'vi')); + +const page = await context.newPage(); +const star = (n) => `[data-key="lp-star-photo:1-${n}"]`; +const bar = '[data-key="offline-bar"]'; +const noise = []; +page.on('console', (m) => { + // The outage is the check's own doing, and a request that fails is a request + // the browser logs. Everything else is a real complaint. + if (m.type() === 'error' && !m.text().includes('net::ERR_FAILED')) noise.push(m.text()); +}); +const queued = () => + page.evaluate((k) => JSON.parse(localStorage.getItem(k) ?? '[]'), KEY); + +await page.goto(base, { waitUntil: 'domcontentloaded' }); +await page.waitForSelector(star(5), { timeout: 60_000 }); + +// 1 — a write with no server is held, not lost. +gone = true; +await page.$eval(star(5), (el) => el.click()); +await page + .waitForFunction((k) => JSON.parse(localStorage.getItem(k) ?? '[]').length === 1, KEY, { timeout: 15_000 }) + .catch(() => {}); +let held = await queued(); +step( + 'a vote cast with no server is held', + held.length === 1 && held[0].method === 'POST' && held[0].path === '/ratings', + JSON.stringify(held) +); +const notice = await page.textContent(bar).catch(() => null); +step('and the bar says how many are waiting', notice !== null && notice.includes('Chưa gửi được: 1'), notice ?? 'no bar'); + +// 2 — the same vote again replaces the held one instead of stacking. +await page.$eval(star(3), (el) => el.click()); +await page.waitForTimeout(500); +held = await queued(); +step( + 'a second vote on the same frame is one write, the later one', + held.length === 1 && String(held[0].body).includes('"stars":3'), + JSON.stringify(held) +); + +// 3 — the reload keeps it. +await page.goto(base, { waitUntil: 'domcontentloaded' }); +await page.waitForSelector(star(5), { timeout: 60_000 }); +held = await queued(); +step('and it is still held across a reload with no server', held.length === 1, JSON.stringify(held)); + +// 4 — a server answering sends it, with no one asking. +gone = false; +await page.goto(base, { waitUntil: 'domcontentloaded' }); +await page + .waitForFunction((k) => JSON.parse(localStorage.getItem(k) ?? '[]').length === 0, KEY, { timeout: 30_000 }) + .catch(() => {}); +held = await queued(); +step( + 'the held write goes out when a server answers', + held.length === 0 && + sent.some((s) => s.method === 'POST' && s.path === '/api/ratings' && s.body === '{"key":"photo:1","stars":3}'), + JSON.stringify(sent) +); +step('and the bar goes with it', (await page.$(bar)) === null); +step('nothing thrown along the way', noise.length === 0, noise[0] ?? ''); + +await browser.close(); +app.kill('SIGTERM'); +console.log(failures === 0 ? 'all pass' : `${failures} failed`); +process.exit(failures === 0 ? 0 : 1); diff --git a/docker/frontend/scripts/offline-read-check.mjs b/docker/frontend/scripts/offline-read-check.mjs new file mode 100644 index 0000000..b83d693 --- /dev/null +++ b/docker/frontend/scripts/offline-read-check.mjs @@ -0,0 +1,155 @@ +// The offline answer to a big read, checked in a browser: the strip's listing is +// a few hundred kilobytes and used to have no offline answer at all, because the +// last one was kept in localStorage under a cap of two hundred thousand +// characters. It is kept whole in IndexedDB now, and an offline visit opens on it. +// +// The API is stubbed on the page's own origin the way the container's nginx +// serves it, and "no server" is a route that fails the request — which is exactly +// what a dead server looks like from `fetch`. +// +// npm run build && node scripts/offline-read-check.mjs +// APP_PORT=4886 PLAYWRIGHT_CORE= \ +// node scripts/offline-read-check.mjs +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); +const APP_PORT = Number(process.env.APP_PORT ?? 4187); +// The cap the read used to be kept under, and the store it is kept in now. +const OLD_CAP = 200_000; +const DB = 'recipescam-offline'; +const READS = 'reads'; + +let failures = 0; +function step(name, ok, detail = '') { + if (!ok) failures++; + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); + return ok; +} + +const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); +if (!playwright) { + console.log('SKIP playwright-core is not installed'); + process.exit(0); +} + +// The listing as a studio with a real catalogue has it: enough frames that the +// JSON is past the old cap on its own, each carrying the look it was saved with — +// a curve is the bulk of one, and a hundred points of it is a small one. +const PHOTOS = Array.from({ length: 60 }, (_, i) => ({ + id: i + 1, + createdAt: '2026-01-01T00:00:00Z', + slots: ['strip'], + tag: `#P${i + 1}`, + title: `Frame ${i + 1}`, + meta: `ISO 400 · 35mm`, + consent: true, + hasPreset: false, + recipe: { name: 'PROVIPES', curve: Array.from({ length: 512 }, (_, n) => n / 512) }, +})); +const LISTING = JSON.stringify({ photos: PHOTOS }); + +const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: FRONTEND, + stdio: 'ignore', +}); +const base = `http://127.0.0.1:${APP_PORT}`; +let up = false; +for (let i = 0; i < 60 && !up; i++) { + up = await fetch(`${base}/`).then((r) => r.ok).catch(() => false); + if (!up) await new Promise((r) => setTimeout(r, 500)); +} +if (!up) { + app.kill('SIGTERM'); + console.log(`FAIL no preview on ${base} — run \`npm run build\` first`); + process.exit(1); +} + +const browser = await playwright.chromium.launch({ + executablePath: process.env.CHROME || undefined, + args: ['--no-sandbox', '--enable-unsafe-swiftshader'], +}); +const context = await browser.newContext(); + +let gone = false; // whether the page's server is there + +await context.route('**/api/**', (route) => { + if (gone) return route.abort('failed'); + const req = route.request(); + const url = req.url(); + const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + if (url.includes('/auth/me')) return json({ user: null }); + if (url.includes('/photos')) return route.fulfill({ status: 200, contentType: 'application/json', body: LISTING }); + if (url.includes('/ratings')) return json({ ratings: {} }); + if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } }); + if (url.includes('/recipes')) return json({ recipes: [] }); + return json({}); +}); + +const page = await context.newPage(); +const noise = []; +page.on('console', (m) => { + // The outage is the check's own doing, and a request that fails is a request + // the browser logs. Everything else is a real complaint. + if (m.type() === 'error' && !m.text().includes('net::ERR_FAILED')) noise.push(m.text()); +}); +const frames = () => page.$$eval('.lp-frame', (els) => els.length); +const readStale = (key) => + page + .evaluate( + ([db, store, key]) => + new Promise((resolve, reject) => { + const req = indexedDB.open(db, 1); + req.onupgradeneeded = () => { + if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store); + }; + req.onsuccess = () => { + const d = req.result; + if (!d.objectStoreNames.contains(store)) return resolve(null); + const get = d.transaction(store, 'readonly').objectStore(store).get(key); + get.onsuccess = () => resolve(get.result ?? null); + get.onerror = () => reject(get.error); + }; + req.onerror = () => reject(req.error); + }), + [DB, READS, key] + ) + .catch(() => null); + +step('the listing this check serves is past the cap the old code kept', LISTING.length > OLD_CAP, `${LISTING.length} chars`); + +await page.goto(base, { waitUntil: 'domcontentloaded' }); +await page.waitForSelector('.lp-frame', { timeout: 60_000 }); +await page.waitForFunction(() => document.querySelectorAll('.lp-frame').length >= 60, null, { timeout: 30_000 }).catch(() => {}); +const online = await frames(); + +const stored = await readStale('/photos'); +step( + 'a listing past the old cap is kept for offline', + typeof stored === 'string' && stored.length > OLD_CAP, + typeof stored === 'string' ? `${stored.length} chars in the store` : 'no record' +); + +const leftBehind = await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('rc.stale.')).length); +step('and nothing is left in localStorage under the old key', leftBehind === 0, `${leftBehind} keys`); + +const ratings = await readStale('/ratings'); +step('a small read is kept the same way', typeof ratings === 'string', ratings === null ? 'no record' : 'kept'); + +// The reload with no server: the listing is the last one it was shown, so the +// reel is the same reel rather than an empty band. +gone = true; +await page.goto(base, { waitUntil: 'domcontentloaded' }); +await page.waitForSelector('.lp-frame', { timeout: 30_000 }).catch(() => {}); +const offline = await frames(); +step( + 'and the offline visit opens on the listing it kept', + online >= 60 && offline === online, + `${online} frames online, ${offline} offline` +); +step('nothing thrown along the way', noise.length === 0, noise[0] ?? ''); + +await browser.close(); +app.kill('SIGTERM'); +console.log(failures === 0 ? 'all pass' : `${failures} failed`); +process.exit(failures === 0 ? 0 : 1); diff --git a/docker/frontend/scripts/tree-scan-check.mjs b/docker/frontend/scripts/tree-scan-check.mjs new file mode 100644 index 0000000..7007d1f --- /dev/null +++ b/docker/frontend/scripts/tree-scan-check.mjs @@ -0,0 +1,746 @@ +// The three things the reader reported, checked in a browser: the counts beside +// the folder names while a reading is running, what the column holds after the +// tab comes back — every folder the walk went into, the empty ones included, with +// an interrupted reading picking itself up — and a long roll's tiles, every frame +// the wall draws read out of the reading that brought them. +// +// The app is behind an account and answers `/api` on its own origin, so the check +// stands in for the API the way the container's nginx does; without it the page +// is a guest and there is no folder to pick. The catalogue is read back out of +// IndexedDB rather than out of the strip, which draws one node's own frames. +// +// npm run build && node scripts/tree-scan-check.mjs +// APP_PORT=4395 SAMPLE_PORT=4396 SAMPLE_DIR= \ +// PLAYWRIGHT_CORE= CHROME= \ +// node scripts/tree-scan-check.mjs +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); +const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..', 'sample'); +const APP_PORT = Number(process.env.APP_PORT ?? 4185); +const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319); +const JPG = 'P1010256.JPG'; +const RAW = 'P1010256.RW2'; +// What the frames of the long roll are served: a small JPEG, because a hundred +// copies of an eight-megabyte one is eight hundred megabytes to prove a point +// about tiles. +const SMALL = 'Main.jpg'; +const LONG = 60; +const LONG_NAMES = Array.from({ length: LONG }, (_, i) => `B${String(i).padStart(3, '0')}.JPG`); + +let failures = 0; +function step(name, ok, detail = '') { + if (!ok) failures++; + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); + return ok; +} + +const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); +if (!playwright) { + console.log('SKIP playwright-core is not installed'); + process.exit(0); +} + +// Every frame the page asks for, by extension: one real JPEG and one real RAW — see +// the route that hands them over, below. +const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: FRONTEND, + stdio: 'ignore', +}); +const base = `http://127.0.0.1:${APP_PORT}`; +let up = false; +for (let i = 0; i < 60 && !up; i++) { + up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); + if (!up) await new Promise((r) => setTimeout(r, 500)); +} +if (!up) { + app.kill('SIGTERM'); + console.log(`FAIL no preview on ${base} — run \`npm run build\` first`); + process.exit(1); +} + +const browser = await playwright.chromium.launch({ + executablePath: process.env.CHROME || undefined, + args: ['--no-sandbox', '--enable-unsafe-swiftshader'], +}); +const context = await browser.newContext(); +// The sample frames are handed over inside the browser rather than off a socket of +// its own: a page is given six sockets to a host, and the walk's sixty reads fill +// all six, so the shelf's own reads would queue behind the reading they are meant +// to keep up with — a queue no real disk puts in the way. +await context.route(`http://127.0.0.1:${SAMPLE_PORT}/**`, async (route) => { + const name = path.basename(decodeURIComponent(new URL(route.request().url()).pathname)); + const file = /^B\d+\.JPG$/.test(name) ? SMALL : /\.jpe?g$/i.test(name) ? JPG : /\.rw2$/i.test(name) ? RAW : null; + if (!file) return route.fulfill({ status: 404, body: '' }); + return route.fulfill({ + status: 200, + contentType: 'application/octet-stream', + headers: { 'Access-Control-Allow-Origin': '*' }, + body: await readFile(path.join(SAMPLES, file)), + }); +}); + +// The catalogue is behind an account, and the header reads the account's own +// folder: the API answers for both, the way the container beside the page does. +await context.route('**/api/**', (route) => { + const url = route.request().url(); + const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check' } }); + if (url.includes('/photos')) return json({ photos: [] }); + if (url.includes('/recipes')) return json({ recipes: [] }); + return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'no' }) }); +}); + +await context.addInitScript( + ({ origin, folder, rolls, small }) => { + const bytes = new Map(); + // One fetch per real file whatever the handle is called: the frames of the + // long roll are one small JPEG served under sixty names. + const load = async (name) => { + const file = /^B\d+\.JPG$/.test(name) ? small : name; + if (!bytes.has(file)) bytes.set(file, fetch(`${origin}/${file}`).then((r) => r.arrayBuffer())); + return bytes.get(file); + }; + const fileHandle = (name) => { + const handle = { kind: 'file', name, __fake: true }; + Object.defineProperty(handle, 'getFile', { + value: async () => { + const gets = JSON.parse(sessionStorage.getItem('check.gets') ?? '{}'); + gets[name] = (gets[name] ?? 0) + 1; + sessionStorage.setItem('check.gets', JSON.stringify(gets)); + while (localStorage.getItem('check.hold') === '1') await new Promise((r) => setTimeout(r, 25)); + // A read of a frame is nearly all waiting on a real disk, and a reading + // that is over in a blink is one the wall's own work cannot be watched + // against. `check.slow` is that wait, in milliseconds. + const slow = Number(localStorage.getItem('check.slow') ?? 0); + if (slow) await new Promise((r) => setTimeout(r, slow)); + return new File([await load(name)], name, { type: 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1) }); + }, + }); + return handle; + }; + const dirHandle = (label, node) => { + const handle = { kind: 'directory', name: label, __fake: true }; + Object.defineProperties(handle, { + values: { + value: () => { + const lists = JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'); + lists[label] = (lists[label] ?? 0) + 1; + sessionStorage.setItem('check.lists', JSON.stringify(lists)); + if (node.fail && localStorage.getItem('check.broken') !== 'open') throw new Error(`${label} will not open`); + return [ + ...(node.files ?? []).map(fileHandle), + ...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)), + ][Symbol.iterator](); + }, + }, + queryPermission: { value: async () => 'granted' }, + requestPermission: { value: async () => 'granted' }, + getDirectoryHandle: { + value: async (name) => { + const kid = node.dirs?.[name]; + if (!kid) throw new Error(`no folder named ${name}`); + return dirHandle(name, kid); + }, + }, + getFileHandle: { + value: async (name) => { + if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`); + return fileHandle(name); + }, + }, + }); + return handle; + }; + const pick = (name) => dirHandle(name, { files: rolls[name].files, dirs: rolls[name].dirs }); + // The roll in play, not the one this script was handed: a handle that comes + // back out of the catalogue belongs to the folder being read, and the reading + // a reload takes up is of that one. + const root = () => pick(localStorage.getItem('check.roll') ?? folder); + window.showDirectoryPicker = async () => root(); + // What the disk holds, for the check to change behind the app's back: a frame + // taken off the disk is a folder the walk meets one frame fewer in. + window.__rolls = rolls; + const revive = (row) => { + if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { + row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name); + } + return row; + }; + for (const method of ['get', 'getAll']) { + const original = IDBObjectStore.prototype[method]; + IDBObjectStore.prototype[method] = function (...args) { + const request = original.apply(this, args); + const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); + Object.defineProperty(request, 'result', { + configurable: true, + get() { + const value = descriptor.get.call(this); + return Array.isArray(value) ? value.map(revive) : revive(value); + }, + }); + return request; + }; + } + }, + { + origin: `http://127.0.0.1:${SAMPLE_PORT}`, + folder: 'CheckRoll', + small: SMALL, + rolls: { + // One frame at the top, one a folder down, one two folders down, one in a + // second branch — and a folder holding no frame at all. + CheckRoll: { + files: [JPG], + dirs: { + 2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } }, + 2025: { files: [JPG], dirs: {} }, + Empty: { files: ['notes.txt'], dirs: {} }, + }, + }, + // A roll with a folder that will not open: a permission taken back, a + // volume pulled out — a folder the walk meets and cannot list. + BrokenRoll: { + files: [JPG], + dirs: { Broken: { fail: true, files: [JPG], dirs: {} } }, + }, + // A roll long enough that its reading outlasts the wall's own work: sixty + // frames read at a walking pace, so the wall has time to tile them while + // the reading is still going. + BigRoll: { + files: LONG_NAMES, + dirs: {}, + }, + // The same roll read for the first time, on a tab that goes away. + CutRoll: { + files: [JPG], + dirs: { Broken: { fail: true, files: [JPG], dirs: {} } }, + }, + // A roll picked at a drive root, which Chromium hands over named `\` — the + // one name a folder under it is spelled from and that is itself no folder + // name at all. See the check at the foot of this file. + '\\': { + files: [JPG], + dirs: { Roll: { files: [RAW], dirs: {} } }, + }, + }, + } +); + +const page = await context.newPage(); +const rowStates = () => + page.$$eval('[data-key^="lib-node-"]', (els) => + els.map((e) => `${e.dataset.key.replace('lib-node-', '')}:${(e.querySelector('.adm-album-count')?.textContent ?? '').trim()}`) + ); +const progress = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => ''); +const counts = async () => { + const out = {}; + for (const row of await rowStates()) { + const [key, n] = row.split(':'); + out[key] = n; + } + return out; +}; +// Row order is the tree's path order, not the order of this file's expectations. +const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()); +// What the reading has stored, asked of the catalogue rather than of the strip: +// the strip draws the open node's own frames, and three of these four are one +// folder down. +const stored = () => + page + .evaluate(async () => { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + return new Promise((resolve, reject) => { + const req = db.transaction('photos', 'readonly').objectStore('photos').count(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + }) + .catch(() => -1); +// What the walk found, which is the whole of what the column is asked to say. +const WANT = { + CheckRoll: '4', + 'CheckRoll/2025': '1', + 'CheckRoll/2026': '2', + 'CheckRoll/2026/04': '1', + 'CheckRoll/Empty': '0', +}; + +// --- a reading, watched while it runs --------------------------------------- +await page.goto(`${base}/library`); +// Held on the first frame it reads, so the walk is through its first pass — the +// names and their numbers are the reading's, and the reading is not done. +await page.evaluate(() => localStorage.setItem('check.hold', '1')); +await page.click('[data-key="lib-add"]'); +await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 }); +// Every row of the pass is a row now, and a count beside each name is what the +// walk met — the reading itself has read nothing. +await page + .waitForFunction(() => { + const rows = [...document.querySelectorAll('[data-key^="lib-node-"]')]; + return rows.length === 4 && /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''); + }, null, { timeout: 60_000, polling: 50 }) + .catch(() => {}); +const midFloat = await counts(); +const midLine = (await progress()).replace(/\s+/g, ' ').trim(); +const midStored = await stored(); +// The whole roll is one pass of four folders, so the numbers are the walk's the +// moment the reading has listed the tree — before a single frame is read. +step( + 'the counts beside the folder names are up while the reading runs', + same(midFloat, WANT), + `${JSON.stringify(midFloat)} at "${midLine}"` +); +step( + 'and they are the walk’s own, not the catalogue’s: nothing has been read yet', + midStored === 0, + `${midStored} rows stored` +); +step('and the reading is in flight while the column already says so', Boolean(await page.$('[data-key="lib-progress"]')), `"${midLine}"`); + +// --- the reading runs out ---------------------------------------------------- +await page.evaluate(() => localStorage.removeItem('check.hold')); +// The progress span is in the page whether or not a reading is, and its text is +// empty until the first pass has said what the roll is: silence is the reading +// being through. +await page + .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { + timeout: 120_000, + polling: 50, + }) + .catch(() => {}); +const done = await counts(); +step( + 'a reading that ran out leaves the roll counted, empty folders and all', + same(done, WANT), + `${JSON.stringify(done)}, ${await stored()} rows stored` +); + +// --- the tab comes back ------------------------------------------------------ +await page.reload(); +await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 60_000 }); +await page.waitForTimeout(1000); +const back = await counts(); +step( + 'and the next visit draws the same column with nothing right-clicked', + same(back, done), + JSON.stringify(back) +); +step( + 'a folder the walk went into and met no frame in keeps its row', + typeof back['CheckRoll/Empty'] === 'string', + `Empty: ${back['CheckRoll/Empty']}` +); +// Nothing is left to read, so this visit offers nothing and starts nothing: the +// catalogue comes back off the disk and the screen waits to be asked. +step( + 'and a visit with nothing left to read reads nothing at all', + !(await page.$('[data-key="lib-resume"]')) && (await progress()).trim() === '', + `resume ${Boolean(await page.$('[data-key="lib-resume"]'))}, line "${(await progress()).trim()}"` +); + +// --- a reading cut off ------------------------------------------------------- +// The catalogue is emptied first, so every frame is one this reading has to read +// itself: a reading that does not go on by itself leaves the strip at nothing. +await page.evaluate(async () => { + sessionStorage.clear(); + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction('photos', 'readwrite'); + tx.objectStore('photos').clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + localStorage.setItem('check.hold', '1'); +}); +const counting = () => + page + .waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, { + timeout: 60_000, + polling: 50, + }) + .then(() => progress()) + .then((line) => line.replace(/\s+/g, ' ').trim()) + .catch(() => ''); +// A reading of the whole roll, held on the first frame it reads: what a tab that +// is closed mid-reading leaves behind is exactly this. +await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); +await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 }); +await page.click('[data-key="lib-rescan-CheckRoll"]'); +const cut = await counting(); +// The walk writes its own position out at most once a second, and the reload is +// what has to be cut off mid-reading rather than before it started. +await page.waitForTimeout(2000); +// Nothing here asks for another reading, and neither does the visit that comes +// back: the roll the reload cut off is put on offer beside the progress line, and +// the press of that offer is the only thing that starts the second reading. The +// first frame is still held, so a visit that went on by itself would hang on it +// rather than pass. +await page.reload(); +await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 }); +const offered = (await progress()).trim(); +step( + 'a reading a reload cut off is offered by the next visit, not run by it', + /\d+\/\d+/.test(cut) && offered === '', + `"${cut}" then idle "${offered}"` +); +await page.click('[data-key="lib-resume"]'); +const cutAgain = await counting(); +step('and the offer is that reading taken up where it stopped', /\d+\/\d+/.test(cutAgain), `"${cutAgain}"`); + +await page.evaluate(() => localStorage.removeItem('check.hold')); +// The progress span is in the page whether or not a reading is, and its text is +// empty until the first pass has said what the roll is: silence is the reading +// being through. +await page + .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { + timeout: 120_000, + polling: 50, + }) + .catch(() => {}); +const resumed = await stored(); +const lists = await page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}')); +step( + 'and the visit after it finishes the roll on its own, without listing a folder twice', + resumed === 4 && lists.CheckRoll === 1 && lists['2026'] === 1 && lists.Empty === 1 && lists['2025'] === 1, + `${resumed} rows stored from ${JSON.stringify(lists)}` +); + +const keys = (store) => + page + .evaluate( + (store) => + new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => { + const db = req.result; + const get = db.transaction(store, 'readonly').objectStore(store).getAllKeys(); + get.onsuccess = () => resolve(get.result); + get.onerror = () => reject(get.error); + }; + req.onerror = () => reject(req.error); + }), + store + ) + .catch(() => []); +const quiet = () => + page + .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { + timeout: 120_000, + polling: 50, + }) + .catch(() => {}); +// A reading, watched out: the line is up once it has told the column what the +// roll is, and silence is the reading being through. Waiting for silence alone +// would read the line before the reading has written it. +const readOut = async () => { + await page + .waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, { + timeout: 20_000, + polling: 50, + }) + .catch(() => {}); + await quiet(); +}; +// A row for a frame the disk has not got, with the tile and the marks that go +// with it — the state a catalogue is left in when a frame is taken off the disk. +const seed = (ids) => + page.evaluate( + (ids) => + new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction(['photos', 'thumbs', 'edits'], 'readwrite'); + for (const id of ids) { + const parts = id.split('/'); + tx.objectStore('photos').put({ + id, + folder: parts[0], + dir: parts.slice(1, -1).join('/'), + name: parts[parts.length - 1], + handle: { kind: 'file', name: parts[parts.length - 1], __fake: true }, + taken: 1, + size: 1, + mtime: 1, + addedAt: 1, + }); + tx.objectStore('thumbs').put({ id, thumb: new Blob(['x']) }); + tx.objectStore('edits').put({ photoId: id, recipe: {}, updatedAt: 1 }); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }; + req.onerror = () => reject(req.error); + }), + ids + ); + +// --- the catalogue brought to the disk -------------------------------------- +// The reader's report: a folder's frames and the number kept for it disagree, and +// a rescan of the parent does not put them right. Both halves are here — a frame +// taken off the disk while its row stayed in the catalogue, and a row for a frame +// that was never on the disk at all — and one rescan of the roll has to settle +// both, the numbers under the subfolders included. +await seed(['CheckRoll/2025/Gone.JPG', 'BrokenRoll/Broken/x.JPG']); +await page.evaluate(() => { + window.__rolls.CheckRoll.dirs['2025'].files.length = 0; +}); +step( + 'the catalogue is holding two rows the disk has nothing for', + (await keys('photos')).includes('CheckRoll/2025/Gone.JPG') && + (await keys('photos')).includes('CheckRoll/2025/P1010256.JPG'), + JSON.stringify(await keys('photos')) +); +await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); +await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 }); +await page.click('[data-key="lib-rescan-CheckRoll"]'); +await readOut(); +const repaired = await counts(); +const rows = (await keys('photos')).filter((k) => k.startsWith('CheckRoll/')); +step( + 'a rescan of the parent brings every number under it to the disk', + same(repaired, { + ...WANT, + CheckRoll: '3', + 'CheckRoll/2025': '0', + }), + JSON.stringify(repaired) +); +step( + 'and the frames the reading did not meet are gone from the catalogue', + rows.length === 3 && !rows.includes('CheckRoll/2025/P1010256.JPG') && !rows.includes('CheckRoll/2025/Gone.JPG'), + JSON.stringify(rows) +); +step( + 'with their tiles and their marks', + !(await keys('thumbs')).includes('CheckRoll/2025/Gone.JPG') && + !(await keys('edits')).includes('CheckRoll/2025/Gone.JPG'), + JSON.stringify(await keys('edits')) +); + +// --- a folder the walk could not open --------------------------------------- +// The other side of the same rule: the walk met nothing under `Broken`, and a +// reading that read silence for absence would empty a folder it never listed. +// The number beside its name is the catalogue's, which is the last reading that +// could open it: a reading that could not open a folder writes no nought there — +// a nought takes the place of the frames the catalogue holds and reads as a +// folder gone empty until the reader rescans the roll. +await page.evaluate(() => localStorage.setItem('check.roll', 'BrokenRoll')); +await page.click('[data-key="lib-add"]'); +await page.waitForSelector('[data-key="lib-node-BrokenRoll"]', { timeout: 60_000 }); +await readOut(); +const broken = await counts(); +step('a folder the walk could not open keeps its row', broken['BrokenRoll/Broken'] === '1', JSON.stringify(broken)); +step( + 'and nothing under it is dropped: silence is not absence', + (await keys('photos')).includes('BrokenRoll/Broken/x.JPG'), + JSON.stringify(await keys('photos')) +); +step( + 'while the frame the walk did meet is stored', + (await keys('photos')).includes('BrokenRoll/P1010256.JPG'), + JSON.stringify(await keys('photos')) +); + +// --- a folder the reading could not open, and a tab that goes away ----------- +// Why the number beside a folder stays at nothing through update after update, +// which is the state the reader photographed. A roll is read for the first time +// and the tab goes while the reading is in it: the position it leaves behind must +// not say it walked a folder it could not open, because an update takes that +// position up — a folder filed as read is a folder no update opens again, and its +// number stays at nothing however many times the reader asks. The folder opens on +// the visit that takes the reading up, and the reading is there to open it. +const opens = () => page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}')); +const opened = (m, name) => m[name] ?? 0; +await page.evaluate(async () => { + sessionStorage.removeItem('check.lists'); + localStorage.setItem('check.roll', 'CutRoll'); + localStorage.setItem('check.hold', '1'); + // Nothing in hand: the frame is one this reading has to read itself, which is + // what the hold is holding — and a reading held on the frame it is reading is + // the reading a closed tab leaves behind. + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction('photos', 'readwrite'); + tx.objectStore('photos').clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +}); +await page.click('[data-key="lib-add"]'); +await page.waitForSelector('[data-key="lib-node-CutRoll"]', { timeout: 60_000 }); +await counting(); +await new Promise((r) => setTimeout(r, 1500)); +const askedBroken = opened(await opens(), 'Broken'); +const held = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { + const dir = await root.getDirectoryHandle('walk', { create: false }); + const fh = await dir.getFileHandle(`${encodeURIComponent('CutRoll')}.json`, { create: false }); + return JSON.parse(await (await fh.getFile()).text()); + } catch { + return null; + } +}); +step( + 'the position a closed tab leaves files no folder it could not open as walked', + !!held && !held.walked.some((r) => r.startsWith('Broken')) && held.pending.includes('Broken'), + JSON.stringify(held && { walked: held.walked, pending: held.pending }) +); +// The tab goes, and the roll it cut off is offered on the visit that comes back: +// the offer is pressed, and the reading it takes up is the reading that opens the +// folder the last one could not. +await page.evaluate(() => localStorage.setItem('check.broken', 'open')); +await page.reload(); +await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 }); +await page.click('[data-key="lib-resume"]'); +const again = await counting(); +await page.evaluate(() => localStorage.removeItem('check.hold')); +await readOut(); +const restarted = await counts(); +step( + 'and the visit that takes the reading up is the visit that opens it', + opened(await opens(), 'Broken') > askedBroken, + `Broken asked ${askedBroken} times, then ${opened(await opens(), 'Broken')}` +); +step( + 'so the folder is counted on the disk, not left at nothing', + restarted['CutRoll'] === '2' && restarted['CutRoll/Broken'] === '1', + JSON.stringify(restarted) +); + +// --- a roll picked at a drive root ------------------------------------------ +// The reader picks a whole drive and every number beside a folder name stands at +// nought. The picker hands that folder over named `\`, and a row's number is +// looked up by the path that name spells: swapped for a bare backslash the roll +// is `/` and its folders `//Roll`, while the walk files its frames — and writes +// its counts — under the empty root a `normPath` fold leaves behind. Two +// spellings of one folder, and a column of noughts over a roll full of frames. +await page.evaluate(async () => { + sessionStorage.removeItem('check.lists'); + localStorage.removeItem('check.hold'); + localStorage.setItem('check.roll', '\\'); + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction('photos', 'readwrite'); + tx.objectStore('photos').clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +}); +await page.click('[data-key="lib-add"]'); +await page.waitForSelector('[data-key="lib-node-/Roll"]', { timeout: 60_000 }).catch(() => {}); +// The reading runs out on its own, and silence is the reading being through. +await page + .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { + timeout: 120_000, + polling: 50, + }) + .catch(() => {}); +const drive = await counts(); +step( + 'a roll picked at a drive root counts its frames beside its own name', + drive[''] === '2' && drive['/Roll'] === '1', + JSON.stringify(drive) +); + +// --- the shelf, out of a reading --------------------------------------------- +// The reading hands the screen the frames it found a batch at a time, and a batch +// used to be handed on only when it was full: the reader's folder of 26 RAWs is +// under one batch, so the screen stood empty for the whole of the reading and then +// filled at once, and the tiles had nothing to be drawn from until the reading was +// over. Frames go down on the clock as well now, so the count climbs and the first +// tiles appear while the roll is still being read. Sixty frames read at a walking +// pace is a long roll read the way the reader read theirs. +await page.evaluate(async () => { + sessionStorage.removeItem('check.lists'); + localStorage.removeItem('check.hold'); + localStorage.setItem('check.slow', '150'); + localStorage.setItem('check.roll', 'BigRoll'); + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction(['photos', 'thumbs'], 'readwrite'); + tx.objectStore('photos').clear(); + tx.objectStore('thumbs').clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +}); +await page.click('[data-key="lib-add"]'); +await page.waitForSelector('[data-key="lib-node-BigRoll"]', { timeout: 60_000 }); +await page + .waitForFunction(() => (document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim().length > 0, null, { + timeout: 60_000, + polling: 25, + }) + .catch(() => {}); +// The reading is in flight and the frames are being read: what is asked of it here +// is whether the screen is told of them as they land. A roll under one `BATCH` +// reached the screen in a single lump at the end of the reading, however long the +// reading was — the reader's folder of 26 RAWs was one batch, and so one empty +// screen for the whole of it. Every count between none and all is a reading that +// said something on the way past. +const landed = []; +for (let i = 0; i < 120; i++) { + const reading = await page + .$eval('[data-key="lib-progress"]', (e) => (e.textContent ?? '').trim()) + .catch(() => ''); + const listed = Number( + (await page.$eval('[data-key="lib-listed"]', (e) => e.textContent ?? '').catch(() => '0')).replace(/\D+/g, '') + ); + if (reading && listed > 0) landed.push(listed); + if (!reading) break; + await new Promise((r) => setTimeout(r, 150)); +} +const seen = [...new Set(landed)]; +step( + 'the frames the reading lands are on screen as it lands them', + seen.length >= 2, + `${seen.length} counts while the reading ran: ${seen.join(', ') || 'none'}` +); +await page + .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { + timeout: 120_000, + polling: 25, + }) + .catch(() => {}); +// The frames the wall draws are read one behind another and the tiles land in +// starts and stops, so the count is taken once it has stood still for a while. +let atEnd = 0; +for (let i = 0, same = 0; i < 160 && same < 10; i++) { + await new Promise((r) => setTimeout(r, 250)); + const n = (await keys('thumbs')).length; + same = n === atEnd ? same + 1 : 0; + atEnd = n; +} +step('the wall tiles the frames a reading brought it', atEnd >= 10, `${atEnd} tiles of ${LONG} drawn`); +await page.evaluate(() => localStorage.removeItem('check.slow')); + +await browser.close(); +app.kill('SIGTERM'); +console.log(failures ? `\n${failures} failed` : '\nall checks passed'); +process.exit(failures ? 1 : 0); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 6145fbd..4cf23da 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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() {

); - return ; + return ; } -function Catalogue() { +function Catalogue({ admin }: { admin: boolean }) { const { t } = useI18n(); const [folders, setFolders] = useState(() => cachedFolders()); const [ready, setReady] = useState>(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(() => 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([]); + const [walked, setWalked] = useState>({}); const [edited, setEdited] = useState>(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 = {}; + 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(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(); const foldersUnder = new Map>(); 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(); foldersUnder.set(lowerF, set); @@ -1038,16 +1111,24 @@ function Catalogue() { const nodes = useMemo(() => { 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')} + {/* 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 ? ( + + ) : 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`. */} - - {live && live.progress.total - ? t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added }) - : ''} - + {live && live.progress.total ? ( + + {t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added })} + + ) : 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. + + ) : null} {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 = {}; @@ -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 diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 5795ed8..fa2cd07 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -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(path: string, init?: RequestInit): Promise { - 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(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('/auth/logout', { method: 'POST' }), + call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }) + .finally(forgetStale), + logout: () => call('/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' }), diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 2fc9ea0..fc6b7cb 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -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 { 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 { const db = await openDb(); await new Promise((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 { 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): Promise { + 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((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; +} + +export async function listWalkedDirs(): Promise { + try { + const rows = await ask(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 | 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 +): Promise { + 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): Record => { + const out: Record = {}; + 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(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) => { + 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 = {}; + 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 { + 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 { 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 = {}; + // 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 = { ...(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(); + // 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(); 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((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 => { 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); diff --git a/docker/frontend/src/engine/libraryBackup.ts b/docker/frontend/src/engine/libraryBackup.ts index 730e9b9..61aa49a 100644 --- a/docker/frontend/src/engine/libraryBackup.ts +++ b/docker/frontend/src/engine/libraryBackup.ts @@ -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); diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index 3e6dc32..5082f85 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -567,21 +567,57 @@ function getCamToSrgbMatrix(cd: any, brand?: CameraBrand): number[][] { } } -export async function developRaw(bytes: Uint8Array, fileName?: string): Promise { - // 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 { + // 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; diff --git a/docker/frontend/src/engine/rollWalk.ts b/docker/frontend/src/engine/rollWalk.ts index df4804d..4da1055 100644 --- a/docker/frontend/src/engine/rollWalk.ts +++ b/docker/frontend/src/engine/rollWalk.ts @@ -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 { 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; diff --git a/docker/frontend/src/i18n/I18nProvider.tsx b/docker/frontend/src/i18n/I18nProvider.tsx index b093a07..3e2b027 100644 --- a/docker/frontend/src/i18n/I18nProvider.tsx +++ b/docker/frontend/src/i18n/I18nProvider.tsx @@ -25,6 +25,21 @@ interface I18nValue { const Ctx = createContext(null); +function translate(lang: Lang, key: MsgKey, params?: Record): 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 { + return translate(readLang(), key, params); +} + export function I18nProvider({ children }: { children: ReactNode }) { const [lang, setLangState] = useState(readLang); @@ -40,13 +55,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { const value = useMemo(() => ({ 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 {children}; diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 6b54233..aa1d4a4 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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)', }; diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 88e67ae..1f0572f 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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; diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx index 04a9c65..4fee423 100644 --- a/docker/frontend/src/main.tsx +++ b/docker/frontend/src/main.tsx @@ -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( + ); diff --git a/docker/frontend/src/pwa/offline.ts b/docker/frontend/src/pwa/offline.ts new file mode 100644 index 0000000..346c9d0 --- /dev/null +++ b/docker/frontend/src/pwa/offline.ts @@ -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 | null = null; + +function openDb(): Promise { + dbPromise ??= new Promise((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 { + 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(path: string): Promise { + 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); diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index de18647..6657c24 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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)); } +} diff --git a/docker/frontend/src/ui/OfflineBar.tsx b/docker/frontend/src/ui/OfflineBar.tsx new file mode 100644 index 0000000..afbb586 --- /dev/null +++ b/docker/frontend/src/ui/OfflineBar.tsx @@ -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 ( +
+ {lines.filter(Boolean).join(' ')} +
+ ); +}