fix(library): add one-time DB cursor migration to clean legacy Blobs and implement hierarchical Add Folder tree matching
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
canBrowseFolders,
|
canBrowseFolders,
|
||||||
clearLibraryCache,
|
clearLibraryCache,
|
||||||
ensurePermission,
|
ensurePermission,
|
||||||
|
findFolderRelationship,
|
||||||
getPhoto,
|
getPhoto,
|
||||||
getPhotoThumbs,
|
getPhotoThumbs,
|
||||||
HEAD_BYTES,
|
HEAD_BYTES,
|
||||||
@@ -659,16 +660,54 @@ export function Library() {
|
|||||||
try {
|
try {
|
||||||
const folder = await pickFolder();
|
const folder = await pickFolder();
|
||||||
if (!folder) return;
|
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));
|
const relInfo = await findFolderRelationship(folder.handle, folders);
|
||||||
setNode(folder.name);
|
|
||||||
await scan(folder);
|
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) {
|
} 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;
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
setNote(t('lib.failed'));
|
setNote(t('lib.failed'));
|
||||||
}
|
}
|
||||||
}, [scan, t]);
|
}, [folders, scan, t]);
|
||||||
|
|
||||||
const reconnect = useCallback(
|
const reconnect = useCallback(
|
||||||
async (folder: LibraryFolder) => {
|
async (folder: LibraryFolder) => {
|
||||||
@@ -1107,7 +1146,7 @@ export function Library() {
|
|||||||
same row and ellipsises — the catalogue is the content, this bar
|
same row and ellipsises — the catalogue is the content, this bar
|
||||||
is furniture. */}
|
is furniture. */}
|
||||||
<div className="chip-row lib-bar">
|
<div className="chip-row lib-bar">
|
||||||
<button type="button" className="btn primary" data-key="lib-add" disabled={!!live} onClick={() => void add()}>
|
<button type="button" className="btn primary" data-key="lib-add" onClick={() => void add()}>
|
||||||
{t('lib.add')}
|
{t('lib.add')}
|
||||||
</button>
|
</button>
|
||||||
{live || scanQueue().length ? (
|
{live || scanQueue().length ? (
|
||||||
@@ -1732,7 +1771,6 @@ export function Library() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="lib-menu-item"
|
className="lib-menu-item"
|
||||||
data-key="lib-menu-add"
|
data-key="lib-menu-add"
|
||||||
disabled={!!live}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMenu(null);
|
setMenu(null);
|
||||||
void add();
|
void add();
|
||||||
|
|||||||
@@ -162,6 +162,45 @@ export function getCachedThumb(id: string): Blob | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyDatabase(db: IDBDatabase): Promise<void> {
|
||||||
|
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<void>((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<IDBDatabase> | null = null;
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
function openDb(): Promise<IDBDatabase> {
|
function openDb(): Promise<IDBDatabase> {
|
||||||
@@ -180,12 +219,44 @@ function openDb(): Promise<IDBDatabase> {
|
|||||||
}
|
}
|
||||||
if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' });
|
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);
|
req.onerror = () => reject(req.error);
|
||||||
});
|
});
|
||||||
return dbPromise;
|
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
|
// 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
|
// 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
|
// walk) goes through `walk` below instead, because it holds one transaction
|
||||||
@@ -1311,30 +1382,6 @@ export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null
|
|||||||
const rows = await ask<LibraryPhoto[]>(PHOTOS, 'readonly', (s) =>
|
const rows = await ask<LibraryPhoto[]>(PHOTOS, 'readonly', (s) =>
|
||||||
folder ? s.index('folder').getAll(folder) : s.getAll()
|
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++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
if (rows[i].thumb) delete (rows[i] as { thumb?: Blob | null }).thumb;
|
if (rows[i].thumb) delete (rows[i] as { thumb?: Blob | null }).thumb;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
964002fe33356e941152f0ba1a3576b9a58497308eff5b6092f6e4d3be15eee0 recipescam-images.tar.gz
|
c496643cb1660e2b1011efedf1725659053af9de7fc6e9340ffd04d5e74e0d66 recipescam-images.tar.gz
|
||||||
|
|||||||
Reference in New Issue
Block a user