Organize folders, details sheet, and richer previews #2
@@ -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
|
||||
|
||||
+8
-4
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+37
-6
@@ -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<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
@@ -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 };
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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(' ');
|
||||
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
@@ -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>
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SwipeAnything — 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
@@ -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 @@
|
||||
</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…</button>
|
||||
</div>
|
||||
`;
|
||||
attachBrowse(row.querySelector('[data-browse-for]'), `field_${field.key}_${key}_path`, null);
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
+434
-30
@@ -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 {
|
||||
|
||||
Executable
+127
@@ -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"
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user