commit 36d3180c826d00772156fb5a45563d5fd3d1cc25 Author: ilia Date: Sat Jul 25 18:51:43 2026 -0400 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e663c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +swipeanything.config.json +.swipeanything-trash/ +.DS_Store +*.log diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..2a6deba --- /dev/null +++ b/.gitleaks.toml @@ -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''', +] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a59a3d1 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96c887d --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c4b7c5 --- /dev/null +++ b/README.md @@ -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). diff --git a/adapters/base.js b/adapters/base.js new file mode 100644 index 0000000..a218085 --- /dev/null +++ b/adapters/base.js @@ -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, + * }>>} + */ + 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 }; diff --git a/adapters/folder.js b/adapters/folder.js new file mode 100644 index 0000000..4e8950a --- /dev/null +++ b/adapters/folder.js @@ -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 }; diff --git a/adapters/registry.js b/adapters/registry.js new file mode 100644 index 0000000..991250a --- /dev/null +++ b/adapters/registry.js @@ -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 }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0ffa66a --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..28e70da --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..af5dcf0 --- /dev/null +++ b/public/app.js @@ -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]) => `${n} ${id}`) + .join('    '); + empty.innerHTML = `All done. ${state.total} item(s) reviewed.
${countBits}
`; + 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 = `
${message}
`; + } + + 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(); +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3a8f1e2 --- /dev/null +++ b/public/index.html @@ -0,0 +1,26 @@ + + + + + +SwipeAnything + + + +
+
+

SwipeAnything

+ +
+
+
+
+
+
+
+ + + diff --git a/public/settings.html b/public/settings.html new file mode 100644 index 0000000..32e90c4 --- /dev/null +++ b/public/settings.html @@ -0,0 +1,24 @@ + + + + + +SwipeAnything — Settings + + + +
+
+

Settings

+
+ Back +
+
+
Choose what to swipe through, and how.
+
+
+
+
+ + + diff --git a/public/settings.js b/public/settings.js new file mode 100644 index 0000000..29d5fd7 --- /dev/null +++ b/public/settings.js @@ -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 = ` + + + `; + return wrap; + } + + wrap.className = 'form-field'; + const inputType = field.type === 'number' ? 'number' : 'text'; + wrap.innerHTML = ` + + + `; + 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 = ` +
+ + ${adapter.label} +
+
${adapter.description || ''}
+ `; + 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; + }); +})(); diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..bd5c039 --- /dev/null +++ b/public/style.css @@ -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; +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..ead170d --- /dev/null +++ b/server.js @@ -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}`); +}); diff --git a/swipeanything.config.example.json b/swipeanything.config.example.json new file mode 100644 index 0000000..025edc6 --- /dev/null +++ b/swipeanything.config.example.json @@ -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" + } +}