Initial scaffold: adapter framework + local folder reference adapter

Node/Express backend with a pluggable Adapter contract, a folder adapter
(safe non-destructive reject-to-trash, undo, keep/reject/skip), and a
vanilla JS swipe UI + generic settings form driven by each adapter's
configSchema.
This commit is contained in:
2026-07-25 18:51:43 -04:00
commit 36d3180c82
17 changed files with 2401 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
'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.
*/
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' | 'checkbox' | 'number' | 'select',
* default?: any,
* options?: Array<{ value: string, label: string }>, // for type 'select'
* placeholder?: string,
* required?: boolean,
* }
*/
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()`);
}
/**
* Optional: resolve an item id to an absolute file path so the
* /api/preview endpoint can stream it. Adapters without file-like
* previews (e.g. a future database-row adapter) can leave this as-is;
* the UI falls back to title/subtitle/meta only.
*/
async resolvePreviewPath(itemId) {
return null;
}
/** Optional short string describing the source, shown in the UI header. */
describeSource() {
return '';
}
}
module.exports = { Adapter };
+166
View File
@@ -0,0 +1,166 @@
'use strict';
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { Adapter } = require('./base');
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'svg', 'tiff', 'avif']);
const AUDIO_EXT = new Set(['mp3', 'wav', 'flac', 'm4a', 'ogg', 'aac']);
const VIDEO_EXT = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi']);
const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'log']);
function extOf(filePath) {
return path.extname(filePath).slice(1).toLowerCase();
}
function previewTypeFor(filePath) {
const ext = extOf(filePath);
if (IMAGE_EXT.has(ext)) return 'image';
if (AUDIO_EXT.has(ext)) return 'audio';
if (VIDEO_EXT.has(ext)) return 'video';
if (TEXT_EXT.has(ext)) return 'text';
return 'none';
}
function idFor(relativePath) {
return Buffer.from(relativePath).toString('base64url');
}
function pathForId(id) {
return Buffer.from(id, 'base64url').toString('utf8');
}
/**
* Reference adapter: point at any local folder and swipe through its files.
* "Reject" never deletes -- it moves the file into a trash folder alongside
* the source, and "Undo" moves it right back.
*/
class FolderAdapter extends Adapter {
static id = 'folder';
static label = 'Local folder';
static description =
'Point at any local folder and swipe through its files. Rejected files move to a trash folder, never deleted outright.';
static configSchema = [
{
key: 'folderPath',
label: 'Folder path',
type: 'text',
required: true,
placeholder: '/Users/you/Pictures/to-sort',
},
{ key: 'recursive', label: 'Include subfolders', type: 'checkbox', default: false },
{
key: 'extensions',
label: 'File extensions (comma separated, blank = all files)',
type: 'text',
default: 'jpg,jpeg,png,gif,webp,heic,bmp',
},
{ key: 'trashDirName', label: 'Trash folder name', type: 'text', default: '.swipeanything-trash' },
];
static actions = [
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true },
{ id: 'skip', label: 'Skip', key: ' ', direction: 'down' },
];
constructor(settings) {
super(settings);
this.folderPath = path.resolve(settings.folderPath || '.');
this.trashDirName = settings.trashDirName || '.swipeanything-trash';
this.trashDir = path.join(this.folderPath, this.trashDirName);
this.recursive = Boolean(settings.recursive);
const extList = String(settings.extensions || '')
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
this.extensions = extList.length ? new Set(extList) : null; // null = allow all
}
async init() {
const stat = await fsp.stat(this.folderPath).catch(() => null);
if (!stat || !stat.isDirectory()) {
throw new Error(`Folder not found: ${this.folderPath}`);
}
await fsp.mkdir(this.trashDir, { recursive: true });
}
async _walk(dir, relativeBase = '') {
const entries = await fsp.readdir(dir, { withFileTypes: true });
let files = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue; // skips dotfiles and our own trash dir
const abs = path.join(dir, entry.name);
const rel = relativeBase ? path.join(relativeBase, entry.name) : entry.name;
if (entry.isDirectory()) {
if (this.recursive) files = files.concat(await this._walk(abs, rel));
continue;
}
const ext = extOf(entry.name);
if (this.extensions && !this.extensions.has(ext)) continue;
files.push(rel);
}
return files;
}
async list() {
const relativePaths = await this._walk(this.folderPath);
relativePaths.sort((a, b) => a.localeCompare(b));
const items = [];
for (const rel of relativePaths) {
const abs = path.join(this.folderPath, rel);
const stat = await fsp.stat(abs);
const dir = path.dirname(rel);
items.push({
id: idFor(rel),
title: path.basename(rel),
subtitle: dir === '.' ? undefined : dir,
previewType: previewTypeFor(rel),
meta: {
sizeKb: Math.round(stat.size / 1024),
modified: stat.mtime.toISOString().slice(0, 10),
},
});
}
return items;
}
_absoluteFor(itemId) {
const rel = pathForId(itemId);
const abs = path.resolve(this.folderPath, rel);
if (abs !== this.folderPath && !abs.startsWith(this.folderPath + path.sep)) {
throw new Error('Invalid item id');
}
return abs;
}
async resolvePreviewPath(itemId) {
return this._absoluteFor(itemId);
}
async applyAction(item, actionId) {
if (actionId === 'reject') {
const from = this._absoluteFor(item.id);
const to = path.join(this.trashDir, `${Date.now()}__${path.basename(from)}`);
await fsp.rename(from, to);
return { type: 'move', from, to };
}
// 'keep' and 'skip' have no filesystem effect.
return null;
}
async undo(record) {
if (record && record.type === 'move') {
await fsp.mkdir(path.dirname(record.from), { recursive: true });
await fsp.rename(record.to, record.from);
}
}
describeSource() {
return this.folderPath;
}
}
module.exports = { FolderAdapter };
+24
View File
@@ -0,0 +1,24 @@
'use strict';
const { FolderAdapter } = require('./folder');
// Register new adapters here. See CONTRIBUTING.md for the full guide.
const ADAPTERS = {
[FolderAdapter.id]: FolderAdapter,
};
function getAdapter(id) {
return ADAPTERS[id];
}
function listAdapters() {
return Object.values(ADAPTERS).map((AdapterClass) => ({
id: AdapterClass.id,
label: AdapterClass.label,
description: AdapterClass.description,
configSchema: AdapterClass.configSchema,
actions: AdapterClass.actions,
}));
}
module.exports = { ADAPTERS, getAdapter, listAdapters };