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