Organize folders, details sheet, and richer previews #2

Merged
ilia merged 1 commits from feature/organize-details-previews into main 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]
### 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 09)** 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
every push/PR — the first PR gate for this repo.
- 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 },
];
// Optional: override the default keep/reject pair, e.g. add a third
// "later" action. Each needs a distinct key and direction.
// Optional: override the default keep/reject pair. Prefer getActions()
// when the set depends on settings (see folder.js destinations 09).
static actions = [
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true },
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right', group: 'primary' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true, group: 'primary' },
];
getActions() {
return this.constructor.actions; // or append setting-driven actions
}
async init() {
// Validate settings / open a connection. Throw a descriptive Error
// to surface a message in the settings UI.
+22 -2
View File
@@ -28,6 +28,11 @@ see [CONTRIBUTING.md](CONTRIBUTING.md).
## Features
- 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/`
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
@@ -83,13 +88,15 @@ live server. The same suite is the PR gate in [`.gitea/workflows/ci.yml`](.gitea
| Action | Gesture | Key | Button |
|---|---|---|---|
| Keep | Drag right | `→` | Keep → |
| Keep (leave in place) | Drag right | `→` | Keep → |
| Reject (moves to trash) | Drag left | `←` | Reject ← |
| Skip / next (no decision) | — | `↓` or `Space` | Skip ↓ |
| 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 |
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 doesnt jump.
## 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
once there's anything to empty) permanently deletes what's in there.
In Settings, fill any of the **Organize folders (keys 09)** 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
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,
* label: string,
* type: 'text' | 'password' | 'checkbox' | 'number' | 'select' | 'folder',
* type: 'text' | 'password' | 'checkbox' | 'number' | 'select' | 'folder' | 'folderMap',
* default?: any,
* options?: Array<{ value: string, label: string }>, // for type 'select'
* placeholder?: string,
@@ -37,19 +37,21 @@ class Adapter {
* }
* `type: 'folder'` renders a text field plus a "Browse..." button backed
* by a native folder picker where available (see server.js /api/browse-folder).
* `type: 'folderMap'` renders keys 09, each with an optional label and a
* folder path (for organize-into-buckets flows).
*/
static configSchema = [];
/**
* Actions available on every card. The first two are the swipe defaults
* (right = keep, left = reject); adapters may add more, e.g. a third
* "later" bucket, as long as each has a distinct `key` and `direction`.
* Default actions available on every card. Prefer overriding getActions()
* when the set depends on settings (e.g. numbered destination folders).
* {
* id: string,
* label: string,
* key: string, // KeyboardEvent.key that triggers it
* direction: 'left' | 'right' | 'up' | 'down',
* direction?: 'left' | 'right' | 'up' | 'down',
* isDestructive?: boolean,
* group?: 'primary' | 'organize', // UI layout hint
* }
*/
static actions = [
@@ -61,6 +63,14 @@ class Adapter {
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
* database, create a trash directory, etc. Throw a descriptive Error to
@@ -75,7 +85,7 @@ class Adapter {
* id: string,
* title: string,
* subtitle?: string,
* previewType?: 'image' | 'audio' | 'video' | 'text' | 'none',
* previewType?: 'image' | 'audio' | 'video' | 'pdf' | 'archive' | 'text' | 'none',
* meta?: Record<string, string | number>,
* }>>}
*/
@@ -116,6 +126,22 @@ class Adapter {
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
* the UI can offer an explicit, confirm-guarded "Empty trash" action.
@@ -135,6 +161,11 @@ class Adapter {
describeSource() {
return '';
}
/** Optional UI hints (e.g. showDetailsByDefault) merged into /api/queue. */
uiHints() {
return {};
}
}
module.exports = { Adapter };
+208 -16
View File
@@ -5,11 +5,39 @@ const fsp = fs.promises;
const path = require('path');
const { Adapter } = require('./base');
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 VIDEO_EXT = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi']);
const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'log']);
const VIDEO_EXT = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'm4v']);
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) {
return path.extname(filePath).slice(1).toLowerCase();
@@ -21,17 +49,26 @@ const MIME_OVERRIDES = {
heic: 'image/heic',
heif: 'image/heif',
flac: 'audio/flac',
dng: 'image/x-adobe-dng',
};
function previewTypeFor(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 (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';
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) {
if (bytes < 1024) return `${bytes} B`;
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');
}
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.
* "Reject" never deletes -- it moves the file into a trash folder alongside
* the source, and "Undo" moves it right back. "Empty trash" (a separate,
* confirm-guarded action) is the only place this adapter permanently deletes
* anything.
* the source, and "Undo" moves it right back. Number keys 09 can move a
* file into configured destination folders (organize, not just triage).
* "Empty trash" (a separate, confirm-guarded action) is the only place this
* adapter permanently deletes anything.
*/
class FolderAdapter extends Adapter {
static id = 'folder';
static label = 'Local folder';
static description =
'Point at any local folder and swipe through its files. Rejected files move to a trash folder, never deleted outright.';
'Point at any local folder and swipe through its files. Rejected files move to a trash folder; keys 09 can organize into other folders.';
static configSchema = [
{
@@ -72,17 +128,28 @@ class FolderAdapter extends Adapter {
key: 'extensions',
label: 'File extensions (comma separated, blank = all files)',
type: 'text',
default: 'jpg,jpeg,png,gif,webp,heic,bmp',
placeholder: '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,heic,pdf,dng,mp4,mov',
},
{ 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 09)',
type: 'folderMap',
default: {},
},
];
static actions = [
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true },
// Down = next without deciding (skip). Up = undo/go back (handled in the UI, not an action).
{ id: 'skip', label: 'Skip', key: 'ArrowDown', direction: 'down' },
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right', group: 'primary' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true, group: 'primary' },
{ id: 'skip', label: 'Skip', key: 'ArrowDown', direction: 'down', group: 'primary' },
];
constructor(settings) {
@@ -96,6 +163,24 @@ class FolderAdapter extends Adapter {
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
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() {
@@ -104,6 +189,25 @@ class FolderAdapter extends Adapter {
throw new Error(`Folder not found: ${this.folderPath}`);
}
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 = '') {
@@ -114,6 +218,7 @@ class FolderAdapter extends Adapter {
const abs = path.join(dir, entry.name);
const rel = relativeBase ? path.join(relativeBase, entry.name) : entry.name;
if (entry.isDirectory()) {
if (this._isExcludedDir(abs)) continue;
if (this.recursive) files = files.concat(await this._walk(abs, rel));
continue;
}
@@ -158,6 +263,30 @@ class FolderAdapter extends Adapter {
async streamPreview(itemId, res) {
const abs = this._absoluteFor(itemId);
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)];
if (override) res.type(override);
res.sendFile(abs);
@@ -173,6 +302,56 @@ class FolderAdapter extends Adapter {
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) {
if (actionId === 'reject') {
const from = this._absoluteFor(item.id);
@@ -180,6 +359,18 @@ class FolderAdapter extends Adapter {
await fsp.rename(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.
return null;
}
@@ -207,8 +398,9 @@ class FolderAdapter extends Adapter {
}
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);
}
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) {
if (actionId === 'reject') {
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: `150200ms` 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 ~150200ms
- [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",
"scripts": {
"start": "node server.js",
"test": "node --test"
"test": "node --test",
"seed-demo": "bash scripts/seed-demo.sh"
},
"license": "MIT",
"author": "",
+282 -39
View File
@@ -3,6 +3,15 @@
const deckEl = document.getElementById('deck');
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 statsLineEl = document.getElementById('statsLine');
const sourceLabelEl = document.getElementById('sourceLabel');
@@ -11,6 +20,7 @@
const shortcutsLink = document.getElementById('shortcutsLink');
const shortcutsModal = document.getElementById('shortcutsModal');
const shortcutsClose = document.getElementById('shortcutsClose');
const shortcutListEl = document.getElementById('shortcutList');
const liveRegionEl = document.getElementById('liveRegion');
const DRAG_THRESHOLD = 110;
@@ -18,6 +28,10 @@
let dragging = null;
let busy = false;
let focusBeforeModal = null;
let detailsOpen = false;
let detailsDismissed = false;
let detailsForId = null;
let detailsCache = null;
const KEY_LABEL = {
ArrowLeft: '←',
@@ -85,7 +99,7 @@
return;
}
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.color = 'var(--sub)';
wrap.style.whiteSpace = 'pre-line';
@@ -96,6 +110,50 @@
wrap.appendChild(img);
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': {
const audio = document.createElement('audio');
audio.controls = true;
@@ -149,6 +207,12 @@
const title = document.createElement('div');
title.className = '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);
if (item.subtitle) {
@@ -230,7 +294,7 @@
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) {
if (busy || !state || !state.current) return;
const card = deckEl.querySelector('.card');
@@ -240,17 +304,33 @@
}
const dir = action.direction === 'right' ? 1 : action.direction === 'left' ? -1 : 0;
const stamp = card.querySelector(`.stamp.${action.direction}`);
if (stamp) stamp.style.opacity = '1';
if (action.direction === 'left' || action.direction === 'right') {
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);
return;
}
busy = true;
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';
setTimeout(() => {
busy = false;
@@ -262,38 +342,126 @@
if (busy || !state) return;
const action = (state.actions || []).find((a) => a.id === actionId);
if (!action) return;
if (action.direction === 'left' || action.direction === 'right') {
if (action.direction === 'left' || action.direction === 'right' || action.group === 'organize') {
animateAction(action);
} else {
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) {
actionsRowEl.innerHTML = '';
for (const action of actions) {
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');
organizeRowEl.innerHTML = '';
const label = document.createElement('span');
label.className = 'action-label';
label.textContent = action.label;
label.setAttribute('aria-hidden', 'true');
const primary = (actions || []).filter((a) => a.group !== 'organize');
const organize = (actions || []).filter((a) => a.group === 'organize');
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));
actionsRowEl.appendChild(btn);
for (const action of primary) {
actionsRowEl.appendChild(makeActionButton(action, 'action-btn'));
}
const undoBtn = document.createElement('button');
@@ -313,6 +481,33 @@
undoBtn.appendChild(undoKey);
undoBtn.addEventListener('click', undo);
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() {
@@ -322,8 +517,9 @@
progressFillEl.setAttribute('aria-valuemax', String(state.total));
progressFillEl.setAttribute('aria-valuenow', String(state.reviewed));
sourceLabelEl.textContent = state.sourceLabel || '';
const labels = Object.fromEntries((state.actions || []).map((a) => [a.id, a.label]));
const countBits = Object.entries(state.counts || {})
.map(([id, n]) => `${id}: ${n}`)
.map(([id, n]) => `${labels[id] || id}: ${n}`)
.join(' \u00b7 ');
statsLineEl.textContent = `${state.reviewed}/${state.total} reviewed${countBits ? ' \u2014 ' + countBits : ''}`;
@@ -341,12 +537,14 @@
if (!state.current) {
const empty = document.createElement('div');
empty.className = 'empty-state';
const labels = Object.fromEntries((state.actions || []).map((a) => [a.id, a.label]));
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; ');
empty.innerHTML = `All done. ${state.total} item(s) reviewed.<div class="summary">${countBits}</div>`;
deckEl.appendChild(empty);
renderActionsRow([]);
closeDetailsPanel({ silent: true });
announce(`All done. ${state.total} items reviewed.`);
return;
}
@@ -355,6 +553,17 @@
deckEl.appendChild(card);
renderActionsRow(state.actions);
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() {
@@ -477,6 +686,31 @@
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) => {
// Shift+? (Shift+/ on most keyboards) toggles shortcuts help
if (e.key === '?' || (e.shiftKey && e.key === '/')) {
@@ -489,6 +723,11 @@
closeShortcuts();
return;
}
if (e.key === 'Escape' && detailsOpen) {
e.preventDefault();
closeDetailsPanel();
return;
}
if (!shortcutsModal.hidden) return;
if (!state) return;
@@ -497,22 +736,26 @@
undo();
return;
}
if ((e.key === 'i' || e.key === 'I') && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
toggleDetails();
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();
triggerAction('skip');
return;
}
if (e.key === 'ArrowRight') {
const action = (state.actions || []).find((a) => a.key === e.key);
if (action) {
e.preventDefault();
triggerAction('keep');
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
triggerAction('reject');
return;
triggerAction(action.id);
}
});
+26 -3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>
@@ -12,6 +15,7 @@
<h1>SwipeAnything</h1>
<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="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="emptyTrashLink" hidden>Empty trash</button>
<a class="icon-link" href="settings.html">Settings</a>
@@ -24,26 +28,45 @@
<div class="stats" id="statsLine"></div>
<div class="deck" id="deck"></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>
<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 class="modal-card" role="dialog" aria-modal="true" aria-labelledby="shortcutsTitle">
<div class="modal-header">
<h2 id="shortcutsTitle">Shortcuts</h2>
<button type="button" class="icon-link modal-close" id="shortcutsClose" aria-label="Close shortcuts help"></button>
</div>
<dl class="shortcut-list">
<dl class="shortcut-list" id="shortcutList">
<div><dt></dt><dd>Keep</dd></div>
<div><dt></dt><dd>Reject (to trash)</dd></div>
<div><dt></dt><dd>Skip / next</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>Ctrl/Cmd+Z</dt><dd>Undo</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>
<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>
+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>
+69 -16
View File
@@ -21,6 +21,20 @@
}
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}`);
if (!el) return field.default;
if (field.type === 'checkbox') return el.checked;
@@ -28,6 +42,23 @@
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) {
return String(value).replace(/"/g, '&quot;');
}
@@ -58,22 +89,44 @@
</div>
<div class="field-hint" data-browse-hint-for="field_${field.key}"></div>
`;
const browseBtn = wrap.querySelector(`[data-browse-for="field_${field.key}"]`);
const hintEl = wrap.querySelector(`[data-browse-hint-for="field_${field.key}"]`);
browseBtn.addEventListener('click', async () => {
hintEl.textContent = '';
browseBtn.disabled = true;
browseBtn.textContent = 'Waiting for Finder\u2026';
try {
const { path: chosen } = await api('/api/browse-folder', { method: 'POST' });
document.getElementById(`field_${field.key}`).value = chosen;
} catch (err) {
if (err.message !== 'Cancelled') hintEl.textContent = err.message;
} finally {
browseBtn.disabled = false;
browseBtn.textContent = 'Browse\u2026';
}
});
attachBrowse(
wrap.querySelector(`[data-browse-for="field_${field.key}"]`),
`field_${field.key}`,
wrap.querySelector(`[data-browse-hint-for="field_${field.key}"]`)
);
return wrap;
}
if (field.type === 'folderMap') {
wrap.className = 'form-field folder-map';
const heading = document.createElement('div');
heading.className = 'folder-map-heading';
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>`;
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;
}
+434 -30
View File
@@ -1,14 +1,20 @@
:root {
--bg: #0d0f12;
--card: #16191d;
--card2: #1d2227;
--text: #f0f2f4;
--sub: #9aa3ab;
--keep: #35c46a;
--reject: #e5484d;
--neutral: #f0b429;
--accent: #6ea8ff;
--border: #2a2f35;
color-scheme: dark;
--bg: #0a0a0b;
--card: #141416;
--card2: #1a1a1e;
--text: #f4f4f5;
--sub: #a1a1aa;
--keep: #34d399;
--reject: #fb7185;
--neutral: #fbbf24;
--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%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-family: var(--font-ui);
overscroll-behavior: none;
-webkit-font-smoothing: antialiased;
}
a {
@@ -49,6 +56,13 @@ a {
button {
font: inherit;
cursor: pointer;
}
a.icon-link,
button.icon-link {
cursor: pointer;
transition: color var(--ease), border-color var(--ease), background var(--ease);
}
#app {
@@ -71,10 +85,12 @@ header {
h1 {
font-size: 17px;
margin: 0;
font-weight: 600;
font-weight: 650;
letter-spacing: -0.02em;
}
.source-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--sub);
word-break: break-all;
@@ -83,47 +99,50 @@ h1 {
.header-actions {
display: flex;
gap: 10px;
gap: 8px;
flex: none;
flex-wrap: wrap;
justify-content: flex-end;
}
.icon-link {
color: var(--sub);
text-decoration: none;
font-size: 13px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 9px;
background: var(--card);
cursor: pointer;
}
.icon-link.danger {
color: var(--reject);
border-color: var(--reject);
border-color: color-mix(in srgb, var(--reject) 45%, var(--border));
}
.icon-link:hover {
color: var(--text);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
}
.stats {
font-size: 12px;
color: var(--sub);
font-variant-numeric: tabular-nums;
}
.progress-track {
height: 4px;
height: 3px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
margin-bottom: 16px;
margin-bottom: 14px;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.2s;
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 40%, var(--keep)));
transition: width 0.2s ease;
}
.deck {
@@ -153,7 +172,7 @@ h1 {
text-align: center;
user-select: none;
cursor: grab;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
box-shadow: var(--shadow-card);
touch-action: none;
}
@@ -162,12 +181,13 @@ h1 {
}
.card .preview {
position: relative;
width: 100%;
min-height: 160px;
max-height: 280px;
border-radius: 12px;
overflow: hidden;
background: #0a0c0e;
background: #050506;
display: flex;
align-items: center;
justify-content: center;
@@ -180,7 +200,46 @@ h1 {
max-height: 280px;
object-fit: contain;
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 {
@@ -191,6 +250,7 @@ h1 {
.card .preview pre {
margin: 0;
padding: 12px;
font-family: var(--font-mono);
font-size: 11px;
text-align: left;
white-space: pre-wrap;
@@ -206,10 +266,18 @@ h1 {
font-weight: 700;
line-height: 1.3;
word-break: break-word;
letter-spacing: -0.01em;
cursor: pointer;
transition: color var(--ease);
}
.card .title:hover {
color: var(--accent);
}
.card .subtitle {
font-size: 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--sub);
word-break: break-all;
}
@@ -227,7 +295,9 @@ h1 {
padding: 2px 8px;
border-radius: 20px;
border: 1px solid var(--border);
background: #1b1f24;
background: var(--card2);
font-family: var(--font-mono);
font-size: 10px;
}
.stamp {
@@ -262,6 +332,19 @@ h1 {
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 {
display: flex;
justify-content: center;
@@ -270,6 +353,207 @@ h1 {
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 {
flex: none;
min-width: 64px;
@@ -288,6 +572,11 @@ h1 {
gap: 2px;
padding: 6px 4px;
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 {
@@ -295,29 +584,50 @@ h1 {
}
.action-btn .action-key {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 700;
opacity: 0.85;
opacity: 0.9;
}
.action-btn.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);
background: color-mix(in srgb, var(--keep) 10%, var(--card));
}
.action-btn.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);
background: color-mix(in srgb, var(--reject) 10%, var(--card));
}
.action-btn.skip {
color: var(--neutral);
border-color: color-mix(in srgb, var(--neutral) 70%, var(--border));
}
.action-btn.skip:hover:not(:disabled) {
border-color: var(--neutral);
background: color-mix(in srgb, var(--neutral) 10%, var(--card));
}
.action-btn.undo {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
}
.action-btn.undo:hover:not(:disabled) {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--card));
}
.action-btn:disabled {
@@ -325,6 +635,28 @@ h1 {
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 */
.modal {
position: fixed;
@@ -385,7 +717,8 @@ h1 {
.shortcut-list dt {
flex: none;
min-width: 88px;
font-size: 13px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
color: var(--accent);
font-variant-numeric: tabular-nums;
@@ -450,7 +783,21 @@ h1 {
border: 1px solid var(--border);
background: var(--card2);
color: var(--text);
font: inherit;
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 {
@@ -460,7 +807,7 @@ h1 {
.form-field select {
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-position: right 12px center;
padding-right: 32px;
@@ -485,6 +832,53 @@ h1 {
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 {
flex: none;
padding: 10px 14px;
@@ -495,6 +889,11 @@ h1 {
font-size: 13px;
cursor: pointer;
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 {
@@ -528,12 +927,12 @@ h1 {
}
.adapter-card:hover {
border-color: #3a424a;
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
.adapter-card.selected {
border-color: var(--accent);
background: #141c28;
background: color-mix(in srgb, var(--accent) 9%, var(--card));
}
.adapter-card-title {
@@ -582,6 +981,11 @@ h1 {
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: filter var(--ease), opacity var(--ease);
}
.primary-btn:hover:not(:disabled) {
filter: brightness(1.06);
}
.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 {
adapterId: s.adapterId,
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,
reviewed: s.index,
counts,
@@ -108,6 +108,7 @@ async function queuePayload(s) {
upcoming: remaining.slice(1, 4),
canUndo: s.history.length > 0,
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) => {
const s = await ensureSession().catch(() => null);
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) {
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) {
return res.status(400).json({ error: `Unknown action: ${actionId}` });
}
+7 -2
View File
@@ -3,7 +3,12 @@
"settings": {
"folderPath": "/absolute/path/to/a/folder",
"recursive": false,
"extensions": "jpg,jpeg,png,gif,webp,heic,bmp",
"trashDirName": ".swipeanything-trash"
"extensions": "jpg,jpeg,png,gif,webp,heic,bmp,pdf,dng,cr2,nef,arw,mp4,mov",
"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}`);
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 });
});
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 () => {
const dir = makeFixture();
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' });
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 });
});