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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
swipeanything.config.json
.swipeanything-trash/
.DS_Store
*.log
+13
View File
@@ -0,0 +1,13 @@
# Gitleaks config for SwipeAnything
# useDefault is required so detection rules load.
title = "SwipeAnything gitleaks"
[extend]
useDefault = true
[allowlist]
description = "Known false positives"
paths = [
'''swipeanything\.config\.example\.json''',
]
+104
View File
@@ -0,0 +1,104 @@
# Contributing
Adapters are the whole point of this project — the swipe UI, keyboard
shortcuts, drag gestures, undo stack, and settings form are all generic and
work for any adapter that implements the contract below.
## Writing a new adapter
1. Create `adapters/your-adapter.js` and extend the base class:
```js
const { Adapter } = require('./base');
class YourAdapter extends Adapter {
static id = 'your-adapter'; // used in swipeanything.config.json
static label = 'Your Adapter'; // shown in the settings UI
static description = 'One line describing what this swipes through.';
// Settings UI is generated from this — no frontend code needed.
static configSchema = [
{ key: 'someSetting', label: 'Some setting', type: 'text', required: true },
];
// Optional: override the default keep/reject pair, e.g. add a third
// "later" action. Each needs a distinct key and direction.
static actions = [
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true },
];
async init() {
// Validate settings / open a connection. Throw a descriptive Error
// to surface a message in the settings UI.
}
async list() {
// Return the full queue: [{ id, title, subtitle?, previewType?, meta? }]
}
async applyAction(item, actionId) {
// Perform the effect. Return whatever undo() needs, or null/undefined.
}
async undo(record) {
// Reverse applyAction() using the record it returned.
}
// Optional, only if items have a file-like preview:
async resolvePreviewPath(itemId) {
return null;
}
describeSource() {
return ''; // short string shown in the UI header
}
}
module.exports = { YourAdapter };
```
2. Register it in `adapters/registry.js`:
```js
const { YourAdapter } = require('./your-adapter');
const ADAPTERS = {
[FolderAdapter.id]: FolderAdapter,
[YourAdapter.id]: YourAdapter,
};
```
3. That's it — it now shows up in the Settings page automatically, with a
generated form from `configSchema`, and the swipe UI works against it.
## Design constraints to keep in mind
- **Non-destructive by default.** Follow the folder adapter's lead: prefer
"move to a recoverable place" / "mark as read" / "flag a row" over
irreversible deletes, and make `undo()` actually reverse it.
- **No adapter-specific frontend code.** If you find yourself editing
`public/app.js` to special-case your adapter, the contract is probably
missing something generic — open an issue/PR to discuss extending
`configSchema`, `actions`, or the item shape instead of forking the UI.
- **Validate in `init()`**, not `list()`. Throwing from `init()` surfaces a
clean error in the settings form before anything gets saved.
- **Keep `list()` fast enough for a session.** It's called once per session
(or on "Rescan"), not per card — pagination/streaming can come later if
a real adapter needs it.
## Local development
```bash
npm install
npm start
```
There's no build step — edit files under `public/` or `adapters/` and
refresh the browser.
## Pull requests
Keep adapters self-contained in their own file. Include a short section in
the PR description covering: what it swipes through, what each action does,
and how undo works.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ilia Dobkin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+97
View File
@@ -0,0 +1,97 @@
# SwipeAnything
Unleash the swipe on anything. Point it at a folder, and (via adapters) at
your inbox, a database table, or whatever else you need to triage — one
card at a time, right = keep, left = reject, like a dating app for your
backlog.
## Why
Tinder-style triage tools already exist, but each one is wired to a single
source: an image folder ([image-tinder](https://github.com/wsamuelw/image-tinder),
[photo-tinder-desktop](https://github.com/relaxis/photo-tinder-desktop)), an
inbox ([SwipeMail](https://github.com/RyanAJensen/SwipeMail)), or an AI
approval queue ([decision-desk](https://github.com/jdubb118/decision-desk)).
SwipeAnything separates the swipe UI from the source. An **adapter** turns
any collection of things into a queue of cards; the UI, keyboard shortcuts,
drag gestures, undo stack, and settings form all work the same regardless of
what's behind them. Write a new adapter and you get the whole UI for free.
## Status
v1 ships with one polished adapter — **local folder** — plus the framework
for more. Email, database-row, and other adapters are welcome as
contributions; see [CONTRIBUTING.md](CONTRIBUTING.md).
## Features
- Swipe (touch/mouse drag) or use the keyboard
- Non-destructive by default: "reject" moves files to a `.swipeanything-trash/`
folder next to the source, never a hard delete
- Undo, any number of steps back
- Live progress + per-action counts
- Generic settings UI: every adapter declares its own config fields and gets
a form for free — no adapter-specific frontend code required
- Zero build step: Node.js + Express + vanilla HTML/CSS/JS
## Quickstart
```bash
git clone https://git.levkin.ca/ilia/SwipeAnything.git
cd SwipeAnything
npm install
npm start
```
Open `http://localhost:5757`. On first run you'll land on **Settings**
pick the "Local folder" adapter, point it at a folder, and start swiping.
Alternatively, copy `swipeanything.config.example.json` to
`swipeanything.config.json` and edit it directly:
```bash
cp swipeanything.config.example.json swipeanything.config.json
```
## Controls
| Action | Gesture | Key | Button |
|---|---|---|---|
| Keep | Drag right | `→` | green circle |
| Reject (moves to trash) | Drag left | `←` | red circle |
| Skip (folder adapter) | — | `Space` | circle |
| Undo | — | `Ctrl/Cmd+Z` | Undo |
## How adapters work
Every adapter implements a small contract (`adapters/base.js`): declare a
settings schema, list the items to review, and apply/undo actions on them.
The bundled `adapters/folder.js` is the reference implementation — read it
first, then see [CONTRIBUTING.md](CONTRIBUTING.md) for a full walkthrough of
writing your own (email, database rows, RSS, anything).
```
UI (public/) --> Express API (server.js) --> Adapter Registry --> your adapter
```
## Project layout
```
server.js Express app: API + static file serving
adapters/base.js Adapter contract every adapter implements
adapters/folder.js Reference adapter: local files/folders
adapters/registry.js Adapter registration
public/ Vanilla HTML/CSS/JS swipe UI + settings UI
swipeanything.config.example.json Copy to swipeanything.config.json to configure
```
## Roadmap ideas
- Email adapter (IMAP): swipe archive/delete/label
- Generic JSON/CSV/database-row adapter: swipe to tag or update a status column
- Multi-select "later" bucket as a first-class third action everywhere
## License
MIT, see [LICENSE](LICENSE).
+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 };
+832
View File
@@ -0,0 +1,832 @@
{
"name": "swipeanything",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "swipeanything",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"express": "^4.19.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "swipeanything",
"version": "0.1.0",
"description": "A local-first, pluggable swipe-to-triage UI. Point it at a folder today; write an adapter and point it at anything tomorrow.",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"license": "MIT",
"author": "",
"repository": {
"type": "git",
"url": "https://git.levkin.ca/ilia/SwipeAnything.git"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^4.19.2"
}
}
+290
View File
@@ -0,0 +1,290 @@
(() => {
'use strict';
const deckEl = document.getElementById('deck');
const actionsRowEl = document.getElementById('actionsRow');
const progressFillEl = document.getElementById('progressFill');
const statsLineEl = document.getElementById('statsLine');
const sourceLabelEl = document.getElementById('sourceLabel');
const rescanLink = document.getElementById('rescanLink');
const DRAG_THRESHOLD = 110;
let state = null; // last /api/queue payload
let dragging = null;
async function api(path, options) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `Request failed: ${res.status}`);
return body;
}
function fmtMeta(meta) {
if (!meta) return [];
return Object.entries(meta).map(([key, value]) => `${key}: ${value}`);
}
function renderPreview(item) {
const wrap = document.createElement('div');
wrap.className = 'preview';
const src = `/api/preview/${item.id}`;
switch (item.previewType) {
case 'image': {
const img = document.createElement('img');
img.src = src;
img.alt = item.title;
img.draggable = false;
wrap.appendChild(img);
break;
}
case 'audio': {
const audio = document.createElement('audio');
audio.controls = true;
audio.src = src;
wrap.style.padding = '40px 10px';
wrap.appendChild(audio);
break;
}
case 'video': {
const video = document.createElement('video');
video.controls = true;
video.src = src;
wrap.appendChild(video);
break;
}
case 'text': {
const pre = document.createElement('pre');
pre.textContent = 'Loading preview...';
wrap.appendChild(pre);
fetch(src)
.then((r) => r.text())
.then((text) => {
pre.textContent = text.slice(0, 2000);
})
.catch(() => {
pre.textContent = '(preview unavailable)';
});
break;
}
default: {
wrap.classList.add('file-icon');
wrap.textContent = '\u{1F4C4}';
}
}
return wrap;
}
function renderCard(item, actions) {
const card = document.createElement('div');
card.className = 'card';
card.appendChild(renderPreview(item));
const title = document.createElement('div');
title.className = 'title';
title.textContent = item.title;
card.appendChild(title);
if (item.subtitle) {
const subtitle = document.createElement('div');
subtitle.className = 'subtitle';
subtitle.textContent = item.subtitle;
card.appendChild(subtitle);
}
const metaEntries = fmtMeta(item.meta);
if (metaEntries.length) {
const row = document.createElement('div');
row.className = 'meta-row';
for (const entry of metaEntries) {
const badge = document.createElement('span');
badge.className = 'meta-badge';
badge.textContent = entry;
row.appendChild(badge);
}
card.appendChild(row);
}
for (const dir of ['left', 'right']) {
const action = actions.find((a) => a.direction === dir);
if (!action) continue;
const stamp = document.createElement('div');
stamp.className = `stamp ${dir}`;
stamp.dataset.direction = dir;
stamp.textContent = action.label;
card.appendChild(stamp);
}
attachDrag(card, actions);
return card;
}
function attachDrag(card, actions) {
const leftAction = actions.find((a) => a.direction === 'left');
const rightAction = actions.find((a) => a.direction === 'right');
const leftStamp = card.querySelector('.stamp.left');
const rightStamp = card.querySelector('.stamp.right');
function onPointerDown(e) {
dragging = { startX: e.clientX, startY: e.clientY, dx: 0 };
card.setPointerCapture(e.pointerId);
}
function onPointerMove(e) {
if (!dragging) return;
dragging.dx = e.clientX - dragging.startX;
const dy = (e.clientY - dragging.startY) * 0.2;
const rotate = dragging.dx / 18;
card.style.transform = `translate(${dragging.dx}px, ${dy}px) rotate(${rotate}deg)`;
const ratio = Math.min(Math.abs(dragging.dx) / DRAG_THRESHOLD, 1);
if (dragging.dx > 0 && rightStamp) rightStamp.style.opacity = String(ratio);
if (dragging.dx < 0 && leftStamp) leftStamp.style.opacity = String(ratio);
}
function onPointerUp() {
if (!dragging) return;
const dx = dragging.dx;
dragging = null;
if (dx > DRAG_THRESHOLD && rightAction) {
flingAndAct(card, rightAction, 1);
} else if (dx < -DRAG_THRESHOLD && leftAction) {
flingAndAct(card, leftAction, -1);
} else {
card.style.transform = '';
if (leftStamp) leftStamp.style.opacity = '0';
if (rightStamp) rightStamp.style.opacity = '0';
}
}
card.addEventListener('pointerdown', onPointerDown);
card.addEventListener('pointermove', onPointerMove);
card.addEventListener('pointerup', onPointerUp);
card.addEventListener('pointercancel', onPointerUp);
}
function flingAndAct(card, action, dir) {
card.style.transition = 'transform 0.25s ease-out';
card.style.transform = `translate(${dir * 500}px, -40px) rotate(${dir * 25}deg)`;
setTimeout(() => performAction(action.id), 120);
}
function renderActionsRow(actions) {
actionsRowEl.innerHTML = '';
for (const action of actions) {
const btn = document.createElement('button');
btn.className = 'action-btn';
if (action.id === 'keep') btn.classList.add('keep');
if (action.isDestructive) btn.classList.add('reject');
btn.textContent = action.label;
btn.addEventListener('click', () => performAction(action.id));
actionsRowEl.appendChild(btn);
}
const undoBtn = document.createElement('button');
undoBtn.className = 'action-btn undo';
undoBtn.textContent = 'Undo';
undoBtn.disabled = !state || !state.canUndo;
undoBtn.addEventListener('click', undo);
actionsRowEl.appendChild(undoBtn);
}
function renderStats() {
if (!state) return;
const pct = state.total ? Math.round((state.reviewed / state.total) * 100) : 0;
progressFillEl.style.width = `${pct}%`;
sourceLabelEl.textContent = state.sourceLabel || '';
const countBits = Object.entries(state.counts || {})
.map(([id, n]) => `${id}: ${n}`)
.join(' \u00b7 ');
statsLineEl.textContent = `${state.reviewed}/${state.total} reviewed${countBits ? ' \u2014 ' + countBits : ''}`;
}
function renderDeck() {
deckEl.innerHTML = '';
if (!state) return;
if (!state.current) {
const empty = document.createElement('div');
empty.className = 'empty-state';
const countBits = Object.entries(state.counts || {})
.map(([id, n]) => `<strong>${n}</strong> ${id}`)
.join(' &nbsp;&nbsp; ');
empty.innerHTML = `All done. ${state.total} item(s) reviewed.<div class="summary">${countBits}</div>`;
deckEl.appendChild(empty);
renderActionsRow([]);
return;
}
const card = renderCard(state.current, state.actions);
deckEl.appendChild(card);
renderActionsRow(state.actions);
}
function render() {
renderStats();
renderDeck();
}
function showError(message) {
deckEl.innerHTML = `<div class="error-state">${message}</div>`;
}
async function refresh() {
try {
state = await api('/api/queue');
render();
} catch (err) {
if (String(err.message).includes('Not configured')) {
window.location.href = 'settings.html';
return;
}
showError(err.message);
}
}
async function performAction(actionId) {
if (!state || !state.current) return;
const itemId = state.current.id;
try {
state = await api('/api/action', { method: 'POST', body: JSON.stringify({ itemId, actionId }) });
render();
} catch (err) {
showError(err.message);
setTimeout(refresh, 800);
}
}
async function undo() {
try {
state = await api('/api/undo', { method: 'POST' });
render();
} catch (err) {
showError(err.message);
}
}
rescanLink.addEventListener('click', async (e) => {
e.preventDefault();
try {
state = await api('/api/rescan', { method: 'POST' });
render();
} catch (err) {
showError(err.message);
}
});
document.addEventListener('keydown', (e) => {
if (!state || !state.current) return;
if ((e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
undo();
return;
}
const action = (state.actions || []).find((a) => a.key === e.key);
if (action) {
e.preventDefault();
performAction(action.id);
}
});
refresh();
})();
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SwipeAnything</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<header>
<h1>SwipeAnything</h1>
<div class="header-actions">
<a class="icon-link" href="#" id="rescanLink">Rescan</a>
<a class="icon-link" href="settings.html">Settings</a>
</div>
</header>
<div class="source-label" id="sourceLabel"></div>
<div class="progress-track"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
<div class="stats" id="statsLine"></div>
<div class="deck" id="deck"></div>
<div class="actions-row" id="actionsRow"></div>
</div>
<script src="app.js"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SwipeAnything &mdash; Settings</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<header>
<h1>Settings</h1>
<div class="header-actions">
<a class="icon-link" href="index.html">Back</a>
</div>
</header>
<div class="source-label">Choose what to swipe through, and how.</div>
<div id="adapterList"></div>
<form id="settingsForm"></form>
<div class="settings-error" id="settingsError"></div>
</div>
<script src="settings.js"></script>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
(() => {
'use strict';
const adapterListEl = document.getElementById('adapterList');
const formEl = document.getElementById('settingsForm');
const errorEl = document.getElementById('settingsError');
let adapters = [];
let selectedAdapterId = null;
let currentSettings = {};
async function api(path, options) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `Request failed: ${res.status}`);
return body;
}
function fieldValue(field) {
const el = document.getElementById(`field_${field.key}`);
if (!el) return field.default;
if (field.type === 'checkbox') return el.checked;
if (field.type === 'number') return Number(el.value);
return el.value;
}
function renderField(field) {
const wrap = document.createElement('div');
const existing = currentSettings[field.key];
const value = existing !== undefined ? existing : field.default;
if (field.type === 'checkbox') {
wrap.className = 'form-field checkbox';
wrap.innerHTML = `
<input type="checkbox" id="field_${field.key}" ${value ? 'checked' : ''}>
<label for="field_${field.key}">${field.label}</label>
`;
return wrap;
}
wrap.className = 'form-field';
const inputType = field.type === 'number' ? 'number' : 'text';
wrap.innerHTML = `
<label for="field_${field.key}">${field.label}${field.required ? ' *' : ''}</label>
<input type="${inputType}" id="field_${field.key}"
value="${value !== undefined ? String(value).replace(/"/g, '&quot;') : ''}"
placeholder="${field.placeholder || ''}">
`;
return wrap;
}
function renderForm() {
formEl.innerHTML = '';
const adapter = adapters.find((a) => a.id === selectedAdapterId);
if (!adapter) return;
for (const field of adapter.configSchema) {
formEl.appendChild(renderField(field));
}
const saveBtn = document.createElement('button');
saveBtn.type = 'submit';
saveBtn.className = 'primary-btn';
saveBtn.textContent = 'Save & start swiping';
formEl.appendChild(saveBtn);
}
function renderAdapterList() {
adapterListEl.innerHTML = '';
for (const adapter of adapters) {
const card = document.createElement('label');
card.className = 'adapter-card';
card.innerHTML = `
<div class="form-field checkbox" style="margin-bottom:0">
<input type="radio" name="adapter" value="${adapter.id}" ${adapter.id === selectedAdapterId ? 'checked' : ''}>
<strong>${adapter.label}</strong>
</div>
<div class="adapter-desc">${adapter.description || ''}</div>
`;
card.querySelector('input').addEventListener('change', () => {
selectedAdapterId = adapter.id;
renderForm();
});
adapterListEl.appendChild(card);
}
}
formEl.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.textContent = '';
const adapter = adapters.find((a) => a.id === selectedAdapterId);
if (!adapter) return;
const settings = {};
for (const field of adapter.configSchema) {
settings[field.key] = fieldValue(field);
}
try {
await api('/api/config', {
method: 'POST',
body: JSON.stringify({ adapter: adapter.id, settings }),
});
window.location.href = 'index.html';
} catch (err) {
errorEl.textContent = err.message;
}
});
async function init() {
const [adapterList, configResp] = await Promise.all([api('/api/adapters'), api('/api/config')]);
adapters = adapterList;
if (configResp.config) {
selectedAdapterId = configResp.config.adapter;
currentSettings = configResp.config.settings || {};
} else {
selectedAdapterId = adapters[0] && adapters[0].id;
}
renderAdapterList();
renderForm();
}
init().catch((err) => {
errorEl.textContent = err.message;
});
})();
+371
View File
@@ -0,0 +1,371 @@
:root {
--bg: #0d0f12;
--card: #16191d;
--card2: #1d2227;
--text: #f0f2f4;
--sub: #9aa3ab;
--keep: #35c46a;
--reject: #e5484d;
--neutral: #f0b429;
--accent: #6ea8ff;
--border: #2a2f35;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
overscroll-behavior: none;
}
a {
color: var(--accent);
}
#app {
max-width: 480px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 16px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
gap: 10px;
}
h1 {
font-size: 17px;
margin: 0;
font-weight: 600;
}
.source-label {
font-size: 11px;
color: var(--sub);
word-break: break-all;
margin-bottom: 10px;
}
.header-actions {
display: flex;
gap: 10px;
flex: none;
}
.icon-link {
color: var(--sub);
text-decoration: none;
font-size: 13px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 9px;
background: var(--card);
}
.icon-link:hover {
color: var(--text);
}
.stats {
font-size: 12px;
color: var(--sub);
}
.progress-track {
height: 4px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
margin-bottom: 16px;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.2s;
}
.deck {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 360px;
}
.card {
position: absolute;
width: 100%;
max-width: 400px;
min-height: 340px;
max-height: 78vh;
overflow-y: auto;
background: linear-gradient(180deg, var(--card2), var(--card));
border-radius: 20px;
border: 1px solid var(--border);
padding: 22px 22px 26px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
user-select: none;
cursor: grab;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
touch-action: none;
}
.card:active {
cursor: grabbing;
}
.card .preview {
width: 100%;
max-height: 280px;
border-radius: 12px;
overflow: hidden;
background: #0a0c0e;
display: flex;
align-items: center;
justify-content: center;
}
.card .preview img,
.card .preview video {
width: 100%;
max-height: 280px;
object-fit: contain;
display: block;
}
.card .preview.file-icon {
height: 140px;
font-size: 48px;
}
.card .preview pre {
margin: 0;
padding: 12px;
font-size: 11px;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
color: var(--sub);
max-height: 240px;
overflow: auto;
width: 100%;
}
.card .title {
font-size: 17px;
font-weight: 700;
line-height: 1.3;
word-break: break-word;
}
.card .subtitle {
font-size: 12px;
color: var(--sub);
word-break: break-all;
}
.meta-row {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
font-size: 11px;
color: var(--sub);
}
.meta-badge {
padding: 2px 8px;
border-radius: 20px;
border: 1px solid var(--border);
background: #1b1f24;
}
.stamp {
position: absolute;
top: 24px;
font-size: 22px;
font-weight: 800;
letter-spacing: 0.08em;
padding: 6px 14px;
border-radius: 8px;
border: 3px solid;
opacity: 0;
pointer-events: none;
transform: rotate(-12deg);
text-transform: uppercase;
}
.stamp.right {
right: 20px;
color: var(--keep);
border-color: var(--keep);
}
.stamp.left {
left: 20px;
color: var(--reject);
border-color: var(--reject);
transform: rotate(12deg);
}
.actions-row {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 18px;
flex-wrap: wrap;
}
.action-btn {
flex: none;
min-width: 56px;
height: 56px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
}
.action-btn.keep {
color: var(--keep);
border-color: var(--keep);
}
.action-btn.reject {
color: var(--reject);
border-color: var(--reject);
}
.action-btn.undo {
color: var(--accent);
border-color: var(--accent);
}
.action-btn:disabled {
opacity: 0.35;
cursor: default;
}
.empty-state,
.error-state {
text-align: center;
padding: 40px 16px;
color: var(--sub);
}
.error-state {
color: var(--reject);
}
.summary {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 10px;
font-size: 13px;
}
.summary strong {
display: block;
font-size: 20px;
}
/* Settings page */
.form-field {
margin-bottom: 16px;
text-align: left;
}
.form-field label {
display: block;
font-size: 13px;
color: var(--sub);
margin-bottom: 6px;
}
.form-field input[type='text'],
.form-field input[type='number'],
.form-field select {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
font-size: 14px;
}
.form-field.checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.form-field.checkbox label {
margin-bottom: 0;
}
.adapter-card {
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
background: var(--card);
margin-bottom: 16px;
}
.adapter-desc {
font-size: 12px;
color: var(--sub);
margin-top: 4px;
}
.primary-btn {
width: 100%;
padding: 12px;
border-radius: 10px;
border: none;
background: var(--accent);
color: #06121f;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.primary-btn:disabled {
opacity: 0.5;
cursor: default;
}
.settings-error {
color: var(--reject);
font-size: 13px;
margin-top: 10px;
min-height: 18px;
}
+161
View File
@@ -0,0 +1,161 @@
'use strict';
const express = require('express');
const fs = require('fs');
const path = require('path');
const { getAdapter, listAdapters } = require('./adapters/registry');
const app = express();
const PORT = process.env.PORT || 5757;
const CONFIG_PATH = path.join(__dirname, 'swipeanything.config.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} */
let session = null;
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) return null;
try {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
} catch {
return null;
}
}
function saveConfig(config) {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
}
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: [] };
return session;
}
async function ensureSession() {
if (session) return session;
const config = loadConfig();
if (!config) return null;
return startSession(config);
}
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;
return {
adapterId: s.adapterId,
sourceLabel: s.adapter.describeSource(),
actions: s.adapter.constructor.actions,
total: s.queue.length,
reviewed: s.index,
counts,
current: remaining[0] || null,
upcoming: remaining.slice(1, 4),
canUndo: s.history.length > 0,
};
}
app.get('/api/adapters', (req, res) => {
res.json(listAdapters());
});
app.get('/api/config', (req, res) => {
res.json({ config: loadConfig() });
});
app.post('/api/config', async (req, res) => {
const { adapter, settings } = req.body || {};
const AdapterClass = getAdapter(adapter);
if (!AdapterClass) {
return res.status(400).json({ error: `Unknown adapter: ${adapter}` });
}
const config = { adapter, settings: settings || {} };
try {
await startSession(config); // validates settings before persisting
} catch (err) {
return res.status(400).json({ error: err.message });
}
saveConfig(config);
res.json({ ok: true });
});
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));
});
app.post('/api/rescan', async (req, res) => {
const config = loadConfig();
if (!config) return res.status(409).json({ error: 'Not configured yet' });
session = null;
try {
const s = await startSession(config);
res.json(queuePayload(s));
} catch (err) {
res.status(400).json({ error: err.message });
}
});
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);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.post('/api/action', async (req, res) => {
const s = await ensureSession().catch(() => null);
if (!s) return res.status(409).json({ error: 'Not configured yet' });
const { itemId, actionId } = req.body || {};
const item = s.queue[s.index];
if (!item || item.id !== itemId) {
return res.status(409).json({ error: 'Item is stale, refresh the queue' });
}
const validAction = s.adapter.constructor.actions.some((a) => a.id === actionId);
if (!validAction) {
return res.status(400).json({ error: `Unknown action: ${actionId}` });
}
let record;
try {
record = await s.adapter.applyAction(item, actionId);
} catch (err) {
return res.status(400).json({ error: err.message });
}
s.history.push({ index: s.index, item, actionId, record: record || null });
s.index += 1;
res.json(queuePayload(s));
});
app.post('/api/undo', async (req, res) => {
const s = await ensureSession().catch(() => null);
if (!s) return res.status(409).json({ error: 'Not configured yet' });
const entry = s.history.pop();
if (!entry) return res.status(409).json({ error: 'Nothing to undo' });
try {
if (entry.record) await s.adapter.undo(entry.record);
} catch (err) {
// put the history entry back so the user doesn't lose track of it
s.history.push(entry);
return res.status(400).json({ error: err.message });
}
s.index = entry.index;
res.json(queuePayload(s));
});
app.listen(PORT, () => {
console.log(`SwipeAnything running at http://localhost:${PORT}`);
});
+9
View File
@@ -0,0 +1,9 @@
{
"adapter": "folder",
"settings": {
"folderPath": "/absolute/path/to/a/folder",
"recursive": false,
"extensions": "jpg,jpeg,png,gif,webp,heic,bmp",
"trashDirName": ".swipeanything-trash"
}
}