diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 988538b..c56e9c5 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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 => { + 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')} - + {/* 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. */} + {t('lib.hint')} diff --git a/docker/frontend/src/engine/libraryBackup.ts b/docker/frontend/src/engine/libraryBackup.ts index 2fe1a03..4bed896 100644 --- a/docker/frontend/src/engine/libraryBackup.ts +++ b/docker/frontend/src/engine/libraryBackup.ts @@ -145,8 +145,26 @@ export async function backupStatus(): Promise { }; } +// 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 { + 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 { if (!('showDirectoryPicker' in window)) throw new Error('no-folder-picker'); const handle = await ( @@ -159,7 +177,14 @@ export async function pickBackupFolder(): Promise { // 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(WHERE, 'readwrite', (s) => (meta ? s.put(meta) : s.delete('meta'))); + } return handle.name; } diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 8286c40..bd9f208 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 75fdf06..ad5864f 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 06298ab..210aa3f 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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