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.
407 lines
13 KiB
JavaScript
407 lines
13 KiB
JavaScript
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const fsp = fs.promises;
|
||
const path = require('path');
|
||
const { Adapter } = require('./base');
|
||
const { getThumbnail } = require('../lib/thumbnails');
|
||
const { collectFileDetails } = require('../lib/file-details');
|
||
const { listZipEntries, formatZipListing } = require('../lib/zip-list');
|
||
const { execFile } = require('child_process');
|
||
const { promisify } = require('util');
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
|
||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'svg', 'tiff', 'tif', 'avif']);
|
||
const RAW_EXT = new Set([
|
||
'dng',
|
||
'cr2',
|
||
'cr3',
|
||
'nef',
|
||
'nrw',
|
||
'arw',
|
||
'srf',
|
||
'sr2',
|
||
'orf',
|
||
'rw2',
|
||
'raf',
|
||
'pef',
|
||
'ptx',
|
||
'x3f',
|
||
'raw',
|
||
'rwl',
|
||
'srw',
|
||
]);
|
||
const AUDIO_EXT = new Set(['mp3', 'wav', 'flac', 'm4a', 'ogg', 'aac']);
|
||
const VIDEO_EXT = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'm4v']);
|
||
const PDF_EXT = new Set(['pdf']);
|
||
const ZIP_EXT = new Set(['zip']);
|
||
const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'tsv', 'log', 'yaml', 'yml']);
|
||
const DEST_KEYS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||
|
||
function extOf(filePath) {
|
||
return path.extname(filePath).slice(1).toLowerCase();
|
||
}
|
||
|
||
// Express/`send`'s bundled mime database doesn't know some of these, and
|
||
// browsers need the right Content-Type to render/play a streamed preview.
|
||
const MIME_OVERRIDES = {
|
||
heic: 'image/heic',
|
||
heif: 'image/heif',
|
||
flac: 'audio/flac',
|
||
dng: 'image/x-adobe-dng',
|
||
};
|
||
|
||
function previewTypeFor(filePath) {
|
||
const ext = extOf(filePath);
|
||
if (IMAGE_EXT.has(ext) || RAW_EXT.has(ext)) return 'image';
|
||
if (AUDIO_EXT.has(ext)) return 'audio';
|
||
if (VIDEO_EXT.has(ext)) return 'video';
|
||
if (PDF_EXT.has(ext)) return 'pdf';
|
||
if (ZIP_EXT.has(ext)) return 'archive';
|
||
if (TEXT_EXT.has(ext)) return 'text';
|
||
return 'none';
|
||
}
|
||
|
||
/** Exts whose original bytes don't render in most browsers — serve QL thumb as preview when possible. */
|
||
function needsThumbnailPreview(filePath) {
|
||
const ext = extOf(filePath);
|
||
return RAW_EXT.has(ext) || ext === 'heic' || ext === 'heif';
|
||
}
|
||
|
||
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`;
|
||
}
|
||
|
||
function idFor(relativePath) {
|
||
return Buffer.from(relativePath).toString('base64url');
|
||
}
|
||
|
||
function pathForId(id) {
|
||
return Buffer.from(id, 'base64url').toString('utf8');
|
||
}
|
||
|
||
function parseDestinations(raw) {
|
||
const destinations = {};
|
||
if (!raw || typeof raw !== 'object') return destinations;
|
||
for (const key of DEST_KEYS) {
|
||
const entry = raw[key];
|
||
if (!entry) continue;
|
||
const folderPath = typeof entry === 'string' ? entry : entry.path;
|
||
if (!folderPath || !String(folderPath).trim()) continue;
|
||
const resolved = path.resolve(String(folderPath).trim());
|
||
const label =
|
||
(typeof entry === 'object' && entry.label && String(entry.label).trim()) ||
|
||
path.basename(resolved) ||
|
||
`Folder ${key}`;
|
||
destinations[key] = { key, path: resolved, label };
|
||
}
|
||
return destinations;
|
||
}
|
||
|
||
/**
|
||
* Reference adapter: point at any local folder and swipe through its files.
|
||
* "Reject" never deletes -- it moves the file into a trash folder alongside
|
||
* the source, and "Undo" moves it right back. Number keys 0–9 can move a
|
||
* file into configured destination folders (organize, not just triage).
|
||
* "Empty trash" (a separate, confirm-guarded action) is the only place this
|
||
* adapter permanently deletes anything.
|
||
*/
|
||
class FolderAdapter extends Adapter {
|
||
static id = 'folder';
|
||
static label = 'Local folder';
|
||
static description =
|
||
'Point at any local folder and swipe through its files. Rejected files move to a trash folder; keys 0–9 can organize into other folders.';
|
||
|
||
static configSchema = [
|
||
{
|
||
key: 'folderPath',
|
||
label: 'Folder path',
|
||
type: 'folder',
|
||
required: true,
|
||
placeholder: '/Users/you/Pictures/to-sort',
|
||
},
|
||
{ key: 'recursive', label: 'Include subfolders', type: 'checkbox', default: false },
|
||
{
|
||
key: 'extensions',
|
||
label: 'File extensions (comma separated, blank = all files)',
|
||
type: 'text',
|
||
default: 'jpg,jpeg,png,gif,webp,heic,bmp,pdf,dng,cr2,nef,arw,mp4,mov',
|
||
placeholder: 'jpg,jpeg,png,heic,pdf,dng,mp4,mov',
|
||
},
|
||
{ key: 'trashDirName', label: 'Trash folder name', type: 'text', default: '.swipeanything-trash' },
|
||
{
|
||
key: 'showDetailsByDefault',
|
||
label: 'Show file details on each card by default',
|
||
type: 'checkbox',
|
||
default: false,
|
||
},
|
||
{
|
||
key: 'destinations',
|
||
label: 'Organize folders (keys 0–9)',
|
||
type: 'folderMap',
|
||
default: {},
|
||
},
|
||
];
|
||
|
||
static actions = [
|
||
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right', group: 'primary' },
|
||
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true, group: 'primary' },
|
||
{ id: 'skip', label: 'Skip', key: 'ArrowDown', direction: 'down', group: 'primary' },
|
||
];
|
||
|
||
constructor(settings) {
|
||
super(settings);
|
||
this.folderPath = path.resolve(settings.folderPath || '.');
|
||
this.trashDirName = settings.trashDirName || '.swipeanything-trash';
|
||
this.trashDir = path.join(this.folderPath, this.trashDirName);
|
||
this.recursive = Boolean(settings.recursive);
|
||
const extList = String(settings.extensions || '')
|
||
.split(',')
|
||
.map((e) => e.trim().toLowerCase())
|
||
.filter(Boolean);
|
||
this.extensions = extList.length ? new Set(extList) : null; // null = allow all
|
||
this.destinations = parseDestinations(settings.destinations);
|
||
this.showDetailsByDefault = Boolean(settings.showDetailsByDefault);
|
||
}
|
||
|
||
getActions() {
|
||
const actions = this.constructor.actions.map((a) => ({ ...a }));
|
||
for (const key of DEST_KEYS) {
|
||
const dest = this.destinations[key];
|
||
if (!dest) continue;
|
||
actions.push({
|
||
id: `move-${key}`,
|
||
label: dest.label,
|
||
key,
|
||
group: 'organize',
|
||
destPath: dest.path,
|
||
});
|
||
}
|
||
return actions;
|
||
}
|
||
|
||
async init() {
|
||
const stat = await fsp.stat(this.folderPath).catch(() => null);
|
||
if (!stat || !stat.isDirectory()) {
|
||
throw new Error(`Folder not found: ${this.folderPath}`);
|
||
}
|
||
await fsp.mkdir(this.trashDir, { recursive: true });
|
||
|
||
for (const dest of Object.values(this.destinations)) {
|
||
if (dest.path === this.folderPath) {
|
||
throw new Error(`Destination ${dest.key} cannot be the same as the source folder`);
|
||
}
|
||
await fsp.mkdir(dest.path, { recursive: true });
|
||
const destStat = await fsp.stat(dest.path).catch(() => null);
|
||
if (!destStat || !destStat.isDirectory()) {
|
||
throw new Error(`Destination ${dest.key} is not a folder: ${dest.path}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
_isExcludedDir(absPath) {
|
||
if (absPath === this.trashDir || absPath.startsWith(this.trashDir + path.sep)) return true;
|
||
for (const dest of Object.values(this.destinations)) {
|
||
if (absPath === dest.path || absPath.startsWith(dest.path + path.sep)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async _walk(dir, relativeBase = '') {
|
||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||
let files = [];
|
||
for (const entry of entries) {
|
||
if (entry.name.startsWith('.')) continue; // skips dotfiles and our own trash dir
|
||
const abs = path.join(dir, entry.name);
|
||
const rel = relativeBase ? path.join(relativeBase, entry.name) : entry.name;
|
||
if (entry.isDirectory()) {
|
||
if (this._isExcludedDir(abs)) continue;
|
||
if (this.recursive) files = files.concat(await this._walk(abs, rel));
|
||
continue;
|
||
}
|
||
const ext = extOf(entry.name);
|
||
if (this.extensions && !this.extensions.has(ext)) continue;
|
||
files.push(rel);
|
||
}
|
||
return files;
|
||
}
|
||
|
||
async list() {
|
||
const relativePaths = await this._walk(this.folderPath);
|
||
relativePaths.sort((a, b) => a.localeCompare(b));
|
||
const items = [];
|
||
for (const rel of relativePaths) {
|
||
const abs = path.join(this.folderPath, rel);
|
||
const stat = await fsp.stat(abs);
|
||
const dir = path.dirname(rel);
|
||
items.push({
|
||
id: idFor(rel),
|
||
title: path.basename(rel),
|
||
subtitle: dir === '.' ? undefined : dir,
|
||
previewType: previewTypeFor(rel),
|
||
meta: {
|
||
size: formatSize(stat.size),
|
||
modified: stat.mtime.toISOString().slice(0, 10),
|
||
},
|
||
});
|
||
}
|
||
return items;
|
||
}
|
||
|
||
_absoluteFor(itemId) {
|
||
const rel = pathForId(itemId);
|
||
const abs = path.resolve(this.folderPath, rel);
|
||
if (abs !== this.folderPath && !abs.startsWith(this.folderPath + path.sep)) {
|
||
throw new Error('Invalid item id');
|
||
}
|
||
return abs;
|
||
}
|
||
|
||
async streamPreview(itemId, res) {
|
||
const abs = this._absoluteFor(itemId);
|
||
if (!fs.existsSync(abs)) return false;
|
||
|
||
if (ZIP_EXT.has(extOf(abs))) {
|
||
try {
|
||
const listing = await listZipEntries(abs);
|
||
res.type('text/plain; charset=utf-8');
|
||
res.send(formatZipListing(listing));
|
||
return true;
|
||
} catch (err) {
|
||
res.type('text/plain; charset=utf-8');
|
||
res.status(200).send(`(could not list zip: ${err.message})`);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// RAW / HEIC: browsers usually can't decode the original — prefer a Quick Look PNG.
|
||
if (needsThumbnailPreview(abs)) {
|
||
const thumbPath = await getThumbnail(abs);
|
||
if (thumbPath) {
|
||
res.type('image/png');
|
||
res.sendFile(thumbPath);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
const override = MIME_OVERRIDES[extOf(abs)];
|
||
if (override) res.type(override);
|
||
res.sendFile(abs);
|
||
return true;
|
||
}
|
||
|
||
async streamThumbnail(itemId, res) {
|
||
const abs = this._absoluteFor(itemId);
|
||
if (!fs.existsSync(abs)) return false;
|
||
const thumbPath = await getThumbnail(abs);
|
||
if (!thumbPath) return false;
|
||
res.sendFile(thumbPath);
|
||
return true;
|
||
}
|
||
|
||
async getDetails(itemId) {
|
||
const abs = this._absoluteFor(itemId);
|
||
if (!fs.existsSync(abs)) throw new Error('File not found');
|
||
const details = await collectFileDetails(abs);
|
||
if (ZIP_EXT.has(extOf(abs))) {
|
||
try {
|
||
const listing = await listZipEntries(abs);
|
||
const files = listing.entries.filter((e) => !e.isDir).length;
|
||
details.fields.push({
|
||
label: 'Zip entries',
|
||
value: `${listing.totalEntries}${listing.truncated ? '+' : ''} (${files} files)`,
|
||
});
|
||
const sample = listing.entries
|
||
.slice(0, 8)
|
||
.map((e) => e.name)
|
||
.join(', ');
|
||
if (sample) details.fields.push({ label: 'Contains', value: sample + (listing.entries.length > 8 ? '…' : '') });
|
||
} catch {
|
||
// ignore listing failures in details
|
||
}
|
||
}
|
||
details.actions = process.platform === 'darwin' ? [{ id: 'reveal', label: 'Reveal in Finder' }] : [];
|
||
return details;
|
||
}
|
||
|
||
async reveal(itemId) {
|
||
if (process.platform !== 'darwin') return false;
|
||
const abs = this._absoluteFor(itemId);
|
||
if (!fs.existsSync(abs)) throw new Error('File not found');
|
||
await execFileAsync('open', ['-R', abs], { timeout: 5000 });
|
||
return true;
|
||
}
|
||
|
||
uiHints() {
|
||
return { showDetailsByDefault: this.showDetailsByDefault, supportsDetails: true };
|
||
}
|
||
|
||
async _uniqueTarget(destDir, basename) {
|
||
let candidate = path.join(destDir, basename);
|
||
if (!fs.existsSync(candidate)) return candidate;
|
||
const ext = path.extname(basename);
|
||
const stem = path.basename(basename, ext);
|
||
let i = 1;
|
||
while (fs.existsSync(candidate)) {
|
||
candidate = path.join(destDir, `${stem}-${i}${ext}`);
|
||
i += 1;
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
async applyAction(item, actionId) {
|
||
if (actionId === 'reject') {
|
||
const from = this._absoluteFor(item.id);
|
||
const to = path.join(this.trashDir, `${Date.now()}__${path.basename(from)}`);
|
||
await fsp.rename(from, to);
|
||
return { type: 'move', from, to };
|
||
}
|
||
|
||
const moveMatch = /^move-([0-9])$/.exec(actionId);
|
||
if (moveMatch) {
|
||
const dest = this.destinations[moveMatch[1]];
|
||
if (!dest) throw new Error(`No destination configured for key ${moveMatch[1]}`);
|
||
const from = this._absoluteFor(item.id);
|
||
await fsp.mkdir(dest.path, { recursive: true });
|
||
const to = await this._uniqueTarget(dest.path, path.basename(from));
|
||
await fsp.rename(from, to);
|
||
return { type: 'move', from, to, destKey: dest.key, destLabel: dest.label };
|
||
}
|
||
|
||
// 'keep' and 'skip' have no filesystem effect.
|
||
return null;
|
||
}
|
||
|
||
async undo(record) {
|
||
if (record && record.type === 'move') {
|
||
await fsp.mkdir(path.dirname(record.from), { recursive: true });
|
||
await fsp.rename(record.to, record.from);
|
||
}
|
||
}
|
||
|
||
async describeTrash() {
|
||
const entries = await fsp.readdir(this.trashDir).catch(() => []);
|
||
const count = entries.filter((name) => !name.startsWith('.')).length;
|
||
return { count, label: `${this.trashDirName} (permanent delete)` };
|
||
}
|
||
|
||
async emptyTrash() {
|
||
const entries = await fsp.readdir(this.trashDir).catch(() => []);
|
||
await Promise.all(
|
||
entries
|
||
.filter((name) => !name.startsWith('.'))
|
||
.map((name) => fsp.rm(path.join(this.trashDir, name), { recursive: true, force: true }))
|
||
);
|
||
}
|
||
|
||
describeSource() {
|
||
const n = Object.keys(this.destinations).length;
|
||
return n ? `${this.folderPath} · ${n} organize folder${n === 1 ? '' : 's'}` : this.folderPath;
|
||
}
|
||
}
|
||
|
||
module.exports = { FolderAdapter, parseDestinations, DEST_KEYS };
|