Merge pull request 'Organize folders, details sheet, and richer previews' (#2) from feature/organize-details-previews into main
CI / Gitleaks (push) Successful in 18s
CI / Unit tests (push) Successful in 21s

This commit was merged in pull request #2.
This commit is contained in:
2026-07-26 20:40:43 -05:00
21 changed files with 1905 additions and 121 deletions
+19
View File
@@ -6,6 +6,25 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- **PDF / RAW / ZIP previews** on the folder adapter: PDFs show a first-page
Quick Look thumb (iframe fallback); camera RAW (`dng`, `cr2`, `nef`, …)
uses QL thumbs; `.zip` lists contained files (no extract) via a zero-dep
central-directory reader (`lib/zip-list.js`).
- **Video / CSV** covered in demo seed + preview-type tests; `npm run seed-demo`
rebuilds `/tmp/swipe-demo`.
- **File details** bottom sheet: press `i` / Details / click the filename
(path, size, dates; image dimensions / camera on macOS; zip entry summary).
Copy path + Reveal in Finder. Setting "Show file details on each card by
default". Immich returns EXIF-ish fields from its asset API.
- **Organize folders (keys 0–9)** on the local folder adapter: map number
keys to destination folders in Settings, then press `0`–`9` (or tap the
chips) to move the current file there. Undo restores it. Destination
dirs inside the source tree are excluded from the queue so sorted files
don't bounce back in.
- OLED dark design tokens (DM Sans + IBM Plex Mono) documented under
`design-system/swipeanything/MASTER.md`.
- New settings field type `folderMap` (and adapter `getActions()`) so
organize destinations are schema-driven like everything else.
- Gitea Actions CI (`.gitea/workflows/ci.yml`): `npm test` + gitleaks on - Gitea Actions CI (`.gitea/workflows/ci.yml`): `npm test` + gitleaks on
every push/PR — the first PR gate for this repo. every push/PR — the first PR gate for this repo.
- Settings UI polish: selected-adapter highlight, custom radios (no native - Settings UI polish: selected-adapter highlight, custom radios (no native
+8 -4
View File
@@ -21,13 +21,17 @@ work for any adapter that implements the contract below.
{ key: 'someSetting', label: 'Some setting', type: 'text', required: true }, { key: 'someSetting', label: 'Some setting', type: 'text', required: true },
]; ];
// Optional: override the default keep/reject pair, e.g. add a third // Optional: override the default keep/reject pair. Prefer getActions()
// "later" action. Each needs a distinct key and direction. // when the set depends on settings (see folder.js destinations 0–9).
static actions = [ static actions = [
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' }, { id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right', group: 'primary' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true }, { id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true, group: 'primary' },
]; ];
getActions() {
return this.constructor.actions; // or append setting-driven actions
}
async init() { async init() {
// Validate settings / open a connection. Throw a descriptive Error // Validate settings / open a connection. Throw a descriptive Error
// to surface a message in the settings UI. // to surface a message in the settings UI.
+22 -2
View File
@@ -28,6 +28,11 @@ see [CONTRIBUTING.md](CONTRIBUTING.md).
## Features ## Features
- Swipe (touch/mouse drag) or use the keyboard - Swipe (touch/mouse drag) or use the keyboard
- **Organize, not just triage:** map keys `0`–`9` to destination folders in
Settings, then press a number (or tap the chip) to move the current file
there. Undo brings it back.
- **Inspect before deciding:** press `i` for path, size, dates, and (on
macOS) image dimensions / camera metadata; Reveal in Finder + Copy path.
- Non-destructive by default: "reject" moves files to a `.swipeanything-trash/` - Non-destructive by default: "reject" moves files to a `.swipeanything-trash/`
folder (or, for Immich, the library's own trash) — never a hard delete. folder (or, for Immich, the library's own trash) — never a hard delete.
A separate, confirm-guarded "Empty trash" action is the only place that A separate, confirm-guarded "Empty trash" action is the only place that
@@ -83,13 +88,15 @@ live server. The same suite is the PR gate in [`.gitea/workflows/ci.yml`](.gitea
| Action | Gesture | Key | Button | | Action | Gesture | Key | Button |
|---|---|---|---| |---|---|---|---|
| Keep | Drag right | `→` | Keep → | | Keep (leave in place) | Drag right | `→` | Keep → |
| Reject (moves to trash) | Drag left | `←` | Reject ← | | Reject (moves to trash) | Drag left | `←` | Reject ← |
| Skip / next (no decision) | — | `↓` or `Space` | Skip ↓ | | Skip / next (no decision) | — | `↓` or `Space` | Skip ↓ |
| Undo / go back | — | `↑` or `Ctrl/Cmd+Z` | Undo ↑ | | Undo / go back | — | `↑` or `Ctrl/Cmd+Z` | Undo ↑ |
| Move to organize folder | — | `0`–`9` (if configured) | chips under the main buttons |
| File details (path, size, dimensions, …) | — | `i` | Details in header |
| Shortcuts help | — | `Shift+?` | `?` in header | | Shortcuts help | — | `Shift+?` | `?` in header |
Keep/Reject (button or key) flash the KEEP/REJECT stamp and fling the card, same as a drag. Keep/Reject (button or key) flash the KEEP/REJECT stamp and fling the card, same as a drag. Numbered organize actions flash the destination label and lift the card away. Details open as a bottom sheet over the card so the layout doesn’t jump.
## Adapters ## Adapters
@@ -100,6 +107,19 @@ extension. "Reject" moves the file into `.swipeanything-trash/` next to the
source; "Empty trash" (a separate, confirm-guarded action shown in the header source; "Empty trash" (a separate, confirm-guarded action shown in the header
once there's anything to empty) permanently deletes what's in there. once there's anything to empty) permanently deletes what's in there.
In Settings, fill any of the **Organize folders (keys 0–9)** rows with a
destination path (and optional label). Only configured keys appear as
actions — leave a row blank to disable it. Destination folders are created
if missing, and if a destination sits inside the source tree it is skipped
when listing so sorted files don't reappear in the queue.
Press `i` (or **Details** in the header) to inspect the current file before
deciding: full path, size, dates, and on macOS image dimensions / camera /
capture date when Spotlight knows them. Details open as a bottom sheet over
the card. **Reveal in Finder** and **Copy path** are there too. Turn on
**Show file details on each card by default** in Settings if you always want
the sheet open.
### Immich ### Immich
Point at a self-hosted [Immich](https://immich.app) server + API key and Point at a self-hosted [Immich](https://immich.app) server + API key and
+37 -6
View File
@@ -29,7 +29,7 @@ class Adapter {
* { * {
* key: string, * key: string,
* label: string, * label: string,
* type: 'text' | 'password' | 'checkbox' | 'number' | 'select' | 'folder', * type: 'text' | 'password' | 'checkbox' | 'number' | 'select' | 'folder' | 'folderMap',
* default?: any, * default?: any,
* options?: Array<{ value: string, label: string }>, // for type 'select' * options?: Array<{ value: string, label: string }>, // for type 'select'
* placeholder?: string, * placeholder?: string,
@@ -37,19 +37,21 @@ class Adapter {
* } * }
* `type: 'folder'` renders a text field plus a "Browse..." button backed * `type: 'folder'` renders a text field plus a "Browse..." button backed
* by a native folder picker where available (see server.js /api/browse-folder). * by a native folder picker where available (see server.js /api/browse-folder).
* `type: 'folderMap'` renders keys 0–9, each with an optional label and a
* folder path (for organize-into-buckets flows).
*/ */
static configSchema = []; static configSchema = [];
/** /**
* Actions available on every card. The first two are the swipe defaults * Default actions available on every card. Prefer overriding getActions()
* (right = keep, left = reject); adapters may add more, e.g. a third * when the set depends on settings (e.g. numbered destination folders).
* "later" bucket, as long as each has a distinct `key` and `direction`.
* { * {
* id: string, * id: string,
* label: string, * label: string,
* key: string, // KeyboardEvent.key that triggers it * key: string, // KeyboardEvent.key that triggers it
* direction: 'left' | 'right' | 'up' | 'down', * direction?: 'left' | 'right' | 'up' | 'down',
* isDestructive?: boolean, * isDestructive?: boolean,
* group?: 'primary' | 'organize', // UI layout hint
* } * }
*/ */
static actions = [ static actions = [
@@ -61,6 +63,14 @@ class Adapter {
this.settings = settings; this.settings = settings;
} }
/**
* Actions for the current session. Defaults to the static `actions` list;
* override when actions depend on settings (folder destinations, etc.).
*/
getActions() {
return this.constructor.actions;
}
/** /**
* Optional async setup: validate settings, open a mailbox, connect to a * Optional async setup: validate settings, open a mailbox, connect to a
* database, create a trash directory, etc. Throw a descriptive Error to * database, create a trash directory, etc. Throw a descriptive Error to
@@ -75,7 +85,7 @@ class Adapter {
* id: string, * id: string,
* title: string, * title: string,
* subtitle?: string, * subtitle?: string,
* previewType?: 'image' | 'audio' | 'video' | 'text' | 'none', * previewType?: 'image' | 'audio' | 'video' | 'pdf' | 'archive' | 'text' | 'none',
* meta?: Record<string, string | number>, * meta?: Record<string, string | number>,
* }>>} * }>>}
*/ */
@@ -116,6 +126,22 @@ class Adapter {
return false; return false;
} }
/**
* Optional: richer details for the inspect-before-deciding panel.
* @returns {Promise<{ fields: Array<{ label: string, value: string }>, path?: string, actions?: Array<{ id: string, label: string }> } | null>}
*/
async getDetails(itemId) {
return null;
}
/**
* Optional: reveal / open the item in the native file manager.
* Return true if handled.
*/
async reveal(itemId) {
return false;
}
/** /**
* Optional: describes a reversible "trash" this adapter maintains, so * Optional: describes a reversible "trash" this adapter maintains, so
* the UI can offer an explicit, confirm-guarded "Empty trash" action. * the UI can offer an explicit, confirm-guarded "Empty trash" action.
@@ -135,6 +161,11 @@ class Adapter {
describeSource() { describeSource() {
return ''; return '';
} }
/** Optional UI hints (e.g. showDetailsByDefault) merged into /api/queue. */
uiHints() {
return {};
}
} }
module.exports = { Adapter }; module.exports = { Adapter };
+208 -16
View File
@@ -5,11 +5,39 @@ const fsp = fs.promises;
const path = require('path'); const path = require('path');
const { Adapter } = require('./base'); const { Adapter } = require('./base');
const { getThumbnail } = require('../lib/thumbnails'); const { getThumbnail } = require('../lib/thumbnails');
const { collectFileDetails } = require('../lib/file-details');
const { listZipEntries, formatZipListing } = require('../lib/zip-list');
const { execFile } = require('child_process');
const { promisify } = require('util');
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'svg', 'tiff', 'avif']); const execFileAsync = promisify(execFile);
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'svg', 'tiff', 'tif', 'avif']);
const RAW_EXT = new Set([
'dng',
'cr2',
'cr3',
'nef',
'nrw',
'arw',
'srf',
'sr2',
'orf',
'rw2',
'raf',
'pef',
'ptx',
'x3f',
'raw',
'rwl',
'srw',
]);
const AUDIO_EXT = new Set(['mp3', 'wav', 'flac', 'm4a', 'ogg', 'aac']); const AUDIO_EXT = new Set(['mp3', 'wav', 'flac', 'm4a', 'ogg', 'aac']);
const VIDEO_EXT = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi']); const VIDEO_EXT = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'm4v']);
const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'log']); const PDF_EXT = new Set(['pdf']);
const ZIP_EXT = new Set(['zip']);
const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'tsv', 'log', 'yaml', 'yml']);
const DEST_KEYS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
function extOf(filePath) { function extOf(filePath) {
return path.extname(filePath).slice(1).toLowerCase(); return path.extname(filePath).slice(1).toLowerCase();
@@ -21,17 +49,26 @@ const MIME_OVERRIDES = {
heic: 'image/heic', heic: 'image/heic',
heif: 'image/heif', heif: 'image/heif',
flac: 'audio/flac', flac: 'audio/flac',
dng: 'image/x-adobe-dng',
}; };
function previewTypeFor(filePath) { function previewTypeFor(filePath) {
const ext = extOf(filePath); const ext = extOf(filePath);
if (IMAGE_EXT.has(ext)) return 'image'; if (IMAGE_EXT.has(ext) || RAW_EXT.has(ext)) return 'image';
if (AUDIO_EXT.has(ext)) return 'audio'; if (AUDIO_EXT.has(ext)) return 'audio';
if (VIDEO_EXT.has(ext)) return 'video'; if (VIDEO_EXT.has(ext)) return 'video';
if (PDF_EXT.has(ext)) return 'pdf';
if (ZIP_EXT.has(ext)) return 'archive';
if (TEXT_EXT.has(ext)) return 'text'; if (TEXT_EXT.has(ext)) return 'text';
return 'none'; return 'none';
} }
/** Exts whose original bytes don't render in most browsers — serve QL thumb as preview when possible. */
function needsThumbnailPreview(filePath) {
const ext = extOf(filePath);
return RAW_EXT.has(ext) || ext === 'heic' || ext === 'heif';
}
function formatSize(bytes) { function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
@@ -46,18 +83,37 @@ function pathForId(id) {
return Buffer.from(id, 'base64url').toString('utf8'); return Buffer.from(id, 'base64url').toString('utf8');
} }
function parseDestinations(raw) {
const destinations = {};
if (!raw || typeof raw !== 'object') return destinations;
for (const key of DEST_KEYS) {
const entry = raw[key];
if (!entry) continue;
const folderPath = typeof entry === 'string' ? entry : entry.path;
if (!folderPath || !String(folderPath).trim()) continue;
const resolved = path.resolve(String(folderPath).trim());
const label =
(typeof entry === 'object' && entry.label && String(entry.label).trim()) ||
path.basename(resolved) ||
`Folder ${key}`;
destinations[key] = { key, path: resolved, label };
}
return destinations;
}
/** /**
* Reference adapter: point at any local folder and swipe through its files. * Reference adapter: point at any local folder and swipe through its files.
* "Reject" never deletes -- it moves the file into a trash folder alongside * "Reject" never deletes -- it moves the file into a trash folder alongside
* the source, and "Undo" moves it right back. "Empty trash" (a separate, * the source, and "Undo" moves it right back. Number keys 0–9 can move a
* confirm-guarded action) is the only place this adapter permanently deletes * file into configured destination folders (organize, not just triage).
* anything. * "Empty trash" (a separate, confirm-guarded action) is the only place this
* adapter permanently deletes anything.
*/ */
class FolderAdapter extends Adapter { class FolderAdapter extends Adapter {
static id = 'folder'; static id = 'folder';
static label = 'Local folder'; static label = 'Local folder';
static description = static description =
'Point at any local folder and swipe through its files. Rejected files move to a trash folder, never deleted outright.'; 'Point at any local folder and swipe through its files. Rejected files move to a trash folder; keys 0–9 can organize into other folders.';
static configSchema = [ static configSchema = [
{ {
@@ -72,17 +128,28 @@ class FolderAdapter extends Adapter {
key: 'extensions', key: 'extensions',
label: 'File extensions (comma separated, blank = all files)', label: 'File extensions (comma separated, blank = all files)',
type: 'text', type: 'text',
default: 'jpg,jpeg,png,gif,webp,heic,bmp', default: 'jpg,jpeg,png,gif,webp,heic,bmp,pdf,dng,cr2,nef,arw,mp4,mov',
placeholder: 'jpg,jpeg,png,gif,webp,heic,bmp', placeholder: 'jpg,jpeg,png,heic,pdf,dng,mp4,mov',
}, },
{ key: 'trashDirName', label: 'Trash folder name', type: 'text', default: '.swipeanything-trash' }, { key: 'trashDirName', label: 'Trash folder name', type: 'text', default: '.swipeanything-trash' },
{
key: 'showDetailsByDefault',
label: 'Show file details on each card by default',
type: 'checkbox',
default: false,
},
{
key: 'destinations',
label: 'Organize folders (keys 0–9)',
type: 'folderMap',
default: {},
},
]; ];
static actions = [ static actions = [
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' }, { id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right', group: 'primary' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true }, { id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true, group: 'primary' },
// Down = next without deciding (skip). Up = undo/go back (handled in the UI, not an action). { id: 'skip', label: 'Skip', key: 'ArrowDown', direction: 'down', group: 'primary' },
{ id: 'skip', label: 'Skip', key: 'ArrowDown', direction: 'down' },
]; ];
constructor(settings) { constructor(settings) {
@@ -96,6 +163,24 @@ class FolderAdapter extends Adapter {
.map((e) => e.trim().toLowerCase()) .map((e) => e.trim().toLowerCase())
.filter(Boolean); .filter(Boolean);
this.extensions = extList.length ? new Set(extList) : null; // null = allow all this.extensions = extList.length ? new Set(extList) : null; // null = allow all
this.destinations = parseDestinations(settings.destinations);
this.showDetailsByDefault = Boolean(settings.showDetailsByDefault);
}
getActions() {
const actions = this.constructor.actions.map((a) => ({ ...a }));
for (const key of DEST_KEYS) {
const dest = this.destinations[key];
if (!dest) continue;
actions.push({
id: `move-${key}`,
label: dest.label,
key,
group: 'organize',
destPath: dest.path,
});
}
return actions;
} }
async init() { async init() {
@@ -104,6 +189,25 @@ class FolderAdapter extends Adapter {
throw new Error(`Folder not found: ${this.folderPath}`); throw new Error(`Folder not found: ${this.folderPath}`);
} }
await fsp.mkdir(this.trashDir, { recursive: true }); await fsp.mkdir(this.trashDir, { recursive: true });
for (const dest of Object.values(this.destinations)) {
if (dest.path === this.folderPath) {
throw new Error(`Destination ${dest.key} cannot be the same as the source folder`);
}
await fsp.mkdir(dest.path, { recursive: true });
const destStat = await fsp.stat(dest.path).catch(() => null);
if (!destStat || !destStat.isDirectory()) {
throw new Error(`Destination ${dest.key} is not a folder: ${dest.path}`);
}
}
}
_isExcludedDir(absPath) {
if (absPath === this.trashDir || absPath.startsWith(this.trashDir + path.sep)) return true;
for (const dest of Object.values(this.destinations)) {
if (absPath === dest.path || absPath.startsWith(dest.path + path.sep)) return true;
}
return false;
} }
async _walk(dir, relativeBase = '') { async _walk(dir, relativeBase = '') {
@@ -114,6 +218,7 @@ class FolderAdapter extends Adapter {
const abs = path.join(dir, entry.name); const abs = path.join(dir, entry.name);
const rel = relativeBase ? path.join(relativeBase, entry.name) : entry.name; const rel = relativeBase ? path.join(relativeBase, entry.name) : entry.name;
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (this._isExcludedDir(abs)) continue;
if (this.recursive) files = files.concat(await this._walk(abs, rel)); if (this.recursive) files = files.concat(await this._walk(abs, rel));
continue; continue;
} }
@@ -158,6 +263,30 @@ class FolderAdapter extends Adapter {
async streamPreview(itemId, res) { async streamPreview(itemId, res) {
const abs = this._absoluteFor(itemId); const abs = this._absoluteFor(itemId);
if (!fs.existsSync(abs)) return false; if (!fs.existsSync(abs)) return false;
if (ZIP_EXT.has(extOf(abs))) {
try {
const listing = await listZipEntries(abs);
res.type('text/plain; charset=utf-8');
res.send(formatZipListing(listing));
return true;
} catch (err) {
res.type('text/plain; charset=utf-8');
res.status(200).send(`(could not list zip: ${err.message})`);
return true;
}
}
// RAW / HEIC: browsers usually can't decode the original — prefer a Quick Look PNG.
if (needsThumbnailPreview(abs)) {
const thumbPath = await getThumbnail(abs);
if (thumbPath) {
res.type('image/png');
res.sendFile(thumbPath);
return true;
}
}
const override = MIME_OVERRIDES[extOf(abs)]; const override = MIME_OVERRIDES[extOf(abs)];
if (override) res.type(override); if (override) res.type(override);
res.sendFile(abs); res.sendFile(abs);
@@ -173,6 +302,56 @@ class FolderAdapter extends Adapter {
return true; return true;
} }
async getDetails(itemId) {
const abs = this._absoluteFor(itemId);
if (!fs.existsSync(abs)) throw new Error('File not found');
const details = await collectFileDetails(abs);
if (ZIP_EXT.has(extOf(abs))) {
try {
const listing = await listZipEntries(abs);
const files = listing.entries.filter((e) => !e.isDir).length;
details.fields.push({
label: 'Zip entries',
value: `${listing.totalEntries}${listing.truncated ? '+' : ''} (${files} files)`,
});
const sample = listing.entries
.slice(0, 8)
.map((e) => e.name)
.join(', ');
if (sample) details.fields.push({ label: 'Contains', value: sample + (listing.entries.length > 8 ? '…' : '') });
} catch {
// ignore listing failures in details
}
}
details.actions = process.platform === 'darwin' ? [{ id: 'reveal', label: 'Reveal in Finder' }] : [];
return details;
}
async reveal(itemId) {
if (process.platform !== 'darwin') return false;
const abs = this._absoluteFor(itemId);
if (!fs.existsSync(abs)) throw new Error('File not found');
await execFileAsync('open', ['-R', abs], { timeout: 5000 });
return true;
}
uiHints() {
return { showDetailsByDefault: this.showDetailsByDefault, supportsDetails: true };
}
async _uniqueTarget(destDir, basename) {
let candidate = path.join(destDir, basename);
if (!fs.existsSync(candidate)) return candidate;
const ext = path.extname(basename);
const stem = path.basename(basename, ext);
let i = 1;
while (fs.existsSync(candidate)) {
candidate = path.join(destDir, `${stem}-${i}${ext}`);
i += 1;
}
return candidate;
}
async applyAction(item, actionId) { async applyAction(item, actionId) {
if (actionId === 'reject') { if (actionId === 'reject') {
const from = this._absoluteFor(item.id); const from = this._absoluteFor(item.id);
@@ -180,6 +359,18 @@ class FolderAdapter extends Adapter {
await fsp.rename(from, to); await fsp.rename(from, to);
return { type: 'move', from, to }; return { type: 'move', from, to };
} }
const moveMatch = /^move-([0-9])$/.exec(actionId);
if (moveMatch) {
const dest = this.destinations[moveMatch[1]];
if (!dest) throw new Error(`No destination configured for key ${moveMatch[1]}`);
const from = this._absoluteFor(item.id);
await fsp.mkdir(dest.path, { recursive: true });
const to = await this._uniqueTarget(dest.path, path.basename(from));
await fsp.rename(from, to);
return { type: 'move', from, to, destKey: dest.key, destLabel: dest.label };
}
// 'keep' and 'skip' have no filesystem effect. // 'keep' and 'skip' have no filesystem effect.
return null; return null;
} }
@@ -207,8 +398,9 @@ class FolderAdapter extends Adapter {
} }
describeSource() { describeSource() {
return this.folderPath; const n = Object.keys(this.destinations).length;
return n ? `${this.folderPath} · ${n} organize folder${n === 1 ? '' : 's'}` : this.folderPath;
} }
} }
module.exports = { FolderAdapter }; module.exports = { FolderAdapter, parseDestinations, DEST_KEYS };
+29
View File
@@ -126,6 +126,35 @@ class ImmichAdapter extends Adapter {
return this._streamAssetImage(itemId, 'thumbnail', res); return this._streamAssetImage(itemId, 'thumbnail', res);
} }
async getDetails(itemId) {
const res = await this._request(`/assets/${itemId}`);
const asset = await res.json();
const fields = [
{ label: 'Name', value: asset.originalFileName || asset.id },
{ label: 'Type', value: asset.type },
{ label: 'Created', value: asset.fileCreatedAt },
{ label: 'Favorite', value: asset.isFavorite ? 'yes' : 'no' },
];
const exif = asset.exifInfo || {};
if (exif.exifImageWidth && exif.exifImageHeight) {
fields.push({ label: 'Dimensions', value: `${exif.exifImageWidth} × ${exif.exifImageHeight}` });
}
if (exif.make || exif.model) {
fields.push({ label: 'Camera', value: [exif.make, exif.model].filter(Boolean).join(' ') });
}
if (exif.dateTimeOriginal) fields.push({ label: 'Captured', value: exif.dateTimeOriginal });
if (exif.city || exif.country) {
fields.push({ label: 'Location', value: [exif.city, exif.state, exif.country].filter(Boolean).join(', ') });
}
if (exif.lensModel) fields.push({ label: 'Lens', value: exif.lensModel });
if (asset.originalPath) fields.push({ label: 'Path', value: asset.originalPath });
return { fields: fields.filter((f) => f.value), actions: [] };
}
uiHints() {
return { showDetailsByDefault: false, supportsDetails: true };
}
async applyAction(item, actionId) { async applyAction(item, actionId) {
if (actionId === 'reject') { if (actionId === 'reject') {
await this._request('/assets', { await this._request('/assets', {
+107
View File
@@ -0,0 +1,107 @@
# Design System Master File
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
---
**Project:** SwipeAnything
**Updated:** 2026-07-26
**Category:** Local media / file triage utility
**Source:** ui-ux-pro-max search (curated — auto-match rejected)
---
## Why this system (not the auto pick)
`search.py --design-system` suggested **Vibrant & Block-based** + rose light palette
(`#FFF1F2` / `#E11D48`) and Inter — a social-media landing pattern. Wrong product.
**Curated from skill DB instead:**
- **Style:** Dark Mode (OLED) — deep black / dark grey, high contrast, minimal glow
- **Color notes:** Podcast / financial-dashboard dark rows (dark bg + warm/cool accent)
- **Typography:** DM Sans + IBM Plex Mono (not Inter — avoids AI-default stack)
- **UX:** keyboard-first, visible focus, overlay sheets (no layout jump), WCAG contrast
---
## Global Rules
### Color Palette
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Background | `#0a0a0b` | `--bg` |
| Surface | `#141416` | `--card` |
| Surface raised | `#1a1a1e` | `--card2` |
| Text | `#f4f4f5` | `--text` |
| Muted text | `#a1a1aa` | `--sub` |
| Accent / CTA | `#38bdf8` | `--accent` |
| Keep | `#34d399` | `--keep` |
| Reject | `#fb7185` | `--reject` |
| Skip / warn | `#fbbf24` | `--neutral` |
| Border | `#27272a` | `--border` |
**Color notes:** OLED-safe near-black; semantic keep/reject/skip stay color+label (not color-only).
### Typography
- **UI / headings:** DM Sans
- **Paths, keys, meta values:** IBM Plex Mono
- **Mood:** Quiet utility, high legibility at night
### Motion
- Transitions: `150–200ms` ease
- Respect `prefers-reduced-motion`
- Details sheet: rise + backdrop fade (already present)
- No neon glow / glitch / maximalism
### Spacing
| Token | Value |
|-------|-------|
| `--space-xs` | `4px` |
| `--space-sm` | `8px` |
| `--space-md` | `16px` |
| `--space-lg` | `24px` |
### Shadows
| Token | Value | Usage |
|-------|-------|-------|
| `--shadow-card` | `0 12px 40px rgba(0,0,0,0.55)` | Deck card |
| `--shadow-sheet` | `0 -16px 48px rgba(0,0,0,0.55)` | Details sheet |
---
## Component Specs
### Deck card
- Max width ~400px, radius 20px, border 1px `--border`
- Preview media edge-to-edge in rounded well
- Title clickable → details sheet
### Action buttons
- Circular primary actions; labels + key glyphs (not color alone)
- `cursor: pointer`; hover border → accent/keep/reject
### Details
- Fixed bottom sheet over dimmed backdrop (no document reflow)
- Mono for path values; uppercase muted labels
### Header chips
- Compact bordered pills; hover → text primary
---
## Pre-Delivery Checklist (from skill)
- [x] No emoji-as-icons for critical actions (key glyphs OK)
- [x] `cursor: pointer` on clickable controls
- [x] Hover transitions ~150–200ms
- [x] Dark text contrast high on near-black
- [x] `:focus-visible` rings
- [x] `prefers-reduced-motion` respected on sheet
- [x] Narrow column works at ~375px
+160
View File
@@ -0,0 +1,160 @@
'use strict';
// Best-effort file details for the "inspect before deciding" panel.
// Uses fs.stat always; on macOS also sips (dimensions) and mdls (camera /
// capture date) when available. Failures are silent — callers get whatever
// we could gather.
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const IS_MACOS = process.platform === 'darwin';
const IMAGE_EXT = new Set([
'jpg',
'jpeg',
'png',
'gif',
'webp',
'heic',
'heif',
'bmp',
'tiff',
'tif',
'avif',
// Camera RAW — sips/mdls often still know dimensions / capture metadata on macOS
'dng',
'cr2',
'cr3',
'nef',
'nrw',
'arw',
'orf',
'rw2',
'raf',
'pef',
'raw',
'srw',
]);
const PDF_EXT = new Set(['pdf']);
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatDate(d) {
if (!(d instanceof Date) || Number.isNaN(d.getTime())) return null;
return d.toISOString().replace('T', ' ').slice(0, 19);
}
async function sipsDimensions(absPath) {
if (!IS_MACOS) return null;
try {
const { stdout } = await execFileAsync('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', absPath], {
timeout: 3000,
killSignal: 'SIGKILL',
});
const width = /pixelWidth:\s*(\d+)/.exec(stdout);
const height = /pixelHeight:\s*(\d+)/.exec(stdout);
if (width && height) return { width: Number(width[1]), height: Number(height[1]) };
} catch {
// ignore
}
return null;
}
async function mdlsFields(absPath) {
if (!IS_MACOS) return {};
const keys = [
'kMDItemAcquisitionMake',
'kMDItemAcquisitionModel',
'kMDItemContentCreationDate',
'kMDItemLatitude',
'kMDItemLongitude',
'kMDItemOrientation',
'kMDItemProfileName',
];
try {
const args = [];
for (const key of keys) args.push('-name', key);
args.push(absPath);
const { stdout } = await execFileAsync('mdls', args, { timeout: 3000, killSignal: 'SIGKILL' });
const out = {};
for (const key of keys) {
const re = new RegExp(`${key}\\s*=\\s*(.+)$`, 'm');
const match = re.exec(stdout);
if (!match) continue;
let value = match[1].trim();
if (value === '(null)' || value === 'null') continue;
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
out[key] = value;
}
return out;
} catch {
return {};
}
}
/**
* @returns {Promise<{ fields: Array<{ label: string, value: string }>, path: string }>}
*/
async function collectFileDetails(absPath) {
const fields = [];
const stat = await fsp.stat(absPath);
const ext = path.extname(absPath).slice(1).toLowerCase();
fields.push({ label: 'Path', value: absPath });
fields.push({ label: 'Name', value: path.basename(absPath) });
if (ext) fields.push({ label: 'Type', value: ext.toUpperCase() });
fields.push({ label: 'Size', value: `${formatSize(stat.size)} (${stat.size.toLocaleString()} bytes)` });
fields.push({ label: 'Modified', value: formatDate(stat.mtime) });
fields.push({ label: 'Created', value: formatDate(stat.birthtime) || formatDate(stat.ctime) });
if (IMAGE_EXT.has(ext)) {
const dims = await sipsDimensions(absPath);
if (dims) fields.push({ label: 'Dimensions', value: `${dims.width} × ${dims.height}` });
const md = await mdlsFields(absPath);
if (md.kMDItemAcquisitionMake || md.kMDItemAcquisitionModel) {
fields.push({
label: 'Camera',
value: [md.kMDItemAcquisitionMake, md.kMDItemAcquisitionModel].filter(Boolean).join(' '),
});
}
if (md.kMDItemContentCreationDate) {
fields.push({ label: 'Captured', value: md.kMDItemContentCreationDate.replace(' +0000', ' UTC') });
}
if (md.kMDItemLatitude && md.kMDItemLongitude) {
fields.push({ label: 'Location', value: `${md.kMDItemLatitude}, ${md.kMDItemLongitude}` });
}
if (md.kMDItemProfileName) fields.push({ label: 'Color profile', value: md.kMDItemProfileName });
}
if (PDF_EXT.has(ext) && IS_MACOS) {
const md = await mdlsFields(absPath);
// Page count shows up under kMDItemNumberOfPages for many PDFs
try {
const { stdout } = await execFileAsync('mdls', ['-name', 'kMDItemNumberOfPages', absPath], {
timeout: 3000,
killSignal: 'SIGKILL',
});
const match = /kMDItemNumberOfPages\s*=\s*(\d+)/.exec(stdout);
if (match) fields.push({ label: 'Pages', value: match[1] });
} catch {
// ignore
}
if (md.kMDItemContentCreationDate) {
fields.push({ label: 'Document date', value: md.kMDItemContentCreationDate.replace(' +0000', ' UTC') });
}
}
return { path: absPath, fields: fields.filter((f) => f.value) };
}
module.exports = { collectFileDetails, formatSize };
+98
View File
@@ -0,0 +1,98 @@
'use strict';
// Lightweight ZIP central-directory reader — list entry names + sizes without
// extracting. No npm deps. Supports store/deflate archives (standard .zip).
// Not a full unzipper: encrypted, zip64, and split archives are skipped/partial.
const fs = require('fs');
const fsp = fs.promises;
const EOCD_SIG = 0x06054b50;
const CEN_SIG = 0x02014b50;
const MAX_SCAN = 64 * 1024; // EOCD lives in the last 64KiB (+comment)
const MAX_ENTRIES = 200;
const MAX_NAME_BYTES = 1024;
/**
* @returns {Promise<{ entries: Array<{ name: string, size: number, compressedSize: number, isDir: boolean }>, truncated: boolean, totalEntries: number }>}
*/
async function listZipEntries(absPath) {
const stat = await fsp.stat(absPath);
const fd = await fsp.open(absPath, 'r');
try {
const scanLen = Math.min(stat.size, MAX_SCAN);
const tail = Buffer.alloc(scanLen);
await fd.read(tail, 0, scanLen, stat.size - scanLen);
let eocd = -1;
for (let i = tail.length - 22; i >= 0; i -= 1) {
if (tail.readUInt32LE(i) === EOCD_SIG) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error('Not a zip archive (EOCD not found)');
const totalEntries = tail.readUInt16LE(eocd + 10);
const cenSize = tail.readUInt32LE(eocd + 12);
const cenOffset = tail.readUInt32LE(eocd + 16);
// Zip64 / huge archives: offsets of 0xffffffff mean we can't parse simply.
if (cenOffset === 0xffffffff || cenSize === 0xffffffff) {
throw new Error('Zip64 archives are not supported for listing');
}
const cen = Buffer.alloc(cenSize);
const { bytesRead } = await fd.read(cen, 0, cenSize, cenOffset);
if (bytesRead !== cenSize) throw new Error('Truncated zip central directory');
const entries = [];
let offset = 0;
let truncated = false;
while (offset + 46 <= cen.length && entries.length < MAX_ENTRIES) {
if (cen.readUInt32LE(offset) !== CEN_SIG) break;
const compressedSize = cen.readUInt32LE(offset + 20);
const size = cen.readUInt32LE(offset + 24);
const nameLen = cen.readUInt16LE(offset + 28);
const extraLen = cen.readUInt16LE(offset + 30);
const commentLen = cen.readUInt16LE(offset + 32);
const nameStart = offset + 46;
const nameEnd = nameStart + Math.min(nameLen, MAX_NAME_BYTES);
let name = cen.slice(nameStart, nameEnd).toString('utf8');
// Prefer UTF-8 when the language-encoding flag is set; otherwise still try utf8.
const isDir = name.endsWith('/');
entries.push({ name, size, compressedSize, isDir });
offset = nameStart + nameLen + extraLen + commentLen;
}
if (totalEntries > entries.length) truncated = true;
return { entries, truncated, totalEntries: totalEntries || entries.length };
} finally {
await fd.close();
}
}
function formatZipListing(listing) {
const lines = [];
const files = listing.entries.filter((e) => !e.isDir);
const dirs = listing.entries.filter((e) => e.isDir);
lines.push(`${listing.totalEntries} entr${listing.totalEntries === 1 ? 'y' : 'ies'} (${files.length} files${dirs.length ? `, ${dirs.length} folders` : ''})`);
lines.push('');
for (const entry of listing.entries) {
if (entry.isDir) {
lines.push(` [dir] ${entry.name}`);
} else {
lines.push(` ${formatSize(entry.size).padStart(8)} ${entry.name}`);
}
}
if (listing.truncated) lines.push('', `… listing capped at ${MAX_ENTRIES} entries`);
return lines.join('\n');
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
module.exports = { listZipEntries, formatZipListing, MAX_ENTRIES };
+2 -1
View File
@@ -5,7 +5,8 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"test": "node --test" "test": "node --test",
"seed-demo": "bash scripts/seed-demo.sh"
}, },
"license": "MIT", "license": "MIT",
"author": "", "author": "",
+282 -39
View File
@@ -3,6 +3,15 @@
const deckEl = document.getElementById('deck'); const deckEl = document.getElementById('deck');
const actionsRowEl = document.getElementById('actionsRow'); const actionsRowEl = document.getElementById('actionsRow');
const organizeRowEl = document.getElementById('organizeRow');
const detailsSheetEl = document.getElementById('detailsSheet');
const detailsListEl = document.getElementById('detailsList');
const detailsStatusEl = document.getElementById('detailsStatus');
const detailsCopyPathBtn = document.getElementById('detailsCopyPath');
const detailsRevealBtn = document.getElementById('detailsReveal');
const detailsCloseBtn = document.getElementById('detailsClose');
const detailsBackdrop = document.getElementById('detailsBackdrop');
const detailsLink = document.getElementById('detailsLink');
const progressFillEl = document.getElementById('progressFill'); const progressFillEl = document.getElementById('progressFill');
const statsLineEl = document.getElementById('statsLine'); const statsLineEl = document.getElementById('statsLine');
const sourceLabelEl = document.getElementById('sourceLabel'); const sourceLabelEl = document.getElementById('sourceLabel');
@@ -11,6 +20,7 @@
const shortcutsLink = document.getElementById('shortcutsLink'); const shortcutsLink = document.getElementById('shortcutsLink');
const shortcutsModal = document.getElementById('shortcutsModal'); const shortcutsModal = document.getElementById('shortcutsModal');
const shortcutsClose = document.getElementById('shortcutsClose'); const shortcutsClose = document.getElementById('shortcutsClose');
const shortcutListEl = document.getElementById('shortcutList');
const liveRegionEl = document.getElementById('liveRegion'); const liveRegionEl = document.getElementById('liveRegion');
const DRAG_THRESHOLD = 110; const DRAG_THRESHOLD = 110;
@@ -18,6 +28,10 @@
let dragging = null; let dragging = null;
let busy = false; let busy = false;
let focusBeforeModal = null; let focusBeforeModal = null;
let detailsOpen = false;
let detailsDismissed = false;
let detailsForId = null;
let detailsCache = null;
const KEY_LABEL = { const KEY_LABEL = {
ArrowLeft: '←', ArrowLeft: '←',
@@ -85,7 +99,7 @@
return; return;
} }
wrap.classList.add('file-icon'); wrap.classList.add('file-icon');
wrap.textContent = 'image failed to load\n(try jpg/png/webp — heic may need a Mac to preview)'; wrap.textContent = 'preview failed\n(jpg/png/webp work everywhere; HEIC/RAW need macOS Quick Look)';
wrap.style.fontSize = '12px'; wrap.style.fontSize = '12px';
wrap.style.color = 'var(--sub)'; wrap.style.color = 'var(--sub)';
wrap.style.whiteSpace = 'pre-line'; wrap.style.whiteSpace = 'pre-line';
@@ -96,6 +110,50 @@
wrap.appendChild(img); wrap.appendChild(img);
break; break;
} }
case 'pdf': {
// First-page Quick Look thumb when available; otherwise in-browser PDF.
const img = document.createElement('img');
img.src = thumbSrc;
img.alt = item.title;
img.draggable = false;
const badge = document.createElement('div');
badge.className = 'preview-badge';
badge.textContent = 'PDF';
wrap.appendChild(badge);
img.addEventListener(
'error',
() => {
img.remove();
const frame = document.createElement('iframe');
frame.className = 'pdf-frame';
frame.src = src;
frame.title = item.title;
wrap.appendChild(frame);
},
{ once: true }
);
wrap.appendChild(img);
break;
}
case 'archive': {
const pre = document.createElement('pre');
pre.className = 'archive-listing';
pre.textContent = 'Listing archive…';
wrap.appendChild(pre);
const badge = document.createElement('div');
badge.className = 'preview-badge';
badge.textContent = 'ZIP';
wrap.appendChild(badge);
fetch(src)
.then((r) => r.text())
.then((text) => {
pre.textContent = text.slice(0, 4000);
})
.catch(() => {
pre.textContent = '(could not list archive)';
});
break;
}
case 'audio': { case 'audio': {
const audio = document.createElement('audio'); const audio = document.createElement('audio');
audio.controls = true; audio.controls = true;
@@ -149,6 +207,12 @@
const title = document.createElement('div'); const title = document.createElement('div');
title.className = 'title'; title.className = 'title';
title.textContent = item.title; title.textContent = item.title;
title.title = 'Click for details (or press i)';
title.style.cursor = 'pointer';
title.addEventListener('click', (e) => {
e.stopPropagation();
toggleDetails();
});
card.appendChild(title); card.appendChild(title);
if (item.subtitle) { if (item.subtitle) {
@@ -230,7 +294,7 @@
card.addEventListener('pointercancel', onPointerUp); card.addEventListener('pointercancel', onPointerUp);
} }
/** Show KEEP/REJECT stamp + fling, then commit the action. */ /** Show KEEP/REJECT/organize stamp + fling, then commit the action. */
function animateAction(action) { function animateAction(action) {
if (busy || !state || !state.current) return; if (busy || !state || !state.current) return;
const card = deckEl.querySelector('.card'); const card = deckEl.querySelector('.card');
@@ -240,17 +304,33 @@
} }
const dir = action.direction === 'right' ? 1 : action.direction === 'left' ? -1 : 0; const dir = action.direction === 'right' ? 1 : action.direction === 'left' ? -1 : 0;
const stamp = card.querySelector(`.stamp.${action.direction}`); if (action.direction === 'left' || action.direction === 'right') {
if (stamp) stamp.style.opacity = '1'; const stamp = card.querySelector(`.stamp.${action.direction}`);
if (stamp) stamp.style.opacity = '1';
} else if (action.group === 'organize') {
let stamp = card.querySelector('.stamp.center');
if (!stamp) {
stamp = document.createElement('div');
stamp.className = 'stamp center';
stamp.setAttribute('aria-hidden', 'true');
card.appendChild(stamp);
}
stamp.textContent = action.label;
stamp.style.opacity = '1';
}
if (dir === 0) { if (dir === 0 && action.group !== 'organize') {
performAction(action.id); performAction(action.id);
return; return;
} }
busy = true; busy = true;
card.style.transition = 'transform 0.28s ease-out, opacity 0.28s ease-out'; card.style.transition = 'transform 0.28s ease-out, opacity 0.28s ease-out';
card.style.transform = `translate(${dir * 500}px, -40px) rotate(${dir * 25}deg)`; if (dir !== 0) {
card.style.transform = `translate(${dir * 500}px, -40px) rotate(${dir * 25}deg)`;
} else {
card.style.transform = 'translate(0, -120px) scale(0.92)';
}
card.style.opacity = '0'; card.style.opacity = '0';
setTimeout(() => { setTimeout(() => {
busy = false; busy = false;
@@ -262,38 +342,126 @@
if (busy || !state) return; if (busy || !state) return;
const action = (state.actions || []).find((a) => a.id === actionId); const action = (state.actions || []).find((a) => a.id === actionId);
if (!action) return; if (!action) return;
if (action.direction === 'left' || action.direction === 'right') { if (action.direction === 'left' || action.direction === 'right' || action.group === 'organize') {
animateAction(action); animateAction(action);
} else { } else {
performAction(actionId); performAction(actionId);
} }
} }
function makeActionButton(action, className) {
const btn = document.createElement('button');
btn.className = className;
btn.type = 'button';
btn.title = action.destPath ? `${action.label}\n${action.destPath}` : action.label;
btn.setAttribute('aria-label', `${action.label}, ${keySpoken(action.key)}`);
if (action.id === 'keep') btn.classList.add('keep');
if (action.isDestructive) btn.classList.add('reject');
if (action.id === 'skip') btn.classList.add('skip');
const label = document.createElement('span');
label.className = 'action-label';
label.textContent = action.label;
label.setAttribute('aria-hidden', 'true');
const key = document.createElement('span');
key.className = 'action-key';
key.textContent = keyGlyph(action.key);
key.setAttribute('aria-hidden', 'true');
btn.appendChild(label);
btn.appendChild(key);
btn.addEventListener('click', () => triggerAction(action.id));
return btn;
}
function hideDetailsPanel() {
detailsSheetEl.hidden = true;
detailsSheetEl.setAttribute('aria-hidden', 'true');
detailsListEl.innerHTML = '';
detailsStatusEl.textContent = '';
detailsCopyPathBtn.hidden = true;
detailsRevealBtn.hidden = true;
detailsForId = null;
detailsCache = null;
}
function renderDetailsFields(details) {
detailsListEl.innerHTML = '';
for (const field of details.fields || []) {
const row = document.createElement('div');
const dt = document.createElement('dt');
dt.textContent = field.label;
const dd = document.createElement('dd');
dd.textContent = field.value;
row.appendChild(dt);
row.appendChild(dd);
detailsListEl.appendChild(row);
}
const canReveal = (details.actions || []).some((a) => a.id === 'reveal');
detailsRevealBtn.hidden = !canReveal;
detailsCopyPathBtn.hidden = !details.path;
detailsCache = details;
}
async function loadDetails(itemId, { force } = {}) {
if (!itemId) return;
if (!force && detailsForId === itemId && detailsCache) {
renderDetailsFields(detailsCache);
return;
}
detailsForId = itemId;
detailsStatusEl.textContent = 'Loading…';
detailsListEl.innerHTML = '';
detailsCopyPathBtn.hidden = true;
detailsRevealBtn.hidden = true;
try {
const details = await api(`/api/details/${encodeURIComponent(itemId)}`);
if (detailsForId !== itemId) return;
detailsStatusEl.textContent = '';
renderDetailsFields(details);
} catch (err) {
if (detailsForId !== itemId) return;
detailsStatusEl.textContent = err.message;
}
}
async function showDetailsPanel() {
if (!state || !state.current) return;
if (state.ui && state.ui.supportsDetails === false) {
announce('Details are not available for this adapter.');
return;
}
detailsOpen = true;
detailsDismissed = false;
detailsSheetEl.hidden = false;
detailsSheetEl.setAttribute('aria-hidden', 'false');
await loadDetails(state.current.id);
detailsCloseBtn.focus();
announce('File details shown. Press i to hide.');
}
function closeDetailsPanel({ silent } = {}) {
detailsOpen = false;
detailsDismissed = true;
hideDetailsPanel();
if (!silent) announce('File details hidden.');
}
async function toggleDetails() {
if (detailsOpen) closeDetailsPanel();
else await showDetailsPanel();
}
function renderActionsRow(actions) { function renderActionsRow(actions) {
actionsRowEl.innerHTML = ''; actionsRowEl.innerHTML = '';
for (const action of actions) { organizeRowEl.innerHTML = '';
const btn = document.createElement('button');
btn.className = 'action-btn';
btn.type = 'button';
btn.setAttribute('aria-label', `${action.label}, ${keySpoken(action.key)}`);
if (action.id === 'keep') btn.classList.add('keep');
if (action.isDestructive) btn.classList.add('reject');
if (action.id === 'skip') btn.classList.add('skip');
const label = document.createElement('span'); const primary = (actions || []).filter((a) => a.group !== 'organize');
label.className = 'action-label'; const organize = (actions || []).filter((a) => a.group === 'organize');
label.textContent = action.label;
label.setAttribute('aria-hidden', 'true');
const key = document.createElement('span'); for (const action of primary) {
key.className = 'action-key'; actionsRowEl.appendChild(makeActionButton(action, 'action-btn'));
key.textContent = keyGlyph(action.key);
key.setAttribute('aria-hidden', 'true');
btn.appendChild(label);
btn.appendChild(key);
btn.addEventListener('click', () => triggerAction(action.id));
actionsRowEl.appendChild(btn);
} }
const undoBtn = document.createElement('button'); const undoBtn = document.createElement('button');
@@ -313,6 +481,33 @@
undoBtn.appendChild(undoKey); undoBtn.appendChild(undoKey);
undoBtn.addEventListener('click', undo); undoBtn.addEventListener('click', undo);
actionsRowEl.appendChild(undoBtn); actionsRowEl.appendChild(undoBtn);
if (organize.length) {
organizeRowEl.hidden = false;
for (const action of organize) {
organizeRowEl.appendChild(makeActionButton(action, 'organize-btn'));
}
} else {
organizeRowEl.hidden = true;
}
if (detailsLink) {
detailsLink.disabled = !state || !state.current;
detailsLink.hidden = Boolean(state && state.ui && state.ui.supportsDetails === false);
}
updateShortcutList(organize);
}
function updateShortcutList(organizeActions) {
if (!shortcutListEl) return;
shortcutListEl.querySelectorAll('[data-organize-shortcut]').forEach((el) => el.remove());
for (const action of organizeActions || []) {
const row = document.createElement('div');
row.dataset.organizeShortcut = '1';
row.innerHTML = `<dt>${action.key}</dt><dd>Move to ${action.label}</dd>`;
shortcutListEl.appendChild(row);
}
} }
function renderStats() { function renderStats() {
@@ -322,8 +517,9 @@
progressFillEl.setAttribute('aria-valuemax', String(state.total)); progressFillEl.setAttribute('aria-valuemax', String(state.total));
progressFillEl.setAttribute('aria-valuenow', String(state.reviewed)); progressFillEl.setAttribute('aria-valuenow', String(state.reviewed));
sourceLabelEl.textContent = state.sourceLabel || ''; sourceLabelEl.textContent = state.sourceLabel || '';
const labels = Object.fromEntries((state.actions || []).map((a) => [a.id, a.label]));
const countBits = Object.entries(state.counts || {}) const countBits = Object.entries(state.counts || {})
.map(([id, n]) => `${id}: ${n}`) .map(([id, n]) => `${labels[id] || id}: ${n}`)
.join(' \u00b7 '); .join(' \u00b7 ');
statsLineEl.textContent = `${state.reviewed}/${state.total} reviewed${countBits ? ' \u2014 ' + countBits : ''}`; statsLineEl.textContent = `${state.reviewed}/${state.total} reviewed${countBits ? ' \u2014 ' + countBits : ''}`;
@@ -341,12 +537,14 @@
if (!state.current) { if (!state.current) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'empty-state'; empty.className = 'empty-state';
const labels = Object.fromEntries((state.actions || []).map((a) => [a.id, a.label]));
const countBits = Object.entries(state.counts || {}) const countBits = Object.entries(state.counts || {})
.map(([id, n]) => `<strong>${n}</strong> ${id}`) .map(([id, n]) => `<strong>${n}</strong> ${labels[id] || id}`)
.join(' &nbsp;&nbsp; '); .join(' &nbsp;&nbsp; ');
empty.innerHTML = `All done. ${state.total} item(s) reviewed.<div class="summary">${countBits}</div>`; empty.innerHTML = `All done. ${state.total} item(s) reviewed.<div class="summary">${countBits}</div>`;
deckEl.appendChild(empty); deckEl.appendChild(empty);
renderActionsRow([]); renderActionsRow([]);
closeDetailsPanel({ silent: true });
announce(`All done. ${state.total} items reviewed.`); announce(`All done. ${state.total} items reviewed.`);
return; return;
} }
@@ -355,6 +553,17 @@
deckEl.appendChild(card); deckEl.appendChild(card);
renderActionsRow(state.actions); renderActionsRow(state.actions);
announce(`Item ${position.index} of ${position.total}: ${state.current.title}`); announce(`Item ${position.index} of ${position.total}: ${state.current.title}`);
const wantDetails =
detailsOpen || ((state.ui && state.ui.showDetailsByDefault) && !detailsDismissed);
if (wantDetails) {
detailsOpen = true;
detailsSheetEl.hidden = false;
detailsSheetEl.setAttribute('aria-hidden', 'false');
loadDetails(state.current.id);
} else if (!detailsOpen) {
hideDetailsPanel();
}
} }
function render() { function render() {
@@ -477,6 +686,31 @@
if (e.target === shortcutsModal) closeShortcuts(); if (e.target === shortcutsModal) closeShortcuts();
}); });
detailsCloseBtn.addEventListener('click', () => closeDetailsPanel());
detailsBackdrop.addEventListener('click', () => closeDetailsPanel());
if (detailsLink) {
detailsLink.addEventListener('click', () => toggleDetails());
}
detailsCopyPathBtn.addEventListener('click', async () => {
if (!detailsCache || !detailsCache.path) return;
try {
await navigator.clipboard.writeText(detailsCache.path);
detailsStatusEl.textContent = 'Path copied.';
announce('Path copied to clipboard.');
} catch (err) {
detailsStatusEl.textContent = err.message || 'Could not copy path.';
}
});
detailsRevealBtn.addEventListener('click', async () => {
if (!state || !state.current) return;
try {
await api(`/api/reveal/${encodeURIComponent(state.current.id)}`, { method: 'POST' });
detailsStatusEl.textContent = 'Revealed in Finder.';
} catch (err) {
detailsStatusEl.textContent = err.message;
}
});
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
// Shift+? (Shift+/ on most keyboards) toggles shortcuts help // Shift+? (Shift+/ on most keyboards) toggles shortcuts help
if (e.key === '?' || (e.shiftKey && e.key === '/')) { if (e.key === '?' || (e.shiftKey && e.key === '/')) {
@@ -489,6 +723,11 @@
closeShortcuts(); closeShortcuts();
return; return;
} }
if (e.key === 'Escape' && detailsOpen) {
e.preventDefault();
closeDetailsPanel();
return;
}
if (!shortcutsModal.hidden) return; if (!shortcutsModal.hidden) return;
if (!state) return; if (!state) return;
@@ -497,22 +736,26 @@
undo(); undo();
return; return;
} }
if ((e.key === 'i' || e.key === 'I') && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
toggleDetails();
return;
}
if (!state.current || busy) return; if (!state.current || busy) return;
if (e.key === 'ArrowDown' || e.key === ' ') { // Space is an alias for Skip even if Skip's declared key is ↓.
if (e.key === ' ') {
e.preventDefault(); e.preventDefault();
triggerAction('skip'); triggerAction('skip');
return; return;
} }
if (e.key === 'ArrowRight') {
const action = (state.actions || []).find((a) => a.key === e.key);
if (action) {
e.preventDefault(); e.preventDefault();
triggerAction('keep'); triggerAction(action.id);
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
triggerAction('reject');
return;
} }
}); });
+26 -3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SwipeAnything</title> <title>SwipeAnything</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700;1,9..40,400&family=IBM+Plex+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
@@ -12,6 +15,7 @@
<h1>SwipeAnything</h1> <h1>SwipeAnything</h1>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="icon-link" id="shortcutsLink" title="Keyboard shortcuts (Shift+?)" aria-haspopup="dialog" aria-controls="shortcutsModal">?</button> <button type="button" class="icon-link" id="shortcutsLink" title="Keyboard shortcuts (Shift+?)" aria-haspopup="dialog" aria-controls="shortcutsModal">?</button>
<button type="button" class="icon-link" id="detailsLink" title="File details (i)" aria-haspopup="dialog" aria-controls="detailsSheet">Details</button>
<button type="button" class="icon-link" id="rescanLink">Rescan</button> <button type="button" class="icon-link" id="rescanLink">Rescan</button>
<button type="button" class="icon-link" id="emptyTrashLink" hidden>Empty trash</button> <button type="button" class="icon-link" id="emptyTrashLink" hidden>Empty trash</button>
<a class="icon-link" href="settings.html">Settings</a> <a class="icon-link" href="settings.html">Settings</a>
@@ -24,26 +28,45 @@
<div class="stats" id="statsLine"></div> <div class="stats" id="statsLine"></div>
<div class="deck" id="deck"></div> <div class="deck" id="deck"></div>
<div class="actions-row" id="actionsRow"></div> <div class="actions-row" id="actionsRow"></div>
<div class="organize-row" id="organizeRow" hidden></div>
<div id="liveRegion" class="sr-only" aria-live="polite" aria-atomic="true"></div> <div id="liveRegion" class="sr-only" aria-live="polite" aria-atomic="true"></div>
</div> </div>
<div id="detailsSheet" class="details-sheet" hidden aria-hidden="true">
<button type="button" class="details-backdrop" id="detailsBackdrop" aria-label="Close details"></button>
<aside class="details-panel" role="dialog" aria-modal="true" aria-labelledby="detailsTitle">
<div class="details-header">
<h2 id="detailsTitle">Details</h2>
<div class="details-header-actions">
<button type="button" class="icon-link" id="detailsCopyPath" hidden>Copy path</button>
<button type="button" class="icon-link" id="detailsReveal" hidden>Reveal</button>
<button type="button" class="icon-link" id="detailsClose" aria-label="Hide details">✕</button>
</div>
</div>
<dl class="details-list" id="detailsList"></dl>
<div class="details-status" id="detailsStatus"></div>
<p class="details-hint">Press <kbd>i</kbd> or Esc to close</p>
</aside>
</div>
<div id="shortcutsModal" class="modal" hidden aria-hidden="true"> <div id="shortcutsModal" class="modal" hidden aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="shortcutsTitle"> <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="shortcutsTitle">
<div class="modal-header"> <div class="modal-header">
<h2 id="shortcutsTitle">Shortcuts</h2> <h2 id="shortcutsTitle">Shortcuts</h2>
<button type="button" class="icon-link modal-close" id="shortcutsClose" aria-label="Close shortcuts help">✕</button> <button type="button" class="icon-link modal-close" id="shortcutsClose" aria-label="Close shortcuts help">✕</button>
</div> </div>
<dl class="shortcut-list"> <dl class="shortcut-list" id="shortcutList">
<div><dt>→</dt><dd>Keep</dd></div> <div><dt>→</dt><dd>Keep</dd></div>
<div><dt>←</dt><dd>Reject (to trash)</dd></div> <div><dt>←</dt><dd>Reject (to trash)</dd></div>
<div><dt>↓</dt><dd>Skip / next</dd></div> <div><dt>↓</dt><dd>Skip / next</dd></div>
<div><dt>↑</dt><dd>Undo / go back</dd></div> <div><dt>↑</dt><dd>Undo / go back</dd></div>
<div><dt>i</dt><dd>Toggle file details</dd></div>
<div><dt>Space</dt><dd>Skip / next</dd></div> <div><dt>Space</dt><dd>Skip / next</dd></div>
<div><dt>Ctrl/Cmd+Z</dt><dd>Undo</dd></div> <div><dt>Ctrl/Cmd+Z</dt><dd>Undo</dd></div>
<div><dt>Shift+?</dt><dd>Toggle this help</dd></div> <div><dt>Shift+?</dt><dd>Toggle this help</dd></div>
<div><dt>Esc</dt><dd>Close help</dd></div> <div><dt>Esc</dt><dd>Close details / help</dd></div>
</dl> </dl>
<p class="modal-hint">Drag the card left or right, or use the buttons below.</p> <p class="modal-hint" id="shortcutHint">Drag the card left or right, or use the buttons below. Number keys move into organize folders when configured in Settings.</p>
</div> </div>
</div> </div>
+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SwipeAnything &mdash; Settings</title> <title>SwipeAnything &mdash; Settings</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700;1,9..40,400&family=IBM+Plex+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
+69 -16
View File
@@ -21,6 +21,20 @@
} }
function fieldValue(field) { function fieldValue(field) {
if (field.type === 'folderMap') {
const map = {};
for (let i = 0; i <= 9; i += 1) {
const key = String(i);
const pathEl = document.getElementById(`field_${field.key}_${key}_path`);
const labelEl = document.getElementById(`field_${field.key}_${key}_label`);
if (!pathEl) continue;
const folderPath = pathEl.value.trim();
if (!folderPath) continue;
const label = labelEl ? labelEl.value.trim() : '';
map[key] = label ? { path: folderPath, label } : { path: folderPath };
}
return map;
}
const el = document.getElementById(`field_${field.key}`); const el = document.getElementById(`field_${field.key}`);
if (!el) return field.default; if (!el) return field.default;
if (field.type === 'checkbox') return el.checked; if (field.type === 'checkbox') return el.checked;
@@ -28,6 +42,23 @@
return el.value; return el.value;
} }
function attachBrowse(browseBtn, inputId, hintEl) {
browseBtn.addEventListener('click', async () => {
if (hintEl) hintEl.textContent = '';
browseBtn.disabled = true;
browseBtn.textContent = 'Waiting for Finder\u2026';
try {
const { path: chosen } = await api('/api/browse-folder', { method: 'POST' });
document.getElementById(inputId).value = chosen;
} catch (err) {
if (hintEl && err.message !== 'Cancelled') hintEl.textContent = err.message;
} finally {
browseBtn.disabled = false;
browseBtn.textContent = 'Browse\u2026';
}
});
}
function escapeAttr(value) { function escapeAttr(value) {
return String(value).replace(/"/g, '&quot;'); return String(value).replace(/"/g, '&quot;');
} }
@@ -58,22 +89,44 @@
</div> </div>
<div class="field-hint" data-browse-hint-for="field_${field.key}"></div> <div class="field-hint" data-browse-hint-for="field_${field.key}"></div>
`; `;
const browseBtn = wrap.querySelector(`[data-browse-for="field_${field.key}"]`); attachBrowse(
const hintEl = wrap.querySelector(`[data-browse-hint-for="field_${field.key}"]`); wrap.querySelector(`[data-browse-for="field_${field.key}"]`),
browseBtn.addEventListener('click', async () => { `field_${field.key}`,
hintEl.textContent = ''; wrap.querySelector(`[data-browse-hint-for="field_${field.key}"]`)
browseBtn.disabled = true; );
browseBtn.textContent = 'Waiting for Finder\u2026'; return wrap;
try { }
const { path: chosen } = await api('/api/browse-folder', { method: 'POST' });
document.getElementById(`field_${field.key}`).value = chosen; if (field.type === 'folderMap') {
} catch (err) { wrap.className = 'form-field folder-map';
if (err.message !== 'Cancelled') hintEl.textContent = err.message; const heading = document.createElement('div');
} finally { heading.className = 'folder-map-heading';
browseBtn.disabled = false; heading.innerHTML = `<span>${field.label}</span><span class="folder-map-hint">Leave a row blank to disable that key. Press the number while swiping to move the file there.</span>`;
browseBtn.textContent = 'Browse\u2026'; wrap.appendChild(heading);
}
}); const map = value && typeof value === 'object' ? value : {};
for (let i = 0; i <= 9; i += 1) {
const key = String(i);
const entry = map[key] || {};
const folderPath = typeof entry === 'string' ? entry : entry.path || '';
const label = typeof entry === 'object' ? entry.label || '' : '';
const row = document.createElement('div');
row.className = 'folder-map-row';
row.innerHTML = `
<span class="folder-map-key" aria-hidden="true">${key}</span>
<input type="text" id="field_${field.key}_${key}_label"
placeholder="Label (optional)" value="${escapeAttr(label)}"
aria-label="Label for key ${key}">
<div class="folder-field-row">
<input type="text" id="field_${field.key}_${key}_path"
placeholder="/path/to/folder" value="${escapeAttr(folderPath)}"
aria-label="Folder path for key ${key}">
<button type="button" class="secondary-btn" data-browse-for="field_${field.key}_${key}_path">Browse&hellip;</button>
</div>
`;
attachBrowse(row.querySelector('[data-browse-for]'), `field_${field.key}_${key}_path`, null);
wrap.appendChild(row);
}
return wrap; return wrap;
} }
+434 -30
View File
@@ -1,14 +1,20 @@
:root { :root {
--bg: #0d0f12; color-scheme: dark;
--card: #16191d; --bg: #0a0a0b;
--card2: #1d2227; --card: #141416;
--text: #f0f2f4; --card2: #1a1a1e;
--sub: #9aa3ab; --text: #f4f4f5;
--keep: #35c46a; --sub: #a1a1aa;
--reject: #e5484d; --keep: #34d399;
--neutral: #f0b429; --reject: #fb7185;
--accent: #6ea8ff; --neutral: #fbbf24;
--border: #2a2f35; --accent: #38bdf8;
--border: #27272a;
--shadow-card: 0 12px 40px rgba(0, 0, 0, 0.55);
--shadow-sheet: 0 -16px 48px rgba(0, 0, 0, 0.55);
--font-ui: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--ease: 160ms ease;
} }
* { * {
@@ -22,8 +28,9 @@ body {
min-height: 100%; min-height: 100%;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-family: var(--font-ui);
overscroll-behavior: none; overscroll-behavior: none;
-webkit-font-smoothing: antialiased;
} }
a { a {
@@ -49,6 +56,13 @@ a {
button { button {
font: inherit; font: inherit;
cursor: pointer;
}
a.icon-link,
button.icon-link {
cursor: pointer;
transition: color var(--ease), border-color var(--ease), background var(--ease);
} }
#app { #app {
@@ -71,10 +85,12 @@ header {
h1 { h1 {
font-size: 17px; font-size: 17px;
margin: 0; margin: 0;
font-weight: 600; font-weight: 650;
letter-spacing: -0.02em;
} }
.source-label { .source-label {
font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--sub); color: var(--sub);
word-break: break-all; word-break: break-all;
@@ -83,47 +99,50 @@ h1 {
.header-actions { .header-actions {
display: flex; display: flex;
gap: 10px; gap: 8px;
flex: none; flex: none;
flex-wrap: wrap;
justify-content: flex-end;
} }
.icon-link { .icon-link {
color: var(--sub); color: var(--sub);
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
padding: 5px 9px; padding: 5px 9px;
background: var(--card); background: var(--card);
cursor: pointer;
} }
.icon-link.danger { .icon-link.danger {
color: var(--reject); color: var(--reject);
border-color: var(--reject); border-color: color-mix(in srgb, var(--reject) 45%, var(--border));
} }
.icon-link:hover { .icon-link:hover {
color: var(--text); color: var(--text);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
} }
.stats { .stats {
font-size: 12px; font-size: 12px;
color: var(--sub); color: var(--sub);
font-variant-numeric: tabular-nums;
} }
.progress-track { .progress-track {
height: 4px; height: 3px;
background: var(--border); background: var(--border);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
margin-bottom: 16px; margin-bottom: 14px;
} }
.progress-fill { .progress-fill {
height: 100%; height: 100%;
background: var(--accent); background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 40%, var(--keep)));
transition: width 0.2s; transition: width 0.2s ease;
} }
.deck { .deck {
@@ -153,7 +172,7 @@ h1 {
text-align: center; text-align: center;
user-select: none; user-select: none;
cursor: grab; cursor: grab;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); box-shadow: var(--shadow-card);
touch-action: none; touch-action: none;
} }
@@ -162,12 +181,13 @@ h1 {
} }
.card .preview { .card .preview {
position: relative;
width: 100%; width: 100%;
min-height: 160px; min-height: 160px;
max-height: 280px; max-height: 280px;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #0a0c0e; background: #050506;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -180,7 +200,46 @@ h1 {
max-height: 280px; max-height: 280px;
object-fit: contain; object-fit: contain;
display: block; display: block;
background: #12151a; background: #0a0a0b;
}
.card .preview iframe.pdf-frame {
width: 100%;
min-height: 200px;
height: 280px;
max-height: 280px;
border: 0;
background: #0a0a0b;
}
.card .preview .preview-badge {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--text);
background: rgba(10, 10, 11, 0.72);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 7px;
}
.card .preview pre.archive-listing {
margin: 0;
padding: 28px 12px 12px;
font-family: var(--font-mono);
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 .preview.file-icon { .card .preview.file-icon {
@@ -191,6 +250,7 @@ h1 {
.card .preview pre { .card .preview pre {
margin: 0; margin: 0;
padding: 12px; padding: 12px;
font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
text-align: left; text-align: left;
white-space: pre-wrap; white-space: pre-wrap;
@@ -206,10 +266,18 @@ h1 {
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.3;
word-break: break-word; word-break: break-word;
letter-spacing: -0.01em;
cursor: pointer;
transition: color var(--ease);
}
.card .title:hover {
color: var(--accent);
} }
.card .subtitle { .card .subtitle {
font-size: 12px; font-family: var(--font-mono);
font-size: 11px;
color: var(--sub); color: var(--sub);
word-break: break-all; word-break: break-all;
} }
@@ -227,7 +295,9 @@ h1 {
padding: 2px 8px; padding: 2px 8px;
border-radius: 20px; border-radius: 20px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: #1b1f24; background: var(--card2);
font-family: var(--font-mono);
font-size: 10px;
} }
.stamp { .stamp {
@@ -262,6 +332,19 @@ h1 {
opacity: 1; opacity: 1;
} }
.stamp.center {
left: 50%;
right: auto;
top: 40%;
transform: translateX(-50%) rotate(-6deg);
color: var(--accent);
border-color: var(--accent);
max-width: 80%;
text-align: center;
font-size: 16px;
letter-spacing: 0.04em;
}
.actions-row { .actions-row {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -270,6 +353,207 @@ h1 {
flex-wrap: wrap; flex-wrap: wrap;
} }
.organize-row {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.organize-row[hidden] {
display: none;
}
.details-sheet {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: none;
}
.details-sheet[hidden] {
display: none;
}
.details-sheet:not([hidden]) {
pointer-events: auto;
}
.details-backdrop {
position: absolute;
inset: 0;
border: 0;
padding: 0;
margin: 0;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(4px);
cursor: pointer;
animation: details-fade 0.18s ease-out;
}
.details-panel {
position: relative;
z-index: 1;
width: min(480px, 100%);
max-height: min(58vh, 520px);
margin: 0;
border: 1px solid var(--border);
border-bottom: none;
border-radius: 18px 18px 0 0;
background: var(--card2);
padding: 14px 16px 18px;
box-shadow: var(--shadow-sheet);
display: flex;
flex-direction: column;
animation: details-rise 0.22s ease-out;
overflow: hidden;
}
.details-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
flex: none;
}
.details-header h2 {
margin: 0;
font-size: 14px;
font-weight: 650;
letter-spacing: 0.02em;
}
.details-header-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.details-list {
margin: 0;
display: grid;
gap: 8px;
overflow: auto;
padding-right: 2px;
flex: 1;
}
.details-list div {
display: grid;
grid-template-columns: 88px 1fr;
gap: 10px;
font-size: 12px;
align-items: start;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.details-list div:last-child {
border-bottom: none;
padding-bottom: 0;
}
.details-list dt {
margin: 0;
color: var(--sub);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.details-list dd {
margin: 0;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text);
word-break: break-word;
line-height: 1.35;
}
.details-status {
font-size: 12px;
color: var(--sub);
min-height: 16px;
margin-top: 8px;
flex: none;
}
.details-hint {
margin: 8px 0 0;
font-size: 11px;
color: var(--sub);
flex: none;
}
.details-hint kbd {
font: inherit;
font-weight: 700;
color: var(--accent);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 5px;
background: var(--card);
}
@keyframes details-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes details-rise {
from { transform: translateY(18px); opacity: 0.6; }
to { transform: translateY(0); opacity: 1; }
}
.organize-btn {
min-width: 72px;
max-width: 110px;
height: auto;
min-height: 52px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--card2);
color: var(--text);
font-size: 11px;
font-weight: 600;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 6px;
line-height: 1.15;
transition: border-color var(--ease), background var(--ease), color var(--ease);
}
.organize-btn:hover {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, var(--card2));
}
.organize-btn .action-label {
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 96px;
}
.organize-btn .action-key {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 700;
color: var(--accent);
}
.action-btn { .action-btn {
flex: none; flex: none;
min-width: 64px; min-width: 64px;
@@ -288,6 +572,11 @@ h1 {
gap: 2px; gap: 2px;
padding: 6px 4px; padding: 6px 4px;
line-height: 1.1; line-height: 1.1;
transition: border-color var(--ease), background var(--ease), transform var(--ease);
}
.action-btn:hover:not(:disabled) {
transform: translateY(-1px);
} }
.action-btn .action-label { .action-btn .action-label {
@@ -295,29 +584,50 @@ h1 {
} }
.action-btn .action-key { .action-btn .action-key {
font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
opacity: 0.85; opacity: 0.9;
} }
.action-btn.keep { .action-btn.keep {
color: var(--keep); color: var(--keep);
border-color: color-mix(in srgb, var(--keep) 70%, var(--border));
}
.action-btn.keep:hover:not(:disabled) {
border-color: var(--keep); border-color: var(--keep);
background: color-mix(in srgb, var(--keep) 10%, var(--card));
} }
.action-btn.reject { .action-btn.reject {
color: var(--reject); color: var(--reject);
border-color: color-mix(in srgb, var(--reject) 70%, var(--border));
}
.action-btn.reject:hover:not(:disabled) {
border-color: var(--reject); border-color: var(--reject);
background: color-mix(in srgb, var(--reject) 10%, var(--card));
} }
.action-btn.skip { .action-btn.skip {
color: var(--neutral); color: var(--neutral);
border-color: color-mix(in srgb, var(--neutral) 70%, var(--border));
}
.action-btn.skip:hover:not(:disabled) {
border-color: var(--neutral); border-color: var(--neutral);
background: color-mix(in srgb, var(--neutral) 10%, var(--card));
} }
.action-btn.undo { .action-btn.undo {
color: var(--accent); color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
}
.action-btn.undo:hover:not(:disabled) {
border-color: var(--accent); border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--card));
} }
.action-btn:disabled { .action-btn:disabled {
@@ -325,6 +635,28 @@ h1 {
cursor: default; cursor: default;
} }
@media (prefers-reduced-motion: reduce) {
.details-backdrop,
.details-panel {
animation: none;
}
.action-btn:hover:not(:disabled) {
transform: none;
}
.progress-fill,
a.icon-link,
button.icon-link,
.organize-btn,
.action-btn,
.adapter-card,
.primary-btn,
.secondary-btn {
transition: none;
}
}
/* Shortcuts modal */ /* Shortcuts modal */
.modal { .modal {
position: fixed; position: fixed;
@@ -385,7 +717,8 @@ h1 {
.shortcut-list dt { .shortcut-list dt {
flex: none; flex: none;
min-width: 88px; min-width: 88px;
font-size: 13px; font-family: var(--font-mono);
font-size: 12px;
font-weight: 700; font-weight: 700;
color: var(--accent); color: var(--accent);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -450,7 +783,21 @@ h1 {
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--card2); background: var(--card2);
color: var(--text); color: var(--text);
font: inherit;
font-size: 14px; font-size: 14px;
transition: border-color var(--ease);
}
.form-field input:focus,
.form-field select:focus {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
outline: none;
}
.form-field input:focus-visible,
.form-field select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
} }
.form-field input::placeholder { .form-field input::placeholder {
@@ -460,7 +807,7 @@ h1 {
.form-field select { .form-field select {
appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%239aa3ab' d='M1 1l5 5 5-5'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23a1a1aa' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 12px center; background-position: right 12px center;
padding-right: 32px; padding-right: 32px;
@@ -485,6 +832,53 @@ h1 {
flex: 1; flex: 1;
} }
.folder-map {
margin-top: 8px;
}
.folder-map-heading {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
}
.folder-map-hint {
font-size: 11px;
font-weight: 400;
color: var(--sub);
line-height: 1.35;
}
.folder-map-row {
display: grid;
grid-template-columns: 28px 1fr;
gap: 8px;
align-items: center;
margin-bottom: 10px;
}
.folder-map-row .folder-field-row {
grid-column: 2;
}
.folder-map-key {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card2);
color: var(--accent);
font-family: var(--font-mono);
font-weight: 700;
font-size: 13px;
}
.secondary-btn { .secondary-btn {
flex: none; flex: none;
padding: 10px 14px; padding: 10px 14px;
@@ -495,6 +889,11 @@ h1 {
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: border-color var(--ease), background var(--ease);
}
.secondary-btn:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
} }
.secondary-btn:disabled { .secondary-btn:disabled {
@@ -528,12 +927,12 @@ h1 {
} }
.adapter-card:hover { .adapter-card:hover {
border-color: #3a424a; border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
} }
.adapter-card.selected { .adapter-card.selected {
border-color: var(--accent); border-color: var(--accent);
background: #141c28; background: color-mix(in srgb, var(--accent) 9%, var(--card));
} }
.adapter-card-title { .adapter-card-title {
@@ -582,6 +981,11 @@ h1 {
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: filter var(--ease), opacity var(--ease);
}
.primary-btn:hover:not(:disabled) {
filter: brightness(1.06);
} }
.primary-btn:disabled { .primary-btn:disabled {
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Seed /tmp/swipe-demo with a mix of preview types for local QA.
set -euo pipefail
DEST="${1:-/tmp/swipe-demo}"
mkdir -p "$DEST"
echo "Meeting notes — keep or toss?" >"$DEST/meeting-notes.txt"
echo "Random screenshot memo" >"$DEST/screenshot-memo.txt"
printf 'item,status\nbeach photo,keep?\nfood photo,keep?\nclip,review\n' >"$DEST/inbox.csv"
# Solid PNGs (no Pillow required)
python3 - <<'PY' "$DEST"
import struct, zlib, sys
from pathlib import Path
dest = Path(sys.argv[1])
def chunk(tag: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
def solid_png(w: int, h: int, r: int, g: int, b: int) -> bytes:
raw = b"".join(b"\x00" + bytes([r, g, b]) * w for _ in range(h))
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
(dest / "photo-beach.png").write_bytes(solid_png(320, 200, 56, 120, 200))
(dest / "photo-food.png").write_bytes(solid_png(320, 200, 200, 90, 60))
print(f"wrote PNGs into {dest}")
PY
if command -v ffmpeg >/dev/null 2>&1; then
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "color=c=#38bdf8:s=320x180:d=2" \
-f lavfi -i "sine=f=440:d=2" \
-c:v libx264 -pix_fmt yuv420p -c:a aac -shortest \
"$DEST/clip-demo.mp4"
echo "wrote clip-demo.mp4"
else
echo "ffmpeg not found — skipping video seed" >&2
fi
# One-page PDF with visible Helvetica text (so Quick Look / iframe aren't blank)
python3 - <<'PY' "$DEST"
from pathlib import Path
import sys
content = b"BT /F1 22 Tf 40 200 Td (Receipt scan demo) Tj 0 -36 Td (Keep or toss?) Tj ET"
stream = b"<< /Length %d >>stream\n" % len(content) + content + b"\nendstream"
parts = []
def add(obj: bytes):
parts.append(obj)
return sum(len(p) for p in parts[:-1])
# Build with xref
objs = []
objs.append(b"1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n")
objs.append(b"2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n")
objs.append(
b"3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] "
b"/Contents 4 0 R /Resources<< /Font<< /F1 5 0 R >> >> >>endobj\n"
)
objs.append(b"4 0 obj" + stream + b"\nendobj\n")
objs.append(b"5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj\n")
out = bytearray(b"%PDF-1.4\n")
offsets = [0]
for obj in objs:
offsets.append(len(out))
out.extend(obj)
xref_pos = len(out)
out.extend(f"xref\n0 {len(offsets)}\n".encode())
out.extend(b"0000000000 65535 f \n")
for off in offsets[1:]:
out.extend(f"{off:010d} 00000 n \n".encode())
out.extend(f"trailer<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n".encode())
Path(sys.argv[1], "receipt-scan.pdf").write_bytes(out)
print("wrote receipt-scan.pdf")
PY
# Small zip with a couple of files inside (list-only preview)
rm -f "$DEST/bundle-demo.zip"
(
cd "$DEST"
printf 'hello from zip\n' > /tmp/swipe-zip-a.txt
printf 'item,qty\nwidgets,3\n' > /tmp/swipe-zip-b.csv
zip -q bundle-demo.zip /tmp/swipe-zip-a.txt /tmp/swipe-zip-b.csv 2>/dev/null \
|| python3 - <<'PY'
import zipfile, pathlib
z = pathlib.Path("/tmp/swipe-demo/bundle-demo.zip")
with zipfile.ZipFile(z, "w") as zf:
zf.writestr("readme.txt", "hello from zip\n")
zf.writestr("data/rows.csv", "item,qty\nwidgets,3\n")
print("wrote", z)
PY
)
# Prefer tidy names inside the archive when zip CLI used absolute paths
if command -v zip >/dev/null 2>&1; then
rm -f "$DEST/bundle-demo.zip"
mkdir -p /tmp/swipe-zip-seed/data
printf 'hello from zip\n' > /tmp/swipe-zip-seed/readme.txt
printf 'item,qty\nwidgets,3\n' > /tmp/swipe-zip-seed/data/rows.csv
(cd /tmp/swipe-zip-seed && zip -qr "$DEST/bundle-demo.zip" readme.txt data)
echo "wrote bundle-demo.zip"
fi
# Optional: copy a real RAW from the user library if present (can't synthesize these)
if ! compgen -G "$DEST/sample-raw.*" >/dev/null 2>&1; then
sample="$(find "$HOME/Pictures" "$HOME/Desktop" "$HOME/Downloads" \
\( -iname '*.dng' -o -iname '*.cr2' -o -iname '*.cr3' -o -iname '*.nef' -o -iname '*.arw' \) \
-type f 2>/dev/null | head -n 1 || true)"
if [[ -n "${sample}" ]]; then
ext="${sample##*.}"
cp "$sample" "$DEST/sample-raw.${ext}"
echo "copied RAW sample from $sample"
else
echo "no RAW found under Pictures/Desktop/Downloads — drop a .dng/.cr2 into $DEST to try"
fi
fi
echo "Demo ready: $DEST"
ls -la "$DEST"
+35 -2
View File
@@ -100,7 +100,7 @@ async function queuePayload(s) {
return { return {
adapterId: s.adapterId, adapterId: s.adapterId,
sourceLabel: s.adapter.describeSource(), sourceLabel: s.adapter.describeSource(),
actions: s.adapter.constructor.actions, actions: typeof s.adapter.getActions === 'function' ? s.adapter.getActions() : s.adapter.constructor.actions,
total: s.queue.length, total: s.queue.length,
reviewed: s.index, reviewed: s.index,
counts, counts,
@@ -108,6 +108,7 @@ async function queuePayload(s) {
upcoming: remaining.slice(1, 4), upcoming: remaining.slice(1, 4),
canUndo: s.history.length > 0, canUndo: s.history.length > 0,
trashInfo, trashInfo,
ui: typeof s.adapter.uiHints === 'function' ? s.adapter.uiHints() : {},
}; };
} }
@@ -190,6 +191,36 @@ app.get('/api/thumbnail/:itemId', async (req, res) => {
} }
}); });
app.get('/api/details/:itemId', async (req, res) => {
const s = await ensureSession().catch(() => null);
if (!s) return res.status(409).json({ error: 'Not configured yet' });
if (typeof s.adapter.getDetails !== 'function') {
return res.status(501).json({ error: 'Details not supported by this adapter' });
}
try {
const details = await s.adapter.getDetails(req.params.itemId);
if (!details) return res.status(404).json({ error: 'No details available' });
res.json(details);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.post('/api/reveal/:itemId', async (req, res) => {
const s = await ensureSession().catch(() => null);
if (!s) return res.status(409).json({ error: 'Not configured yet' });
if (typeof s.adapter.reveal !== 'function') {
return res.status(501).json({ error: 'Reveal not supported by this adapter' });
}
try {
const handled = await s.adapter.reveal(req.params.itemId);
if (!handled) return res.status(501).json({ error: 'Reveal not available on this platform' });
res.json({ ok: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.post('/api/action', async (req, res) => { app.post('/api/action', async (req, res) => {
const s = await ensureSession().catch(() => null); const s = await ensureSession().catch(() => null);
if (!s) return res.status(409).json({ error: 'Not configured yet' }); if (!s) return res.status(409).json({ error: 'Not configured yet' });
@@ -198,7 +229,9 @@ app.post('/api/action', async (req, res) => {
if (!item || item.id !== itemId) { if (!item || item.id !== itemId) {
return res.status(409).json({ error: 'Item is stale, refresh the queue' }); return res.status(409).json({ error: 'Item is stale, refresh the queue' });
} }
const validAction = s.adapter.constructor.actions.some((a) => a.id === actionId); const actions =
typeof s.adapter.getActions === 'function' ? s.adapter.getActions() : s.adapter.constructor.actions;
const validAction = actions.some((a) => a.id === actionId);
if (!validAction) { if (!validAction) {
return res.status(400).json({ error: `Unknown action: ${actionId}` }); return res.status(400).json({ error: `Unknown action: ${actionId}` });
} }
+7 -2
View File
@@ -3,7 +3,12 @@
"settings": { "settings": {
"folderPath": "/absolute/path/to/a/folder", "folderPath": "/absolute/path/to/a/folder",
"recursive": false, "recursive": false,
"extensions": "jpg,jpeg,png,gif,webp,heic,bmp", "extensions": "jpg,jpeg,png,gif,webp,heic,bmp,pdf,dng,cr2,nef,arw,mp4,mov",
"trashDirName": ".swipeanything-trash" "trashDirName": ".swipeanything-trash",
"destinations": {
"1": { "label": "Keep / favorites", "path": "/absolute/path/to/keep" },
"2": { "label": "Vacation", "path": "/absolute/path/to/vacation" },
"3": { "path": "/absolute/path/to/work" }
}
} }
} }
+13
View File
@@ -137,3 +137,16 @@ test('GET /api/preview/:itemId 404s for an unknown item', async () => {
const res = await fetch(`${base}/api/preview/${missingId}`); const res = await fetch(`${base}/api/preview/${missingId}`);
assert.equal(res.status, 404); assert.equal(res.status, 404);
}); });
test('GET /api/details/:itemId returns file metadata fields', async () => {
await api('/api/rescan', { method: 'POST' });
const queue = await api('/api/queue');
assert.equal(queue.status, 200);
assert.ok(queue.body.current);
assert.equal(queue.body.ui.supportsDetails, true);
const { status, body } = await api(`/api/details/${queue.body.current.id}`);
assert.equal(status, 200);
assert.ok(Array.isArray(body.fields));
assert.ok(body.fields.some((f) => f.label === 'Path'));
assert.ok(body.fields.some((f) => f.label === 'Size'));
});
+128
View File
@@ -45,6 +45,38 @@ test('list() filters by extension allowlist', async () => {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
}); });
test('list() sets previewType for video, audio, csv, pdf, raw, and unknown binary', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-preview-'));
fs.writeFileSync(path.join(dir, 'clip.mp4'), 'fake-mp4-bytes');
fs.writeFileSync(path.join(dir, 'clip.MOV'), 'fake-mov-bytes'); // case-insensitive
fs.writeFileSync(path.join(dir, 'song.mp3'), 'fake-mp3-bytes');
fs.writeFileSync(path.join(dir, 'rows.csv'), 'a,b\n1,2\n');
fs.writeFileSync(path.join(dir, 'scan.pdf'), '%PDF-1.0');
fs.writeFileSync(path.join(dir, 'shot.dng'), 'fake-dng');
fs.writeFileSync(path.join(dir, 'shot.CR2'), 'fake-cr2');
fs.writeFileSync(path.join(dir, 'bundle.zip'), 'PK\x03\x04');
fs.writeFileSync(path.join(dir, 'sheet.xlsx'), 'PK\x03\x04-not-really-xlsx');
fs.writeFileSync(path.join(dir, 'photo.webp'), 'fake-webp');
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const byTitle = Object.fromEntries((await adapter.list()).map((i) => [i.title, i.previewType]));
assert.equal(byTitle['clip.mp4'], 'video');
assert.equal(byTitle['clip.MOV'], 'video');
assert.equal(byTitle['song.mp3'], 'audio');
assert.equal(byTitle['rows.csv'], 'text');
assert.equal(byTitle['scan.pdf'], 'pdf');
assert.equal(byTitle['shot.dng'], 'image');
assert.equal(byTitle['shot.CR2'], 'image');
assert.equal(byTitle['bundle.zip'], 'archive');
assert.equal(byTitle['photo.webp'], 'image');
// Excel is a zip container — no in-browser preview yet (generic card).
assert.equal(byTitle['sheet.xlsx'], 'none');
fs.rmSync(dir, { recursive: true, force: true });
});
test('reject moves the file to the trash dir, undo restores it', async () => { test('reject moves the file to the trash dir, undo restores it', async () => {
const dir = makeFixture(); const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' }); const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
@@ -120,3 +152,99 @@ test('init() throws a clear error for a missing folder', async () => {
const adapter = new FolderAdapter({ folderPath: '/definitely/does/not/exist' }); const adapter = new FolderAdapter({ folderPath: '/definitely/does/not/exist' });
await assert.rejects(() => adapter.init(), /Folder not found/); await assert.rejects(() => adapter.init(), /Folder not found/);
}); });
test('getActions() adds organize actions only for configured destination keys', async () => {
const dir = makeFixture();
const destA = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-a-'));
const destB = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-b-'));
const adapter = new FolderAdapter({
folderPath: dir,
extensions: '',
destinations: {
1: { path: destA, label: 'Vacation' },
7: destB,
},
});
await adapter.init();
const actions = adapter.getActions();
assert.ok(actions.some((a) => a.id === 'keep'));
assert.ok(actions.some((a) => a.id === 'move-1' && a.label === 'Vacation' && a.key === '1'));
assert.ok(actions.some((a) => a.id === 'move-7' && a.key === '7'));
assert.equal(actions.filter((a) => a.group === 'organize').length, 2);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(destA, { recursive: true, force: true });
fs.rmSync(destB, { recursive: true, force: true });
});
test('move-N moves the file into the destination folder and undo restores it', async () => {
const dir = makeFixture();
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-'));
const adapter = new FolderAdapter({
folderPath: dir,
extensions: '',
destinations: { 2: { path: dest, label: 'Work' } },
});
await adapter.init();
const items = await adapter.list();
const target = items.find((i) => i.title === 'keep-me.txt');
const record = await adapter.applyAction(target, 'move-2');
assert.equal(record.type, 'move');
assert.equal(record.destKey, '2');
assert.ok(!fs.existsSync(path.join(dir, 'keep-me.txt')));
assert.ok(fs.existsSync(path.join(dest, 'keep-me.txt')));
await adapter.undo(record);
assert.ok(fs.existsSync(path.join(dir, 'keep-me.txt')));
assert.ok(!fs.existsSync(path.join(dest, 'keep-me.txt')));
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dest, { recursive: true, force: true });
});
test('list() skips destination folders that sit inside the source tree', async () => {
const dir = makeFixture();
const nestedDest = path.join(dir, 'sorted');
fs.mkdirSync(nestedDest);
fs.writeFileSync(path.join(nestedDest, 'already-sorted.txt'), 'done');
const adapter = new FolderAdapter({
folderPath: dir,
extensions: '',
recursive: true,
destinations: { 0: { path: nestedDest, label: 'Sorted' } },
});
await adapter.init();
const titles = (await adapter.list()).map((i) => i.title);
assert.ok(!titles.includes('already-sorted.txt'));
assert.ok(titles.includes('keep-me.txt'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('move-N refuses an unconfigured key', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', destinations: {} });
await adapter.init();
const items = await adapter.list();
await assert.rejects(() => adapter.applyAction(items[0], 'move-3'), /No destination configured/);
fs.rmSync(dir, { recursive: true, force: true });
});
test('getDetails() returns path, size, and type fields', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const items = await adapter.list();
const target = items.find((i) => i.title === 'keep-me.txt');
const details = await adapter.getDetails(target.id);
const labels = details.fields.map((f) => f.label);
assert.ok(labels.includes('Path'));
assert.ok(labels.includes('Size'));
assert.ok(labels.includes('Type'));
assert.equal(details.path, path.join(dir, 'keep-me.txt'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('uiHints() reflects showDetailsByDefault', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', showDetailsByDefault: true });
await adapter.init();
assert.deepEqual(adapter.uiHints(), { showDetailsByDefault: true, supportsDetails: true });
fs.rmSync(dir, { recursive: true, force: true });
});
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { listZipEntries, formatZipListing } = require('../lib/zip-list');
function writeTinyZip(dir) {
// Minimal hand-rolled store-method zip with two files (no compression).
// Local file headers + central directory + EOCD.
function local(name, data) {
const n = Buffer.from(name);
const header = Buffer.alloc(30);
header.writeUInt32LE(0x04034b50, 0);
header.writeUInt16LE(20, 4); // version
header.writeUInt16LE(0, 6); // flags
header.writeUInt16LE(0, 8); // store
header.writeUInt16LE(0, 10);
header.writeUInt16LE(0, 12);
header.writeUInt32LE(0, 14); // crc ignored for our reader
header.writeUInt32LE(data.length, 18);
header.writeUInt32LE(data.length, 22);
header.writeUInt16LE(n.length, 26);
header.writeUInt16LE(0, 28);
return Buffer.concat([header, n, data]);
}
function central(name, data, localOffset) {
const n = Buffer.from(name);
const header = Buffer.alloc(46);
header.writeUInt32LE(0x02014b50, 0);
header.writeUInt16LE(20, 4);
header.writeUInt16LE(20, 6);
header.writeUInt16LE(0, 8);
header.writeUInt16LE(0, 10);
header.writeUInt16LE(0, 12);
header.writeUInt16LE(0, 14);
header.writeUInt32LE(0, 16);
header.writeUInt32LE(data.length, 20);
header.writeUInt32LE(data.length, 24);
header.writeUInt16LE(n.length, 28);
header.writeUInt16LE(0, 30);
header.writeUInt16LE(0, 32);
header.writeUInt16LE(0, 34);
header.writeUInt16LE(0, 36);
header.writeUInt32LE(0, 38);
header.writeUInt32LE(localOffset, 42);
return Buffer.concat([header, n]);
}
const a = Buffer.from('alpha\n');
const b = Buffer.from('item,qty\n1,2\n');
const locA = local('readme.txt', a);
const locB = local('data/rows.csv', b);
const body = Buffer.concat([locA, locB]);
const cen = Buffer.concat([
central('readme.txt', a, 0),
central('data/rows.csv', b, locA.length),
]);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(2, 8);
eocd.writeUInt16LE(2, 10);
eocd.writeUInt32LE(cen.length, 12);
eocd.writeUInt32LE(body.length, 16);
eocd.writeUInt16LE(0, 20);
const zipPath = path.join(dir, 'sample.zip');
fs.writeFileSync(zipPath, Buffer.concat([body, cen, eocd]));
return zipPath;
}
test('listZipEntries reads names and sizes without extracting', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-zip-'));
const zipPath = writeTinyZip(dir);
const listing = await listZipEntries(zipPath);
assert.equal(listing.totalEntries, 2);
assert.equal(listing.truncated, false);
assert.deepEqual(
listing.entries.map((e) => e.name),
['readme.txt', 'data/rows.csv']
);
assert.equal(listing.entries[0].size, 6);
const text = formatZipListing(listing);
assert.match(text, /readme\.txt/);
assert.match(text, /data\/rows\.csv/);
fs.rmSync(dir, { recursive: true, force: true });
});