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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user