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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) }),
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user