Add organize folders, details sheet, and richer previews.
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:
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user