- 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.
141 lines
4.7 KiB
JavaScript
141 lines
4.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Base contract every SwipeAnything adapter implements.
|
|
*
|
|
* An adapter turns "some collection of things" (files, emails, database
|
|
* rows, ...) into a queue of swipeable cards, and knows how to apply and
|
|
* undo the actions a user can take on each card. The core server and UI
|
|
* never know what an adapter actually touches -- they only talk to this
|
|
* interface.
|
|
*
|
|
* See CONTRIBUTING.md for a walkthrough of writing a new adapter, and
|
|
* adapters/folder.js (local files) / adapters/immich.js (remote API) for
|
|
* two different reference implementations.
|
|
*/
|
|
class Adapter {
|
|
/** Unique machine id, e.g. "folder". Stored in swipeanything.config.json. */
|
|
static id = 'base';
|
|
|
|
/** Human-friendly name shown in the settings UI. */
|
|
static label = 'Base adapter';
|
|
|
|
/** One-line description shown in the settings UI. */
|
|
static description = '';
|
|
|
|
/**
|
|
* Declares the settings this adapter needs, so the settings UI can render
|
|
* a generic form without adapter-specific frontend code. Each entry:
|
|
* {
|
|
* key: string,
|
|
* label: string,
|
|
* type: 'text' | 'password' | 'checkbox' | 'number' | 'select' | 'folder',
|
|
* default?: any,
|
|
* options?: Array<{ value: string, label: string }>, // for type 'select'
|
|
* placeholder?: string,
|
|
* required?: boolean,
|
|
* }
|
|
* `type: 'folder'` renders a text field plus a "Browse..." button backed
|
|
* by a native folder picker where available (see server.js /api/browse-folder).
|
|
*/
|
|
static configSchema = [];
|
|
|
|
/**
|
|
* Actions available on every card. The first two are the swipe defaults
|
|
* (right = keep, left = reject); adapters may add more, e.g. a third
|
|
* "later" bucket, as long as each has a distinct `key` and `direction`.
|
|
* {
|
|
* id: string,
|
|
* label: string,
|
|
* key: string, // KeyboardEvent.key that triggers it
|
|
* direction: 'left' | 'right' | 'up' | 'down',
|
|
* isDestructive?: boolean,
|
|
* }
|
|
*/
|
|
static actions = [
|
|
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' },
|
|
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true },
|
|
];
|
|
|
|
constructor(settings = {}) {
|
|
this.settings = settings;
|
|
}
|
|
|
|
/**
|
|
* Optional async setup: validate settings, open a mailbox, connect to a
|
|
* database, create a trash directory, etc. Throw a descriptive Error to
|
|
* surface a validation message in the settings UI.
|
|
*/
|
|
async init() {}
|
|
|
|
/**
|
|
* Returns the full queue of items to review, in order. Called once per
|
|
* session (see the "Rescan" action in the UI to rebuild it).
|
|
* @returns {Promise<Array<{
|
|
* id: string,
|
|
* title: string,
|
|
* subtitle?: string,
|
|
* previewType?: 'image' | 'audio' | 'video' | 'text' | 'none',
|
|
* meta?: Record<string, string | number>,
|
|
* }>>}
|
|
*/
|
|
async list() {
|
|
throw new Error(`${this.constructor.name} must implement list()`);
|
|
}
|
|
|
|
/**
|
|
* Applies `actionId` to `item`. Return whatever undo() needs to reverse
|
|
* the effect, or null/undefined if the action has no side effect (e.g.
|
|
* "keep" on a filesystem adapter just leaves the file alone).
|
|
*/
|
|
async applyAction(item, actionId) {
|
|
throw new Error(`${this.constructor.name} must implement applyAction()`);
|
|
}
|
|
|
|
/** Reverses the effect described by the record returned from applyAction(). */
|
|
async undo(record) {
|
|
throw new Error(`${this.constructor.name} must implement undo()`);
|
|
}
|
|
|
|
/**
|
|
* Writes the full-size preview for `itemId` directly to the Express
|
|
* response (res.sendFile for local files, a piped fetch for remote
|
|
* APIs, etc). Return true if you wrote a response, false to let the
|
|
* server respond 404 (e.g. this item has no visual preview).
|
|
*/
|
|
async streamPreview(itemId, res) {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Same as streamPreview, but for a smaller/faster thumbnail (used for
|
|
* the card image and as a video poster frame). Optional -- the default
|
|
* does nothing, and the frontend falls back to streamPreview.
|
|
*/
|
|
async streamThumbnail(itemId, res) {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Optional: describes a reversible "trash" this adapter maintains, so
|
|
* the UI can offer an explicit, confirm-guarded "Empty trash" action.
|
|
* Return null if this adapter has no such concept.
|
|
* @returns {Promise<{ count: number, label: string } | null>}
|
|
*/
|
|
async describeTrash() {
|
|
return null;
|
|
}
|
|
|
|
/** Optional: permanently clears whatever describeTrash() described. */
|
|
async emptyTrash() {
|
|
throw new Error(`${this.constructor.name} does not support emptyTrash()`);
|
|
}
|
|
|
|
/** Optional short string describing the source, shown in the UI header. */
|
|
describeSource() {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
module.exports = { Adapter };
|