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
+13
View File
@@ -137,3 +137,16 @@ test('GET /api/preview/:itemId 404s for an unknown item', async () => {
const res = await fetch(`${base}/api/preview/${missingId}`);
assert.equal(res.status, 404);
});
test('GET /api/details/:itemId returns file metadata fields', async () => {
await api('/api/rescan', { method: 'POST' });
const queue = await api('/api/queue');
assert.equal(queue.status, 200);
assert.ok(queue.body.current);
assert.equal(queue.body.ui.supportsDetails, true);
const { status, body } = await api(`/api/details/${queue.body.current.id}`);
assert.equal(status, 200);
assert.ok(Array.isArray(body.fields));
assert.ok(body.fields.some((f) => f.label === 'Path'));
assert.ok(body.fields.some((f) => f.label === 'Size'));
});
+128
View File
@@ -45,6 +45,38 @@ test('list() filters by extension allowlist', async () => {
fs.rmSync(dir, { recursive: true, force: true });
});
test('list() sets previewType for video, audio, csv, pdf, raw, and unknown binary', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-preview-'));
fs.writeFileSync(path.join(dir, 'clip.mp4'), 'fake-mp4-bytes');
fs.writeFileSync(path.join(dir, 'clip.MOV'), 'fake-mov-bytes'); // case-insensitive
fs.writeFileSync(path.join(dir, 'song.mp3'), 'fake-mp3-bytes');
fs.writeFileSync(path.join(dir, 'rows.csv'), 'a,b\n1,2\n');
fs.writeFileSync(path.join(dir, 'scan.pdf'), '%PDF-1.0');
fs.writeFileSync(path.join(dir, 'shot.dng'), 'fake-dng');
fs.writeFileSync(path.join(dir, 'shot.CR2'), 'fake-cr2');
fs.writeFileSync(path.join(dir, 'bundle.zip'), 'PK\x03\x04');
fs.writeFileSync(path.join(dir, 'sheet.xlsx'), 'PK\x03\x04-not-really-xlsx');
fs.writeFileSync(path.join(dir, 'photo.webp'), 'fake-webp');
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const byTitle = Object.fromEntries((await adapter.list()).map((i) => [i.title, i.previewType]));
assert.equal(byTitle['clip.mp4'], 'video');
assert.equal(byTitle['clip.MOV'], 'video');
assert.equal(byTitle['song.mp3'], 'audio');
assert.equal(byTitle['rows.csv'], 'text');
assert.equal(byTitle['scan.pdf'], 'pdf');
assert.equal(byTitle['shot.dng'], 'image');
assert.equal(byTitle['shot.CR2'], 'image');
assert.equal(byTitle['bundle.zip'], 'archive');
assert.equal(byTitle['photo.webp'], 'image');
// Excel is a zip container — no in-browser preview yet (generic card).
assert.equal(byTitle['sheet.xlsx'], 'none');
fs.rmSync(dir, { recursive: true, force: true });
});
test('reject moves the file to the trash dir, undo restores it', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
@@ -120,3 +152,99 @@ test('init() throws a clear error for a missing folder', async () => {
const adapter = new FolderAdapter({ folderPath: '/definitely/does/not/exist' });
await assert.rejects(() => adapter.init(), /Folder not found/);
});
test('getActions() adds organize actions only for configured destination keys', async () => {
const dir = makeFixture();
const destA = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-a-'));
const destB = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-b-'));
const adapter = new FolderAdapter({
folderPath: dir,
extensions: '',
destinations: {
1: { path: destA, label: 'Vacation' },
7: destB,
},
});
await adapter.init();
const actions = adapter.getActions();
assert.ok(actions.some((a) => a.id === 'keep'));
assert.ok(actions.some((a) => a.id === 'move-1' && a.label === 'Vacation' && a.key === '1'));
assert.ok(actions.some((a) => a.id === 'move-7' && a.key === '7'));
assert.equal(actions.filter((a) => a.group === 'organize').length, 2);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(destA, { recursive: true, force: true });
fs.rmSync(destB, { recursive: true, force: true });
});
test('move-N moves the file into the destination folder and undo restores it', async () => {
const dir = makeFixture();
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-'));
const adapter = new FolderAdapter({
folderPath: dir,
extensions: '',
destinations: { 2: { path: dest, label: 'Work' } },
});
await adapter.init();
const items = await adapter.list();
const target = items.find((i) => i.title === 'keep-me.txt');
const record = await adapter.applyAction(target, 'move-2');
assert.equal(record.type, 'move');
assert.equal(record.destKey, '2');
assert.ok(!fs.existsSync(path.join(dir, 'keep-me.txt')));
assert.ok(fs.existsSync(path.join(dest, 'keep-me.txt')));
await adapter.undo(record);
assert.ok(fs.existsSync(path.join(dir, 'keep-me.txt')));
assert.ok(!fs.existsSync(path.join(dest, 'keep-me.txt')));
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dest, { recursive: true, force: true });
});
test('list() skips destination folders that sit inside the source tree', async () => {
const dir = makeFixture();
const nestedDest = path.join(dir, 'sorted');
fs.mkdirSync(nestedDest);
fs.writeFileSync(path.join(nestedDest, 'already-sorted.txt'), 'done');
const adapter = new FolderAdapter({
folderPath: dir,
extensions: '',
recursive: true,
destinations: { 0: { path: nestedDest, label: 'Sorted' } },
});
await adapter.init();
const titles = (await adapter.list()).map((i) => i.title);
assert.ok(!titles.includes('already-sorted.txt'));
assert.ok(titles.includes('keep-me.txt'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('move-N refuses an unconfigured key', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', destinations: {} });
await adapter.init();
const items = await adapter.list();
await assert.rejects(() => adapter.applyAction(items[0], 'move-3'), /No destination configured/);
fs.rmSync(dir, { recursive: true, force: true });
});
test('getDetails() returns path, size, and type fields', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const items = await adapter.list();
const target = items.find((i) => i.title === 'keep-me.txt');
const details = await adapter.getDetails(target.id);
const labels = details.fields.map((f) => f.label);
assert.ok(labels.includes('Path'));
assert.ok(labels.includes('Size'));
assert.ok(labels.includes('Type'));
assert.equal(details.path, path.join(dir, 'keep-me.txt'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('uiHints() reflects showDetailsByDefault', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', showDetailsByDefault: true });
await adapter.init();
assert.deepEqual(adapter.uiHints(), { showDetailsByDefault: true, supportsDetails: true });
fs.rmSync(dir, { recursive: true, force: true });
});
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { listZipEntries, formatZipListing } = require('../lib/zip-list');
function writeTinyZip(dir) {
// Minimal hand-rolled store-method zip with two files (no compression).
// Local file headers + central directory + EOCD.
function local(name, data) {
const n = Buffer.from(name);
const header = Buffer.alloc(30);
header.writeUInt32LE(0x04034b50, 0);
header.writeUInt16LE(20, 4); // version
header.writeUInt16LE(0, 6); // flags
header.writeUInt16LE(0, 8); // store
header.writeUInt16LE(0, 10);
header.writeUInt16LE(0, 12);
header.writeUInt32LE(0, 14); // crc ignored for our reader
header.writeUInt32LE(data.length, 18);
header.writeUInt32LE(data.length, 22);
header.writeUInt16LE(n.length, 26);
header.writeUInt16LE(0, 28);
return Buffer.concat([header, n, data]);
}
function central(name, data, localOffset) {
const n = Buffer.from(name);
const header = Buffer.alloc(46);
header.writeUInt32LE(0x02014b50, 0);
header.writeUInt16LE(20, 4);
header.writeUInt16LE(20, 6);
header.writeUInt16LE(0, 8);
header.writeUInt16LE(0, 10);
header.writeUInt16LE(0, 12);
header.writeUInt16LE(0, 14);
header.writeUInt32LE(0, 16);
header.writeUInt32LE(data.length, 20);
header.writeUInt32LE(data.length, 24);
header.writeUInt16LE(n.length, 28);
header.writeUInt16LE(0, 30);
header.writeUInt16LE(0, 32);
header.writeUInt16LE(0, 34);
header.writeUInt16LE(0, 36);
header.writeUInt32LE(0, 38);
header.writeUInt32LE(localOffset, 42);
return Buffer.concat([header, n]);
}
const a = Buffer.from('alpha\n');
const b = Buffer.from('item,qty\n1,2\n');
const locA = local('readme.txt', a);
const locB = local('data/rows.csv', b);
const body = Buffer.concat([locA, locB]);
const cen = Buffer.concat([
central('readme.txt', a, 0),
central('data/rows.csv', b, locA.length),
]);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(2, 8);
eocd.writeUInt16LE(2, 10);
eocd.writeUInt32LE(cen.length, 12);
eocd.writeUInt32LE(body.length, 16);
eocd.writeUInt16LE(0, 20);
const zipPath = path.join(dir, 'sample.zip');
fs.writeFileSync(zipPath, Buffer.concat([body, cen, eocd]));
return zipPath;
}
test('listZipEntries reads names and sizes without extracting', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-zip-'));
const zipPath = writeTinyZip(dir);
const listing = await listZipEntries(zipPath);
assert.equal(listing.totalEntries, 2);
assert.equal(listing.truncated, false);
assert.deepEqual(
listing.entries.map((e) => e.name),
['readme.txt', 'data/rows.csv']
);
assert.equal(listing.entries[0].size, 6);
const text = formatZipListing(listing);
assert.match(text, /readme\.txt/);
assert.match(text, /data\/rows\.csv/);
fs.rmSync(dir, { recursive: true, force: true });
});