- Immich adapter (adapters/immich.js): swipe a self-hosted photo library over its REST API, proving the adapter contract works for remote sources, not just the filesystem. - Refactor the preview contract from resolvePreviewPath() to streamPreview()/streamThumbnail(), so adapters can serve previews from anywhere (local file, proxied fetch, etc). - macOS Quick Look-backed thumbnail cache (lib/thumbnails.js): real resized thumbnails, HEIC/HEIF, and video poster frames, with a hard timeout and graceful fallback to the original file everywhere else. - Native "Browse..." folder picker (osascript) in Settings, with manual typing as the fallback on other platforms. - Confirm-guarded "Empty trash" action -- the only place this project permanently deletes anything. - Session resume: progress now survives a server restart and an unchanged Rescan via .swipeanything-session.json. - Accessibility pass: live region item announcements, aria-labels on action buttons, focus-trapped shortcuts dialog with focus restore, visible focus rings, a real role="progressbar", and <button>s instead of <a href="#"> in the header. - node:test suite (23 tests) covering the folder adapter, the Immich adapter (mocked fetch, no live server needed), and the HTTP API end-to-end; wired up as `npm test`. - Also includes the keyboard shortcuts (up/down undo/skip), swipe stamps, and shortcuts modal from the previous session that hadn't been committed yet.
123 lines
4.9 KiB
JavaScript
123 lines
4.9 KiB
JavaScript
'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 { FolderAdapter } = require('../adapters/folder');
|
|
|
|
function makeFixture() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-test-'));
|
|
fs.writeFileSync(path.join(dir, 'keep-me.txt'), 'hello world');
|
|
fs.writeFileSync(path.join(dir, 'reject-me.txt'), 'bye world');
|
|
fs.mkdirSync(path.join(dir, 'sub'));
|
|
fs.writeFileSync(path.join(dir, 'sub', 'nested.txt'), 'nested');
|
|
return dir;
|
|
}
|
|
|
|
test('list() finds top-level files and skips dotfiles/hidden trash dir', async () => {
|
|
const dir = makeFixture();
|
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
|
|
await adapter.init();
|
|
const items = await adapter.list();
|
|
const titles = items.map((i) => i.title).sort();
|
|
assert.deepEqual(titles, ['keep-me.txt', 'reject-me.txt']);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('list() with recursive:true includes subfolders', async () => {
|
|
const dir = makeFixture();
|
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', recursive: true });
|
|
await adapter.init();
|
|
const items = await adapter.list();
|
|
assert.ok(items.some((i) => i.title === 'nested.txt' && i.subtitle === 'sub'));
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('list() filters by extension allowlist', async () => {
|
|
const dir = makeFixture();
|
|
fs.writeFileSync(path.join(dir, 'photo.png'), 'not-really-a-png');
|
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: 'png' });
|
|
await adapter.init();
|
|
const items = await adapter.list();
|
|
assert.deepEqual(items.map((i) => i.title), ['photo.png']);
|
|
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: '' });
|
|
await adapter.init();
|
|
const items = await adapter.list();
|
|
const target = items.find((i) => i.title === 'reject-me.txt');
|
|
|
|
const record = await adapter.applyAction(target, 'reject');
|
|
assert.equal(record.type, 'move');
|
|
assert.ok(!fs.existsSync(path.join(dir, 'reject-me.txt')));
|
|
assert.ok(fs.existsSync(record.to));
|
|
|
|
const trash = await adapter.describeTrash();
|
|
assert.equal(trash.count, 1);
|
|
|
|
await adapter.undo(record);
|
|
assert.ok(fs.existsSync(path.join(dir, 'reject-me.txt')));
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('keep and skip have no filesystem effect', async () => {
|
|
const dir = makeFixture();
|
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
|
|
await adapter.init();
|
|
const items = await adapter.list();
|
|
const target = items[0];
|
|
const keepRecord = await adapter.applyAction(target, 'keep');
|
|
const skipRecord = await adapter.applyAction(target, 'skip');
|
|
assert.equal(keepRecord, null);
|
|
assert.equal(skipRecord, null);
|
|
assert.ok(fs.existsSync(path.join(dir, target.title)));
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('emptyTrash permanently deletes trashed files', 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 === 'reject-me.txt');
|
|
await adapter.applyAction(target, 'reject');
|
|
assert.equal((await adapter.describeTrash()).count, 1);
|
|
await adapter.emptyTrash();
|
|
assert.equal((await adapter.describeTrash()).count, 0);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('streamPreview rejects item ids that escape the folder', async () => {
|
|
const dir = makeFixture();
|
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
|
|
await adapter.init();
|
|
const evilId = Buffer.from('../../etc/passwd').toString('base64url');
|
|
const fakeRes = { sendFile: () => assert.fail('should not have sent a file'), type: () => {} };
|
|
await assert.rejects(() => adapter.streamPreview(evilId, fakeRes), /Invalid item id/);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('streamPreview serves an existing file via res.sendFile', async () => {
|
|
const dir = makeFixture();
|
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
|
|
await adapter.init();
|
|
const items = await adapter.list();
|
|
const target = items[0];
|
|
let sentPath = null;
|
|
const fakeRes = { sendFile: (p) => (sentPath = p), type: () => {} };
|
|
const handled = await adapter.streamPreview(target.id, fakeRes);
|
|
assert.equal(handled, true);
|
|
assert.equal(sentPath, path.join(dir, target.title));
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
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/);
|
|
});
|