From 791e7d093c0fec6356277e845fe8ad79361de409 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sun, 11 Oct 2026 12:00:18 +0700 Subject: [PATCH] fix(library): a folder that is not connected is not a file that was moved --- docker/frontend/scripts/library-check.mjs | 1195 ++++++--------------- docker/frontend/src/Library.tsx | 5 +- docker/frontend/src/engine/library.ts | 76 +- docker/frontend/src/i18n/en.ts | 2 + docker/frontend/src/i18n/vi.ts | 2 + 5 files changed, 386 insertions(+), 894 deletions(-) diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index 4e4255a..51b7356 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -1,22 +1,31 @@ // The catalogue, checked where it actually lives: in a browser. A directory // handle, IndexedDB, a grid of tiles and a handover into the studio have no -// meaning outside one, so this is a Playwright run rather than a pure-node -// assert. +// meaning outside one, so this is a Playwright run rather than a pure-node assert. // -// Only Chromium can hand a page a folder, and only a person can answer the -// picker. The check stands in for both: it serves two real frames (one JPEG, one -// RAW) off a local server and answers `showDirectoryPicker` with a folder made of -// them. The stand-in handles carry their methods as *non-enumerable* properties, -// which is what lets them go through IndexedDB's structured clone at all (a -// clone copies enumerable own keys and drops the rest, and a function anywhere -// else is a DataCloneError); the methods are put back where the catalogue reads -// them, because a cloned handle is a plain object with no `getFile`. +// What it holds down is the two faults that read the same on the screen and have +// nothing to do with each other: +// +// 1. The catalogue is a folder the visitor picked, and the browser keeps the +// handle across a reload while dropping the permission on it. Only a gesture +// can hand it back, so the click on the folder is where it is asked for — +// both for the folder's own handle and for the handle a row of an older build +// carries. Without the ask every frame of a folder read yesterday answers +// "moved or deleted" while the file sits untouched on the disk. +// 2. Two builds behind one domain — a test server and a release server — open +// the one database the origin owns. A build that names a version older than +// the one on the disk is answered `VersionError` and every read falls back on +// an empty column, with no note and nothing to read off the screen. // // npm run build && node scripts/library-check.mjs // PLAYWRIGHT_CORE= node scripts/library-check.mjs +// CHROME= PLAYWRIGHT_CORE=... node scripts/library-check.mjs +// BASE=http://127.0.0.1:8090 node scripts/library-check.mjs # a server already up // -// Playwright is not a dependency of this package (a browser run needs it, a -// build does not), so a run without it says SKIP and exits 0. +// Playwright is not a dependency of this package (a browser run needs it, a build +// does not), so a run without it says SKIP and exits 0. Only Chromium can hand a +// page a folder, and only a person can answer the picker: the check stands in for +// both, serving two real frames off a local server and answering +// `showDirectoryPicker` with a folder made of them. import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; @@ -29,51 +38,8 @@ 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'; -// Where the served JPEG claims to have been shot. No sample frame carries a GPS -// tag, and a file that has one is the only way to check the place under the -// preview, so the check writes one: an APP1 segment holding a GPS IFD and -// nothing else, spliced in right after the frame's own SOI. -// -// It is deliberately the first APP1: a JPEG carries one EXIF segment, and the -// catalogue reads the first, so spending it on the coordinates is what makes -// this a file that has a place — the camera's own numbers go with the segment -// it replaced, which is why the RAW below is the one that shows a spec line. -const GPS = { - lat: [[16, 1], [3, 1], [1584, 100]], // 16° 3' 15.84" - lng: [[108, 1], [12, 1], [792, 100]], // 108° 12' 7.92" - text: '16.0544, 108.2022', -}; -function withGps(jpg) { - const tiff = Buffer.alloc(128); - tiff.write('II', 0, 'latin1'); - tiff.writeUInt16LE(42, 2); - tiff.writeUInt32LE(8, 4); // IFD0 - const ifd = (at, entries) => { - tiff.writeUInt16LE(entries.length, at); - entries.forEach(([tag, type, count, value], i) => { - const e = at + 2 + i * 12; - tiff.writeUInt16LE(tag, e); - tiff.writeUInt16LE(type, e + 2); - tiff.writeUInt32LE(count, e + 4); - tiff.writeUInt32LE(value, e + 8); - }); - }; - ifd(8, [[0x8825, 4, 1, 26]]); // GPSInfoIFDPointer - ifd(26, [ - [0x0001, 2, 2, 0x4e], // GPSLatitudeRef "N" - [0x0002, 5, 3, 80], // GPSLatitude - [0x0003, 2, 2, 0x45], // GPSLongitudeRef "E" - [0x0004, 5, 3, 104], // GPSLongitude - ]); - for (const [at, parts] of [[80, GPS.lat], [104, GPS.lng]]) - parts.forEach(([n, d], i) => { - tiff.writeUInt32LE(n, at + i * 8); - tiff.writeUInt32LE(d, at + i * 8 + 4); - }); - const app1 = Buffer.concat([Buffer.from([0xff, 0xe1, 0, 0]), Buffer.from('Exif\0\0', 'latin1'), tiff]); - app1.writeUInt16BE(app1.length - 2, 2); - return Buffer.concat([jpg.subarray(0, 2), app1, jpg.subarray(2)]); -} +const DB = 'recipescam-library'; +const STORES = ['folders', 'photos', 'thumbs', 'edits', 'dirs']; let failures = 0; function step(name, ok, detail = '') { @@ -81,14 +47,6 @@ function step(name, ok, detail = '') { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); return ok; } -// The mark each command of an open context menu leads with. Every command carries -// one of its own: a column of marks reads at a glance where a block of uppercase -// does not, and a mark the platform's fonts have no glyph for is a box. -const menuMarks = () => - page.$$eval('[data-key="lib-menu"] .lib-menu-item', (els) => - els.map((e) => (e.querySelector('.lib-menu-icon')?.textContent ?? '').trim()) - ); -const marked = (marks) => marks.length > 0 && marks.every((m) => [...m].length === 1) && new Set(marks).size === marks.length; const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); if (!playwright) { @@ -104,36 +62,35 @@ const sampleServer = createServer(async (req, res) => { return; } const bytes = await readFile(path.join(SAMPLES, name)); - const served = name === JPG ? withGps(bytes) : bytes; - // A beat per frame: the catalogue reads two files faster than a poll, and the - // ring that says "this roll is being read" needs a window to be caught in. - await new Promise((resolve) => setTimeout(resolve, 250)); res.writeHead(200, { 'Content-Type': 'application/octet-stream', - 'Content-Length': served.length, + 'Content-Length': bytes.length, 'Access-Control-Allow-Origin': '*', + 'Cross-Origin-Resource-Policy': 'cross-origin', }); - res.end(served); + res.end(bytes); }); await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); -// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which -// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails. -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'); - sampleServer.close(); - console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); - process.exit(1); +// A server already up, when one is named; otherwise the build in `dist/`. +let app = null; +const base = process.env.BASE ?? `http://127.0.0.1:${APP_PORT}`; +if (!process.env.BASE) { + app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: FRONTEND, + stdio: 'ignore', + }); + 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'); + sampleServer.close(); + console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); + process.exit(1); + } } // --- the browser ------------------------------------------------------------ @@ -142,820 +99,290 @@ const browser = await playwright.chromium.launch({ executablePath: process.env.CHROME || undefined, args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], }); -const context = await browser.newContext(); -// The folder the picker would have returned, plus the revival of its handles on -// the way back out of IndexedDB. -await context.addInitScript( - ({ origin, names, subs, folder }) => { - // A folder the browser will not let the page read is the one state a run of - // its own has to ask for: the check sets the key, reloads, and the handles - // come back refused. - window.__deny = localStorage.getItem('check.deny') === '1'; - const bytes = new Map(); - const load = async (name) => { - if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer())); - return bytes.get(name); - }; - // What a scan costs is the bytes it reads, so they are counted: a part cut - // off a file remembers the file it came off, and every `arrayBuffer` call on - // the whole or the part lands on that file's name. Whole reads and heads are - // counted apart, because they are two different things: a RAW read whole is - // the scan developing a frame, while a head is what gets handed to a parser — - // the catalogue's own date read, or the numbers the preview prints. A frame - // the catalogue already holds is left on its size and its write time without - // a byte of it being read, which is the claim the run below makes. - const reads = (window.__reads = {}); - const heads = (window.__heads = {}); - const off = new WeakMap(); - const part = Blob.prototype.slice; - Blob.prototype.slice = function (...args) { - const cut = part.apply(this, args); - off.set(cut, off.get(this) ?? this.name ?? ''); - return cut; - }; - const pull = Blob.prototype.arrayBuffer; - Blob.prototype.arrayBuffer = function () { - const name = off.get(this) ?? this.name ?? ''; - if (name) { - const counter = off.has(this) ? heads : reads; - counter[name] = (counter[name] ?? 0) + 1; - } - return pull.apply(this); - }; - const fileHandle = (name) => { - const handle = { kind: 'file', name, __fake: true }; - Object.defineProperty(handle, 'getFile', { +// A folder of one frame, handed over the way the browser hands one over: the +// handle is kept across a reload, its permission is not, and only a gesture can +// hand the permission back. `check.allow` stands in for the gesture, since a run +// has no hand; `check.prompt` is the reload that dropped the permission. +const INIT = ({ origin, names, folder }) => { + const asked = () => localStorage.getItem('check.prompt') === '1'; + const granted = () => localStorage.getItem('check.granted') === '1'; + const bump = (key) => localStorage.setItem(key, String(Number(localStorage.getItem(key) ?? 0) + 1)); + const perm = () => (!asked() || granted() ? 'granted' : 'prompt'); + const refuse = (what) => { + bump('check.refused'); + localStorage.setItem(`check.refused.${what}`, '1'); + throw new DOMException('no permission', 'NotAllowedError'); + }; + const bytes = new Map(); + const load = async (n) => { + if (!bytes.has(n)) bytes.set(n, fetch(`${origin}/${n}`).then((r) => r.arrayBuffer())); + return bytes.get(n); + }; + // The browser's answer to an ask for permission: granted with nothing to ask, + // refused while the screen is drawing itself, and granted off the reader's own + // gesture. + const askPerm = async () => { + bump('check.requested'); + if (!asked()) return 'granted'; + if (localStorage.getItem('check.allow') !== '1') return 'denied'; + bump('check.answered'); + localStorage.setItem('check.granted', '1'); + return 'granted'; + }; + const fileHandle = (name) => { + const h = { kind: 'file', name, __fake: true }; + Object.defineProperties(h, { + getFile: { value: async () => { - const gets = JSON.parse(sessionStorage.getItem('check.gets') ?? '{}'); - gets[name] = (gets[name] ?? 0) + 1; - sessionStorage.setItem('check.gets', JSON.stringify(gets)); - // A reading caught in the middle of a frame: the check sets the key, the - // scan stops on the frame it was reading, and the next visit — with the - // key cleared — is the one that goes on. - while (localStorage.getItem('check.hold') === '1') await new Promise((r) => setTimeout(r, 25)); + if (perm() !== 'granted') refuse('getFile'); return new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream', - // A real handle reports one write time for one file, and the same one - // on every read; a fresh `Date.now()` per call would make every frame - // look touched and hide what is being checked. The two frames are - // written years apart, in different parts of the day — the JPEG in the - // afternoon of 15 Feb 2016 and the RAW at seven in the morning of - // 1 Jan 2026 — because the shelf's own filters are read off the - // shutter time: a year, and an hour range. Both are UTC and read back - // on this machine's clock, which is UTC+7. - lastModified: name.endsWith('.JPG') ? Date.UTC(2016, 1, 15, 9, 30) : Date.UTC(2026, 0, 1), + lastModified: Date.UTC(2026, 0, 1), }); }, - }); - return handle; - }; - // The picked folder holds `names` at its top level and the subfolders in - // `subs`, each `{ files, dirs }` — a roll really is a folder of dated - // directories inside dated directories, which is what the tree column walks. - // `__fake` on both kinds is what the revival below goes by: a handle that - // comes back out of IndexedDB is a plain object with no methods on it, and a - // folder that is not revived is a folder the screen has no permission for. - const dirHandle = (label, node) => { - const handle = { kind: 'directory', name: label, __fake: true }; - Object.defineProperties(handle, { - values: { - value: () => { - // Every listing a reading asks for, counted across reloads: a reading - // that starts over lists the same folder twice, and that is the one - // thing on screen a reader reads as "it threw the catalogue away". - const lists = JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'); - lists[label] = (lists[label] ?? 0) + 1; - sessionStorage.setItem('check.lists', JSON.stringify(lists)); - return [ - ...(node.files ?? []).map(fileHandle), - ...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)), - ][Symbol.iterator](); - }, + }, + // A real file handle answers these as well, and the row a catalogue of an + // older build wrote is opened off its own handle. + queryPermission: { value: async () => perm() }, + requestPermission: { value: askPerm }, + }); + return h; + }; + const dirHandle = (label, node) => { + const h = { kind: 'directory', name: label, __fake: true }; + Object.defineProperties(h, { + values: { + value: () => + [ + ...(node.files ?? []).map(fileHandle), + ...Object.entries(node.dirs ?? {}).map(([n, k]) => dirHandle(n, k)), + ][Symbol.iterator](), + }, + queryPermission: { value: async () => perm() }, + requestPermission: { value: askPerm }, + getDirectoryHandle: { + value: async (name) => { + const k = node.dirs?.[name]; + if (!k) throw new DOMException('no folder', 'NotFoundError'); + return dirHandle(name, k); }, - queryPermission: { value: async () => (window.__deny ? 'denied' : 'granted') }, - requestPermission: { value: async () => (window.__deny ? 'denied' : 'granted') }, - // A folder is asked for a child by name as often as it is listed: the - // reading spells its queue as paths, and a path comes back as a handle - // this way. A name that is not there is the error the browser gives. - 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 DOMException('no frame', 'NotFoundError'); + if (perm() !== 'granted') refuse('getFileHandle'); + return fileHandle(name); }, - getFileHandle: { - value: async (name) => { - if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`); - return fileHandle(name); - }, + }, + }); + return h; + }; + const root = () => dirHandle(folder, { files: names, dirs: {} }); + window.showDirectoryPicker = async () => root(); + // A handle out of IndexedDB is a plain object with no methods; put them back. + 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 m of ['get', 'getAll']) { + const original = IDBObjectStore.prototype[m]; + IDBObjectStore.prototype[m] = function (...a) { + const req = original.apply(this, a); + const d = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); + Object.defineProperty(req, 'result', { + configurable: true, + get() { + const v = d.get.call(this); + return Array.isArray(v) ? v.map(revive) : revive(v); }, }); - return handle; + return req; }; - const root = () => dirHandle(folder, { files: names, dirs: subs }); - window.showDirectoryPicker = async () => root(); - - 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}`, - names: [JPG], - // One frame at the top, one a folder down, one two folders down: the walk has - // to reach the bottom of the roll, not just its first level. - // One folder down, one two deep, and one holding no frame the catalogue can - // read: the walk names it long before there is a thumbnail for it. - subs: { - 2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } }, - Empty: { files: ['notes.txt'], dirs: {} }, - }, - folder: 'CheckRoll', } -); - -const page = await context.newPage(); -const catalogue = () => - 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); - }); - const all = (store) => - new Promise((resolve, reject) => { - const req = db.transaction(store).objectStore(store).getAll(); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - const [photos, edits] = await Promise.all([all('photos'), all('edits')]); - const thumbs = []; - for (const photo of photos) { - const head = photo.thumb ? new Uint8Array(await photo.thumb.slice(0, 2).arrayBuffer()) : null; - thumbs.push({ - id: photo.id, - bytes: photo.thumb?.size ?? 0, - jpeg: head ? head[0] === 0xff && head[1] === 0xd8 : false, - // The score the reader gave it, 0 when they have given none. - star: photo.star ?? 0, - }); - } - return { thumbs, edits: edits.map((e) => ({ id: e.photoId, filter: e.recipe?.baseFilter, knobs: Object.keys(e.recipe?.adjustments ?? {}).length })) }; - }); - -// --- the run ---------------------------------------------------------------- -await page.goto(`${base}/library`); -await page.click('[data-key="lib-add"]'); -// A folder that is being read says so on its own row, not only in the toolbar. -const ringed = await page - .waitForFunction( - () => { - const row = document.querySelector('.adm-album[aria-busy="true"]'); - return row?.querySelector('.lib-spin') ? row.dataset.key : null; - }, - null, - { timeout: 10_000, polling: 50 } - ) - .then((handle) => handle.jsonValue()) - .catch(() => null); -step('the folder being read turns a ring on its row', ringed === 'lib-node-CheckRoll', String(ringed)); -const thumbs = '[data-key^="lib-thumb-"]'; -await page.waitForSelector(thumbs, { timeout: 120_000 }); -await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 120_000 }); -step('every frame of the roll is indexed, two folders down', true, `3 tiles from ${JPG} ×2 + ${RAW}`); - -const indexed = await catalogue(); -for (const name of [JPG, RAW]) { - const row = indexed.thumbs.find((t) => t.id.endsWith(`/${name}`)); - step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`); -} - -// The tree opens with every folder shut — the column is the folder that was -// picked and nothing under it until it is asked for — and one click on a row -// draws its subtree, the click after that folds it again. -const shutRows = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); -const shutAria = await page.getAttribute('[data-key="lib-node-CheckRoll"]', 'aria-expanded'); -step( - 'the tree opens with the folders folded away', - shutRows === 1 && shutAria === 'false', - `${shutRows} rows, aria-expanded=${shutAria}` -); - -// The tree: the picked folder, with the subfolder a frame sits in under it, each -// carrying the frames at or below it. The RAW is the nested one. One row's click -// draws that row's children and no more, so the walk goes down a row at a time. -await page.click('[data-key="lib-node-CheckRoll"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, { - timeout: 10_000, - polling: 50, -}); -const oneDeep = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); -step('a row draws its own children, not the whole branch', oneDeep === 3, `${oneDeep} rows`); -await page.click('[data-key="lib-node-CheckRoll/2026"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, { - timeout: 10_000, - polling: 50, -}); -const nodes = await page.$$eval('[data-key^="lib-node-"]', (els) => - els.map((e) => [e.dataset.key, e.querySelector('.adm-album-count')?.textContent]) -); -step( - 'and the row below it draws the roll as folders under folders', - JSON.stringify(nodes) === - JSON.stringify([ - ['lib-node-CheckRoll', '3'], - ['lib-node-CheckRoll/2026', '2'], - ['lib-node-CheckRoll/2026/04', '1'], - // Named by the walk, counted by nothing: a row before a frame. - ['lib-node-CheckRoll/Empty', '0'], - ]), - JSON.stringify(nodes) -); - -// The same click again folds its subtree, and the frames it holds stay in the -// strip — the tree narrows, the pictures do not. -await page.click('[data-key="lib-node-CheckRoll"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, { - timeout: 10_000, - polling: 50, -}); -const foldedThumbs = await page.$$eval(thumbs, (els) => els.length); -const foldedMeta = await page.locator('[data-key="lib-stage-meta"]').innerText(); -const shut = await page.getAttribute('[data-key="lib-node-CheckRoll"]', 'aria-expanded'); -step( - 'a folder folds its subtree and keeps its frames in the strip', - foldedThumbs === 3 && shut === 'false' && /P1010256/.test(foldedMeta), - `${foldedThumbs} tiles, aria-expanded=${shut}, stage ${foldedMeta.split('\n')[0]}` -); -await page.click('[data-key="lib-node-CheckRoll"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, { - timeout: 10_000, - polling: 50, -}); -step('the same click draws it open again', true, '4 rows, the deep row still open'); - -// Show subfolders or not: the strip follows the open folder alone, or the whole -// branch under it. The switch is the reader's and the tree does not change with -// it — folding is about rows, this is about frames. -const beforeSwitch = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); -await page.click('[data-key="lib-subs"]'); -await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 1, thumbs, { timeout: 10_000, polling: 50 }); -const shallow = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); -const shallowStage = await page.locator('[data-key="lib-stage-meta"]').innerText(); -step( - 'the switch leaves the open folder its own frames', - shallow === beforeSwitch && /P1010256\.JPG/.test(shallowStage), - `${shallow} rows, stage ${shallowStage.split('\n')[0]}` -); -await page.click('[data-key="lib-subs"]'); -await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 10_000, polling: 50 }); -step('and puts the branch back', true, '3 tiles from the roll below'); - -// A folder the walk named and the catalogue found nothing in: the row is there, -// the strip says so rather than drawing nothing at all. -await page.click('[data-key="lib-node-CheckRoll/Empty"]'); -await page.waitForFunction(() => document.querySelector('.adm-filmstrip .adm-row-empty') !== null, null, { - timeout: 10_000, - polling: 50, -}); -const emptyThumbs = await page.$$eval(thumbs, (els) => els.length); -const emptyHint = await page.$eval('.adm-filmstrip .adm-row-empty', (e) => e.textContent); -step( - 'a folder the walk named but holds no frame draws an empty strip', - emptyThumbs === 0 && /chưa có ảnh|nothing in this folder/i.test(emptyHint ?? ''), - `${emptyThumbs} tiles, "${emptyHint}"` -); -// Back to the roll: the first click folds it on the way, so it takes two. -await page.click('[data-key="lib-node-CheckRoll"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, { - timeout: 10_000, - polling: 50, -}); -await page.click('[data-key="lib-node-CheckRoll"]'); -await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { - timeout: 10_000, - polling: 50, -}); -step('and the roll comes back with its frames', true, '3 tiles'); - -// Opening the subfolder redraws the strip and the frame above it. -await page.click('[data-key="lib-node-CheckRoll/2026"]'); -await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 30_000 }); -const staged = await page.locator('[data-key="lib-stage-meta"]').innerText(); -step('the open subfolder draws its own strip and frame', /P1010256\.(RW2|JPG)/.test(staged), staged.replace(/\n/g, ' · ')); - -// The switch: the middle becomes every thumbnail of the open node at once. -await page.click('[data-key="lib-view-grid"]'); -await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 }); -const cards = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key)); -step('the grid view draws a card per frame', cards.length === 2, JSON.stringify(cards)); -await page.click('[data-key="lib-view-preview"]'); -await page.waitForSelector(thumbs, { timeout: 30_000 }); - -// The toolbar carries the screen's one button and the two view icons on a single -// row; a folder's own rescan and remove exist only once it has been right-clicked. -const bar = await page.$eval('.lib-bar', (el) => ({ - rows: new Set( - [...el.children].map((c) => { - const box = c.getBoundingClientRect(); - return Math.round(box.top + box.height / 2); - }) - ).size, - icons: [...el.querySelectorAll('[data-key^="lib-view-"]')].every((b) => b.querySelector('svg')), -})); -step('one toolbar row holding the two view icons', bar.rows === 1 && bar.icons, JSON.stringify(bar)); -step('no rescan/remove button in the pane', (await page.$$('[data-key^="lib-rescan-"], [data-key^="lib-drop-"]')).length === 0); - -await page.click('[data-key="lib-node-CheckRoll/2026"]', { button: 'right' }); -await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); -const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); -step('a right click on a folder opens its menu', menu.join(',') === 'lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu)); -// Every command leads with a mark of its own, the way the tool rail and the view -// switch do: a column of marks reads at a glance where a block of uppercase does -// not, and a mark that is not in the font the platform ships is a box. -const marks = await menuMarks(); -step( - 'every command in the folder menu leads with its own mark', - marks.length === menu.length && marked(marks), - JSON.stringify(marks) -); -await page.keyboard.press('Escape'); -step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null); - -// The column is names and counts, nothing else: the count rides on the row. -const column = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width)); -step( - 'no FOLDERS heading, the count on the folder row', - (await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '3', - `${column}px column, stage ${await page.$eval('[data-key="lib-stage"]', (e) => Math.round(e.getBoundingClientRect().width))}px` -); -step('the folder column stays out of the stage\'s way', column <= 130, `${column}px`); - -// A rename paints a label over the folder: the row it belongs to, and the frames -// under it, do not move — the node key is still the directory's own name. -page.once('dialog', (dialog) => dialog.accept('Roll A')); -await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); -await page.waitForSelector('[data-key="lib-rename-CheckRoll"]', { timeout: 10_000 }); -await page.click('[data-key="lib-rename-CheckRoll"]'); -await page.waitForFunction(() => document.querySelector('[data-key="lib-node-CheckRoll"] .adm-album-name')?.textContent === 'Roll A', null, { - timeout: 10_000, -}); -step( - 'a folder can be renamed without being moved', - (await page.$('[data-key="lib-node-CheckRoll/2026"]')) !== null, - await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-name', (e) => e.textContent) -); -// The folder's name is the row's, said once: a heading over the column would only -// repeat it, and that heading would outlive the rows when the tree is folded. -step( - 'the folder is named on its row and nowhere over the column', - (await page.$$('[data-key="lib-root-name"], .lib-col-title')).length === 0 && - (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-name', (e) => e.textContent)) === 'Roll A' -); - -// The empty part of the column is where one more folder comes from. -await page.click('[data-key="lib-tree"]', { button: 'right', position: { x: 10, y: 200 } }); -await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); -const blank = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); -step('a right click on the empty column offers a folder', blank.join(',') === 'lib-menu-add', JSON.stringify(blank)); -const blankMarks = await menuMarks(); -step('and that one command carries its mark too', blankMarks.length === 1 && marked(blankMarks), JSON.stringify(blankMarks)); -await page.keyboard.press('Escape'); -step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null); - -// The head of the tree is the column's own control: a right click on the row of a -// folder picked at the top level folds the whole tree — every row shut, the -// top-level one included, which is the shape the column opens in — and the frames -// in the strip are the strip's business and do not move with the rows. The claim -// is what ONE click does to every row, so the item is asked for with the tree open -// at each level: the subfolder is opened here if the click before it shut it. -for (const key of ['lib-node-CheckRoll', 'lib-node-CheckRoll/2026']) { - if ((await page.getAttribute(`[data-key="${key}"]`, 'aria-expanded')) !== 'false') continue; - await page.click(`[data-key="${key}"]`); - await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length >= 3, null, { - timeout: 10_000, - polling: 50, - }); -} -await page.waitForSelector('[data-key="lib-node-CheckRoll/2026/04"]', { timeout: 10_000 }); -const deepRows = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); -const deepTiles = await page.$$eval(thumbs, (els) => els.length); -// The row is where a reader right-clicks, so fold-the-tree leads that row's menu. -await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); -await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); -const rowMenu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); -step( - 'a right click on the top-level folder row offers to fold the tree', - rowMenu.join(',') === 'lib-menu-collapse,lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', - JSON.stringify(rowMenu), -); -const rowMarks = await menuMarks(); -step('and its four commands each lead with a mark of their own', rowMarks.length === 4 && marked(rowMarks), JSON.stringify(rowMarks)); -await page.click('[data-key="lib-menu-collapse"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, { - timeout: 10_000, - polling: 50, -}); -const flatTiles = await page.$$eval(thumbs, (els) => els.length); -const ended = await page.$$eval('[data-key^="lib-node-"]', (els) => - els.map((e) => `${e.dataset.key}:${e.getAttribute('aria-expanded') ?? '-'}`) -); -step( - 'and one click folds every row, leaving the frames where they were', - deepRows === 4 && flatTiles === deepTiles && ended.join(' ') === 'lib-node-CheckRoll:false', - `${deepRows} rows → ${ended.length}, ${ended.join(' ')}, ${flatTiles} tiles kept`, -); - -// The divider drags the column, and what the visit leaves behind — the width the -// column was left at, the folder it was left on, and the rows that were drawn open -// — is what the next visit opens with. Folding the tree left every row shut, so -// the visit that is left behind is a walk back down it: the row is opened (which -// is also the row that marks the screen) and the subfolder under it is opened the -// same way, and both are left that way. -await page.click('[data-key="lib-node-CheckRoll"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, { - timeout: 10_000, - polling: 50, -}); -await page.click('[data-key="lib-node-CheckRoll/2026"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, { - timeout: 10_000, - polling: 50, -}); -const gripBox = await (await page.$('[data-key="lib-grip"]')).boundingBox(); -await page.mouse.move(gripBox.x + 4, gripBox.y + 120); -await page.mouse.down(); -await page.mouse.move(gripBox.x + 84, gripBox.y + 120, { steps: 6 }); -await page.mouse.up(); -const dragged = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width)); -await page.reload(); -await page.waitForSelector(thumbs, { timeout: 120_000 }); -const reopened = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width)); -const where = await page.$eval('[data-key^="lib-node-"][aria-current="true"]', (e) => e.dataset.key); -const reopenedRows = await page.$$eval('[data-key^="lib-node-"]', (els) => - els.map((e) => `${e.dataset.key}:${e.getAttribute('aria-expanded') ?? '-'}`) -); -step('the column drags and is the same width next time', dragged >= 160 && reopened === dragged, `${dragged}px, then ${reopened}px`); -step( - 'the tree reopens with the rows that were left open', - reopenedRows.length === 4 && - reopenedRows.join(' ') === - 'lib-node-CheckRoll:true lib-node-CheckRoll/2026:true lib-node-CheckRoll/2026/04:- lib-node-CheckRoll/Empty:-', - reopenedRows.join(' ') -); -step('the screen reopens on the folder it was left on', where === 'lib-node-CheckRoll/2026', where); - -// The frame that was raised is remembered too: a reader who comes back finds the -// negative they were looking at, not the first one the node happens to hold. The -// JPEG is the one with a picture to show, so it is the one that gets raised. -const jpgId = indexed.thumbs.find((t) => t.id.endsWith(`/${JPG}`) && t.id.includes('/2026/04/')).id; -await page.click(`[data-key="lib-thumb-${jpgId}"]`); -await page.reload(); -await page.waitForSelector(thumbs, { timeout: 120_000 }); -const raised = await page.$eval('.adm-thumb.on', (e) => e.dataset.key); -step('the screen reopens on the frame that was raised', raised === `lib-thumb-${jpgId}`, raised); - -// The frame's own file says two things under the picture: where it was shot and -// the numbers the camera recorded. The JPEG off this server is the one with a -// GPS tag on it, so it is the one that gets a place — and nothing here names it, -// the page has no geocoder behind it, so the coordinates stand as they were read. -await page.waitForSelector('[data-key="lib-stage-place"]', { timeout: 60_000, state: 'attached' }); -const place = await page.locator('[data-key="lib-stage-place"]').innerText(); -step('the frame says where it was shot, off its own EXIF', place.trim() === GPS.text, place.trim()); - -// A wheel tick over the frame magnifies it about the point under the pointer — -// the one gesture that makes a negative worth the whole window. The page under it -// holds still, and ticking back down fits the frame again. -const shotBox = await (await page.$('[data-key="lib-stage-img"]')).boundingBox(); -await page.mouse.move(shotBox.x + shotBox.width / 3, shotBox.y + shotBox.height / 3); -await page.mouse.wheel(0, -240); -await page.waitForTimeout(150); -const magnified = await page.$eval('[data-key="lib-stage-img"]', (el) => ({ - transform: getComputedStyle(el).transform, - origin: getComputedStyle(el).transformOrigin, -})); -await page.mouse.wheel(0, 600); -await page.waitForTimeout(150); -const fitted = await page.$eval('[data-key="lib-stage-img"]', (el) => getComputedStyle(el).transform); -const scale = Number((magnified.transform.match(/matrix\(([\d.]+)/) ?? [])[1] ?? 1); -step( - 'a wheel tick over the frame zooms it, and back down fits it again', - scale > 1 && scale <= 6 && fitted === 'none', - `${magnified.transform} about ${magnified.origin} → ${fitted}` -); - -// One tap on a thumbnail raises the frame, and the button under it hands it to -// the studio. The RAW is the harder half — it has to be read back off the -// (stand-in) handle and developed — so it is the one that gets opened. -const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id; -await page.click(`[data-key="lib-thumb-${rawId}"]`); -// The RAW is the frame that shows a spec line: a JPEG carries one EXIF segment -// and this server spends the stand-in's on the coordinates. Nothing is invented -// for the numbers a file does not carry, so only what the camera wrote is here — -// no ISO and no frame size, which is what this RW2 answers. -await page.waitForSelector('[data-key="lib-stage-specs"]', { timeout: 60_000, state: 'attached' }); -const specs = (await page.locator('[data-key="lib-stage-specs"]').innerText()).trim(); -step('and prints the numbers the camera recorded', specs === '26.4mm · f/2.8 · 1/320s', specs); -await page.click('[data-key="lib-open"]'); -await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 }); -await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 }); -step('studio developed the frame from its handle', true, 'stage no longer on the drop zone'); - -// The handover is an address, not a state: the studio puts the frame into its -// own store and drops the query, so a reload reopens it like any other photo. -step('the address was handed back', !page.url().includes('lib='), `url=${page.url()}`); - -// Two things a reader and an operator read straight off the header: the preset -// the studio has open wears the theme's accent (the same colour the brand and -// the open tab carry), and the rail's foot names the build this page came from. -await page.waitForSelector('.rail-version', { timeout: 15_000 }); -const chrome = await page.evaluate(() => ({ - version: document.querySelector('.rail-version')?.textContent?.trim() ?? '', - recipe: getComputedStyle(document.querySelector('.recipe-name')).color, - accent: getComputedStyle(document.querySelector('.brand em')).color, -})); -step( - 'the rail foot names the build the page came from', - /^\d+\.\d+\.\d+\+\S+$/.test(chrome.version), - chrome.version -); -step( - 'the preset chip in the header wears the theme accent, not a fixed grey', - chrome.recipe === chrome.accent, - `${chrome.recipe}, brand accent ${chrome.accent}` -); - -const editsIn = () => - 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 await new Promise((resolve) => { - const req = db.transaction('edits').objectStore('edits').getAll(); - req.onsuccess = () => resolve(req.result); - }); - }); -// The recipe is written 600ms after the last change (a slider drag fires it per -// frame), so the row arrives a beat after the frame does. -let filed = []; -for (let i = 0; i < 40 && !filed.length; i++) { - filed = await editsIn(); - if (!filed.length) await new Promise((r) => setTimeout(r, 250)); -} -step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed)); - -await page.goto(`${base}/library`); -await page.waitForSelector(`[data-key="lib-thumb-${rawId}"]`); -await page.click(`[data-key="lib-thumb-${rawId}"]`); -const meta = await page.locator('[data-key="lib-stage-meta"]').innerText(); -step('the frame says it was edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · ')); - -// The strip is a shelf, not a page: when the open folder holds more frames than -// fit, a plain wheel tick over it walks them along — a wheel has no sideways -// axis to scroll a row with. The window is narrowed until the three tiles of the -// open roll no longer fit, which is what a long roll looks like anyway. -await page.click('[data-key="lib-node-CheckRoll"]'); -await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 30_000 }); -await page.setViewportSize({ width: 320, height: 720 }); -await page.waitForTimeout(200); -const shelf = await page.$eval('[data-key="lib-strip"]', (el) => ({ sw: el.scrollWidth, cw: el.clientWidth })); -const shelfBox = await (await page.$('[data-key="lib-strip"]')).boundingBox(); -await page.mouse.move(shelfBox.x + shelfBox.width / 2, shelfBox.y + shelfBox.height / 2); -await page.mouse.wheel(0, 200); -await page.waitForTimeout(300); -const walked = await page.$eval('[data-key="lib-strip"]', (el) => el.scrollLeft); -step('a wheel tick walks the strip along', shelf.sw > shelf.cw && walked > 0, `${shelf.sw}px of tiles in ${shelf.cw}px, scrolled to ${walked}`); - -// The reader's own score on a frame, and the wall read through it. The score is -// given from the row under the picture and nothing else has to be opened or -// visited to see it; the wall is then narrowed by that score, by the year the -// shutter fired and by the hours it fired in. The check's frames were written so -// that all three answer something: the JPEG in the afternoon of 15 Feb 2016, the -// RAW at seven in the morning of 1 Jan 2026. -await page.setViewportSize({ width: 1280, height: 720 }); -await page.click(`[data-key="lib-thumb-${jpgId}"]`); -await page.click('[data-key="lib-star-4"]'); -const starred = (await catalogue()).thumbs.find((t) => t.id === jpgId)?.star; -const lit = await page.$eval('[data-key="lib-star-4"]', (e) => e.getAttribute('aria-pressed')); -step('a frame is given a score from the row under it', starred === 4 && lit === 'true', `catalogue ${starred}, 4th star ${lit}`); - -await page.click('[data-key="lib-view-grid"]'); -await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 }); -// Each filter is set on its own and put back, so what the wall holds is read -// after one choice and never after two. -const wall = () => page.$$eval('[data-key^="lib-photo-"]', (els) => els.length); -const setFilter = async (key, value) => { - await page.selectOption(`[data-key="${key}"]`, value); - await page.waitForTimeout(150); }; -await setFilter('lib-filter-star', '4'); -const fourUp = await wall(); -await setFilter('lib-filter-star', '5'); -const fiveUp = await wall(); -await setFilter('lib-filter-star', '0'); -step('the wall narrows to the frames that were given the stars', fourUp === 1 && fiveUp === 0 && (await wall()) === 3, `4★ ${fourUp}, 5★ ${fiveUp}, any 3`); -await setFilter('lib-filter-year', '2016'); -const in2016 = await wall(); -await setFilter('lib-filter-year', 'all'); -step('and to the year the shutter fired in', in2016 === 2, `2016 → ${in2016} tiles of 3`); - -await setFilter('lib-filter-from', '16'); -await setFilter('lib-filter-to', '18'); -const afternoon = await wall(); -await setFilter('lib-filter-from', 'any'); -await setFilter('lib-filter-to', 'any'); -step('and to the hours it fired in', afternoon === 2, `16:00–18:00 → ${afternoon} tiles of 3`); - -// The order the wall is read in, and back to the shelf: what is left behind is -// the view the checks below read, so the filters go back to holding everything. -await setFilter('lib-sort', 'star'); -const ordered = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key)); -step( - 'the wall reads in the order that was asked for', - ordered[0] === `lib-photo-${jpgId}`, - `${ordered.map((k) => k.replace('lib-photo-', '').split('/').pop()).join(', ')}` -); -await setFilter('lib-sort', 'newest'); -await page.click('[data-key="lib-view-preview"]'); -await page.waitForSelector(thumbs, { timeout: 30_000 }); - -// A catalogue another tab has open — the tab that is still running yesterday's -// build, holding the connection the new build used to ask for a version on. The -// screen has to come up on it: the catalogue is opened as it is, with no version -// to reach for and nothing for the other tab to block. -const hold = await context.newPage(); -await hold.route('**/assets/*.js', (r) => r.abort()); -await hold.goto(`${base}/library`).catch(() => {});const held = await hold.evaluate( - () => - new Promise((resolve, reject) => { - const req = indexedDB.open('recipescam-library'); - req.onsuccess = () => { - window.__held = req.result; - resolve(req.result.version); - }; - req.onerror = () => reject(req.error); +// The library is signed-in ground and neither `vite preview` nor a bare container +// has an API behind it, so a run is a guest the app bounces back to the landing +// page before the column is ever drawn. The one call that decides which it is is +// answered here; the rest of `/api` fails the way a serverless build's does. +const open = async () => { + const context = await browser.newContext(); + await context.route('**/api/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: { id: 1, email: 'check@local', admin: true, verified: true, pro: true } }), }) -); -await page.setViewportSize({ width: 1280, height: 720 }); -await page.goto(`${base}/library`); -await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 }); -const seenAgain = await page.$$eval(thumbs, (els) => els.length); -step( - 'a catalogue another tab is holding still comes up', - held >= 1 && seenAgain === 3, - `held at version ${held}, ${seenAgain} tiles on the other tab` -); - -// A reading that was cut short — the app closed, the tab was gone — is finished by -// the next visit with no one asking for it: the walk starts on the way in, the -// frame the catalogue still holds is left on its size and its write time, and the -// two it never got to are read. Two rows are taken out to stand for a scan that -// stopped before it reached them. -await page.evaluate(async (keep) => { - const db = await new Promise((resolve, reject) => { - const req = indexedDB.open('recipescam-library'); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - const rows = await new Promise((resolve) => { - const req = db.transaction('photos').objectStore('photos').getAll(); - req.onsuccess = () => resolve(req.result); - }); - await new Promise((resolve, reject) => { - const tx = db.transaction('photos', 'readwrite'); - for (const row of rows) if (!row.name.endsWith(keep)) tx.objectStore('photos').delete(row.id); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); -}, RAW); -await page.reload(); -await page - .waitForFunction((n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n, 3, { timeout: 60_000, polling: 50 }) - .catch(() => {}); -const restored = await page.$$eval(thumbs, (els) => els.length).catch(() => 0); -const read = await page.evaluate(() => ({ whole: window.__reads ?? {}, heads: window.__heads ?? {} })); -step( - 'an interrupted reading goes on by itself, over the frames it still holds', - restored === 3 && !read.whole[RAW] && read.heads[JPG] > 0, - `${restored} tiles, ${JSON.stringify(read)}` -); - -// The last of the six marks is the one only a folder whose permission has lapsed -// ever shows — and the one command that can do anything about it. The folder comes -// back refused, offers its way back, and takes it. -await page.evaluate(() => localStorage.setItem('check.deny', '1')); -await page.reload(); -await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 }); -await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); -await page.waitForSelector('[data-key="lib-reconnect-CheckRoll"]', { timeout: 10_000 }); -const refused = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); -const refusedMarks = await menuMarks(); -step( - 'a folder the browser refuses offers the one command that answers it, marked', - refused.join(',') === 'lib-menu-collapse,lib-reconnect-CheckRoll' && marked(refusedMarks) && refusedMarks[1] === '⚿', - `${JSON.stringify(refused)} ${JSON.stringify(refusedMarks)}` -); -await page.evaluate(() => localStorage.removeItem('check.deny')); -await page.evaluate(() => (window.__deny = false)); -await page.click('[data-key="lib-reconnect-CheckRoll"]'); -await page - .waitForFunction(() => document.querySelectorAll('[data-key^="lib-thumb-"]').length === 3, null, { timeout: 60_000, polling: 50 }) - .catch(() => {}); -step('and taking it reads the folder again', (await page.$$eval(thumbs, (els) => els.length)) === 3); - -// A reload in the middle of a reading — the reader holding Ctrl+Shift+R over a -// roll of a few thousand frames, which is the one thing they do while a reading -// runs. The catalogue holds the frames the reading got through, but the reading's -// own position is not in the catalogue: without it the walk starts at the top of -// the roll again, every folder is listed a second time, and the counter starts -// from zero while the frames it already holds are read again to be skipped. -// -// The position belongs to the origin now, so the reading carries on instead of -// starting over. `check.hold` stops the reading on the frame it is reading, which -// is where a reload lands — and the reading it left behind is still there when the -// key goes, so the same reading finishes the roll. Everything is taken out of the -// catalogue first, so the reading has frames on both sides of the reload. -await page.evaluate(async () => { - sessionStorage.clear(); - localStorage.removeItem('recipescam.library.walk.CheckRoll'); - localStorage.setItem('check.hold', '1'); - 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); - }); -}); -// The position the origin is holding, the line the toolbar reads out, and the wait -// for it: the line is how far the reading has come, and it is the one thing on -// screen that says whether a reading started over. -const position = () => page.evaluate(() => JSON.parse(localStorage.getItem('recipescam.library.walk.CheckRoll') ?? 'null')); -const readout = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => ''); -const counting = () => - page.waitForFunction( - () => /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), - null, - { timeout: 30_000, polling: 50 } ); -await page.reload(); -await counting().catch(() => {}); -const stopped = await readout(); -await page.reload(); -await counting().catch(() => {}); -const resumed = await readout(); -const at = await position(); -step( - 'a reading a reload cut in half goes on from where it was', - /\d+\/\d+/.test(stopped) && resumed === stopped && at?.walked.length === 4 && at.pending.length === 0 && at.frames.length === 3, - `stopped at "${stopped.trim()}", back at "${resumed.trim()}", position ${JSON.stringify(at && { walked: at.walked.length, pending: at.pending.length, frames: at.frames.length })}` -); + await context.addInitScript(INIT, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], folder: 'CheckRoll' }); + const page = await context.newPage(); + const errors = []; + const logs = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (m) => m.type() === 'error' && logs.push(m.text())); + return { context, page, errors, logs }; +}; -// The key goes, and the reading the reload interrupted finishes the roll on its -// own: the frames it had found are read, the folders are not listed again — the -// check counts every listing a reading asks for, across reloads — and the position -// is dropped once there is nothing left to carry on from. -await page.evaluate(() => localStorage.removeItem('check.hold')); -await page - .waitForFunction(() => document.querySelectorAll('[data-key^="lib-thumb-"]').length === 3, null, { timeout: 60_000, polling: 50 }) - .catch(() => {}); -const carried = await page.$$eval(thumbs, (els) => els.length).catch(() => 0); -const again = await page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}')); -step( - 'and it finishes the roll without listing a folder twice', - carried === 3 && again.CheckRoll === 1 && again['2026'] === 1 && again.Empty === 1 && again['04'] === 1 && (await position()) === null, - `${carried} tiles, folders listed ${JSON.stringify(again)}` -); +const tiles = '[data-key^="lib-thumb-"]'; +const status = (page) => + page + .$eval('[data-key="lib-status"]', (e) => e.textContent.trim()) + .catch(() => ''); +// The handover into the studio is an address; the studio reads it and clears it, +// so a run that opened a frame lands on `/app`. +const inStudio = (page) => /^\/app(\?|$)/.test(new URL(page.url()).pathname + new URL(page.url()).search); +const openFrame = async (page) => { + await page.evaluate(() => localStorage.setItem('check.allow', '1')); + await page.click('[data-key="lib-open"]'); + await page.waitForTimeout(3000); +}; + +// --- 1. a folder whose permission the reload dropped ------------------------- +{ + const { context, page, errors } = await open(); + await page.goto(`${base}/library`); + await page.click('[data-key="lib-add"]'); + await page.waitForSelector(tiles, { timeout: 120_000 }); + step('the roll is indexed', true); + + // The tab closed: the browser kept the handle, not the permission. + await page.evaluate(() => { + localStorage.setItem('check.prompt', '1'); + for (const k of ['check.granted', 'check.requested', 'check.refused', 'check.allow', 'check.answered']) + localStorage.removeItem(k); + }); + await page.reload(); + await page.waitForSelector(tiles, { timeout: 60_000 }); + const noOneToAnswer = await page.evaluate(() => localStorage.getItem('check.granted')); + const startupAsks = await page.evaluate(() => Number(localStorage.getItem('check.requested') ?? 0)); + + const before = errors.length; + await openFrame(page); + const answered = Number(await page.evaluate(() => localStorage.getItem('check.answered') ?? 0)); + const asked = Number(await page.evaluate(() => localStorage.getItem('check.requested') ?? 0)); + const granted = await page.evaluate(() => localStorage.getItem('check.granted')); + const note = await status(page); + + step('the folder really has no permission while the screen draws itself', noOneToAnswer === null, `start-up asked x${startupAsks}, granted=${noOneToAnswer}`); + step('the click asks the browser for the folder back', answered > 0 && granted === '1', `answered x${answered} of x${asked}`); + step('the frame opens once the click has handed the permission back', inStudio(page), page.url()); + step( + 'no "moved or deleted" note for a frame that is still on the disk', + inStudio(page) && !/moved or deleted|di chuyển|lib\.missing/.test(note), + note || '(no note)' + ); + step('no uncaught error while opening', errors.length === before, errors.slice(before).join(' | ')); + await context.close(); +} + +// --- 2. a catalogue a newer build wrote ------------------------------------- +{ + const { context, page, errors, logs } = await open(); + await page.goto(`${base}/`); + await page.evaluate( + ({ DB, STORES }) => + new Promise((resolve, reject) => { + const req = indexedDB.open(DB, 99); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORES[0])) db.createObjectStore(STORES[0], { keyPath: 'name' }); + if (!db.objectStoreNames.contains(STORES[1])) { + const s = db.createObjectStore(STORES[1], { keyPath: 'id' }); + s.createIndex('folder', 'folder'); + s.createIndex('taken', 'taken'); + } + if (!db.objectStoreNames.contains(STORES[2])) db.createObjectStore(STORES[2], { keyPath: 'id' }); + if (!db.objectStoreNames.contains(STORES[3])) db.createObjectStore(STORES[3], { keyPath: 'photoId' }); + if (!db.objectStoreNames.contains(STORES[4])) db.createObjectStore(STORES[4], { keyPath: 'folder' }); + }; + req.onsuccess = () => { + req.result.close(); + resolve(); + }; + req.onerror = () => reject(req.error); + }), + { DB, STORES } + ); + step('a catalogue stands at version 99', true); + + const before = errors.length; + await page.goto(`${base}/library`); + await page.click('[data-key="lib-add"]'); + await page.waitForSelector(tiles, { timeout: 120_000 }); + step('the older build reads and writes the newer catalogue', true); + + const version = await page.evaluate( + async (DB) => (await indexedDB.databases()).find((d) => d.name === DB)?.version, + DB + ); + step('it was taken as it stood, not upgraded', version === 99, `version ${version}`); + step('no uncaught error', errors.length === before, errors.slice(before).join(' | ')); + step( + 'no "the database is missing"', + !logs.some((l) => l.includes('the database is missing')), + logs.join(' | ') + ); + await context.close(); +} + +// --- 3. a frame out of a catalogue an older build wrote --------------------- +// That build kept a handle on every row, and a handle out of IndexedDB comes back +// without its permission. The shortcut that opens a frame off its own row has to +// ask for it back the same way the folder's does, or the frame answers "nothing +// at" while it sits untouched on the disk. +{ + const { context, page, errors } = await open(); + await page.goto(`${base}/library`); + await page.click('[data-key="lib-add"]'); + await page.waitForSelector(tiles, { timeout: 120_000 }); + + const legacy = await page.evaluate( + (name) => + new Promise((resolve, reject) => { + const req = indexedDB.open(name); + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction('photos', 'readwrite'); + const store = tx.objectStore('photos'); + const all = store.getAll(); + all.onsuccess = () => { + const rows = all.result; + // A record, not a live handle: the browser hands it back with its + // methods and with no permission. + for (const row of rows) store.put({ ...row, handle: { kind: 'file', name: row.name, __fake: true } }); + resolve(rows.map((r) => r.id)); + }; + all.onerror = () => reject(all.error); + }; + req.onerror = () => reject(req.error); + }), + DB + ); + step('the catalogue keeps a handle on every row, as the older build wrote it', legacy.length === 1, legacy.join()); + + await page.evaluate(() => { + localStorage.setItem('check.prompt', '1'); + for (const k of ['check.granted', 'check.requested', 'check.allow', 'check.answered']) localStorage.removeItem(k); + }); + await page.reload(); + await page.waitForSelector(tiles, { timeout: 60_000 }); + + const before = errors.length; + await openFrame(page); + const note = await status(page); + step( + 'a frame off a legacy row opens instead of reading "nothing at"', + inStudio(page) && !/nothing at|lib\.missing/.test(note), + note || page.url() + ); + step('no uncaught error', errors.length === before, errors.slice(before).join(' | ')); + await context.close(); +} await browser.close(); sampleServer.close(); -app.kill('SIGTERM'); -console.log(failures ? `\n${failures} failed` : '\nall checks passed'); +app?.kill('SIGTERM'); +console.log(failures ? `\n${failures} FAILED` : '\nall ok'); process.exit(failures ? 1 : 0); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index af593f0..ce0a8b2 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -1269,8 +1269,9 @@ function Catalogue({ admin }: { admin: boolean }) { // a studio that opens on nothing. try { await readPhotoFile(row); - } catch { - setNote(t('lib.missing', { name: row.name })); + } catch (err) { + const off = err instanceof Error && err.message === 'no-folder'; + setNote(t(off ? 'lib.offline' : 'lib.missing', { name: row.name })); return; } go(`/app?lib=${encodeURIComponent(photo.id)}`); diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 6925c55..9b1fbc1 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -436,22 +436,67 @@ export async function listFolders(): Promise { // the screen down, and it is the folder's own handle, one row, that is needed to // ask for it again. The folder part of a path is opened once and kept: a wall's // worth of frames is nearly always a handful of folders. -const dirHandles = new Map(); +const dirHandles = new Map(); -export async function fileHandleFor(photo: LibraryPhoto): Promise { - if (photo.handle) return photo.handle; - if (photo.remoteId) return null; +// The folder a frame's file is asked of: the one record the catalogue kept of it, +// and the permission its handle comes back without — the browser drops that when +// the tab closes, and the reader's click on the frame is the one gesture that can +// ask for it again. It is asked for here, at the click, and not only at start-up, +// where nobody is there to answer: without it every frame of a folder read +// yesterday answers "moved or deleted" while the file sits untouched on the disk. +async function folderFor(photo: LibraryPhoto): Promise { const folder = (await listFolders()).find((f) => f.handle && normPath(f.name) === normPath(photo.folder)); if (!folder?.handle) return null; - const key = `${folder.name}\u0000${photo.dir}`; - if (!dirHandles.has(key)) dirHandles.set(key, photo.dir ? await openAt(folder.handle, photo.dir) : folder.handle); - const dir = dirHandles.get(key); + if (!(await ensurePermission(folder.handle))) return null; + // The row's own `dir` is normalised here rather than trusted: a catalogue read + // by an older build can carry a backslash or an empty string where a path + // belongs, and a wrong directory answers `getFileHandle` with a `NotFoundError` + // that no permission ask can undo. + const rel = normPath(photo.dir ?? ''); + const key = `${folder.name}\u0000${rel}`; + const held = dirHandles.get(key); + if (held) return held; + const dir = rel ? await openAt(folder.handle, rel) : folder.handle; + // A folder that will not open is not written down: a refusal is the reader's to + // take back, and a null kept here would answer for every frame of that folder + // for the rest of the visit instead of asking again on the next click. if (!dir) return null; + dirHandles.set(key, dir); + return dir; +} + +// The file asked of the directory it was catalogued in, by name and then — only +// when the name answers nothing — by a pass over the directory. `getFileHandle` +// matches the name byte for byte, while the disk underneath Windows does not: a +// row that carries a case the file no longer has asks for a name the directory +// will not answer with, and Explorer shows the frame sitting there all the same. +// The listing costs one pass over one directory and runs on the failing ask +// alone, so a frame that opens pays nothing for it. +async function fileIn(dir: FileSystemDirectoryHandle, name: string): Promise { try { - return await dir.getFileHandle(photo.name); + return await dir.getFileHandle(name); + } catch { + // The ask is answered below, and where it is not, by the caller. + } + const want = name.toLowerCase(); + try { + for await (const entry of dir.values()) { + if (entry.kind === 'file' && entry.name.toLowerCase() === want) return entry as FileSystemFileHandle; + } } catch { return null; } + return null; +} + +export async function fileHandleFor(photo: LibraryPhoto): Promise { + if (photo.remoteId) return null; + const dir = await folderFor(photo); + // A folder that cannot be had is not a file that is not there, and the caller + // tells them apart: `no-folder` is a drive to plug back in, `null` a file the + // directory does not answer for. `readPhotoFile` is the only caller. + if (!dir) throw new Error('no-folder'); + return fileIn(dir, photo.name); } // A folder the reader has just handed back, and what the catalogue knows of the @@ -2440,6 +2485,21 @@ export async function readPhotoFile(photo: LibraryPhoto): Promise { const blob = await res.blob(); return new File([blob], photo.name, { type: blob.type || 'application/octet-stream' }); } + // A row an older build wrote carries its own handle, and the browser hands it + // back with no permission on it — the same ask the folder gets, for the same + // reason. Without it the shortcut answers `NotAllowedError` and a frame that is + // where the reader left it reads as moved or deleted. A handle that will not + // answer is no shortcut at all: the folder below is the way in, and the one + // that says why the frame is not there. + if (photo.handle) { + try { + if (await ensurePermission(photo.handle)) return await photo.handle.getFile(); + } catch { + // A shortcut that will not answer is a stale one: the drive went away or + // the file did. Falling through costs the folder walk below and buys the + // reader the real reason instead of "moved or deleted". + } + } const handle = await fileHandleFor(photo); if (!handle) throw new Error('no-file'); return handle.getFile(); diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 8f6b412..f6d57fc 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -313,6 +313,8 @@ export const en: Dict = { 'lib.blocked': 'Another tab of this app is holding the library open from an older build. Close that tab, or reload it, and this one picks the library up.', 'lib.missing': 'Could not open {name} — the file has been moved or deleted.', + 'lib.offline': + 'Could not open {name} — the photo folder is not connected. Plug the drive back in and try again.', 'lib.failed': 'That folder could not be read.', 'lib.unsupported': 'This browser will not let a page read a folder from the disk (Chrome, Edge, Opera and Brave will). Photos still edit fine — drag and drop them into the studio.', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 570a15e..d800448 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -325,6 +325,8 @@ export const vi = { 'lib.blocked': 'Một tab khác của ứng dụng đang giữ thư viện ở bản cũ hơn. Hãy đóng tab đó, hoặc tải lại nó, để tab này mở được thư viện.', 'lib.missing': 'Không mở được {name} — tệp đã bị di chuyển hoặc xoá.', + 'lib.offline': + 'Không mở được {name} — thư mục ảnh chưa kết nối. Cắm lại ổ đĩa rồi thử lại.', 'lib.failed': 'Không đọc được thư mục này.', 'lib.unsupported': 'Trình duyệt này không cho phép app đọc thư mục trên máy (cần Chrome, Edge, Opera hoặc Brave). Ảnh vẫn chỉnh được bình thường bằng cách kéo & thả vào studio.',