fix(library): spell the names the browser refuses to write

A backup folder is picked, not created: the frames in it carry the names a
camera or a person gave them, and Chromium's IsSafePathComponent will not
spell a name back onto a disk if it holds a control or format character, one
of ": * ? " < > | \ /", a space, dot or tilde at either end, or a .lnk/.scf/
.url tail. The library is read through a handle, which lets all of those
through, so the refusal only surfaced on the way out: getDirectoryHandle threw
"Name is not allowed" on the first such folder, the run stopped there and left
an empty thumbs/ behind it.

Spell a component that would be refused as %XX per its UTF-8 bytes, on the way
out and on the way back in, so those frames keep their tiles in the backup and
the restore still finds them. A name that was already safe is handed back
untouched, which keeps an existing folder readable, copyable and rsyncable by
hand.

The run also no longer dies on the one frame that will not write: it skips it,
names it in the console, and finishes the rest.
This commit is contained in:
2026-10-02 21:51:23 +07:00
parent 895a055a03
commit e43c02c36f
+67 -10
View File
@@ -7,7 +7,9 @@
// //
// The folder holds one file of rows — thumbnail, star, recipe, the frame's size // The folder holds one file of rows — thumbnail, star, recipe, the frame's size
// and write time, and no handle, which is not a thing that can be written down — // and write time, and no handle, which is not a thing that can be written down —
// and the tiles beside it, one file per frame under `thumbs/<frame id>`, so the // and the tiles beside it, one file per frame under `thumbs/<frame id>` — a
// component the browser refuses to write spelled out as `%XX` (see `spell`) so
// the tiles of such a folder land too — so the
// folder mirrors the library exactly and a tile can be looked at, copied or // folder mirrors the library exactly and a tile can be looked at, copied or
// rsynced by hand. A frame that is being restored is matched to a frame on the // rsynced by hand. A frame that is being restored is matched to a frame on the
// disk by that size and write time, so putting a catalogue back costs the tiles // disk by that size and write time, so putting a catalogue back costs the tiles
@@ -197,6 +199,50 @@ async function writable(): Promise<FileSystemDirectoryHandle> {
return handle; return handle;
} }
// Chrome will not spell a name it will not put on a disk: no `* ? " < > | : \ /`,
// no control or format character, no space, dot or tilde at either end, no
// `.lnk` `.scf` `.url`, and it has to be UTF-8 — the whole of what Chromium's
// `IsSafePathComponent` refuses, and every one of those is a name a camera or a
// person is free to give a folder. The library is read through a handle, which
// lets every one of them through, so the refusal only ever surfaces here, on the
// way out: `getDirectoryHandle` says `Name is not allowed` and the whole backup
// stops. This spells such a name instead — the offending character as `%XX` of
// its UTF-8 bytes — and it is the same function on the way back in, so a tile
// under a folder called `2024 : ảnh.` still comes home. A name that was already
// safe is handed back untouched, which is what keeps the folder a person can
// read, copy or rsync by hand.
const UNSAFE = /[<>:"|?*\\/]|\p{Cc}|\p{Cf}|[\uFDD0-\uFDEF\uFFFE\uFFFF]/u;
const EDGE = /^[ .~]|[ .~]$/;
const SHORTCUT = /\.(lnk|scf|url)$/i;
const hex = (ch: string) =>
[...new TextEncoder().encode(ch)].map((b) => `%${b.toString(16).toUpperCase().padStart(2, '0')}`).join('');
function spell(name: string): string {
// `%` spells here even though a disk takes it: a name that holds one would
// otherwise spell the same as a name whose character was spelled out, and the
// two tiles would land on each other's file.
if (
name &&
name !== '.' &&
name !== '..' &&
!name.includes('%') &&
!UNSAFE.test(name) &&
!EDGE.test(name) &&
!SHORTCUT.test(name)
) {
return name;
}
const chars = [...name];
return chars
.map((ch, i) => {
const edge = (i === 0 || i === chars.length - 1) && /[ .~]/.test(ch);
return ch === '%' || edge || UNSAFE.test(ch) ? hex(ch) : ch;
})
.join('')
.replace(SHORTCUT, (m) => `%2E${m.slice(1)}`);
}
// The directories are asked for by the path in the frame id, and the id of every // The directories are asked for by the path in the frame id, and the id of every
// frame in a folder starts with that folder's: a cache of what has been opened // frame in a folder starts with that folder's: a cache of what has been opened
// turns a folder of 220 000 frames into a walk of the folders it is made of. // turns a folder of 220 000 frames into a walk of the folders it is made of.
@@ -209,13 +255,14 @@ async function at(root: FileSystemDirectoryHandle, rel: string, create = false):
let path = ''; let path = '';
for (const part of rel.split('/')) { for (const part of rel.split('/')) {
if (!part) continue; if (!part) continue;
path = path ? `${path}/${part}` : part; const name = spell(part);
path = path ? `${path}/${name}` : name;
const had = cache.get(path); const had = cache.get(path);
if (had) { if (had) {
node = had; node = had;
continue; continue;
} }
node = await node.getDirectoryHandle(part, { create }); node = await node.getDirectoryHandle(name, { create });
cache.set(path, node); cache.set(path, node);
} }
return node; return node;
@@ -224,7 +271,7 @@ async function at(root: FileSystemDirectoryHandle, rel: string, create = false):
async function put(root: FileSystemDirectoryHandle, rel: string, blob: Blob): Promise<void> { async function put(root: FileSystemDirectoryHandle, rel: string, blob: Blob): Promise<void> {
const cut = rel.lastIndexOf('/'); const cut = rel.lastIndexOf('/');
const dir = cut < 0 ? root : await at(root, rel.slice(0, cut), true); const dir = cut < 0 ? root : await at(root, rel.slice(0, cut), true);
const file = await dir.getFileHandle(cut < 0 ? rel : rel.slice(cut + 1), { create: true }); const file = await dir.getFileHandle(spell(cut < 0 ? rel : rel.slice(cut + 1)), { create: true });
// One write at a time and closed at once: a writable that is left open is a // One write at a time and closed at once: a writable that is left open is a
// file that is not on the disk yet, and a backup that is interrupted is one the // file that is not on the disk yet, and a backup that is interrupted is one the
// next run has to write again. // next run has to write again.
@@ -263,6 +310,7 @@ export async function backupNow(onProgress?: (p: BackupProgress) => void): Promi
await put(root, CATALOGUE, new Blob([JSON.stringify(cat)], { type: 'application/json' })); await put(root, CATALOGUE, new Blob([JSON.stringify(cat)], { type: 'application/json' }));
let written = 0; let written = 0;
let skipped = 0;
let done = 0; let done = 0;
const rows: { id: string; size: number }[] = []; const rows: { id: string; size: number }[] = [];
for (const photo of photos) { for (const photo of photos) {
@@ -274,11 +322,19 @@ export async function backupNow(onProgress?: (p: BackupProgress) => void): Promi
if (had.get(photo.id) === photo.thumb.size) continue; if (had.get(photo.id) === photo.thumb.size) continue;
const parts = photo.id.split('/'); const parts = photo.id.split('/');
parts.pop(); parts.pop();
const dir = await at(root, [THUMBS, ...parts].join('/'), true); const rel = [THUMBS, ...parts, photo.name].join('/');
const file = await dir.getFileHandle(photo.name, { create: true }); try {
const writer = await file.createWritable(); await put(root, rel, photo.thumb);
await writer.write(photo.thumb); } catch (err) {
await writer.close(); // A frame the browser will not write is not a library that is never
// written: the run goes on without it, and the name it stopped on goes to
// the console, since the line on the screen is one line over a strip of
// photographs. Whatever survives `spell` is a name longer than a folder
// will hold, and a name spelled back is a name the restore still finds.
if (skipped < 5) console.warn('backup: could not write', rel, err);
skipped++;
continue;
}
rows.push({ id: photo.id, size: photo.thumb.size }); rows.push({ id: photo.id, size: photo.thumb.size });
written++; written++;
// The list is kept in step with the writes: a run that is interrupted // The list is kept in step with the writes: a run that is interrupted
@@ -287,6 +343,7 @@ export async function backupNow(onProgress?: (p: BackupProgress) => void): Promi
if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length)); if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length));
} }
await keep(WRITTEN, rows); await keep(WRITTEN, rows);
if (skipped) console.warn(`backup: ${skipped} of ${photos.length} tiles skipped`);
onProgress?.({ done: photos.length, total: photos.length }); onProgress?.({ done: photos.length, total: photos.length });
const meta: Meta = { id: 'meta', at: at0, photos: photos.length, wrote: written }; const meta: Meta = { id: 'meta', at: at0, photos: photos.length, wrote: written };
await keep(WHERE, [meta]); await keep(WHERE, [meta]);
@@ -329,7 +386,7 @@ export async function restoreNow(onProgress?: (p: BackupProgress) => void): Prom
const parts = id.split('/'); const parts = id.split('/');
const name = parts.pop() as string; const name = parts.pop() as string;
const dir = await at(root, [THUMBS, ...parts].join('/')); const dir = await at(root, [THUMBS, ...parts].join('/'));
const file = await dir.getFileHandle(name); const file = await dir.getFileHandle(spell(name));
return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' }); return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' });
} catch { } catch {
// A tile that is not there — or that has been taken away by hand — leaves // A tile that is not there — or that has been taken away by hand — leaves