fix(library): a folder that is not connected is not a file that was moved

This commit is contained in:
2026-10-11 12:00:18 +07:00
parent fbc13d437d
commit 791e7d093c
5 changed files with 386 additions and 894 deletions
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -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)}`);
+68 -8
View File
@@ -436,22 +436,67 @@ export async function listFolders(): Promise<LibraryFolder[]> {
// 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<string, FileSystemDirectoryHandle | null>();
const dirHandles = new Map<string, FileSystemDirectoryHandle>();
export async function fileHandleFor(photo: LibraryPhoto): Promise<FileSystemFileHandle | null> {
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<FileSystemDirectoryHandle | null> {
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<FileSystemFileHandle | null> {
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<FileSystemFileHandle | null> {
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<File> {
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();
+2
View File
@@ -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.',
+2
View File
@@ -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.',