Add second adapter, thumbnails, a11y pass, and a real test suite

- 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.
This commit is contained in:
2026-07-25 19:41:47 -04:00
parent 36d3180c82
commit 0c9919f93c
19 changed files with 1536 additions and 76 deletions
+141
View File
@@ -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);
}
);
});