diff --git a/.gitignore b/.gitignore index 7e663c4..0019abc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ swipeanything.config.json +.swipeanything-session.json .swipeanything-trash/ +.cache/ .DS_Store *.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..98137bc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project are documented here. +Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added +- **Immich adapter** (`adapters/immich.js`): swipe through a self-hosted + Immich photo library. Reject moves the asset to Immich's own trash; undo + restores it. Second reference implementation of the adapter contract, + demonstrating a remote-API source alongside the local filesystem. +- **Real thumbnails** on macOS via a `qlmanage` (Quick Look)-backed cache + (`lib/thumbnails.js`), covering HEIC/HEIF images and video poster frames + for free. Falls back to the original file on any failure/timeout or on + non-macOS platforms. +- **Native folder picker**: a "Browse…" button next to folder-type settings + fields opens a real macOS Finder dialog (`POST /api/browse-folder`, via + `osascript`). Falls back to manual typing elsewhere. +- **Empty trash**: a separate, confirm-guarded action (shown in the header + once the folder adapter's trash is non-empty) that permanently deletes + what "Reject" has moved aside. The only place this project deletes + anything outright. +- **Session resume**: review progress now persists to + `.swipeanything-session.json` and survives a server restart (and a + same-folder "Rescan"), as long as the underlying item set hasn't changed. +- **Accessibility pass**: a live region announces "Item X of Y: name" on + every card change, action buttons get full `aria-label`s (e.g. "Keep, + right arrow"), the shortcuts dialog traps and restores focus, focus rings + are visible everywhere, the progress bar is a real + `role="progressbar"`/`aria-valuenow`, and header actions are ` + + Settings
-
+
+
+
+
+ + + diff --git a/public/settings.html b/public/settings.html index 32e90c4..8b89e45 100644 --- a/public/settings.html +++ b/public/settings.html @@ -15,9 +15,12 @@
Choose what to swipe through, and how.
-
+
+ Adapter +
+
-
+ diff --git a/public/settings.js b/public/settings.js index 29d5fd7..7914c9d 100644 --- a/public/settings.js +++ b/public/settings.js @@ -27,6 +27,10 @@ return el.value; } + function escapeAttr(value) { + return String(value).replace(/"/g, '"'); + } + function renderField(field) { const wrap = document.createElement('div'); const existing = currentSettings[field.key]; @@ -41,12 +45,58 @@ return wrap; } + if (field.type === 'folder') { + wrap.className = 'form-field'; + wrap.innerHTML = ` + +
+ + +
+
+ `; + const browseBtn = wrap.querySelector(`[data-browse-for="field_${field.key}"]`); + const hintEl = wrap.querySelector(`[data-browse-hint-for="field_${field.key}"]`); + browseBtn.addEventListener('click', async () => { + hintEl.textContent = ''; + browseBtn.disabled = true; + browseBtn.textContent = 'Waiting for Finder\u2026'; + try { + const { path: chosen } = await api('/api/browse-folder', { method: 'POST' }); + document.getElementById(`field_${field.key}`).value = chosen; + } catch (err) { + if (err.message !== 'Cancelled') hintEl.textContent = err.message; + } finally { + browseBtn.disabled = false; + browseBtn.textContent = 'Browse\u2026'; + } + }); + return wrap; + } + + if (field.type === 'select') { + wrap.className = 'form-field'; + const optionsHtml = (field.options || []) + .map( + (opt) => + `` + ) + .join(''); + wrap.innerHTML = ` + + + `; + return wrap; + } + wrap.className = 'form-field'; - const inputType = field.type === 'number' ? 'number' : 'text'; + const inputType = field.type === 'password' ? 'password' : field.type === 'number' ? 'number' : 'text'; wrap.innerHTML = ` - `; return wrap; diff --git a/public/style.css b/public/style.css index bd5c039..a212927 100644 --- a/public/style.css +++ b/public/style.css @@ -30,6 +30,27 @@ a { color: var(--accent); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +button { + font: inherit; +} + #app { max-width: 480px; margin: 0 auto; @@ -74,6 +95,12 @@ h1 { border-radius: 8px; padding: 5px 9px; background: var(--card); + cursor: pointer; +} + +.icon-link.danger { + color: var(--reject); + border-color: var(--reject); } .icon-link:hover { @@ -136,6 +163,7 @@ h1 { .card .preview { width: 100%; + min-height: 160px; max-height: 280px; border-radius: 12px; overflow: hidden; @@ -148,9 +176,11 @@ h1 { .card .preview img, .card .preview video { width: 100%; + min-height: 120px; max-height: 280px; object-fit: contain; display: block; + background: #12151a; } .card .preview.file-icon { @@ -228,6 +258,10 @@ h1 { transform: rotate(12deg); } +.stamp.visible { + opacity: 1; +} + .actions-row { display: flex; justify-content: center; @@ -238,19 +272,32 @@ h1 { .action-btn { flex: none; - min-width: 56px; - height: 56px; + min-width: 64px; + height: 64px; border-radius: 50%; border: 1px solid var(--border); background: var(--card); color: var(--text); - font-size: 12px; + font-size: 11px; font-weight: 600; cursor: pointer; display: flex; + flex-direction: column; align-items: center; justify-content: center; - padding: 4px; + gap: 2px; + padding: 6px 4px; + line-height: 1.1; +} + +.action-btn .action-label { + font-size: 11px; +} + +.action-btn .action-key { + font-size: 14px; + font-weight: 700; + opacity: 0.85; } .action-btn.keep { @@ -263,6 +310,11 @@ h1 { border-color: var(--reject); } +.action-btn.skip { + color: var(--neutral); + border-color: var(--neutral); +} + .action-btn.undo { color: var(--accent); border-color: var(--accent); @@ -273,6 +325,84 @@ h1 { cursor: default; } +/* Shortcuts modal */ +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 16px; +} + +.modal[hidden] { + display: none; +} + +.modal-card { + width: 100%; + max-width: 360px; + background: var(--card2); + border: 1px solid var(--border); + border-radius: 16px; + padding: 18px 20px 16px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} + +.modal-header h2 { + margin: 0; + font-size: 16px; +} + +.modal-close { + cursor: pointer; + border: 1px solid var(--border); + background: var(--card); +} + +.shortcut-list { + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.shortcut-list > div { + display: flex; + align-items: center; + gap: 12px; +} + +.shortcut-list dt { + flex: none; + min-width: 88px; + font-size: 13px; + font-weight: 700; + color: var(--accent); + font-variant-numeric: tabular-nums; +} + +.shortcut-list dd { + margin: 0; + font-size: 13px; + color: var(--text); +} + +.modal-hint { + margin: 14px 0 0; + font-size: 12px; + color: var(--sub); +} + .empty-state, .error-state { text-align: center; @@ -332,6 +462,45 @@ h1 { margin-bottom: 0; } +.folder-field-row { + display: flex; + gap: 8px; +} + +.folder-field-row input { + flex: 1; +} + +.secondary-btn { + flex: none; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--card2); + color: var(--text); + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} + +.secondary-btn:disabled { + opacity: 0.6; + cursor: default; +} + +.field-hint { + font-size: 11px; + color: var(--reject); + margin-top: 6px; + min-height: 14px; +} + +.adapter-fieldset { + border: none; + margin: 0; + padding: 0; +} + .adapter-card { border: 1px solid var(--border); border-radius: 12px; diff --git a/server.js b/server.js index ead170d..ab58c1a 100644 --- a/server.js +++ b/server.js @@ -3,16 +3,20 @@ const express = require('express'); const fs = require('fs'); const path = require('path'); +const { execFile } = require('child_process'); const { getAdapter, listAdapters } = require('./adapters/registry'); const app = express(); const PORT = process.env.PORT || 5757; -const CONFIG_PATH = path.join(__dirname, 'swipeanything.config.json'); +// Overridable so the test suite (and anyone running multiple instances) can +// point at an isolated config/session file instead of the project's own. +const CONFIG_PATH = process.env.SWIPEANYTHING_CONFIG_PATH || path.join(__dirname, 'swipeanything.config.json'); +const SESSION_PATH = process.env.SWIPEANYTHING_SESSION_PATH || path.join(__dirname, '.swipeanything-session.json'); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); -/** @type {{ adapterId: string, adapter: import('./adapters/base').Adapter, queue: any[], index: number, history: any[] } | null} */ +/** @type {{ adapterId: string, adapter: import('./adapters/base').Adapter, queue: any[], index: number, history: any[], configSignature: string } | null} */ let session = null; function loadConfig() { @@ -28,13 +32,56 @@ function saveConfig(config) { fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); } +function loadSavedSession() { + if (!fs.existsSync(SESSION_PATH)) return null; + try { + return JSON.parse(fs.readFileSync(SESSION_PATH, 'utf8')); + } catch { + return null; + } +} + +function persistSession(s) { + try { + fs.writeFileSync( + SESSION_PATH, + JSON.stringify({ + configSignature: s.configSignature, + itemIds: s.queue.map((item) => item.id), + index: s.index, + history: s.history, + }) + ); + } catch { + // best-effort; a failed write just means we won't resume after a restart + } +} + +function arraysEqual(a, b) { + return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]); +} + async function startSession(config) { const AdapterClass = getAdapter(config.adapter); if (!AdapterClass) throw new Error(`Unknown adapter: ${config.adapter}`); const adapter = new AdapterClass(config.settings || {}); await adapter.init(); const queue = await adapter.list(); - session = { adapterId: config.adapter, adapter, queue, index: 0, history: [] }; + const configSignature = JSON.stringify(config); + + session = { adapterId: config.adapter, adapter, queue, index: 0, history: [], configSignature }; + + // Resume progress if this is the same config reviewing the same items as + // last time the server ran (e.g. after a restart, or a Rescan that found + // no changes). If the item set changed, this naturally falls through to + // a fresh session instead. + const saved = loadSavedSession(); + if (saved && saved.configSignature === configSignature && arraysEqual(saved.itemIds, queue.map((i) => i.id))) { + session.index = saved.index; + session.history = saved.history; + } + + persistSession(session); return session; } @@ -45,10 +92,11 @@ async function ensureSession() { return startSession(config); } -function queuePayload(s) { +async function queuePayload(s) { const remaining = s.queue.slice(s.index); const counts = {}; for (const entry of s.history) counts[entry.actionId] = (counts[entry.actionId] || 0) + 1; + const trashInfo = await s.adapter.describeTrash().catch(() => null); return { adapterId: s.adapterId, sourceLabel: s.adapter.describeSource(), @@ -59,6 +107,7 @@ function queuePayload(s) { current: remaining[0] || null, upcoming: remaining.slice(1, 4), canUndo: s.history.length > 0, + trashInfo, }; } @@ -86,11 +135,25 @@ app.post('/api/config', async (req, res) => { res.json({ ok: true }); }); +app.post('/api/browse-folder', (req, res) => { + if (process.platform !== 'darwin') { + return res.status(501).json({ error: 'Native folder picker is only available on macOS. Enter the path manually.' }); + } + const script = 'POSIX path of (choose folder with prompt "Select a folder for SwipeAnything")'; + execFile('osascript', ['-e', script], { timeout: 120000 }, (err, stdout) => { + if (err) { + const message = /user canceled/i.test(err.message || '') ? 'Cancelled' : err.message; + return res.status(400).json({ error: message }); + } + res.json({ path: stdout.trim() }); + }); +}); + app.get('/api/queue', async (req, res) => { const s = await ensureSession().catch((err) => ({ __error: err })); if (!s) return res.status(409).json({ error: 'Not configured yet' }); if (s.__error) return res.status(400).json({ error: s.__error.message }); - res.json(queuePayload(s)); + res.json(await queuePayload(s)); }); app.post('/api/rescan', async (req, res) => { @@ -99,7 +162,7 @@ app.post('/api/rescan', async (req, res) => { session = null; try { const s = await startSession(config); - res.json(queuePayload(s)); + res.json(await queuePayload(s)); } catch (err) { res.status(400).json({ error: err.message }); } @@ -109,9 +172,19 @@ app.get('/api/preview/:itemId', async (req, res) => { const s = await ensureSession().catch(() => null); if (!s) return res.status(409).end(); try { - const filePath = await s.adapter.resolvePreviewPath(req.params.itemId); - if (!filePath || !fs.existsSync(filePath)) return res.status(404).end(); - res.sendFile(filePath); + const handled = await s.adapter.streamPreview(req.params.itemId, res); + if (!handled) res.status(404).end(); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +app.get('/api/thumbnail/:itemId', async (req, res) => { + const s = await ensureSession().catch(() => null); + if (!s) return res.status(409).end(); + try { + const handled = await s.adapter.streamThumbnail(req.params.itemId, res); + if (!handled) res.status(404).end(); } catch (err) { res.status(400).json({ error: err.message }); } @@ -137,7 +210,8 @@ app.post('/api/action', async (req, res) => { } s.history.push({ index: s.index, item, actionId, record: record || null }); s.index += 1; - res.json(queuePayload(s)); + persistSession(s); + res.json(await queuePayload(s)); }); app.post('/api/undo', async (req, res) => { @@ -153,9 +227,31 @@ app.post('/api/undo', async (req, res) => { return res.status(400).json({ error: err.message }); } s.index = entry.index; - res.json(queuePayload(s)); + persistSession(s); + res.json(await queuePayload(s)); }); -app.listen(PORT, () => { - console.log(`SwipeAnything running at http://localhost:${PORT}`); +app.post('/api/empty-trash', async (req, res) => { + const s = await ensureSession().catch(() => null); + if (!s) return res.status(409).json({ error: 'Not configured yet' }); + try { + await s.adapter.emptyTrash(); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + res.json(await queuePayload(s)); }); + +function start(port = PORT) { + return new Promise((resolve) => { + const server = app.listen(port, () => resolve(server)); + }); +} + +if (require.main === module) { + start().then((server) => { + console.log(`SwipeAnything running at http://localhost:${server.address().port}`); + }); +} + +module.exports = { app, start }; diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..65952e5 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,139 @@ +'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'); + +// Point the server at a throwaway config/session file before requiring it, +// so this suite never touches the real project config. +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-api-test-')); +process.env.SWIPEANYTHING_CONFIG_PATH = path.join(tmpRoot, 'config.json'); +process.env.SWIPEANYTHING_SESSION_PATH = path.join(tmpRoot, 'session.json'); + +const { start } = require('../server'); + +const folderDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-api-folder-')); +fs.writeFileSync(path.join(folderDir, 'a.txt'), 'A'); +fs.writeFileSync(path.join(folderDir, 'b.txt'), 'B'); + +let server; +let base; + +test.before(async () => { + server = await start(0); + base = `http://127.0.0.1:${server.address().port}`; +}); + +test.after(async () => { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + fs.rmSync(folderDir, { recursive: true, force: true }); +}); + +async function api(pathname, options) { + const res = await fetch(`${base}${pathname}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + const body = await res.json().catch(() => ({})); + return { status: res.status, body }; +} + +test('GET /api/adapters lists the registered adapters', async () => { + const { status, body } = await api('/api/adapters'); + assert.equal(status, 200); + assert.ok(body.some((a) => a.id === 'folder')); + assert.ok(body.some((a) => a.id === 'immich')); +}); + +test('GET /api/queue returns 409 before configuration', async () => { + const { status, body } = await api('/api/queue'); + assert.equal(status, 409); + assert.match(body.error, /Not configured/); +}); + +test('POST /api/config rejects an unknown adapter', async () => { + const { status, body } = await api('/api/config', { + method: 'POST', + body: JSON.stringify({ adapter: 'nope', settings: {} }), + }); + assert.equal(status, 400); + assert.match(body.error, /Unknown adapter/); +}); + +test('POST /api/config rejects a missing folder', async () => { + const { status, body } = await api('/api/config', { + method: 'POST', + body: JSON.stringify({ adapter: 'folder', settings: { folderPath: '/definitely/not/real' } }), + }); + assert.equal(status, 400); + assert.match(body.error, /Folder not found/); +}); + +test('full flow: configure, queue, keep, reject, undo, empty-trash', async () => { + const configRes = await api('/api/config', { + method: 'POST', + body: JSON.stringify({ adapter: 'folder', settings: { folderPath: folderDir, extensions: 'txt' } }), + }); + assert.equal(configRes.status, 200); + + const queue1 = await api('/api/queue'); + assert.equal(queue1.status, 200); + assert.equal(queue1.body.total, 2); + assert.equal(queue1.body.reviewed, 0); + const first = queue1.body.current; + + const afterKeep = await api('/api/action', { + method: 'POST', + body: JSON.stringify({ itemId: first.id, actionId: 'keep' }), + }); + assert.equal(afterKeep.status, 200); + assert.equal(afterKeep.body.reviewed, 1); + assert.equal(afterKeep.body.canUndo, true); + const second = afterKeep.body.current; + + const afterReject = await api('/api/action', { + method: 'POST', + body: JSON.stringify({ itemId: second.id, actionId: 'reject' }), + }); + assert.equal(afterReject.status, 200); + assert.equal(afterReject.body.reviewed, 2); + assert.equal(afterReject.body.current, null); + assert.equal(afterReject.body.trashInfo.count, 1); + + const afterUndo = await api('/api/undo', { method: 'POST' }); + assert.equal(afterUndo.status, 200); + assert.equal(afterUndo.body.reviewed, 1); + assert.equal(afterUndo.body.current.id, second.id); + + // put it back so trash state is predictable for the next assertion + await api('/api/action', { method: 'POST', body: JSON.stringify({ itemId: second.id, actionId: 'reject' }) }); + const emptied = await api('/api/empty-trash', { method: 'POST' }); + assert.equal(emptied.status, 200); + assert.equal(emptied.body.trashInfo.count, 0); +}); + +test('POST /api/action with a stale itemId is rejected', async () => { + await api('/api/rescan', { method: 'POST' }); + const { status, body } = await api('/api/action', { + method: 'POST', + body: JSON.stringify({ itemId: 'not-the-current-item', actionId: 'keep' }), + }); + assert.equal(status, 409); + assert.match(body.error, /stale/); +}); + +test('POST /api/undo with nothing to undo is rejected', async () => { + await api('/api/rescan', { method: 'POST' }); + const { status, body } = await api('/api/undo', { method: 'POST' }); + assert.equal(status, 409); + assert.match(body.error, /Nothing to undo/); +}); + +test('GET /api/preview/:itemId 404s for an unknown item', async () => { + const missingId = Buffer.from('nonexistent.txt').toString('base64url'); + const res = await fetch(`${base}/api/preview/${missingId}`); + assert.equal(res.status, 404); +}); diff --git a/test/folder-adapter.test.js b/test/folder-adapter.test.js new file mode 100644 index 0000000..5d172dd --- /dev/null +++ b/test/folder-adapter.test.js @@ -0,0 +1,122 @@ +'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/); +}); diff --git a/test/immich-adapter.test.js b/test/immich-adapter.test.js new file mode 100644 index 0000000..7d58ff2 --- /dev/null +++ b/test/immich-adapter.test.js @@ -0,0 +1,141 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { ImmichAdapter } = require('../adapters/immich'); + +/** Installs a fake global.fetch for the duration of `fn`, then restores it. */ +async function withMockFetch(handler, fn) { + const original = global.fetch; + const calls = []; + global.fetch = async (url, options = {}) => { + calls.push({ url, options }); + return handler(url, options); + }; + try { + await fn(calls); + } finally { + global.fetch = original; + } +} + +function jsonResponse(body, ok = true, status = 200) { + return { + ok, + status, + statusText: ok ? 'OK' : 'Error', + headers: { get: () => 'application/json' }, + json: async () => body, + text: async () => JSON.stringify(body), + }; +} + +test('init() calls /users/me with the API key header and throws on failure', async () => { + await withMockFetch( + () => jsonResponse({}, false, 401), + async () => { + const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'bad-key' }); + await assert.rejects(() => adapter.init(), /Immich API \/users\/me failed: 401/); + } + ); + + await withMockFetch( + () => jsonResponse({ id: 'me' }), + async (calls) => { + const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'good-key' }); + await adapter.init(); + assert.equal(calls[0].url, 'https://immich.example.com/api/users/me'); + assert.equal(calls[0].options.headers['x-api-key'], 'good-key'); + } + ); +}); + +test('list() in chronological mode POSTs /search/metadata and maps assets', async () => { + await withMockFetch( + (url) => { + if (String(url).endsWith('/search/metadata')) { + return jsonResponse({ + assets: { + items: [ + { id: 'a1', originalFileName: 'a.jpg', type: 'IMAGE', isFavorite: false }, + { id: 'a2', originalFileName: 'b.mov', type: 'VIDEO', isFavorite: true }, + ], + }, + }); + } + return jsonResponse({}, false, 404); + }, + async (calls) => { + const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k', mode: 'chronological' }); + const items = await adapter.list(); + assert.equal(items.length, 2); + assert.equal(items[0].previewType, 'image'); + assert.equal(items[1].previewType, 'video'); + const searchCall = calls.find((c) => String(c.url).endsWith('/search/metadata')); + assert.equal(searchCall.options.method, 'POST'); + assert.deepEqual(JSON.parse(searchCall.options.body), { take: 100, order: 'desc' }); + } + ); +}); + +test('list() with skipVideos filters out video assets', async () => { + await withMockFetch( + () => + jsonResponse({ + assets: { + items: [ + { id: 'a1', originalFileName: 'a.jpg', type: 'IMAGE' }, + { id: 'a2', originalFileName: 'b.mov', type: 'VIDEO' }, + ], + }, + }), + async () => { + const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k', skipVideos: true }); + const items = await adapter.list(); + assert.deepEqual(items.map((i) => i.id), ['a1']); + } + ); +}); + +test('list() in random mode GETs /assets/random', async () => { + await withMockFetch( + (url) => { + assert.ok(String(url).includes('/assets/random?count=50')); + return jsonResponse([{ id: 'r1', originalFileName: 'r.jpg', type: 'IMAGE' }]); + }, + async () => { + const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k', mode: 'random', take: 50 }); + const items = await adapter.list(); + assert.equal(items[0].id, 'r1'); + } + ); +}); + +test('applyAction("reject") trashes the asset; undo restores it', async () => { + await withMockFetch( + () => jsonResponse({ ok: true }), + async (calls) => { + const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k' }); + const record = await adapter.applyAction({ id: 'asset-1' }, 'reject'); + assert.deepEqual(record, { type: 'trash', assetId: 'asset-1' }); + const deleteCall = calls.find((c) => c.options.method === 'DELETE'); + assert.equal(deleteCall.url, 'https://immich.example.com/api/assets'); + assert.deepEqual(JSON.parse(deleteCall.options.body), { ids: ['asset-1'], force: false }); + + await adapter.undo(record); + const restoreCall = calls.find((c) => String(c.url).endsWith('/trash/restore/assets')); + assert.deepEqual(JSON.parse(restoreCall.options.body), { ids: ['asset-1'] }); + } + ); +}); + +test('applyAction("keep"/"skip") makes no API calls', async () => { + await withMockFetch( + () => assert.fail('should not call the API for keep/skip'), + async () => { + const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k' }); + assert.equal(await adapter.applyAction({ id: 'x' }, 'keep'), null); + assert.equal(await adapter.applyAction({ id: 'x' }, 'skip'), null); + } + ); +});