web: a folder is chosen by picking one — the backup row names it and opens the picker, and restore always asks which backup
RESTORE was dead on a machine that had never written a backup: the button was `disabled` while no folder was remembered, and nothing in the handler could ever pick one, so the reader who had carried a copy over on a stick had no way to point at it. The button now stands, and the folder it restores from is whatever the reader picks in the dialog that opens — which is the whole of the choice there is, because a backup is a folder of rows and tiles. The folder on the other disk, the one made before the edit: the folder is the version. BACK UP asked for a folder only the first time one was ever chosen. After that it wrote into it in silence, and after a restart — when the browser takes the permission back, since a permission outlives the tab only while the tab does — it wrote nothing and said so, which is a backup that quietly stops happening. It now picks whenever it cannot write, and the picker is the only thing that can hand a remembered folder its permission back; a permission asked for with no picker behind it is one the browser may refuse. The run that follows a scan is untouched: it is guarded by the permission it would be asking for. And the folder is a control, not a caption. The line that names it — with the frames in it and when they were written — is what tells one backup from another, and pressing it is how a folder is picked, a name given, a permission handed back. It keeps the hint's own look through five lines of CSS, so the row still reads as a caption and not as a third button. What the line says is now read out of the folder rather than out of this browser's memory of it. `pickBackupFolder` reads the catalogue file inside the folder just picked and takes its count and its date as the row's own; a folder with no catalogue of its own has none, and the row says so instead of showing the numbers of the folder next door. That is also what the confirm names before a restore — the frames and the time in the folder just picked, which is the thing about to be restored. `restoreNow` already read that file; this reads its header first. Checked on the running bundle with the picker stubbed, since the dialog is one a probe cannot press: with nothing remembered, RESTORE now opens the picker where it did nothing at all, the row names the folder the picker returned, and a folder without a catalogue of its own is refused with a line saying so rather than restored as an empty catalogue. BACK UP opens it too, from the same fresh state. The line computes to a button with no border, no background, the page's own font and the hint's own 12px dim grey — a caption that happens to be pressable. The wall, the strip, the grid, the deep link and the phone's recipes all pass their checks.
This commit is contained in:
@@ -239,6 +239,24 @@ export function Library() {
|
||||
};
|
||||
}, [show]);
|
||||
|
||||
// Open the picker and take what it chose. False when the reader closed it, or
|
||||
// when there is no picker to open — and the one thing this is asked for is the
|
||||
// thing a folder cannot be reached without: it is where a folder is chosen,
|
||||
// where a remembered one is handed its permission back, and where a backup is
|
||||
// named, since the name of the folder is the name of the backup.
|
||||
const pickBackup = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
await pickBackupFolder();
|
||||
await show(await backupStatus());
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Closing the picker is a "not now", not a failure worth a line of text.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return false;
|
||||
setNote(t('lib.failed'));
|
||||
return false;
|
||||
}
|
||||
}, [show, t]);
|
||||
|
||||
// Write the catalogue into the folder. With none picked yet this is also the
|
||||
// way to pick one: one button, because a backup folder is not something to
|
||||
// choose and then not use. `quiet` is the run that follows a scan — it says
|
||||
@@ -246,16 +264,13 @@ export function Library() {
|
||||
const backup = useCallback(
|
||||
async (quiet = false) => {
|
||||
if (backBusy) return;
|
||||
if (!backRef.current?.folder) {
|
||||
try {
|
||||
await pickBackupFolder();
|
||||
} catch (err) {
|
||||
// Closing the picker is a "not now", not a failure worth a line of text.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setNote(t('lib.failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A folder the browser has not let this page write to is a folder to pick
|
||||
// again, and the picker is the only thing that can hand the permission
|
||||
// back — a permission outlives the tab only while the tab does, so this is
|
||||
// the run that follows every restart. Without it the button wrote nothing
|
||||
// and said so, which is a backup that quietly stopped happening. A quiet
|
||||
// run never comes here: it is guarded by the permission it would ask for.
|
||||
if ((!backRef.current?.folder || !backRef.current.granted) && !(await pickBackup())) return;
|
||||
setBackBusy(true);
|
||||
if (!quiet) setNote(t('lib.backupRun', { done: 0, total: backRef.current?.photos ?? 0 }));
|
||||
try {
|
||||
@@ -268,21 +283,41 @@ export function Library() {
|
||||
setBackBusy(false);
|
||||
}
|
||||
},
|
||||
[backBusy, show, t]
|
||||
[backBusy, pickBackup, show, t]
|
||||
);
|
||||
|
||||
// Where the copy goes, said out loud: the line that names the folder is the
|
||||
// control that changes it. A backup is a folder of rows and tiles, so the
|
||||
// folder's name — the one the reader gave it in the picker, or the disk it was
|
||||
// carried in on — is the whole of what tells one backup from another, and
|
||||
// nothing else on this screen can offer a name.
|
||||
const choose = useCallback(async () => {
|
||||
if (backBusy) return;
|
||||
await pickBackup();
|
||||
}, [backBusy, pickBackup]);
|
||||
|
||||
// The other direction, and the one that replaces rows: the frames come back
|
||||
// without a file handle — a handle is not a thing that can be written down —
|
||||
// and picking the folder again afterwards is what hands every one of them back,
|
||||
// without a frame being read.
|
||||
//
|
||||
// The folder is asked for every time, whatever this screen remembers, because
|
||||
// the folder is the whole of the choice: which backup to come back from is
|
||||
// which folder the reader points at — the copy on the other disk, the one made
|
||||
// before the edit. The count and the date on the confirm are read out of the
|
||||
// folder just picked, so the thing described is the thing restored, and a
|
||||
// folder with no catalogue of its own is a folder to point somewhere else at
|
||||
// rather than a run that replaces the reading with nothing.
|
||||
const restore = useCallback(async () => {
|
||||
if (backBusy) return;
|
||||
if (!(await pickBackup())) return;
|
||||
const status = backRef.current;
|
||||
if (!status?.folder) {
|
||||
setNote(t('lib.restoreNone'));
|
||||
if (!status?.at) {
|
||||
setNote(t('lib.restoreNothing', { folder: status?.folder ?? '' }));
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t('lib.restoreConfirm', { folder: status.folder }))) return;
|
||||
if (backBusy) return;
|
||||
const when = new Date(status.at).toLocaleString();
|
||||
if (!window.confirm(t('lib.restoreConfirm', { folder: status.folder ?? '', n: status.photos, time: when }))) return;
|
||||
setBackBusy(true);
|
||||
setNote(t('lib.restoreRun', { done: 0, total: status.photos }));
|
||||
try {
|
||||
@@ -295,7 +330,7 @@ export function Library() {
|
||||
} finally {
|
||||
setBackBusy(false);
|
||||
}
|
||||
}, [backBusy, reload, t]);
|
||||
}, [backBusy, pickBackup, reload, t]);
|
||||
|
||||
// A reading that is over is the one moment the rows are finished, so it is the
|
||||
// moment the catalogue is copied out: a timer would fire in the middle of a
|
||||
@@ -901,12 +936,22 @@ export function Library() {
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-restore"
|
||||
disabled={backBusy || !back?.folder}
|
||||
disabled={backBusy}
|
||||
onClick={() => void restore()}
|
||||
>
|
||||
{t('lib.restore')}
|
||||
</button>
|
||||
<span className="hint" data-key="lib-backup-state">
|
||||
{/* 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. */}
|
||||
<button
|
||||
type="button"
|
||||
className="hint"
|
||||
data-key="lib-backup-state"
|
||||
disabled={backBusy}
|
||||
title={t('lib.backupPick')}
|
||||
onClick={() => void choose()}
|
||||
>
|
||||
{back?.folder
|
||||
? back.granted
|
||||
? t('lib.backupAt', {
|
||||
@@ -916,7 +961,7 @@ export function Library() {
|
||||
})
|
||||
: t('lib.backupBlocked', { folder: back.folder })
|
||||
: t('lib.backupNone')}
|
||||
</span>
|
||||
</button>
|
||||
<span className="hint lib-bar-hint" title={t('lib.hint')}>
|
||||
{t('lib.hint')}
|
||||
</span>
|
||||
|
||||
@@ -145,8 +145,26 @@ export async function backupStatus(): Promise<BackupStatus> {
|
||||
};
|
||||
}
|
||||
|
||||
// The catalogue's own header, read out of the file and not out of this browser's
|
||||
// memory of it. A folder the reader is pointing at may be a copy carried over on
|
||||
// a stick, written by another machine, at another time: it is its own catalogue
|
||||
// that says which backup it is, and its own count and date that the screen has
|
||||
// to show before anything is written to it or read from it.
|
||||
async function head(root: FileSystemDirectoryHandle): Promise<Meta | null> {
|
||||
try {
|
||||
const text = await (await (await root.getFileHandle(CATALOGUE)).getFile()).text();
|
||||
const cat = JSON.parse(text) as { at?: number; photos?: unknown[] };
|
||||
return { id: 'meta', at: cat.at ?? 0, photos: (cat.photos ?? []).length, wrote: 0 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The picker's own dialog, the same way a folder of frames is picked — a second
|
||||
// `id` so the browser remembers this folder apart from the library's.
|
||||
// `id` so the browser remembers this folder apart from the library's. It is the
|
||||
// only way a folder is ever chosen, and the only thing that hands a remembered
|
||||
// one its permission back: the browser takes that away when the tab closes, and
|
||||
// a permission asked for without a picker behind it is one it may refuse.
|
||||
export async function pickBackupFolder(): Promise<string> {
|
||||
if (!('showDirectoryPicker' in window)) throw new Error('no-folder-picker');
|
||||
const handle = await (
|
||||
@@ -159,7 +177,14 @@ export async function pickBackupFolder(): Promise<string> {
|
||||
// A folder that is not the one the last run wrote into starts empty, and the
|
||||
// list of tiles already written is about that folder — kept, it would say every
|
||||
// tile is there and the new folder would come out with none of them.
|
||||
if (before?.name !== handle.name) await ask(WRITTEN, 'readwrite', (s) => s.clear());
|
||||
if (before?.name !== handle.name) {
|
||||
await ask(WRITTEN, 'readwrite', (s) => s.clear());
|
||||
// And the row goes back to saying what this folder holds rather than what
|
||||
// the last one did: a count and a date from the folder next door is a count
|
||||
// and a date about a backup the reader is not looking at.
|
||||
const meta = await head(handle);
|
||||
await ask<Meta | undefined>(WHERE, 'readwrite', (s) => (meta ? s.put(meta) : s.delete('meta')));
|
||||
}
|
||||
return handle.name;
|
||||
}
|
||||
|
||||
|
||||
@@ -285,16 +285,17 @@ export const en: Dict = {
|
||||
// profile takes the reading away from, and these two are what puts it back.
|
||||
'lib.backup': 'BACK UP',
|
||||
'lib.restore': 'RESTORE',
|
||||
'lib.backupNone': 'No backup folder yet — this picks one and writes the catalogue into it.',
|
||||
'lib.backupNone': 'No backup folder yet — press to pick one and write the catalogue into it.',
|
||||
'lib.backupAt': 'Backup: {folder} · {n} frames · {time}',
|
||||
'lib.backupBlocked': 'The browser has not let this page write to {folder}. Press BACK UP and allow it.',
|
||||
'lib.backupPick': 'Pick the folder the catalogue is written into and read back from — the folder name is the backup name.',
|
||||
'lib.backupRun': 'Backing up {done}/{total}…',
|
||||
'lib.backupDone': 'Backed up {n} frames, {w} tiles written.',
|
||||
'lib.backupFailed': 'The backup folder could not be written to.',
|
||||
'lib.restoreConfirm': 'Restore the catalogue from {folder}? Frames the library already holds are left as they are.',
|
||||
'lib.restoreConfirm': 'Restore {n} frames from {folder}, written {time}? Frames the library already holds are left as they are.',
|
||||
'lib.restoreRun': 'Restoring {done}/{total}…',
|
||||
'lib.restoreDone': 'Restored {n} frames. Add the folder again so every frame gets its file back.',
|
||||
'lib.restoreNone': 'Pick a backup folder first — BACK UP does that and writes the catalogue in one go.',
|
||||
'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.',
|
||||
|
||||
'nav.admin': 'Admin',
|
||||
|
||||
@@ -301,13 +301,14 @@ export const vi = {
|
||||
'lib.backupNone': 'Chưa có thư mục sao lưu — bấm để chọn một thư mục và ghi danh mục vào đó.',
|
||||
'lib.backupAt': 'Sao lưu: {folder} · {n} ảnh · {time}',
|
||||
'lib.backupBlocked': 'Trình duyệt chưa cho trang ghi vào {folder}. Bấm SAO LƯU và cho phép.',
|
||||
'lib.backupPick': 'Chọn thư mục mà danh mục được ghi vào và đọc lại từ đó — tên thư mục chính là tên bản sao lưu.',
|
||||
'lib.backupRun': 'Đang sao lưu {done}/{total}…',
|
||||
'lib.backupDone': 'Đã sao lưu {n} ảnh, ghi {w} thumbnail.',
|
||||
'lib.backupFailed': 'Không ghi được vào thư mục sao lưu.',
|
||||
'lib.restoreConfirm': 'Phục hồi danh mục từ {folder}? Những ảnh thư viện đã có sẽ được giữ nguyên.',
|
||||
'lib.restoreConfirm': 'Phục hồi {n} ảnh từ {folder}, ghi lúc {time}? Những ảnh thư viện đã có sẽ được giữ nguyên.',
|
||||
'lib.restoreRun': 'Đang phục hồi {done}/{total}…',
|
||||
'lib.restoreDone': 'Đã phục hồi {n} ảnh. Thêm lại thư mục ảnh để mỗi ảnh lấy lại được tệp của nó.',
|
||||
'lib.restoreNone': 'Chọn thư mục sao lưu trước — nút SAO LƯU làm việc đó và ghi danh mục luôn.',
|
||||
'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.',
|
||||
|
||||
'adm.title': 'Quản trị dải phim',
|
||||
|
||||
@@ -277,6 +277,19 @@
|
||||
input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; }
|
||||
.hint.ok { color: var(--accent); }
|
||||
/* A hint that is also a control — the backup row's folder is the reader's to
|
||||
change, and the line that names it is where it is changed. It keeps the hint's
|
||||
own look, so the row still reads as a caption and not as a third button. */
|
||||
button.hint {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.hint:hover:not(:disabled) { color: var(--text); }
|
||||
button.hint:disabled { cursor: default; }
|
||||
|
||||
/* --- PRO gate bar ------------------------------------------------------- */
|
||||
/* One line between the header and the workspace, shown only while a signed-in
|
||||
|
||||
Reference in New Issue
Block a user