diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index a2dcec7..5861543 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -10,6 +10,7 @@ import { canBrowseFolders, clearLibraryCache, ensurePermission, + findFolderRelationship, getPhoto, getPhotoThumbs, HEAD_BYTES, @@ -659,16 +660,54 @@ export function Library() { try { const folder = await pickFolder(); if (!folder) return; - setFolders((prev) => [...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name))); - setReady((prev) => new Set(prev).add(folder.name)); - setNode(folder.name); - await scan(folder); + + const relInfo = await findFolderRelationship(folder.handle, folders); + + if (relInfo.type === 'subfolder') { + const { parent, relPath } = relInfo; + const subNodeKey = `${parent.name}/${relPath}`; + const parts = relPath.split('/'); + + setExpanded((prev) => { + const next = new Set(prev); + let curr = parent.name; + next.add(curr); + for (let i = 0; i < parts.length; i++) { + curr = `${curr}/${parts[i]}`; + next.add(curr); + } + return next; + }); + + setDirs((prev) => { + if (prev.some((d) => d.id === subNodeKey)) return prev; + return [...prev, { id: subNodeKey, folder: parent.name, rel: relPath }]; + }); + + setNode(subNodeKey); + await scan(parent, false, relPath + '/'); + } else if (relInfo.type === 'parent') { + const { child } = relInfo; + const nextFolders = [...folders.filter((f) => f.name !== child.name), folder].sort((a, b) => + a.name.localeCompare(b.name) + ); + setFolders(nextFolders); + setReady((prev) => new Set(prev).add(folder.name)); + setNode(folder.name); + await scan(folder); + } else { + setFolders((prev) => + [...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name)) + ); + setReady((prev) => new Set(prev).add(folder.name)); + setNode(folder.name); + await scan(folder); + } } 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')); } - }, [scan, t]); + }, [folders, scan, t]); const reconnect = useCallback( async (folder: LibraryFolder) => { @@ -1107,7 +1146,7 @@ export function Library() { same row and ellipsises — the catalogue is the content, this bar is furniture. */}
- {live || scanQueue().length ? ( @@ -1732,7 +1771,6 @@ export function Library() { type="button" className="lib-menu-item" data-key="lib-menu-add" - disabled={!!live} onClick={() => { setMenu(null); void add(); diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index c14f95c..6070045 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -162,6 +162,45 @@ export function getCachedThumb(id: string): Blob | null { return null; } +async function migrateLegacyDatabase(db: IDBDatabase): Promise { + if (typeof window === 'undefined') return; + const MIGRATED_KEY = 'recipescam-migrated-v2'; + if (localStorage.getItem(MIGRATED_KEY) === 'true') return; + if (!db.objectStoreNames.contains(THUMBS) || !db.objectStoreNames.contains(PHOTOS)) return; + + try { + await new Promise((resolve) => { + const tx = db.transaction([PHOTOS, THUMBS], 'readwrite'); + const photoStore = tx.objectStore(PHOTOS); + const thumbStore = tx.objectStore(THUMBS); + const req = photoStore.openCursor(); + + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + const val = cursor.value as any; + if (val && val.thumb) { + const thumbBlob = val.thumb as Blob; + const id = val.id as string; + cacheThumb(id, thumbBlob); + thumbStore.put({ id, thumb: thumbBlob }); + delete val.thumb; + cursor.update(val); + } + cursor.continue(); + } else { + resolve(); + } + }; + req.onerror = () => resolve(); + tx.oncomplete = () => resolve(); + }); + localStorage.setItem(MIGRATED_KEY, 'true'); + } catch { + // Non-fatal if migration is interrupted + } +} + let dbPromise: Promise | null = null; function openDb(): Promise { @@ -180,12 +219,44 @@ function openDb(): Promise { } if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' }); }; - req.onsuccess = () => resolve(req.result); + req.onsuccess = () => { + const db = req.result; + void migrateLegacyDatabase(db).then(() => resolve(db)); + }; req.onerror = () => reject(req.error); }); return dbPromise; } +export async function findFolderRelationship( + newHandle: FileSystemDirectoryHandle, + existingFolders: LibraryFolder[] +): Promise< + | { type: 'subfolder'; parent: LibraryFolder; relPath: string } + | { type: 'parent'; child: LibraryFolder } + | { type: 'root' } +> { + for (const existing of existingFolders) { + try { + const relParts = await existing.handle.resolve(newHandle); + if (relParts && relParts.length > 0) { + return { type: 'subfolder', parent: existing, relPath: relParts.join('/') }; + } + } catch {} + } + + for (const existing of existingFolders) { + try { + const relParts = await newHandle.resolve(existing.handle); + if (relParts && relParts.length > 0) { + return { type: 'parent', child: existing }; + } + } catch {} + } + + return { type: 'root' }; +} + // One request per call: the transaction is the store's own, which is all a // keyed get/put/delete and a getAll ever need. A cursor-based scan (the folder // walk) goes through `walk` below instead, because it holds one transaction @@ -1311,30 +1382,6 @@ export async function readPhotos(folder?: string): Promise(PHOTOS, 'readonly', (s) => folder ? s.index('folder').getAll(folder) : s.getAll() ); - if (db.objectStoreNames.contains(THUMBS)) { - const legacyPhotosToUpdate: LibraryPhoto[] = []; - const legacyThumbsToPut: { id: string; thumb: Blob }[] = []; - for (const row of rows) { - if (row.thumb) { - const t = row.thumb; - cacheThumb(row.id, t); - legacyThumbsToPut.push({ id: row.id, thumb: t }); - const { thumb, ...meta } = row; - legacyPhotosToUpdate.push(meta as LibraryPhoto); - } - } - if (legacyThumbsToPut.length > 0) { - void (async () => { - try { - const tx = db.transaction([PHOTOS, THUMBS], 'readwrite'); - const pStore = tx.objectStore(PHOTOS); - const tStore = tx.objectStore(THUMBS); - for (const item of legacyThumbsToPut) tStore.put(item); - for (const item of legacyPhotosToUpdate) pStore.put(item); - } catch {} - })(); - } - } for (let i = 0; i < rows.length; i++) { if (rows[i].thumb) delete (rows[i] as { thumb?: Blob | null }).thumb; } diff --git a/docker/recipescam-images.tar.gz b/docker/recipescam-images.tar.gz index d857d76..7dc073b 100644 Binary files a/docker/recipescam-images.tar.gz and b/docker/recipescam-images.tar.gz differ diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 6e62703..795e363 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -964002fe33356e941152f0ba1a3576b9a58497308eff5b6092f6e4d3be15eee0 recipescam-images.tar.gz +c496643cb1660e2b1011efedf1725659053af9de7fc6e9340ffd04d5e74e0d66 recipescam-images.tar.gz