Add organize folders, details sheet, and richer previews.
CI / Gitleaks (pull_request) Successful in 17s
CI / Unit tests (pull_request) Successful in 21s

Map keys 0–9 to destinations, inspect files before deciding, and preview
PDF/RAW/ZIP/video with an OLED dark UI polish plus demo seed and tests.
This commit is contained in:
2026-07-26 21:38:36 -04:00
parent 5552f39707
commit ce43c5de6d
21 changed files with 1905 additions and 121 deletions
+160
View File
@@ -0,0 +1,160 @@
'use strict';
// Best-effort file details for the "inspect before deciding" panel.
// Uses fs.stat always; on macOS also sips (dimensions) and mdls (camera /
// capture date) when available. Failures are silent — callers get whatever
// we could gather.
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const IS_MACOS = process.platform === 'darwin';
const IMAGE_EXT = new Set([
'jpg',
'jpeg',
'png',
'gif',
'webp',
'heic',
'heif',
'bmp',
'tiff',
'tif',
'avif',
// Camera RAW — sips/mdls often still know dimensions / capture metadata on macOS
'dng',
'cr2',
'cr3',
'nef',
'nrw',
'arw',
'orf',
'rw2',
'raf',
'pef',
'raw',
'srw',
]);
const PDF_EXT = new Set(['pdf']);
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatDate(d) {
if (!(d instanceof Date) || Number.isNaN(d.getTime())) return null;
return d.toISOString().replace('T', ' ').slice(0, 19);
}
async function sipsDimensions(absPath) {
if (!IS_MACOS) return null;
try {
const { stdout } = await execFileAsync('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', absPath], {
timeout: 3000,
killSignal: 'SIGKILL',
});
const width = /pixelWidth:\s*(\d+)/.exec(stdout);
const height = /pixelHeight:\s*(\d+)/.exec(stdout);
if (width && height) return { width: Number(width[1]), height: Number(height[1]) };
} catch {
// ignore
}
return null;
}
async function mdlsFields(absPath) {
if (!IS_MACOS) return {};
const keys = [
'kMDItemAcquisitionMake',
'kMDItemAcquisitionModel',
'kMDItemContentCreationDate',
'kMDItemLatitude',
'kMDItemLongitude',
'kMDItemOrientation',
'kMDItemProfileName',
];
try {
const args = [];
for (const key of keys) args.push('-name', key);
args.push(absPath);
const { stdout } = await execFileAsync('mdls', args, { timeout: 3000, killSignal: 'SIGKILL' });
const out = {};
for (const key of keys) {
const re = new RegExp(`${key}\\s*=\\s*(.+)$`, 'm');
const match = re.exec(stdout);
if (!match) continue;
let value = match[1].trim();
if (value === '(null)' || value === 'null') continue;
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
out[key] = value;
}
return out;
} catch {
return {};
}
}
/**
* @returns {Promise<{ fields: Array<{ label: string, value: string }>, path: string }>}
*/
async function collectFileDetails(absPath) {
const fields = [];
const stat = await fsp.stat(absPath);
const ext = path.extname(absPath).slice(1).toLowerCase();
fields.push({ label: 'Path', value: absPath });
fields.push({ label: 'Name', value: path.basename(absPath) });
if (ext) fields.push({ label: 'Type', value: ext.toUpperCase() });
fields.push({ label: 'Size', value: `${formatSize(stat.size)} (${stat.size.toLocaleString()} bytes)` });
fields.push({ label: 'Modified', value: formatDate(stat.mtime) });
fields.push({ label: 'Created', value: formatDate(stat.birthtime) || formatDate(stat.ctime) });
if (IMAGE_EXT.has(ext)) {
const dims = await sipsDimensions(absPath);
if (dims) fields.push({ label: 'Dimensions', value: `${dims.width} × ${dims.height}` });
const md = await mdlsFields(absPath);
if (md.kMDItemAcquisitionMake || md.kMDItemAcquisitionModel) {
fields.push({
label: 'Camera',
value: [md.kMDItemAcquisitionMake, md.kMDItemAcquisitionModel].filter(Boolean).join(' '),
});
}
if (md.kMDItemContentCreationDate) {
fields.push({ label: 'Captured', value: md.kMDItemContentCreationDate.replace(' +0000', ' UTC') });
}
if (md.kMDItemLatitude && md.kMDItemLongitude) {
fields.push({ label: 'Location', value: `${md.kMDItemLatitude}, ${md.kMDItemLongitude}` });
}
if (md.kMDItemProfileName) fields.push({ label: 'Color profile', value: md.kMDItemProfileName });
}
if (PDF_EXT.has(ext) && IS_MACOS) {
const md = await mdlsFields(absPath);
// Page count shows up under kMDItemNumberOfPages for many PDFs
try {
const { stdout } = await execFileAsync('mdls', ['-name', 'kMDItemNumberOfPages', absPath], {
timeout: 3000,
killSignal: 'SIGKILL',
});
const match = /kMDItemNumberOfPages\s*=\s*(\d+)/.exec(stdout);
if (match) fields.push({ label: 'Pages', value: match[1] });
} catch {
// ignore
}
if (md.kMDItemContentCreationDate) {
fields.push({ label: 'Document date', value: md.kMDItemContentCreationDate.replace(' +0000', ' UTC') });
}
}
return { path: absPath, fields: fields.filter((f) => f.value) };
}
module.exports = { collectFileDetails, formatSize };
+98
View File
@@ -0,0 +1,98 @@
'use strict';
// Lightweight ZIP central-directory reader — list entry names + sizes without
// extracting. No npm deps. Supports store/deflate archives (standard .zip).
// Not a full unzipper: encrypted, zip64, and split archives are skipped/partial.
const fs = require('fs');
const fsp = fs.promises;
const EOCD_SIG = 0x06054b50;
const CEN_SIG = 0x02014b50;
const MAX_SCAN = 64 * 1024; // EOCD lives in the last 64KiB (+comment)
const MAX_ENTRIES = 200;
const MAX_NAME_BYTES = 1024;
/**
* @returns {Promise<{ entries: Array<{ name: string, size: number, compressedSize: number, isDir: boolean }>, truncated: boolean, totalEntries: number }>}
*/
async function listZipEntries(absPath) {
const stat = await fsp.stat(absPath);
const fd = await fsp.open(absPath, 'r');
try {
const scanLen = Math.min(stat.size, MAX_SCAN);
const tail = Buffer.alloc(scanLen);
await fd.read(tail, 0, scanLen, stat.size - scanLen);
let eocd = -1;
for (let i = tail.length - 22; i >= 0; i -= 1) {
if (tail.readUInt32LE(i) === EOCD_SIG) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error('Not a zip archive (EOCD not found)');
const totalEntries = tail.readUInt16LE(eocd + 10);
const cenSize = tail.readUInt32LE(eocd + 12);
const cenOffset = tail.readUInt32LE(eocd + 16);
// Zip64 / huge archives: offsets of 0xffffffff mean we can't parse simply.
if (cenOffset === 0xffffffff || cenSize === 0xffffffff) {
throw new Error('Zip64 archives are not supported for listing');
}
const cen = Buffer.alloc(cenSize);
const { bytesRead } = await fd.read(cen, 0, cenSize, cenOffset);
if (bytesRead !== cenSize) throw new Error('Truncated zip central directory');
const entries = [];
let offset = 0;
let truncated = false;
while (offset + 46 <= cen.length && entries.length < MAX_ENTRIES) {
if (cen.readUInt32LE(offset) !== CEN_SIG) break;
const compressedSize = cen.readUInt32LE(offset + 20);
const size = cen.readUInt32LE(offset + 24);
const nameLen = cen.readUInt16LE(offset + 28);
const extraLen = cen.readUInt16LE(offset + 30);
const commentLen = cen.readUInt16LE(offset + 32);
const nameStart = offset + 46;
const nameEnd = nameStart + Math.min(nameLen, MAX_NAME_BYTES);
let name = cen.slice(nameStart, nameEnd).toString('utf8');
// Prefer UTF-8 when the language-encoding flag is set; otherwise still try utf8.
const isDir = name.endsWith('/');
entries.push({ name, size, compressedSize, isDir });
offset = nameStart + nameLen + extraLen + commentLen;
}
if (totalEntries > entries.length) truncated = true;
return { entries, truncated, totalEntries: totalEntries || entries.length };
} finally {
await fd.close();
}
}
function formatZipListing(listing) {
const lines = [];
const files = listing.entries.filter((e) => !e.isDir);
const dirs = listing.entries.filter((e) => e.isDir);
lines.push(`${listing.totalEntries} entr${listing.totalEntries === 1 ? 'y' : 'ies'} (${files.length} files${dirs.length ? `, ${dirs.length} folders` : ''})`);
lines.push('');
for (const entry of listing.entries) {
if (entry.isDir) {
lines.push(` [dir] ${entry.name}`);
} else {
lines.push(` ${formatSize(entry.size).padStart(8)} ${entry.name}`);
}
}
if (listing.truncated) lines.push('', `… listing capped at ${MAX_ENTRIES} entries`);
return lines.join('\n');
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
module.exports = { listZipEntries, formatZipListing, MAX_ENTRIES };