fix(library): resolve root folder rename, open in explorer, open at location centering, UPDATE rescan bug, and optimize scan RAM memory usage

This commit is contained in:
2026-10-07 19:16:03 +07:00
parent af76b46c3e
commit 9bfb39be91
5 changed files with 82 additions and 21 deletions
+26 -2
View File
@@ -2,11 +2,11 @@ import { recipeFile } from './recipeFile';
import { sendVerificationMail } from './mailer'; import { sendVerificationMail } from './mailer';
import { placeName } from './place'; import { placeName } from './place';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { spawn, spawnSync } from 'node:child_process'; import { exec, spawn, spawnSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { basename, join } from 'node:path'; import { basename, dirname, join } from 'node:path';
import { import {
DATA_DIR, DATA_DIR,
MAX_PHOTO_BYTES, MAX_PHOTO_BYTES,
@@ -420,6 +420,30 @@ async function lookupGeo(ip: string): Promise<GeoPlace | null> {
// ---- routes ---- // ---- routes ----
app.get('/api/health', async () => ({ ok: true })); app.get('/api/health', async () => ({ ok: true }));
// Native file manager launcher (Open in window explorer)
app.post('/api/open-explorer', async (req, reply) => {
const b = bodyOf(req);
if (!b || typeof b.path !== 'string') return reply.status(400).send({ ok: false, error: 'Invalid path' });
const rawPath = b.path.trim();
if (!rawPath) return reply.status(400).send({ ok: false, error: 'Path empty' });
const platform = process.platform;
try {
if (platform === 'win32') {
const winPath = rawPath.replace(/\//g, '\\');
exec(`explorer.exe /select,"${winPath}"`);
} else if (platform === 'darwin') {
exec(`open -R "${rawPath}"`);
} else {
const dir = dirname(rawPath);
exec(`xdg-open "${dir}"`);
}
return reply.send({ ok: true });
} catch (err: any) {
return reply.status(500).send({ ok: false, error: String(err?.message || err) });
}
});
// The counter's one write. Public and unauthenticated by design: it is fired by // The counter's one write. Public and unauthenticated by design: it is fired by
// a beacon from every page, so it must never be a way to probe who is signed in // a beacon from every page, so it must never be a way to probe who is signed in
// — it answers 204 whatever happens and echoes nothing back. // — it answers 204 whatever happens and echoes nothing back.
+32 -14
View File
@@ -2094,18 +2094,20 @@ function Catalogue() {
{menu.folder ? ( {menu.folder ? (
ready.has(menu.folder.name) ? ( ready.has(menu.folder.name) ? (
<> <>
<button {!menu.root ? (
type="button" <button
className="lib-menu-item" type="button"
data-key={`lib-rename-${menu.folder.name}`} className="lib-menu-item"
onClick={() => { data-key={`lib-rename-${menu.folder.name}`}
setMenu(null); onClick={() => {
void rename(menu.folder!); setMenu(null);
}} void rename(menu.folder!);
> }}
<span className="lib-menu-icon" aria-hidden="true">✎</span> >
{t('lib.rename')} <span className="lib-menu-icon" aria-hidden="true">✎</span>
</button> {t('lib.rename')}
</button>
) : null}
{/* Three things can be asked of a roll: bring it up to date, read it {/* Three things can be asked of a roll: bring it up to date, read it
from the top again, or leave it — and the last only of the from the top again, or leave it — and the last only of the
reading that is this roll's, or of the request for it that is reading that is this roll's, or of the request for it that is
@@ -2271,11 +2273,19 @@ function Catalogue() {
const folderPath = photo.dir ? `${photo.folder}/${photo.dir}` : photo.folder; const folderPath = photo.dir ? `${photo.folder}/${photo.dir}` : photo.folder;
const fullPath = `${folderPath}/${photo.name}`; const fullPath = `${folderPath}/${photo.name}`;
try { try {
const res = await api.openExplorer(fullPath).catch(() => null);
if (res?.ok) {
setNote(t('lib.scanned', { folder: fullPath, added: 0, total: 1 }));
} else if (navigator.clipboard) {
await navigator.clipboard.writeText(fullPath);
setNote(t('lib.copiedPath', { path: fullPath }));
}
} catch {
if (navigator.clipboard) { if (navigator.clipboard) {
await navigator.clipboard.writeText(fullPath); await navigator.clipboard.writeText(fullPath);
setNote(t('lib.copiedPath', { path: fullPath })); setNote(t('lib.copiedPath', { path: fullPath }));
} }
} catch {} }
}} }}
> >
<span className="lib-menu-icon" aria-hidden="true">📁</span> <span className="lib-menu-icon" aria-hidden="true">📁</span>
@@ -2300,6 +2310,14 @@ function Catalogue() {
} }
return next; return next;
}); });
setTimeout(() => {
const el = document.querySelector(`[data-key="lib-folder-${targetNode}"]`) || document.querySelector(`[data-key="lib-folder-${photo.folder}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('lib-folder-highlight');
setTimeout(() => el.classList.remove('lib-folder-highlight'), 2000);
}
}, 120);
}} }}
> >
<span className="lib-menu-icon" aria-hidden="true">🎯</span> <span className="lib-menu-icon" aria-hidden="true">🎯</span>
@@ -2385,7 +2403,7 @@ function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick
// One object URL per tile, made when the row does and dropped with it: a folder // One object URL per tile, made when the row does and dropped with it: a folder
const globalThumbUrls = new Map<string, { url: string; blob: Blob }>(); const globalThumbUrls = new Map<string, { url: string; blob: Blob }>();
const GLOBAL_URL_MAX = 2500; const GLOBAL_URL_MAX = 600;
function cacheObjectUrl(id: string, blob: Blob): string { function cacheObjectUrl(id: string, blob: Blob): string {
const existing = globalThumbUrls.get(id); const existing = globalThumbUrls.get(id);
+3
View File
@@ -353,4 +353,7 @@ export const api = {
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { user: User }; return body as unknown as { user: User };
}, },
// Open photo location in native system file manager (Windows Explorer / Finder / Linux file manager)
openExplorer: (path: string) => call<{ ok: boolean }>('/open-explorer', { method: 'POST', body: JSON.stringify({ path }) }),
}; };
+14 -5
View File
@@ -146,7 +146,7 @@ export function canBrowseFolders(): boolean {
return typeof window !== 'undefined' && 'showDirectoryPicker' in window; return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
} }
const THUMB_CACHE_MAX = 5000; const THUMB_CACHE_MAX = 800;
const thumbCache = new Map<string, Blob>(); const thumbCache = new Map<string, Blob>();
export function cacheThumb(id: string, blob: Blob): void { export function cacheThumb(id: string, blob: Blob): void {
@@ -729,7 +729,13 @@ export async function scanFolder(
// frames it does not re-read are skipped on their size and their time. // frames it does not re-read are skipped on their size and their time.
const bounded = from !== ''; const bounded = from !== '';
const saved = bounded ? null : await loadWalk(folder.name); const saved = bounded ? null : await loadWalk(folder.name);
const tree: Walk = { root: folder.handle, pending: [], walked: new Set(saved?.walked ?? []) }; const treeWalked = new Set(saved?.walked ?? []);
if (from) {
for (const p of [...treeWalked]) {
if (p === from || p.startsWith(from)) treeWalked.delete(p);
}
}
const tree: Walk = { root: folder.handle, pending: [], walked: treeWalked };
// A queue comes back as paths, so the folders are asked for again; a reading // A queue comes back as paths, so the folders are asked for again; a reading
// with nothing written down starts at the folder it was pointed at — the // with nothing written down starts at the folder it was pointed at — the
// picked one, or the one the reader right-clicked inside it. // picked one, or the one the reader right-clicked inside it.
@@ -738,6 +744,10 @@ export async function scanFolder(
const dir = await openAt(folder.handle, rel); const dir = await openAt(folder.handle, rel);
if (dir) tree.pending.push({ dir, rel }); if (dir) tree.pending.push({ dir, rel });
} }
if (from && !tree.pending.some((p) => p.rel === from)) {
const dir = await openAt(folder.handle, from);
if (dir) tree.pending.unshift({ dir, rel: from });
}
} else { } else {
const dir = from ? await openAt(folder.handle, from) : folder.handle; const dir = from ? await openAt(folder.handle, from) : folder.handle;
if (dir) tree.pending.push({ dir, rel: from }); if (dir) tree.pending.push({ dir, rel: from });
@@ -991,9 +1001,8 @@ export async function scanFolder(
progress.done++; progress.done++;
count(rel); count(rel);
} }
// A RAW takes the one open there is before its lane does anything, so a // Queue image decoding one at a time so memory usage stays low and UI stays smooth
// folder of them is read a frame at a time whatever the lanes say. const read = oneRawAtATime(() => readOne(handle, rel));
const read = isRawName(handle.name) ? oneRawAtATime(() => readOne(handle, rel)) : readOne(handle, rel);
const lane = read const lane = read
.catch(() => undefined) .catch(() => undefined)
.finally(() => lanes.delete(lane)); .finally(() => lanes.delete(lane));
+7
View File
@@ -2042,3 +2042,10 @@ button.hint:disabled { cursor: default; }
.adm:not(.lib-root) .adm-table td { padding: 9px 10px; } .adm:not(.lib-root) .adm-table td { padding: 9px 10px; }
.adm:not(.lib-root) .adm-table .btn { min-height: 40px; } .adm:not(.lib-root) .adm-table .btn { min-height: 40px; }
} }
.lib-folder-highlight {
background: var(--accent-soft, rgba(245, 158, 11, 0.25)) !important;
outline: 2px solid var(--accent, #f59e0b) !important;
border-radius: var(--radius-sm, 4px);
transition: background 0.3s ease, outline 0.3s ease;
}