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 { placeName } from './place';
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 { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import { basename, dirname, join } from 'node:path';
import {
DATA_DIR,
MAX_PHOTO_BYTES,
@@ -420,6 +420,30 @@ async function lookupGeo(ip: string): Promise<GeoPlace | null> {
// ---- routes ----
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
// 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.
+32 -14
View File
@@ -2094,18 +2094,20 @@ function Catalogue() {
{menu.folder ? (
ready.has(menu.folder.name) ? (
<>
<button
type="button"
className="lib-menu-item"
data-key={`lib-rename-${menu.folder.name}`}
onClick={() => {
setMenu(null);
void rename(menu.folder!);
}}
>
<span className="lib-menu-icon" aria-hidden="true">✎</span>
{t('lib.rename')}
</button>
{!menu.root ? (
<button
type="button"
className="lib-menu-item"
data-key={`lib-rename-${menu.folder.name}`}
onClick={() => {
setMenu(null);
void rename(menu.folder!);
}}
>
<span className="lib-menu-icon" aria-hidden="true">✎</span>
{t('lib.rename')}
</button>
) : null}
{/* 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
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 fullPath = `${folderPath}/${photo.name}`;
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) {
await navigator.clipboard.writeText(fullPath);
setNote(t('lib.copiedPath', { path: fullPath }));
}
} catch {}
}
}}
>
<span className="lib-menu-icon" aria-hidden="true">📁</span>
@@ -2300,6 +2310,14 @@ function Catalogue() {
}
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>
@@ -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
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 {
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}`);
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;
}
const THUMB_CACHE_MAX = 5000;
const THUMB_CACHE_MAX = 800;
const thumbCache = new Map<string, Blob>();
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.
const bounded = from !== '';
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
// with nothing written down starts at the folder it was pointed at — the
// 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);
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 {
const dir = from ? await openAt(folder.handle, from) : folder.handle;
if (dir) tree.pending.push({ dir, rel: from });
@@ -991,9 +1001,8 @@ export async function scanFolder(
progress.done++;
count(rel);
}
// A RAW takes the one open there is before its lane does anything, so a
// folder of them is read a frame at a time whatever the lanes say.
const read = isRawName(handle.name) ? oneRawAtATime(() => readOne(handle, rel)) : readOne(handle, rel);
// Queue image decoding one at a time so memory usage stays low and UI stays smooth
const read = oneRawAtATime(() => readOne(handle, rel));
const lane = read
.catch(() => undefined)
.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 .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;
}