diff --git a/CHANGELOG.md b/CHANGELOG.md index 122b631..66c464b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 every push/PR — the first PR gate for this repo. - Settings UI polish: selected-adapter highlight, custom radios (no native diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77909a7..2ab4458 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 0–9). 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. diff --git a/README.md b/README.md index 260fc22..d44c3d1 100644 --- a/README.md +++ b/README.md @@ -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 doesn’t 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 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 Point at a self-hosted [Immich](https://immich.app) server + API key and diff --git a/adapters/base.js b/adapters/base.js index 4489502..259f0a9 100644 --- a/adapters/base.js +++ b/adapters/base.js @@ -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 0–9, 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, * }>>} */ @@ -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 }; diff --git a/adapters/folder.js b/adapters/folder.js index 421fb1f..b0be0ff 100644 --- a/adapters/folder.js +++ b/adapters/folder.js @@ -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 0–9 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 0–9 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 0–9)', + 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 }; diff --git a/adapters/immich.js b/adapters/immich.js index 9af87d8..a67456b 100644 --- a/adapters/immich.js +++ b/adapters/immich.js @@ -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', { diff --git a/design-system/swipeanything/MASTER.md b/design-system/swipeanything/MASTER.md new file mode 100644 index 0000000..11a92aa --- /dev/null +++ b/design-system/swipeanything/MASTER.md @@ -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 diff --git a/lib/file-details.js b/lib/file-details.js new file mode 100644 index 0000000..4125dcc --- /dev/null +++ b/lib/file-details.js @@ -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 }; diff --git a/lib/zip-list.js b/lib/zip-list.js new file mode 100644 index 0000000..9b85c4a --- /dev/null +++ b/lib/zip-list.js @@ -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 }; diff --git a/package.json b/package.json index e702362..3f7ed1c 100644 --- a/package.json +++ b/package.json @@ -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": "", diff --git a/public/app.js b/public/app.js index 6f144c6..985939b 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = `
${action.key}
Move to ${action.label}
`; + 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]) => `${n} ${id}`) + .map(([id, n]) => `${n} ${labels[id] || id}`) .join('    '); empty.innerHTML = `All done. ${state.total} item(s) reviewed.
${countBits}
`; 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); } }); diff --git a/public/index.html b/public/index.html index 1db2cbf..2cf738d 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,9 @@ SwipeAnything + + + @@ -12,6 +15,7 @@

SwipeAnything

+ Settings @@ -24,26 +28,45 @@
+
+ + diff --git a/public/settings.html b/public/settings.html index 8b89e45..93835c1 100644 --- a/public/settings.html +++ b/public/settings.html @@ -4,6 +4,9 @@ SwipeAnything — Settings + + + diff --git a/public/settings.js b/public/settings.js index 56527a2..8648782 100644 --- a/public/settings.js +++ b/public/settings.js @@ -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, '"'); } @@ -58,22 +89,44 @@
`; - 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 = `${field.label}Leave a row blank to disable that key. Press the number while swiping to move the file there.`; + 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 = ` + + +
+ + +
+ `; + attachBrowse(row.querySelector('[data-browse-for]'), `field_${field.key}_${key}_path`, null); + wrap.appendChild(row); + } return wrap; } diff --git a/public/style.css b/public/style.css index 309f20c..7320662 100644 --- a/public/style.css +++ b/public/style.css @@ -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 { diff --git a/scripts/seed-demo.sh b/scripts/seed-demo.sh new file mode 100755 index 0000000..e4e4995 --- /dev/null +++ b/scripts/seed-demo.sh @@ -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" diff --git a/server.js b/server.js index ab58c1a..80a2186 100644 --- a/server.js +++ b/server.js @@ -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}` }); } diff --git a/swipeanything.config.example.json b/swipeanything.config.example.json index 025edc6..e2ce55c 100644 --- a/swipeanything.config.example.json +++ b/swipeanything.config.example.json @@ -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" } + } } } diff --git a/test/api.test.js b/test/api.test.js index 65952e5..a4aa59f 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -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')); +}); diff --git a/test/folder-adapter.test.js b/test/folder-adapter.test.js index 5d172dd..669f818 100644 --- a/test/folder-adapter.test.js +++ b/test/folder-adapter.test.js @@ -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 }); +}); diff --git a/test/zip-list.test.js b/test/zip-list.test.js new file mode 100644 index 0000000..447f368 --- /dev/null +++ b/test/zip-list.test.js @@ -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 }); +});