Files
SwipeAnything/adapters/folder.js
T
ilia ce43c5de6d
CI / Gitleaks (pull_request) Successful in 17s
CI / Unit tests (pull_request) Successful in 21s
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.
2026-07-26 21:38:36 -04:00

407 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 09 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 09 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 09)',
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 };