fix(library): a folder that is not connected is not a file that was moved
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)}`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user