diff --git a/docker/frontend/src/engine/libraryBackup.ts b/docker/frontend/src/engine/libraryBackup.ts
index 4bed896..e59ae8e 100644
--- a/docker/frontend/src/engine/libraryBackup.ts
+++ b/docker/frontend/src/engine/libraryBackup.ts
@@ -7,7 +7,9 @@
//
// 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 the tiles beside it, one file per frame under `thumbs/`, so the
+// and the tiles beside it, one file per frame under `thumbs/` — 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
// 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
@@ -197,6 +199,50 @@ async function writable(): Promise {
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
// 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.
@@ -209,13 +255,14 @@ async function at(root: FileSystemDirectoryHandle, rel: string, create = false):
let path = '';
for (const part of rel.split('/')) {
if (!part) continue;
- path = path ? `${path}/${part}` : part;
+ const name = spell(part);
+ path = path ? `${path}/${name}` : name;
const had = cache.get(path);
if (had) {
node = had;
continue;
}
- node = await node.getDirectoryHandle(part, { create });
+ node = await node.getDirectoryHandle(name, { create });
cache.set(path, 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 {
const cut = rel.lastIndexOf('/');
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
// file that is not on the disk yet, and a backup that is interrupted is one the
// 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' }));
let written = 0;
+ let skipped = 0;
let done = 0;
const rows: { id: string; size: number }[] = [];
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;
const parts = photo.id.split('/');
parts.pop();
- const dir = await at(root, [THUMBS, ...parts].join('/'), true);
- const file = await dir.getFileHandle(photo.name, { create: true });
- const writer = await file.createWritable();
- await writer.write(photo.thumb);
- await writer.close();
+ const rel = [THUMBS, ...parts, photo.name].join('/');
+ try {
+ await put(root, rel, photo.thumb);
+ } catch (err) {
+ // 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 });
written++;
// 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));
}
await keep(WRITTEN, rows);
+ if (skipped) console.warn(`backup: ${skipped} of ${photos.length} tiles skipped`);
onProgress?.({ done: photos.length, total: photos.length });
const meta: Meta = { id: 'meta', at: at0, photos: photos.length, wrote: written };
await keep(WHERE, [meta]);
@@ -329,7 +386,7 @@ export async function restoreNow(onProgress?: (p: BackupProgress) => void): Prom
const parts = id.split('/');
const name = parts.pop() as string;
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' });
} catch {
// A tile that is not there — or that has been taken away by hand — leaves