Merge pull request 'Organize folders, details sheet, and richer previews' (#2) from feature/organize-details-previews into main
This commit was merged in pull request #2.
This commit is contained in:
@@ -6,6 +6,25 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **PDF / RAW / ZIP previews** on the folder adapter: PDFs show a first-page
|
||||||
|
Quick Look thumb (iframe fallback); camera RAW (`dng`, `cr2`, `nef`, …)
|
||||||
|
uses QL thumbs; `.zip` lists contained files (no extract) via a zero-dep
|
||||||
|
central-directory reader (`lib/zip-list.js`).
|
||||||
|
- **Video / CSV** covered in demo seed + preview-type tests; `npm run seed-demo`
|
||||||
|
rebuilds `/tmp/swipe-demo`.
|
||||||
|
- **File details** bottom sheet: press `i` / Details / click the filename
|
||||||
|
(path, size, dates; image dimensions / camera on macOS; zip entry summary).
|
||||||
|
Copy path + Reveal in Finder. Setting "Show file details on each card by
|
||||||
|
default". Immich returns EXIF-ish fields from its asset API.
|
||||||
|
- **Organize folders (keys 0–9)** on the local folder adapter: map number
|
||||||
|
keys to destination folders in Settings, then press `0`–`9` (or tap the
|
||||||
|
chips) to move the current file there. Undo restores it. Destination
|
||||||
|
dirs inside the source tree are excluded from the queue so sorted files
|
||||||
|
don't bounce back in.
|
||||||
|
- OLED dark design tokens (DM Sans + IBM Plex Mono) documented under
|
||||||
|
`design-system/swipeanything/MASTER.md`.
|
||||||
|
- New settings field type `folderMap` (and adapter `getActions()`) so
|
||||||
|
organize destinations are schema-driven like everything else.
|
||||||
- Gitea Actions CI (`.gitea/workflows/ci.yml`): `npm test` + gitleaks on
|
- Gitea Actions CI (`.gitea/workflows/ci.yml`): `npm test` + gitleaks on
|
||||||
every push/PR — the first PR gate for this repo.
|
every push/PR — the first PR gate for this repo.
|
||||||
- Settings UI polish: selected-adapter highlight, custom radios (no native
|
- Settings UI polish: selected-adapter highlight, custom radios (no native
|
||||||
|
|||||||
+8
-4
@@ -21,13 +21,17 @@ work for any adapter that implements the contract below.
|
|||||||
{ key: 'someSetting', label: 'Some setting', type: 'text', required: true },
|
{ key: 'someSetting', label: 'Some setting', type: 'text', required: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Optional: override the default keep/reject pair, e.g. add a third
|
// Optional: override the default keep/reject pair. Prefer getActions()
|
||||||
// "later" action. Each needs a distinct key and direction.
|
// when the set depends on settings (see folder.js destinations 0–9).
|
||||||
static actions = [
|
static actions = [
|
||||||
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' },
|
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right', group: 'primary' },
|
||||||
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true },
|
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true, group: 'primary' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
getActions() {
|
||||||
|
return this.constructor.actions; // or append setting-driven actions
|
||||||
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
// Validate settings / open a connection. Throw a descriptive Error
|
// Validate settings / open a connection. Throw a descriptive Error
|
||||||
// to surface a message in the settings UI.
|
// to surface a message in the settings UI.
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Swipe (touch/mouse drag) or use the keyboard
|
- Swipe (touch/mouse drag) or use the keyboard
|
||||||
|
- **Organize, not just triage:** map keys `0`–`9` to destination folders in
|
||||||
|
Settings, then press a number (or tap the chip) to move the current file
|
||||||
|
there. Undo brings it back.
|
||||||
|
- **Inspect before deciding:** press `i` for path, size, dates, and (on
|
||||||
|
macOS) image dimensions / camera metadata; Reveal in Finder + Copy path.
|
||||||
- Non-destructive by default: "reject" moves files to a `.swipeanything-trash/`
|
- Non-destructive by default: "reject" moves files to a `.swipeanything-trash/`
|
||||||
folder (or, for Immich, the library's own trash) — never a hard delete.
|
folder (or, for Immich, the library's own trash) — never a hard delete.
|
||||||
A separate, confirm-guarded "Empty trash" action is the only place that
|
A separate, confirm-guarded "Empty trash" action is the only place that
|
||||||
@@ -83,13 +88,15 @@ live server. The same suite is the PR gate in [`.gitea/workflows/ci.yml`](.gitea
|
|||||||
|
|
||||||
| Action | Gesture | Key | Button |
|
| Action | Gesture | Key | Button |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Keep | Drag right | `→` | Keep → |
|
| Keep (leave in place) | Drag right | `→` | Keep → |
|
||||||
| Reject (moves to trash) | Drag left | `←` | Reject ← |
|
| Reject (moves to trash) | Drag left | `←` | Reject ← |
|
||||||
| Skip / next (no decision) | — | `↓` or `Space` | Skip ↓ |
|
| Skip / next (no decision) | — | `↓` or `Space` | Skip ↓ |
|
||||||
| Undo / go back | — | `↑` or `Ctrl/Cmd+Z` | Undo ↑ |
|
| Undo / go back | — | `↑` or `Ctrl/Cmd+Z` | Undo ↑ |
|
||||||
|
| Move to organize folder | — | `0`–`9` (if configured) | chips under the main buttons |
|
||||||
|
| File details (path, size, dimensions, …) | — | `i` | Details in header |
|
||||||
| Shortcuts help | — | `Shift+?` | `?` in header |
|
| Shortcuts help | — | `Shift+?` | `?` in header |
|
||||||
|
|
||||||
Keep/Reject (button or key) flash the KEEP/REJECT stamp and fling the card, same as a drag.
|
Keep/Reject (button or key) flash the KEEP/REJECT stamp and fling the card, same as a drag. Numbered organize actions flash the destination label and lift the card away. Details open as a bottom sheet over the card so the layout doesn’t jump.
|
||||||
|
|
||||||
## Adapters
|
## Adapters
|
||||||
|
|
||||||
@@ -100,6 +107,19 @@ extension. "Reject" moves the file into `.swipeanything-trash/` next to the
|
|||||||
source; "Empty trash" (a separate, confirm-guarded action shown in the header
|
source; "Empty trash" (a separate, confirm-guarded action shown in the header
|
||||||
once there's anything to empty) permanently deletes what's in there.
|
once there's anything to empty) permanently deletes what's in there.
|
||||||
|
|
||||||
|
In Settings, fill any of the **Organize folders (keys 0–9)** rows with a
|
||||||
|
destination path (and optional label). Only configured keys appear as
|
||||||
|
actions — leave a row blank to disable it. Destination folders are created
|
||||||
|
if missing, and if a destination sits inside the source tree it is skipped
|
||||||
|
when listing so sorted files don't reappear in the queue.
|
||||||
|
|
||||||
|
Press `i` (or **Details** in the header) to inspect the current file before
|
||||||
|
deciding: full path, size, dates, and on macOS image dimensions / camera /
|
||||||
|
capture date when Spotlight knows them. Details open as a bottom sheet over
|
||||||
|
the card. **Reveal in Finder** and **Copy path** are there too. Turn on
|
||||||
|
**Show file details on each card by default** in Settings if you always want
|
||||||
|
the sheet open.
|
||||||
|
|
||||||
### Immich
|
### Immich
|
||||||
|
|
||||||
Point at a self-hosted [Immich](https://immich.app) server + API key and
|
Point at a self-hosted [Immich](https://immich.app) server + API key and
|
||||||
|
|||||||
+37
-6
@@ -29,7 +29,7 @@ class Adapter {
|
|||||||
* {
|
* {
|
||||||
* key: string,
|
* key: string,
|
||||||
* label: string,
|
* label: string,
|
||||||
* type: 'text' | 'password' | 'checkbox' | 'number' | 'select' | 'folder',
|
* type: 'text' | 'password' | 'checkbox' | 'number' | 'select' | 'folder' | 'folderMap',
|
||||||
* default?: any,
|
* default?: any,
|
||||||
* options?: Array<{ value: string, label: string }>, // for type 'select'
|
* options?: Array<{ value: string, label: string }>, // for type 'select'
|
||||||
* placeholder?: string,
|
* placeholder?: string,
|
||||||
@@ -37,19 +37,21 @@ class Adapter {
|
|||||||
* }
|
* }
|
||||||
* `type: 'folder'` renders a text field plus a "Browse..." button backed
|
* `type: 'folder'` renders a text field plus a "Browse..." button backed
|
||||||
* by a native folder picker where available (see server.js /api/browse-folder).
|
* by a native folder picker where available (see server.js /api/browse-folder).
|
||||||
|
* `type: 'folderMap'` renders keys 0–9, each with an optional label and a
|
||||||
|
* folder path (for organize-into-buckets flows).
|
||||||
*/
|
*/
|
||||||
static configSchema = [];
|
static configSchema = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Actions available on every card. The first two are the swipe defaults
|
* Default actions available on every card. Prefer overriding getActions()
|
||||||
* (right = keep, left = reject); adapters may add more, e.g. a third
|
* when the set depends on settings (e.g. numbered destination folders).
|
||||||
* "later" bucket, as long as each has a distinct `key` and `direction`.
|
|
||||||
* {
|
* {
|
||||||
* id: string,
|
* id: string,
|
||||||
* label: string,
|
* label: string,
|
||||||
* key: string, // KeyboardEvent.key that triggers it
|
* key: string, // KeyboardEvent.key that triggers it
|
||||||
* direction: 'left' | 'right' | 'up' | 'down',
|
* direction?: 'left' | 'right' | 'up' | 'down',
|
||||||
* isDestructive?: boolean,
|
* isDestructive?: boolean,
|
||||||
|
* group?: 'primary' | 'organize', // UI layout hint
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
static actions = [
|
static actions = [
|
||||||
@@ -61,6 +63,14 @@ class Adapter {
|
|||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions for the current session. Defaults to the static `actions` list;
|
||||||
|
* override when actions depend on settings (folder destinations, etc.).
|
||||||
|
*/
|
||||||
|
getActions() {
|
||||||
|
return this.constructor.actions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional async setup: validate settings, open a mailbox, connect to a
|
* Optional async setup: validate settings, open a mailbox, connect to a
|
||||||
* database, create a trash directory, etc. Throw a descriptive Error to
|
* database, create a trash directory, etc. Throw a descriptive Error to
|
||||||
@@ -75,7 +85,7 @@ class Adapter {
|
|||||||
* id: string,
|
* id: string,
|
||||||
* title: string,
|
* title: string,
|
||||||
* subtitle?: string,
|
* subtitle?: string,
|
||||||
* previewType?: 'image' | 'audio' | 'video' | 'text' | 'none',
|
* previewType?: 'image' | 'audio' | 'video' | 'pdf' | 'archive' | 'text' | 'none',
|
||||||
* meta?: Record<string, string | number>,
|
* meta?: Record<string, string | number>,
|
||||||
* }>>}
|
* }>>}
|
||||||
*/
|
*/
|
||||||
@@ -116,6 +126,22 @@ class Adapter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: richer details for the inspect-before-deciding panel.
|
||||||
|
* @returns {Promise<{ fields: Array<{ label: string, value: string }>, path?: string, actions?: Array<{ id: string, label: string }> } | null>}
|
||||||
|
*/
|
||||||
|
async getDetails(itemId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: reveal / open the item in the native file manager.
|
||||||
|
* Return true if handled.
|
||||||
|
*/
|
||||||
|
async reveal(itemId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional: describes a reversible "trash" this adapter maintains, so
|
* Optional: describes a reversible "trash" this adapter maintains, so
|
||||||
* the UI can offer an explicit, confirm-guarded "Empty trash" action.
|
* the UI can offer an explicit, confirm-guarded "Empty trash" action.
|
||||||
@@ -135,6 +161,11 @@ class Adapter {
|
|||||||
describeSource() {
|
describeSource() {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optional UI hints (e.g. showDetailsByDefault) merged into /api/queue. */
|
||||||
|
uiHints() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { Adapter };
|
module.exports = { Adapter };
|
||||||
|
|||||||
+208
-16
@@ -5,11 +5,39 @@ const fsp = fs.promises;
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { Adapter } = require('./base');
|
const { Adapter } = require('./base');
|
||||||
const { getThumbnail } = require('../lib/thumbnails');
|
const { getThumbnail } = require('../lib/thumbnails');
|
||||||
|
const { collectFileDetails } = require('../lib/file-details');
|
||||||
|
const { listZipEntries, formatZipListing } = require('../lib/zip-list');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
|
||||||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'svg', 'tiff', 'avif']);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'svg', 'tiff', 'tif', 'avif']);
|
||||||
|
const RAW_EXT = new Set([
|
||||||
|
'dng',
|
||||||
|
'cr2',
|
||||||
|
'cr3',
|
||||||
|
'nef',
|
||||||
|
'nrw',
|
||||||
|
'arw',
|
||||||
|
'srf',
|
||||||
|
'sr2',
|
||||||
|
'orf',
|
||||||
|
'rw2',
|
||||||
|
'raf',
|
||||||
|
'pef',
|
||||||
|
'ptx',
|
||||||
|
'x3f',
|
||||||
|
'raw',
|
||||||
|
'rwl',
|
||||||
|
'srw',
|
||||||
|
]);
|
||||||
const AUDIO_EXT = new Set(['mp3', 'wav', 'flac', 'm4a', 'ogg', 'aac']);
|
const AUDIO_EXT = new Set(['mp3', 'wav', 'flac', 'm4a', 'ogg', 'aac']);
|
||||||
const VIDEO_EXT = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi']);
|
const VIDEO_EXT = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'm4v']);
|
||||||
const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'log']);
|
const PDF_EXT = new Set(['pdf']);
|
||||||
|
const ZIP_EXT = new Set(['zip']);
|
||||||
|
const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'tsv', 'log', 'yaml', 'yml']);
|
||||||
|
const DEST_KEYS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
|
||||||
function extOf(filePath) {
|
function extOf(filePath) {
|
||||||
return path.extname(filePath).slice(1).toLowerCase();
|
return path.extname(filePath).slice(1).toLowerCase();
|
||||||
@@ -21,17 +49,26 @@ const MIME_OVERRIDES = {
|
|||||||
heic: 'image/heic',
|
heic: 'image/heic',
|
||||||
heif: 'image/heif',
|
heif: 'image/heif',
|
||||||
flac: 'audio/flac',
|
flac: 'audio/flac',
|
||||||
|
dng: 'image/x-adobe-dng',
|
||||||
};
|
};
|
||||||
|
|
||||||
function previewTypeFor(filePath) {
|
function previewTypeFor(filePath) {
|
||||||
const ext = extOf(filePath);
|
const ext = extOf(filePath);
|
||||||
if (IMAGE_EXT.has(ext)) return 'image';
|
if (IMAGE_EXT.has(ext) || RAW_EXT.has(ext)) return 'image';
|
||||||
if (AUDIO_EXT.has(ext)) return 'audio';
|
if (AUDIO_EXT.has(ext)) return 'audio';
|
||||||
if (VIDEO_EXT.has(ext)) return 'video';
|
if (VIDEO_EXT.has(ext)) return 'video';
|
||||||
|
if (PDF_EXT.has(ext)) return 'pdf';
|
||||||
|
if (ZIP_EXT.has(ext)) return 'archive';
|
||||||
if (TEXT_EXT.has(ext)) return 'text';
|
if (TEXT_EXT.has(ext)) return 'text';
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exts whose original bytes don't render in most browsers — serve QL thumb as preview when possible. */
|
||||||
|
function needsThumbnailPreview(filePath) {
|
||||||
|
const ext = extOf(filePath);
|
||||||
|
return RAW_EXT.has(ext) || ext === 'heic' || ext === 'heif';
|
||||||
|
}
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
||||||
@@ -46,18 +83,37 @@ function pathForId(id) {
|
|||||||
return Buffer.from(id, 'base64url').toString('utf8');
|
return Buffer.from(id, 'base64url').toString('utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDestinations(raw) {
|
||||||
|
const destinations = {};
|
||||||
|
if (!raw || typeof raw !== 'object') return destinations;
|
||||||
|
for (const key of DEST_KEYS) {
|
||||||
|
const entry = raw[key];
|
||||||
|
if (!entry) continue;
|
||||||
|
const folderPath = typeof entry === 'string' ? entry : entry.path;
|
||||||
|
if (!folderPath || !String(folderPath).trim()) continue;
|
||||||
|
const resolved = path.resolve(String(folderPath).trim());
|
||||||
|
const label =
|
||||||
|
(typeof entry === 'object' && entry.label && String(entry.label).trim()) ||
|
||||||
|
path.basename(resolved) ||
|
||||||
|
`Folder ${key}`;
|
||||||
|
destinations[key] = { key, path: resolved, label };
|
||||||
|
}
|
||||||
|
return destinations;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reference adapter: point at any local folder and swipe through its files.
|
* Reference adapter: point at any local folder and swipe through its files.
|
||||||
* "Reject" never deletes -- it moves the file into a trash folder alongside
|
* "Reject" never deletes -- it moves the file into a trash folder alongside
|
||||||
* the source, and "Undo" moves it right back. "Empty trash" (a separate,
|
* the source, and "Undo" moves it right back. Number keys 0–9 can move a
|
||||||
* confirm-guarded action) is the only place this adapter permanently deletes
|
* file into configured destination folders (organize, not just triage).
|
||||||
* anything.
|
* "Empty trash" (a separate, confirm-guarded action) is the only place this
|
||||||
|
* adapter permanently deletes anything.
|
||||||
*/
|
*/
|
||||||
class FolderAdapter extends Adapter {
|
class FolderAdapter extends Adapter {
|
||||||
static id = 'folder';
|
static id = 'folder';
|
||||||
static label = 'Local folder';
|
static label = 'Local folder';
|
||||||
static description =
|
static description =
|
||||||
'Point at any local folder and swipe through its files. Rejected files move to a trash folder, never deleted outright.';
|
'Point at any local folder and swipe through its files. Rejected files move to a trash folder; keys 0–9 can organize into other folders.';
|
||||||
|
|
||||||
static configSchema = [
|
static configSchema = [
|
||||||
{
|
{
|
||||||
@@ -72,17 +128,28 @@ class FolderAdapter extends Adapter {
|
|||||||
key: 'extensions',
|
key: 'extensions',
|
||||||
label: 'File extensions (comma separated, blank = all files)',
|
label: 'File extensions (comma separated, blank = all files)',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
default: 'jpg,jpeg,png,gif,webp,heic,bmp',
|
default: 'jpg,jpeg,png,gif,webp,heic,bmp,pdf,dng,cr2,nef,arw,mp4,mov',
|
||||||
placeholder: 'jpg,jpeg,png,gif,webp,heic,bmp',
|
placeholder: 'jpg,jpeg,png,heic,pdf,dng,mp4,mov',
|
||||||
},
|
},
|
||||||
{ key: 'trashDirName', label: 'Trash folder name', type: 'text', default: '.swipeanything-trash' },
|
{ key: 'trashDirName', label: 'Trash folder name', type: 'text', default: '.swipeanything-trash' },
|
||||||
|
{
|
||||||
|
key: 'showDetailsByDefault',
|
||||||
|
label: 'Show file details on each card by default',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'destinations',
|
||||||
|
label: 'Organize folders (keys 0–9)',
|
||||||
|
type: 'folderMap',
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
static actions = [
|
static actions = [
|
||||||
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' },
|
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right', group: 'primary' },
|
||||||
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true },
|
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true, group: 'primary' },
|
||||||
// Down = next without deciding (skip). Up = undo/go back (handled in the UI, not an action).
|
{ id: 'skip', label: 'Skip', key: 'ArrowDown', direction: 'down', group: 'primary' },
|
||||||
{ id: 'skip', label: 'Skip', key: 'ArrowDown', direction: 'down' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor(settings) {
|
constructor(settings) {
|
||||||
@@ -96,6 +163,24 @@ class FolderAdapter extends Adapter {
|
|||||||
.map((e) => e.trim().toLowerCase())
|
.map((e) => e.trim().toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
this.extensions = extList.length ? new Set(extList) : null; // null = allow all
|
this.extensions = extList.length ? new Set(extList) : null; // null = allow all
|
||||||
|
this.destinations = parseDestinations(settings.destinations);
|
||||||
|
this.showDetailsByDefault = Boolean(settings.showDetailsByDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActions() {
|
||||||
|
const actions = this.constructor.actions.map((a) => ({ ...a }));
|
||||||
|
for (const key of DEST_KEYS) {
|
||||||
|
const dest = this.destinations[key];
|
||||||
|
if (!dest) continue;
|
||||||
|
actions.push({
|
||||||
|
id: `move-${key}`,
|
||||||
|
label: dest.label,
|
||||||
|
key,
|
||||||
|
group: 'organize',
|
||||||
|
destPath: dest.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@@ -104,6 +189,25 @@ class FolderAdapter extends Adapter {
|
|||||||
throw new Error(`Folder not found: ${this.folderPath}`);
|
throw new Error(`Folder not found: ${this.folderPath}`);
|
||||||
}
|
}
|
||||||
await fsp.mkdir(this.trashDir, { recursive: true });
|
await fsp.mkdir(this.trashDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const dest of Object.values(this.destinations)) {
|
||||||
|
if (dest.path === this.folderPath) {
|
||||||
|
throw new Error(`Destination ${dest.key} cannot be the same as the source folder`);
|
||||||
|
}
|
||||||
|
await fsp.mkdir(dest.path, { recursive: true });
|
||||||
|
const destStat = await fsp.stat(dest.path).catch(() => null);
|
||||||
|
if (!destStat || !destStat.isDirectory()) {
|
||||||
|
throw new Error(`Destination ${dest.key} is not a folder: ${dest.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isExcludedDir(absPath) {
|
||||||
|
if (absPath === this.trashDir || absPath.startsWith(this.trashDir + path.sep)) return true;
|
||||||
|
for (const dest of Object.values(this.destinations)) {
|
||||||
|
if (absPath === dest.path || absPath.startsWith(dest.path + path.sep)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _walk(dir, relativeBase = '') {
|
async _walk(dir, relativeBase = '') {
|
||||||
@@ -114,6 +218,7 @@ class FolderAdapter extends Adapter {
|
|||||||
const abs = path.join(dir, entry.name);
|
const abs = path.join(dir, entry.name);
|
||||||
const rel = relativeBase ? path.join(relativeBase, entry.name) : entry.name;
|
const rel = relativeBase ? path.join(relativeBase, entry.name) : entry.name;
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
if (this._isExcludedDir(abs)) continue;
|
||||||
if (this.recursive) files = files.concat(await this._walk(abs, rel));
|
if (this.recursive) files = files.concat(await this._walk(abs, rel));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -158,6 +263,30 @@ class FolderAdapter extends Adapter {
|
|||||||
async streamPreview(itemId, res) {
|
async streamPreview(itemId, res) {
|
||||||
const abs = this._absoluteFor(itemId);
|
const abs = this._absoluteFor(itemId);
|
||||||
if (!fs.existsSync(abs)) return false;
|
if (!fs.existsSync(abs)) return false;
|
||||||
|
|
||||||
|
if (ZIP_EXT.has(extOf(abs))) {
|
||||||
|
try {
|
||||||
|
const listing = await listZipEntries(abs);
|
||||||
|
res.type('text/plain; charset=utf-8');
|
||||||
|
res.send(formatZipListing(listing));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
res.type('text/plain; charset=utf-8');
|
||||||
|
res.status(200).send(`(could not list zip: ${err.message})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAW / HEIC: browsers usually can't decode the original — prefer a Quick Look PNG.
|
||||||
|
if (needsThumbnailPreview(abs)) {
|
||||||
|
const thumbPath = await getThumbnail(abs);
|
||||||
|
if (thumbPath) {
|
||||||
|
res.type('image/png');
|
||||||
|
res.sendFile(thumbPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const override = MIME_OVERRIDES[extOf(abs)];
|
const override = MIME_OVERRIDES[extOf(abs)];
|
||||||
if (override) res.type(override);
|
if (override) res.type(override);
|
||||||
res.sendFile(abs);
|
res.sendFile(abs);
|
||||||
@@ -173,6 +302,56 @@ class FolderAdapter extends Adapter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDetails(itemId) {
|
||||||
|
const abs = this._absoluteFor(itemId);
|
||||||
|
if (!fs.existsSync(abs)) throw new Error('File not found');
|
||||||
|
const details = await collectFileDetails(abs);
|
||||||
|
if (ZIP_EXT.has(extOf(abs))) {
|
||||||
|
try {
|
||||||
|
const listing = await listZipEntries(abs);
|
||||||
|
const files = listing.entries.filter((e) => !e.isDir).length;
|
||||||
|
details.fields.push({
|
||||||
|
label: 'Zip entries',
|
||||||
|
value: `${listing.totalEntries}${listing.truncated ? '+' : ''} (${files} files)`,
|
||||||
|
});
|
||||||
|
const sample = listing.entries
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((e) => e.name)
|
||||||
|
.join(', ');
|
||||||
|
if (sample) details.fields.push({ label: 'Contains', value: sample + (listing.entries.length > 8 ? '…' : '') });
|
||||||
|
} catch {
|
||||||
|
// ignore listing failures in details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details.actions = process.platform === 'darwin' ? [{ id: 'reveal', label: 'Reveal in Finder' }] : [];
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reveal(itemId) {
|
||||||
|
if (process.platform !== 'darwin') return false;
|
||||||
|
const abs = this._absoluteFor(itemId);
|
||||||
|
if (!fs.existsSync(abs)) throw new Error('File not found');
|
||||||
|
await execFileAsync('open', ['-R', abs], { timeout: 5000 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uiHints() {
|
||||||
|
return { showDetailsByDefault: this.showDetailsByDefault, supportsDetails: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _uniqueTarget(destDir, basename) {
|
||||||
|
let candidate = path.join(destDir, basename);
|
||||||
|
if (!fs.existsSync(candidate)) return candidate;
|
||||||
|
const ext = path.extname(basename);
|
||||||
|
const stem = path.basename(basename, ext);
|
||||||
|
let i = 1;
|
||||||
|
while (fs.existsSync(candidate)) {
|
||||||
|
candidate = path.join(destDir, `${stem}-${i}${ext}`);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
async applyAction(item, actionId) {
|
async applyAction(item, actionId) {
|
||||||
if (actionId === 'reject') {
|
if (actionId === 'reject') {
|
||||||
const from = this._absoluteFor(item.id);
|
const from = this._absoluteFor(item.id);
|
||||||
@@ -180,6 +359,18 @@ class FolderAdapter extends Adapter {
|
|||||||
await fsp.rename(from, to);
|
await fsp.rename(from, to);
|
||||||
return { type: 'move', from, to };
|
return { type: 'move', from, to };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moveMatch = /^move-([0-9])$/.exec(actionId);
|
||||||
|
if (moveMatch) {
|
||||||
|
const dest = this.destinations[moveMatch[1]];
|
||||||
|
if (!dest) throw new Error(`No destination configured for key ${moveMatch[1]}`);
|
||||||
|
const from = this._absoluteFor(item.id);
|
||||||
|
await fsp.mkdir(dest.path, { recursive: true });
|
||||||
|
const to = await this._uniqueTarget(dest.path, path.basename(from));
|
||||||
|
await fsp.rename(from, to);
|
||||||
|
return { type: 'move', from, to, destKey: dest.key, destLabel: dest.label };
|
||||||
|
}
|
||||||
|
|
||||||
// 'keep' and 'skip' have no filesystem effect.
|
// 'keep' and 'skip' have no filesystem effect.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -207,8 +398,9 @@ class FolderAdapter extends Adapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describeSource() {
|
describeSource() {
|
||||||
return this.folderPath;
|
const n = Object.keys(this.destinations).length;
|
||||||
|
return n ? `${this.folderPath} · ${n} organize folder${n === 1 ? '' : 's'}` : this.folderPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { FolderAdapter };
|
module.exports = { FolderAdapter, parseDestinations, DEST_KEYS };
|
||||||
|
|||||||
@@ -126,6 +126,35 @@ class ImmichAdapter extends Adapter {
|
|||||||
return this._streamAssetImage(itemId, 'thumbnail', res);
|
return this._streamAssetImage(itemId, 'thumbnail', res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDetails(itemId) {
|
||||||
|
const res = await this._request(`/assets/${itemId}`);
|
||||||
|
const asset = await res.json();
|
||||||
|
const fields = [
|
||||||
|
{ label: 'Name', value: asset.originalFileName || asset.id },
|
||||||
|
{ label: 'Type', value: asset.type },
|
||||||
|
{ label: 'Created', value: asset.fileCreatedAt },
|
||||||
|
{ label: 'Favorite', value: asset.isFavorite ? 'yes' : 'no' },
|
||||||
|
];
|
||||||
|
const exif = asset.exifInfo || {};
|
||||||
|
if (exif.exifImageWidth && exif.exifImageHeight) {
|
||||||
|
fields.push({ label: 'Dimensions', value: `${exif.exifImageWidth} × ${exif.exifImageHeight}` });
|
||||||
|
}
|
||||||
|
if (exif.make || exif.model) {
|
||||||
|
fields.push({ label: 'Camera', value: [exif.make, exif.model].filter(Boolean).join(' ') });
|
||||||
|
}
|
||||||
|
if (exif.dateTimeOriginal) fields.push({ label: 'Captured', value: exif.dateTimeOriginal });
|
||||||
|
if (exif.city || exif.country) {
|
||||||
|
fields.push({ label: 'Location', value: [exif.city, exif.state, exif.country].filter(Boolean).join(', ') });
|
||||||
|
}
|
||||||
|
if (exif.lensModel) fields.push({ label: 'Lens', value: exif.lensModel });
|
||||||
|
if (asset.originalPath) fields.push({ label: 'Path', value: asset.originalPath });
|
||||||
|
return { fields: fields.filter((f) => f.value), actions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
uiHints() {
|
||||||
|
return { showDetailsByDefault: false, supportsDetails: true };
|
||||||
|
}
|
||||||
|
|
||||||
async applyAction(item, actionId) {
|
async applyAction(item, actionId) {
|
||||||
if (actionId === 'reject') {
|
if (actionId === 'reject') {
|
||||||
await this._request('/assets', {
|
await this._request('/assets', {
|
||||||
|
|||||||
@@ -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",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"test": "node --test"
|
"test": "node --test",
|
||||||
|
"seed-demo": "bash scripts/seed-demo.sh"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
+282
-39
@@ -3,6 +3,15 @@
|
|||||||
|
|
||||||
const deckEl = document.getElementById('deck');
|
const deckEl = document.getElementById('deck');
|
||||||
const actionsRowEl = document.getElementById('actionsRow');
|
const actionsRowEl = document.getElementById('actionsRow');
|
||||||
|
const organizeRowEl = document.getElementById('organizeRow');
|
||||||
|
const detailsSheetEl = document.getElementById('detailsSheet');
|
||||||
|
const detailsListEl = document.getElementById('detailsList');
|
||||||
|
const detailsStatusEl = document.getElementById('detailsStatus');
|
||||||
|
const detailsCopyPathBtn = document.getElementById('detailsCopyPath');
|
||||||
|
const detailsRevealBtn = document.getElementById('detailsReveal');
|
||||||
|
const detailsCloseBtn = document.getElementById('detailsClose');
|
||||||
|
const detailsBackdrop = document.getElementById('detailsBackdrop');
|
||||||
|
const detailsLink = document.getElementById('detailsLink');
|
||||||
const progressFillEl = document.getElementById('progressFill');
|
const progressFillEl = document.getElementById('progressFill');
|
||||||
const statsLineEl = document.getElementById('statsLine');
|
const statsLineEl = document.getElementById('statsLine');
|
||||||
const sourceLabelEl = document.getElementById('sourceLabel');
|
const sourceLabelEl = document.getElementById('sourceLabel');
|
||||||
@@ -11,6 +20,7 @@
|
|||||||
const shortcutsLink = document.getElementById('shortcutsLink');
|
const shortcutsLink = document.getElementById('shortcutsLink');
|
||||||
const shortcutsModal = document.getElementById('shortcutsModal');
|
const shortcutsModal = document.getElementById('shortcutsModal');
|
||||||
const shortcutsClose = document.getElementById('shortcutsClose');
|
const shortcutsClose = document.getElementById('shortcutsClose');
|
||||||
|
const shortcutListEl = document.getElementById('shortcutList');
|
||||||
const liveRegionEl = document.getElementById('liveRegion');
|
const liveRegionEl = document.getElementById('liveRegion');
|
||||||
|
|
||||||
const DRAG_THRESHOLD = 110;
|
const DRAG_THRESHOLD = 110;
|
||||||
@@ -18,6 +28,10 @@
|
|||||||
let dragging = null;
|
let dragging = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let focusBeforeModal = null;
|
let focusBeforeModal = null;
|
||||||
|
let detailsOpen = false;
|
||||||
|
let detailsDismissed = false;
|
||||||
|
let detailsForId = null;
|
||||||
|
let detailsCache = null;
|
||||||
|
|
||||||
const KEY_LABEL = {
|
const KEY_LABEL = {
|
||||||
ArrowLeft: '←',
|
ArrowLeft: '←',
|
||||||
@@ -85,7 +99,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
wrap.classList.add('file-icon');
|
wrap.classList.add('file-icon');
|
||||||
wrap.textContent = 'image failed to load\n(try jpg/png/webp — heic may need a Mac to preview)';
|
wrap.textContent = 'preview failed\n(jpg/png/webp work everywhere; HEIC/RAW need macOS Quick Look)';
|
||||||
wrap.style.fontSize = '12px';
|
wrap.style.fontSize = '12px';
|
||||||
wrap.style.color = 'var(--sub)';
|
wrap.style.color = 'var(--sub)';
|
||||||
wrap.style.whiteSpace = 'pre-line';
|
wrap.style.whiteSpace = 'pre-line';
|
||||||
@@ -96,6 +110,50 @@
|
|||||||
wrap.appendChild(img);
|
wrap.appendChild(img);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'pdf': {
|
||||||
|
// First-page Quick Look thumb when available; otherwise in-browser PDF.
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = thumbSrc;
|
||||||
|
img.alt = item.title;
|
||||||
|
img.draggable = false;
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'preview-badge';
|
||||||
|
badge.textContent = 'PDF';
|
||||||
|
wrap.appendChild(badge);
|
||||||
|
img.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => {
|
||||||
|
img.remove();
|
||||||
|
const frame = document.createElement('iframe');
|
||||||
|
frame.className = 'pdf-frame';
|
||||||
|
frame.src = src;
|
||||||
|
frame.title = item.title;
|
||||||
|
wrap.appendChild(frame);
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
wrap.appendChild(img);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'archive': {
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.className = 'archive-listing';
|
||||||
|
pre.textContent = 'Listing archive…';
|
||||||
|
wrap.appendChild(pre);
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'preview-badge';
|
||||||
|
badge.textContent = 'ZIP';
|
||||||
|
wrap.appendChild(badge);
|
||||||
|
fetch(src)
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then((text) => {
|
||||||
|
pre.textContent = text.slice(0, 4000);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
pre.textContent = '(could not list archive)';
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'audio': {
|
case 'audio': {
|
||||||
const audio = document.createElement('audio');
|
const audio = document.createElement('audio');
|
||||||
audio.controls = true;
|
audio.controls = true;
|
||||||
@@ -149,6 +207,12 @@
|
|||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'title';
|
title.className = 'title';
|
||||||
title.textContent = item.title;
|
title.textContent = item.title;
|
||||||
|
title.title = 'Click for details (or press i)';
|
||||||
|
title.style.cursor = 'pointer';
|
||||||
|
title.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleDetails();
|
||||||
|
});
|
||||||
card.appendChild(title);
|
card.appendChild(title);
|
||||||
|
|
||||||
if (item.subtitle) {
|
if (item.subtitle) {
|
||||||
@@ -230,7 +294,7 @@
|
|||||||
card.addEventListener('pointercancel', onPointerUp);
|
card.addEventListener('pointercancel', onPointerUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show KEEP/REJECT stamp + fling, then commit the action. */
|
/** Show KEEP/REJECT/organize stamp + fling, then commit the action. */
|
||||||
function animateAction(action) {
|
function animateAction(action) {
|
||||||
if (busy || !state || !state.current) return;
|
if (busy || !state || !state.current) return;
|
||||||
const card = deckEl.querySelector('.card');
|
const card = deckEl.querySelector('.card');
|
||||||
@@ -240,17 +304,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dir = action.direction === 'right' ? 1 : action.direction === 'left' ? -1 : 0;
|
const dir = action.direction === 'right' ? 1 : action.direction === 'left' ? -1 : 0;
|
||||||
const stamp = card.querySelector(`.stamp.${action.direction}`);
|
if (action.direction === 'left' || action.direction === 'right') {
|
||||||
if (stamp) stamp.style.opacity = '1';
|
const stamp = card.querySelector(`.stamp.${action.direction}`);
|
||||||
|
if (stamp) stamp.style.opacity = '1';
|
||||||
|
} else if (action.group === 'organize') {
|
||||||
|
let stamp = card.querySelector('.stamp.center');
|
||||||
|
if (!stamp) {
|
||||||
|
stamp = document.createElement('div');
|
||||||
|
stamp.className = 'stamp center';
|
||||||
|
stamp.setAttribute('aria-hidden', 'true');
|
||||||
|
card.appendChild(stamp);
|
||||||
|
}
|
||||||
|
stamp.textContent = action.label;
|
||||||
|
stamp.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
if (dir === 0) {
|
if (dir === 0 && action.group !== 'organize') {
|
||||||
performAction(action.id);
|
performAction(action.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
busy = true;
|
busy = true;
|
||||||
card.style.transition = 'transform 0.28s ease-out, opacity 0.28s ease-out';
|
card.style.transition = 'transform 0.28s ease-out, opacity 0.28s ease-out';
|
||||||
card.style.transform = `translate(${dir * 500}px, -40px) rotate(${dir * 25}deg)`;
|
if (dir !== 0) {
|
||||||
|
card.style.transform = `translate(${dir * 500}px, -40px) rotate(${dir * 25}deg)`;
|
||||||
|
} else {
|
||||||
|
card.style.transform = 'translate(0, -120px) scale(0.92)';
|
||||||
|
}
|
||||||
card.style.opacity = '0';
|
card.style.opacity = '0';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
busy = false;
|
busy = false;
|
||||||
@@ -262,38 +342,126 @@
|
|||||||
if (busy || !state) return;
|
if (busy || !state) return;
|
||||||
const action = (state.actions || []).find((a) => a.id === actionId);
|
const action = (state.actions || []).find((a) => a.id === actionId);
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
if (action.direction === 'left' || action.direction === 'right') {
|
if (action.direction === 'left' || action.direction === 'right' || action.group === 'organize') {
|
||||||
animateAction(action);
|
animateAction(action);
|
||||||
} else {
|
} else {
|
||||||
performAction(actionId);
|
performAction(actionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeActionButton(action, className) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = className;
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.title = action.destPath ? `${action.label}\n${action.destPath}` : action.label;
|
||||||
|
btn.setAttribute('aria-label', `${action.label}, ${keySpoken(action.key)}`);
|
||||||
|
if (action.id === 'keep') btn.classList.add('keep');
|
||||||
|
if (action.isDestructive) btn.classList.add('reject');
|
||||||
|
if (action.id === 'skip') btn.classList.add('skip');
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'action-label';
|
||||||
|
label.textContent = action.label;
|
||||||
|
label.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const key = document.createElement('span');
|
||||||
|
key.className = 'action-key';
|
||||||
|
key.textContent = keyGlyph(action.key);
|
||||||
|
key.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
btn.appendChild(label);
|
||||||
|
btn.appendChild(key);
|
||||||
|
btn.addEventListener('click', () => triggerAction(action.id));
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDetailsPanel() {
|
||||||
|
detailsSheetEl.hidden = true;
|
||||||
|
detailsSheetEl.setAttribute('aria-hidden', 'true');
|
||||||
|
detailsListEl.innerHTML = '';
|
||||||
|
detailsStatusEl.textContent = '';
|
||||||
|
detailsCopyPathBtn.hidden = true;
|
||||||
|
detailsRevealBtn.hidden = true;
|
||||||
|
detailsForId = null;
|
||||||
|
detailsCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailsFields(details) {
|
||||||
|
detailsListEl.innerHTML = '';
|
||||||
|
for (const field of details.fields || []) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = field.label;
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = field.value;
|
||||||
|
row.appendChild(dt);
|
||||||
|
row.appendChild(dd);
|
||||||
|
detailsListEl.appendChild(row);
|
||||||
|
}
|
||||||
|
const canReveal = (details.actions || []).some((a) => a.id === 'reveal');
|
||||||
|
detailsRevealBtn.hidden = !canReveal;
|
||||||
|
detailsCopyPathBtn.hidden = !details.path;
|
||||||
|
detailsCache = details;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetails(itemId, { force } = {}) {
|
||||||
|
if (!itemId) return;
|
||||||
|
if (!force && detailsForId === itemId && detailsCache) {
|
||||||
|
renderDetailsFields(detailsCache);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detailsForId = itemId;
|
||||||
|
detailsStatusEl.textContent = 'Loading…';
|
||||||
|
detailsListEl.innerHTML = '';
|
||||||
|
detailsCopyPathBtn.hidden = true;
|
||||||
|
detailsRevealBtn.hidden = true;
|
||||||
|
try {
|
||||||
|
const details = await api(`/api/details/${encodeURIComponent(itemId)}`);
|
||||||
|
if (detailsForId !== itemId) return;
|
||||||
|
detailsStatusEl.textContent = '';
|
||||||
|
renderDetailsFields(details);
|
||||||
|
} catch (err) {
|
||||||
|
if (detailsForId !== itemId) return;
|
||||||
|
detailsStatusEl.textContent = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDetailsPanel() {
|
||||||
|
if (!state || !state.current) return;
|
||||||
|
if (state.ui && state.ui.supportsDetails === false) {
|
||||||
|
announce('Details are not available for this adapter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detailsOpen = true;
|
||||||
|
detailsDismissed = false;
|
||||||
|
detailsSheetEl.hidden = false;
|
||||||
|
detailsSheetEl.setAttribute('aria-hidden', 'false');
|
||||||
|
await loadDetails(state.current.id);
|
||||||
|
detailsCloseBtn.focus();
|
||||||
|
announce('File details shown. Press i to hide.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetailsPanel({ silent } = {}) {
|
||||||
|
detailsOpen = false;
|
||||||
|
detailsDismissed = true;
|
||||||
|
hideDetailsPanel();
|
||||||
|
if (!silent) announce('File details hidden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDetails() {
|
||||||
|
if (detailsOpen) closeDetailsPanel();
|
||||||
|
else await showDetailsPanel();
|
||||||
|
}
|
||||||
|
|
||||||
function renderActionsRow(actions) {
|
function renderActionsRow(actions) {
|
||||||
actionsRowEl.innerHTML = '';
|
actionsRowEl.innerHTML = '';
|
||||||
for (const action of actions) {
|
organizeRowEl.innerHTML = '';
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = 'action-btn';
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.setAttribute('aria-label', `${action.label}, ${keySpoken(action.key)}`);
|
|
||||||
if (action.id === 'keep') btn.classList.add('keep');
|
|
||||||
if (action.isDestructive) btn.classList.add('reject');
|
|
||||||
if (action.id === 'skip') btn.classList.add('skip');
|
|
||||||
|
|
||||||
const label = document.createElement('span');
|
const primary = (actions || []).filter((a) => a.group !== 'organize');
|
||||||
label.className = 'action-label';
|
const organize = (actions || []).filter((a) => a.group === 'organize');
|
||||||
label.textContent = action.label;
|
|
||||||
label.setAttribute('aria-hidden', 'true');
|
|
||||||
|
|
||||||
const key = document.createElement('span');
|
for (const action of primary) {
|
||||||
key.className = 'action-key';
|
actionsRowEl.appendChild(makeActionButton(action, 'action-btn'));
|
||||||
key.textContent = keyGlyph(action.key);
|
|
||||||
key.setAttribute('aria-hidden', 'true');
|
|
||||||
|
|
||||||
btn.appendChild(label);
|
|
||||||
btn.appendChild(key);
|
|
||||||
btn.addEventListener('click', () => triggerAction(action.id));
|
|
||||||
actionsRowEl.appendChild(btn);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const undoBtn = document.createElement('button');
|
const undoBtn = document.createElement('button');
|
||||||
@@ -313,6 +481,33 @@
|
|||||||
undoBtn.appendChild(undoKey);
|
undoBtn.appendChild(undoKey);
|
||||||
undoBtn.addEventListener('click', undo);
|
undoBtn.addEventListener('click', undo);
|
||||||
actionsRowEl.appendChild(undoBtn);
|
actionsRowEl.appendChild(undoBtn);
|
||||||
|
|
||||||
|
if (organize.length) {
|
||||||
|
organizeRowEl.hidden = false;
|
||||||
|
for (const action of organize) {
|
||||||
|
organizeRowEl.appendChild(makeActionButton(action, 'organize-btn'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
organizeRowEl.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailsLink) {
|
||||||
|
detailsLink.disabled = !state || !state.current;
|
||||||
|
detailsLink.hidden = Boolean(state && state.ui && state.ui.supportsDetails === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateShortcutList(organize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateShortcutList(organizeActions) {
|
||||||
|
if (!shortcutListEl) return;
|
||||||
|
shortcutListEl.querySelectorAll('[data-organize-shortcut]').forEach((el) => el.remove());
|
||||||
|
for (const action of organizeActions || []) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.dataset.organizeShortcut = '1';
|
||||||
|
row.innerHTML = `<dt>${action.key}</dt><dd>Move to ${action.label}</dd>`;
|
||||||
|
shortcutListEl.appendChild(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStats() {
|
function renderStats() {
|
||||||
@@ -322,8 +517,9 @@
|
|||||||
progressFillEl.setAttribute('aria-valuemax', String(state.total));
|
progressFillEl.setAttribute('aria-valuemax', String(state.total));
|
||||||
progressFillEl.setAttribute('aria-valuenow', String(state.reviewed));
|
progressFillEl.setAttribute('aria-valuenow', String(state.reviewed));
|
||||||
sourceLabelEl.textContent = state.sourceLabel || '';
|
sourceLabelEl.textContent = state.sourceLabel || '';
|
||||||
|
const labels = Object.fromEntries((state.actions || []).map((a) => [a.id, a.label]));
|
||||||
const countBits = Object.entries(state.counts || {})
|
const countBits = Object.entries(state.counts || {})
|
||||||
.map(([id, n]) => `${id}: ${n}`)
|
.map(([id, n]) => `${labels[id] || id}: ${n}`)
|
||||||
.join(' \u00b7 ');
|
.join(' \u00b7 ');
|
||||||
statsLineEl.textContent = `${state.reviewed}/${state.total} reviewed${countBits ? ' \u2014 ' + countBits : ''}`;
|
statsLineEl.textContent = `${state.reviewed}/${state.total} reviewed${countBits ? ' \u2014 ' + countBits : ''}`;
|
||||||
|
|
||||||
@@ -341,12 +537,14 @@
|
|||||||
if (!state.current) {
|
if (!state.current) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty-state';
|
empty.className = 'empty-state';
|
||||||
|
const labels = Object.fromEntries((state.actions || []).map((a) => [a.id, a.label]));
|
||||||
const countBits = Object.entries(state.counts || {})
|
const countBits = Object.entries(state.counts || {})
|
||||||
.map(([id, n]) => `<strong>${n}</strong> ${id}`)
|
.map(([id, n]) => `<strong>${n}</strong> ${labels[id] || id}`)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
empty.innerHTML = `All done. ${state.total} item(s) reviewed.<div class="summary">${countBits}</div>`;
|
empty.innerHTML = `All done. ${state.total} item(s) reviewed.<div class="summary">${countBits}</div>`;
|
||||||
deckEl.appendChild(empty);
|
deckEl.appendChild(empty);
|
||||||
renderActionsRow([]);
|
renderActionsRow([]);
|
||||||
|
closeDetailsPanel({ silent: true });
|
||||||
announce(`All done. ${state.total} items reviewed.`);
|
announce(`All done. ${state.total} items reviewed.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,6 +553,17 @@
|
|||||||
deckEl.appendChild(card);
|
deckEl.appendChild(card);
|
||||||
renderActionsRow(state.actions);
|
renderActionsRow(state.actions);
|
||||||
announce(`Item ${position.index} of ${position.total}: ${state.current.title}`);
|
announce(`Item ${position.index} of ${position.total}: ${state.current.title}`);
|
||||||
|
|
||||||
|
const wantDetails =
|
||||||
|
detailsOpen || ((state.ui && state.ui.showDetailsByDefault) && !detailsDismissed);
|
||||||
|
if (wantDetails) {
|
||||||
|
detailsOpen = true;
|
||||||
|
detailsSheetEl.hidden = false;
|
||||||
|
detailsSheetEl.setAttribute('aria-hidden', 'false');
|
||||||
|
loadDetails(state.current.id);
|
||||||
|
} else if (!detailsOpen) {
|
||||||
|
hideDetailsPanel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -477,6 +686,31 @@
|
|||||||
if (e.target === shortcutsModal) closeShortcuts();
|
if (e.target === shortcutsModal) closeShortcuts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
detailsCloseBtn.addEventListener('click', () => closeDetailsPanel());
|
||||||
|
detailsBackdrop.addEventListener('click', () => closeDetailsPanel());
|
||||||
|
if (detailsLink) {
|
||||||
|
detailsLink.addEventListener('click', () => toggleDetails());
|
||||||
|
}
|
||||||
|
detailsCopyPathBtn.addEventListener('click', async () => {
|
||||||
|
if (!detailsCache || !detailsCache.path) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(detailsCache.path);
|
||||||
|
detailsStatusEl.textContent = 'Path copied.';
|
||||||
|
announce('Path copied to clipboard.');
|
||||||
|
} catch (err) {
|
||||||
|
detailsStatusEl.textContent = err.message || 'Could not copy path.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
detailsRevealBtn.addEventListener('click', async () => {
|
||||||
|
if (!state || !state.current) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/reveal/${encodeURIComponent(state.current.id)}`, { method: 'POST' });
|
||||||
|
detailsStatusEl.textContent = 'Revealed in Finder.';
|
||||||
|
} catch (err) {
|
||||||
|
detailsStatusEl.textContent = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
// Shift+? (Shift+/ on most keyboards) toggles shortcuts help
|
// Shift+? (Shift+/ on most keyboards) toggles shortcuts help
|
||||||
if (e.key === '?' || (e.shiftKey && e.key === '/')) {
|
if (e.key === '?' || (e.shiftKey && e.key === '/')) {
|
||||||
@@ -489,6 +723,11 @@
|
|||||||
closeShortcuts();
|
closeShortcuts();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Escape' && detailsOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
closeDetailsPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!shortcutsModal.hidden) return;
|
if (!shortcutsModal.hidden) return;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
@@ -497,22 +736,26 @@
|
|||||||
undo();
|
undo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((e.key === 'i' || e.key === 'I') && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleDetails();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.current || busy) return;
|
if (!state.current || busy) return;
|
||||||
|
|
||||||
if (e.key === 'ArrowDown' || e.key === ' ') {
|
// Space is an alias for Skip even if Skip's declared key is ↓.
|
||||||
|
if (e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
triggerAction('skip');
|
triggerAction('skip');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'ArrowRight') {
|
|
||||||
|
const action = (state.actions || []).find((a) => a.key === e.key);
|
||||||
|
if (action) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
triggerAction('keep');
|
triggerAction(action.id);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault();
|
|
||||||
triggerAction('reject');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+26
-3
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SwipeAnything</title>
|
<title>SwipeAnything</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700;1,9..40,400&family=IBM+Plex+Mono:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,6 +15,7 @@
|
|||||||
<h1>SwipeAnything</h1>
|
<h1>SwipeAnything</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="button" class="icon-link" id="shortcutsLink" title="Keyboard shortcuts (Shift+?)" aria-haspopup="dialog" aria-controls="shortcutsModal">?</button>
|
<button type="button" class="icon-link" id="shortcutsLink" title="Keyboard shortcuts (Shift+?)" aria-haspopup="dialog" aria-controls="shortcutsModal">?</button>
|
||||||
|
<button type="button" class="icon-link" id="detailsLink" title="File details (i)" aria-haspopup="dialog" aria-controls="detailsSheet">Details</button>
|
||||||
<button type="button" class="icon-link" id="rescanLink">Rescan</button>
|
<button type="button" class="icon-link" id="rescanLink">Rescan</button>
|
||||||
<button type="button" class="icon-link" id="emptyTrashLink" hidden>Empty trash</button>
|
<button type="button" class="icon-link" id="emptyTrashLink" hidden>Empty trash</button>
|
||||||
<a class="icon-link" href="settings.html">Settings</a>
|
<a class="icon-link" href="settings.html">Settings</a>
|
||||||
@@ -24,26 +28,45 @@
|
|||||||
<div class="stats" id="statsLine"></div>
|
<div class="stats" id="statsLine"></div>
|
||||||
<div class="deck" id="deck"></div>
|
<div class="deck" id="deck"></div>
|
||||||
<div class="actions-row" id="actionsRow"></div>
|
<div class="actions-row" id="actionsRow"></div>
|
||||||
|
<div class="organize-row" id="organizeRow" hidden></div>
|
||||||
<div id="liveRegion" class="sr-only" aria-live="polite" aria-atomic="true"></div>
|
<div id="liveRegion" class="sr-only" aria-live="polite" aria-atomic="true"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="detailsSheet" class="details-sheet" hidden aria-hidden="true">
|
||||||
|
<button type="button" class="details-backdrop" id="detailsBackdrop" aria-label="Close details"></button>
|
||||||
|
<aside class="details-panel" role="dialog" aria-modal="true" aria-labelledby="detailsTitle">
|
||||||
|
<div class="details-header">
|
||||||
|
<h2 id="detailsTitle">Details</h2>
|
||||||
|
<div class="details-header-actions">
|
||||||
|
<button type="button" class="icon-link" id="detailsCopyPath" hidden>Copy path</button>
|
||||||
|
<button type="button" class="icon-link" id="detailsReveal" hidden>Reveal</button>
|
||||||
|
<button type="button" class="icon-link" id="detailsClose" aria-label="Hide details">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl class="details-list" id="detailsList"></dl>
|
||||||
|
<div class="details-status" id="detailsStatus"></div>
|
||||||
|
<p class="details-hint">Press <kbd>i</kbd> or Esc to close</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="shortcutsModal" class="modal" hidden aria-hidden="true">
|
<div id="shortcutsModal" class="modal" hidden aria-hidden="true">
|
||||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="shortcutsTitle">
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="shortcutsTitle">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="shortcutsTitle">Shortcuts</h2>
|
<h2 id="shortcutsTitle">Shortcuts</h2>
|
||||||
<button type="button" class="icon-link modal-close" id="shortcutsClose" aria-label="Close shortcuts help">✕</button>
|
<button type="button" class="icon-link modal-close" id="shortcutsClose" aria-label="Close shortcuts help">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<dl class="shortcut-list">
|
<dl class="shortcut-list" id="shortcutList">
|
||||||
<div><dt>→</dt><dd>Keep</dd></div>
|
<div><dt>→</dt><dd>Keep</dd></div>
|
||||||
<div><dt>←</dt><dd>Reject (to trash)</dd></div>
|
<div><dt>←</dt><dd>Reject (to trash)</dd></div>
|
||||||
<div><dt>↓</dt><dd>Skip / next</dd></div>
|
<div><dt>↓</dt><dd>Skip / next</dd></div>
|
||||||
<div><dt>↑</dt><dd>Undo / go back</dd></div>
|
<div><dt>↑</dt><dd>Undo / go back</dd></div>
|
||||||
|
<div><dt>i</dt><dd>Toggle file details</dd></div>
|
||||||
<div><dt>Space</dt><dd>Skip / next</dd></div>
|
<div><dt>Space</dt><dd>Skip / next</dd></div>
|
||||||
<div><dt>Ctrl/Cmd+Z</dt><dd>Undo</dd></div>
|
<div><dt>Ctrl/Cmd+Z</dt><dd>Undo</dd></div>
|
||||||
<div><dt>Shift+?</dt><dd>Toggle this help</dd></div>
|
<div><dt>Shift+?</dt><dd>Toggle this help</dd></div>
|
||||||
<div><dt>Esc</dt><dd>Close help</dd></div>
|
<div><dt>Esc</dt><dd>Close details / help</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<p class="modal-hint">Drag the card left or right, or use the buttons below.</p>
|
<p class="modal-hint" id="shortcutHint">Drag the card left or right, or use the buttons below. Number keys move into organize folders when configured in Settings.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SwipeAnything — Settings</title>
|
<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">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+69
-16
@@ -21,6 +21,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fieldValue(field) {
|
function fieldValue(field) {
|
||||||
|
if (field.type === 'folderMap') {
|
||||||
|
const map = {};
|
||||||
|
for (let i = 0; i <= 9; i += 1) {
|
||||||
|
const key = String(i);
|
||||||
|
const pathEl = document.getElementById(`field_${field.key}_${key}_path`);
|
||||||
|
const labelEl = document.getElementById(`field_${field.key}_${key}_label`);
|
||||||
|
if (!pathEl) continue;
|
||||||
|
const folderPath = pathEl.value.trim();
|
||||||
|
if (!folderPath) continue;
|
||||||
|
const label = labelEl ? labelEl.value.trim() : '';
|
||||||
|
map[key] = label ? { path: folderPath, label } : { path: folderPath };
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
const el = document.getElementById(`field_${field.key}`);
|
const el = document.getElementById(`field_${field.key}`);
|
||||||
if (!el) return field.default;
|
if (!el) return field.default;
|
||||||
if (field.type === 'checkbox') return el.checked;
|
if (field.type === 'checkbox') return el.checked;
|
||||||
@@ -28,6 +42,23 @@
|
|||||||
return el.value;
|
return el.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachBrowse(browseBtn, inputId, hintEl) {
|
||||||
|
browseBtn.addEventListener('click', async () => {
|
||||||
|
if (hintEl) hintEl.textContent = '';
|
||||||
|
browseBtn.disabled = true;
|
||||||
|
browseBtn.textContent = 'Waiting for Finder\u2026';
|
||||||
|
try {
|
||||||
|
const { path: chosen } = await api('/api/browse-folder', { method: 'POST' });
|
||||||
|
document.getElementById(inputId).value = chosen;
|
||||||
|
} catch (err) {
|
||||||
|
if (hintEl && err.message !== 'Cancelled') hintEl.textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
browseBtn.disabled = false;
|
||||||
|
browseBtn.textContent = 'Browse\u2026';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function escapeAttr(value) {
|
function escapeAttr(value) {
|
||||||
return String(value).replace(/"/g, '"');
|
return String(value).replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
@@ -58,22 +89,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field-hint" data-browse-hint-for="field_${field.key}"></div>
|
<div class="field-hint" data-browse-hint-for="field_${field.key}"></div>
|
||||||
`;
|
`;
|
||||||
const browseBtn = wrap.querySelector(`[data-browse-for="field_${field.key}"]`);
|
attachBrowse(
|
||||||
const hintEl = wrap.querySelector(`[data-browse-hint-for="field_${field.key}"]`);
|
wrap.querySelector(`[data-browse-for="field_${field.key}"]`),
|
||||||
browseBtn.addEventListener('click', async () => {
|
`field_${field.key}`,
|
||||||
hintEl.textContent = '';
|
wrap.querySelector(`[data-browse-hint-for="field_${field.key}"]`)
|
||||||
browseBtn.disabled = true;
|
);
|
||||||
browseBtn.textContent = 'Waiting for Finder\u2026';
|
return wrap;
|
||||||
try {
|
}
|
||||||
const { path: chosen } = await api('/api/browse-folder', { method: 'POST' });
|
|
||||||
document.getElementById(`field_${field.key}`).value = chosen;
|
if (field.type === 'folderMap') {
|
||||||
} catch (err) {
|
wrap.className = 'form-field folder-map';
|
||||||
if (err.message !== 'Cancelled') hintEl.textContent = err.message;
|
const heading = document.createElement('div');
|
||||||
} finally {
|
heading.className = 'folder-map-heading';
|
||||||
browseBtn.disabled = false;
|
heading.innerHTML = `<span>${field.label}</span><span class="folder-map-hint">Leave a row blank to disable that key. Press the number while swiping to move the file there.</span>`;
|
||||||
browseBtn.textContent = 'Browse\u2026';
|
wrap.appendChild(heading);
|
||||||
}
|
|
||||||
});
|
const map = value && typeof value === 'object' ? value : {};
|
||||||
|
for (let i = 0; i <= 9; i += 1) {
|
||||||
|
const key = String(i);
|
||||||
|
const entry = map[key] || {};
|
||||||
|
const folderPath = typeof entry === 'string' ? entry : entry.path || '';
|
||||||
|
const label = typeof entry === 'object' ? entry.label || '' : '';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'folder-map-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="folder-map-key" aria-hidden="true">${key}</span>
|
||||||
|
<input type="text" id="field_${field.key}_${key}_label"
|
||||||
|
placeholder="Label (optional)" value="${escapeAttr(label)}"
|
||||||
|
aria-label="Label for key ${key}">
|
||||||
|
<div class="folder-field-row">
|
||||||
|
<input type="text" id="field_${field.key}_${key}_path"
|
||||||
|
placeholder="/path/to/folder" value="${escapeAttr(folderPath)}"
|
||||||
|
aria-label="Folder path for key ${key}">
|
||||||
|
<button type="button" class="secondary-btn" data-browse-for="field_${field.key}_${key}_path">Browse…</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
attachBrowse(row.querySelector('[data-browse-for]'), `field_${field.key}_${key}_path`, null);
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+434
-30
@@ -1,14 +1,20 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #0d0f12;
|
color-scheme: dark;
|
||||||
--card: #16191d;
|
--bg: #0a0a0b;
|
||||||
--card2: #1d2227;
|
--card: #141416;
|
||||||
--text: #f0f2f4;
|
--card2: #1a1a1e;
|
||||||
--sub: #9aa3ab;
|
--text: #f4f4f5;
|
||||||
--keep: #35c46a;
|
--sub: #a1a1aa;
|
||||||
--reject: #e5484d;
|
--keep: #34d399;
|
||||||
--neutral: #f0b429;
|
--reject: #fb7185;
|
||||||
--accent: #6ea8ff;
|
--neutral: #fbbf24;
|
||||||
--border: #2a2f35;
|
--accent: #38bdf8;
|
||||||
|
--border: #27272a;
|
||||||
|
--shadow-card: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||||||
|
--shadow-sheet: 0 -16px 48px rgba(0, 0, 0, 0.55);
|
||||||
|
--font-ui: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
|
||||||
|
--ease: 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -22,8 +28,9 @@ body {
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
font-family: var(--font-ui);
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -49,6 +56,13 @@ a {
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.icon-link,
|
||||||
|
button.icon-link {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--ease), border-color var(--ease), background var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
@@ -71,10 +85,12 @@ header {
|
|||||||
h1 {
|
h1 {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 600;
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-label {
|
.source-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--sub);
|
color: var(--sub);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
@@ -83,47 +99,50 @@ h1 {
|
|||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-link {
|
.icon-link {
|
||||||
color: var(--sub);
|
color: var(--sub);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 5px 9px;
|
padding: 5px 9px;
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-link.danger {
|
.icon-link.danger {
|
||||||
color: var(--reject);
|
color: var(--reject);
|
||||||
border-color: var(--reject);
|
border-color: color-mix(in srgb, var(--reject) 45%, var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-link:hover {
|
.icon-link:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--sub);
|
color: var(--sub);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-track {
|
.progress-track {
|
||||||
height: 4px;
|
height: 3px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent);
|
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 40%, var(--keep)));
|
||||||
transition: width 0.2s;
|
transition: width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck {
|
.deck {
|
||||||
@@ -153,7 +172,7 @@ h1 {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
box-shadow: var(--shadow-card);
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,12 +181,13 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card .preview {
|
.card .preview {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #0a0c0e;
|
background: #050506;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -180,7 +200,46 @@ h1 {
|
|||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
background: #12151a;
|
background: #0a0a0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .preview iframe.pdf-frame {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
height: 280px;
|
||||||
|
max-height: 280px;
|
||||||
|
border: 0;
|
||||||
|
background: #0a0a0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .preview .preview-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(10, 10, 11, 0.72);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .preview pre.archive-listing {
|
||||||
|
margin: 0;
|
||||||
|
padding: 28px 12px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--sub);
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card .preview.file-icon {
|
.card .preview.file-icon {
|
||||||
@@ -191,6 +250,7 @@ h1 {
|
|||||||
.card .preview pre {
|
.card .preview pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -206,10 +266,18 @@ h1 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .title:hover {
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card .subtitle {
|
.card .subtitle {
|
||||||
font-size: 12px;
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
color: var(--sub);
|
color: var(--sub);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -227,7 +295,9 @@ h1 {
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #1b1f24;
|
background: var(--card2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stamp {
|
.stamp {
|
||||||
@@ -262,6 +332,19 @@ h1 {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stamp.center {
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
top: 40%;
|
||||||
|
transform: translateX(-50%) rotate(-6deg);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
max-width: 80%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
.actions-row {
|
.actions-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -270,6 +353,207 @@ h1 {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.organize-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organize-row[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-sheet {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-sheet[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-sheet:not([hidden]) {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.62);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
cursor: pointer;
|
||||||
|
animation: details-fade 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(480px, 100%);
|
||||||
|
max-height: min(58vh, 520px);
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 18px 18px 0 0;
|
||||||
|
background: var(--card2);
|
||||||
|
padding: 14px 16px 18px;
|
||||||
|
box-shadow: var(--shadow-sheet);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: details-rise 0.22s ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-list {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-list div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-list div:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-list dt {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sub);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-list dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--sub);
|
||||||
|
min-height: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--sub);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-hint kbd {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes details-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes details-rise {
|
||||||
|
from { transform: translateY(18px); opacity: 0.6; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.organize-btn {
|
||||||
|
min-width: 72px;
|
||||||
|
max-width: 110px;
|
||||||
|
height: auto;
|
||||||
|
min-height: 52px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card2);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
line-height: 1.15;
|
||||||
|
transition: border-color var(--ease), background var(--ease), color var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.organize-btn:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--accent) 8%, var(--card2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.organize-btn .action-label {
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organize-btn .action-key {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
flex: none;
|
flex: none;
|
||||||
min-width: 64px;
|
min-width: 64px;
|
||||||
@@ -288,6 +572,11 @@ h1 {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 6px 4px;
|
padding: 6px 4px;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
|
transition: border-color var(--ease), background var(--ease), transform var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn .action-label {
|
.action-btn .action-label {
|
||||||
@@ -295,29 +584,50 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-btn .action-key {
|
.action-btn .action-key {
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
opacity: 0.85;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.keep {
|
.action-btn.keep {
|
||||||
color: var(--keep);
|
color: var(--keep);
|
||||||
|
border-color: color-mix(in srgb, var(--keep) 70%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.keep:hover:not(:disabled) {
|
||||||
border-color: var(--keep);
|
border-color: var(--keep);
|
||||||
|
background: color-mix(in srgb, var(--keep) 10%, var(--card));
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.reject {
|
.action-btn.reject {
|
||||||
color: var(--reject);
|
color: var(--reject);
|
||||||
|
border-color: color-mix(in srgb, var(--reject) 70%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.reject:hover:not(:disabled) {
|
||||||
border-color: var(--reject);
|
border-color: var(--reject);
|
||||||
|
background: color-mix(in srgb, var(--reject) 10%, var(--card));
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.skip {
|
.action-btn.skip {
|
||||||
color: var(--neutral);
|
color: var(--neutral);
|
||||||
|
border-color: color-mix(in srgb, var(--neutral) 70%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.skip:hover:not(:disabled) {
|
||||||
border-color: var(--neutral);
|
border-color: var(--neutral);
|
||||||
|
background: color-mix(in srgb, var(--neutral) 10%, var(--card));
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.undo {
|
.action-btn.undo {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.undo:hover:not(:disabled) {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--card));
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:disabled {
|
.action-btn:disabled {
|
||||||
@@ -325,6 +635,28 @@ h1 {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.details-backdrop,
|
||||||
|
.details-panel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover:not(:disabled) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill,
|
||||||
|
a.icon-link,
|
||||||
|
button.icon-link,
|
||||||
|
.organize-btn,
|
||||||
|
.action-btn,
|
||||||
|
.adapter-card,
|
||||||
|
.primary-btn,
|
||||||
|
.secondary-btn {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Shortcuts modal */
|
/* Shortcuts modal */
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -385,7 +717,8 @@ h1 {
|
|||||||
.shortcut-list dt {
|
.shortcut-list dt {
|
||||||
flex: none;
|
flex: none;
|
||||||
min-width: 88px;
|
min-width: 88px;
|
||||||
font-size: 13px;
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -450,7 +783,21 @@ h1 {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--card2);
|
background: var(--card2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
transition: border-color var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input:focus,
|
||||||
|
.form-field select:focus {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input:focus-visible,
|
||||||
|
.form-field select:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field input::placeholder {
|
.form-field input::placeholder {
|
||||||
@@ -460,7 +807,7 @@ h1 {
|
|||||||
|
|
||||||
.form-field select {
|
.form-field select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%239aa3ab' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23a1a1aa' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: right 12px center;
|
background-position: right 12px center;
|
||||||
padding-right: 32px;
|
padding-right: 32px;
|
||||||
@@ -485,6 +832,53 @@ h1 {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-map {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-map-heading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-map-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--sub);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-map-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-map-row .folder-field-row {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-map-key {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card2);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.secondary-btn {
|
.secondary-btn {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -495,6 +889,11 @@ h1 {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: border-color var(--ease), background var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn:hover:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-btn:disabled {
|
.secondary-btn:disabled {
|
||||||
@@ -528,12 +927,12 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.adapter-card:hover {
|
.adapter-card:hover {
|
||||||
border-color: #3a424a;
|
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.adapter-card.selected {
|
.adapter-card.selected {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: #141c28;
|
background: color-mix(in srgb, var(--accent) 9%, var(--card));
|
||||||
}
|
}
|
||||||
|
|
||||||
.adapter-card-title {
|
.adapter-card-title {
|
||||||
@@ -582,6 +981,11 @@ h1 {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: filter var(--ease), opacity var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-btn:disabled {
|
.primary-btn:disabled {
|
||||||
|
|||||||
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 {
|
return {
|
||||||
adapterId: s.adapterId,
|
adapterId: s.adapterId,
|
||||||
sourceLabel: s.adapter.describeSource(),
|
sourceLabel: s.adapter.describeSource(),
|
||||||
actions: s.adapter.constructor.actions,
|
actions: typeof s.adapter.getActions === 'function' ? s.adapter.getActions() : s.adapter.constructor.actions,
|
||||||
total: s.queue.length,
|
total: s.queue.length,
|
||||||
reviewed: s.index,
|
reviewed: s.index,
|
||||||
counts,
|
counts,
|
||||||
@@ -108,6 +108,7 @@ async function queuePayload(s) {
|
|||||||
upcoming: remaining.slice(1, 4),
|
upcoming: remaining.slice(1, 4),
|
||||||
canUndo: s.history.length > 0,
|
canUndo: s.history.length > 0,
|
||||||
trashInfo,
|
trashInfo,
|
||||||
|
ui: typeof s.adapter.uiHints === 'function' ? s.adapter.uiHints() : {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +191,36 @@ app.get('/api/thumbnail/:itemId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/details/:itemId', async (req, res) => {
|
||||||
|
const s = await ensureSession().catch(() => null);
|
||||||
|
if (!s) return res.status(409).json({ error: 'Not configured yet' });
|
||||||
|
if (typeof s.adapter.getDetails !== 'function') {
|
||||||
|
return res.status(501).json({ error: 'Details not supported by this adapter' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const details = await s.adapter.getDetails(req.params.itemId);
|
||||||
|
if (!details) return res.status(404).json({ error: 'No details available' });
|
||||||
|
res.json(details);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/reveal/:itemId', async (req, res) => {
|
||||||
|
const s = await ensureSession().catch(() => null);
|
||||||
|
if (!s) return res.status(409).json({ error: 'Not configured yet' });
|
||||||
|
if (typeof s.adapter.reveal !== 'function') {
|
||||||
|
return res.status(501).json({ error: 'Reveal not supported by this adapter' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const handled = await s.adapter.reveal(req.params.itemId);
|
||||||
|
if (!handled) return res.status(501).json({ error: 'Reveal not available on this platform' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/action', async (req, res) => {
|
app.post('/api/action', async (req, res) => {
|
||||||
const s = await ensureSession().catch(() => null);
|
const s = await ensureSession().catch(() => null);
|
||||||
if (!s) return res.status(409).json({ error: 'Not configured yet' });
|
if (!s) return res.status(409).json({ error: 'Not configured yet' });
|
||||||
@@ -198,7 +229,9 @@ app.post('/api/action', async (req, res) => {
|
|||||||
if (!item || item.id !== itemId) {
|
if (!item || item.id !== itemId) {
|
||||||
return res.status(409).json({ error: 'Item is stale, refresh the queue' });
|
return res.status(409).json({ error: 'Item is stale, refresh the queue' });
|
||||||
}
|
}
|
||||||
const validAction = s.adapter.constructor.actions.some((a) => a.id === actionId);
|
const actions =
|
||||||
|
typeof s.adapter.getActions === 'function' ? s.adapter.getActions() : s.adapter.constructor.actions;
|
||||||
|
const validAction = actions.some((a) => a.id === actionId);
|
||||||
if (!validAction) {
|
if (!validAction) {
|
||||||
return res.status(400).json({ error: `Unknown action: ${actionId}` });
|
return res.status(400).json({ error: `Unknown action: ${actionId}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"folderPath": "/absolute/path/to/a/folder",
|
"folderPath": "/absolute/path/to/a/folder",
|
||||||
"recursive": false,
|
"recursive": false,
|
||||||
"extensions": "jpg,jpeg,png,gif,webp,heic,bmp",
|
"extensions": "jpg,jpeg,png,gif,webp,heic,bmp,pdf,dng,cr2,nef,arw,mp4,mov",
|
||||||
"trashDirName": ".swipeanything-trash"
|
"trashDirName": ".swipeanything-trash",
|
||||||
|
"destinations": {
|
||||||
|
"1": { "label": "Keep / favorites", "path": "/absolute/path/to/keep" },
|
||||||
|
"2": { "label": "Vacation", "path": "/absolute/path/to/vacation" },
|
||||||
|
"3": { "path": "/absolute/path/to/work" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,3 +137,16 @@ test('GET /api/preview/:itemId 404s for an unknown item', async () => {
|
|||||||
const res = await fetch(`${base}/api/preview/${missingId}`);
|
const res = await fetch(`${base}/api/preview/${missingId}`);
|
||||||
assert.equal(res.status, 404);
|
assert.equal(res.status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('GET /api/details/:itemId returns file metadata fields', async () => {
|
||||||
|
await api('/api/rescan', { method: 'POST' });
|
||||||
|
const queue = await api('/api/queue');
|
||||||
|
assert.equal(queue.status, 200);
|
||||||
|
assert.ok(queue.body.current);
|
||||||
|
assert.equal(queue.body.ui.supportsDetails, true);
|
||||||
|
const { status, body } = await api(`/api/details/${queue.body.current.id}`);
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.ok(Array.isArray(body.fields));
|
||||||
|
assert.ok(body.fields.some((f) => f.label === 'Path'));
|
||||||
|
assert.ok(body.fields.some((f) => f.label === 'Size'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -45,6 +45,38 @@ test('list() filters by extension allowlist', async () => {
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('list() sets previewType for video, audio, csv, pdf, raw, and unknown binary', async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-preview-'));
|
||||||
|
fs.writeFileSync(path.join(dir, 'clip.mp4'), 'fake-mp4-bytes');
|
||||||
|
fs.writeFileSync(path.join(dir, 'clip.MOV'), 'fake-mov-bytes'); // case-insensitive
|
||||||
|
fs.writeFileSync(path.join(dir, 'song.mp3'), 'fake-mp3-bytes');
|
||||||
|
fs.writeFileSync(path.join(dir, 'rows.csv'), 'a,b\n1,2\n');
|
||||||
|
fs.writeFileSync(path.join(dir, 'scan.pdf'), '%PDF-1.0');
|
||||||
|
fs.writeFileSync(path.join(dir, 'shot.dng'), 'fake-dng');
|
||||||
|
fs.writeFileSync(path.join(dir, 'shot.CR2'), 'fake-cr2');
|
||||||
|
fs.writeFileSync(path.join(dir, 'bundle.zip'), 'PK\x03\x04');
|
||||||
|
fs.writeFileSync(path.join(dir, 'sheet.xlsx'), 'PK\x03\x04-not-really-xlsx');
|
||||||
|
fs.writeFileSync(path.join(dir, 'photo.webp'), 'fake-webp');
|
||||||
|
|
||||||
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
|
||||||
|
await adapter.init();
|
||||||
|
const byTitle = Object.fromEntries((await adapter.list()).map((i) => [i.title, i.previewType]));
|
||||||
|
|
||||||
|
assert.equal(byTitle['clip.mp4'], 'video');
|
||||||
|
assert.equal(byTitle['clip.MOV'], 'video');
|
||||||
|
assert.equal(byTitle['song.mp3'], 'audio');
|
||||||
|
assert.equal(byTitle['rows.csv'], 'text');
|
||||||
|
assert.equal(byTitle['scan.pdf'], 'pdf');
|
||||||
|
assert.equal(byTitle['shot.dng'], 'image');
|
||||||
|
assert.equal(byTitle['shot.CR2'], 'image');
|
||||||
|
assert.equal(byTitle['bundle.zip'], 'archive');
|
||||||
|
assert.equal(byTitle['photo.webp'], 'image');
|
||||||
|
// Excel is a zip container — no in-browser preview yet (generic card).
|
||||||
|
assert.equal(byTitle['sheet.xlsx'], 'none');
|
||||||
|
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('reject moves the file to the trash dir, undo restores it', async () => {
|
test('reject moves the file to the trash dir, undo restores it', async () => {
|
||||||
const dir = makeFixture();
|
const dir = makeFixture();
|
||||||
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
|
||||||
@@ -120,3 +152,99 @@ test('init() throws a clear error for a missing folder', async () => {
|
|||||||
const adapter = new FolderAdapter({ folderPath: '/definitely/does/not/exist' });
|
const adapter = new FolderAdapter({ folderPath: '/definitely/does/not/exist' });
|
||||||
await assert.rejects(() => adapter.init(), /Folder not found/);
|
await assert.rejects(() => adapter.init(), /Folder not found/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getActions() adds organize actions only for configured destination keys', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
const destA = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-a-'));
|
||||||
|
const destB = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-b-'));
|
||||||
|
const adapter = new FolderAdapter({
|
||||||
|
folderPath: dir,
|
||||||
|
extensions: '',
|
||||||
|
destinations: {
|
||||||
|
1: { path: destA, label: 'Vacation' },
|
||||||
|
7: destB,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await adapter.init();
|
||||||
|
const actions = adapter.getActions();
|
||||||
|
assert.ok(actions.some((a) => a.id === 'keep'));
|
||||||
|
assert.ok(actions.some((a) => a.id === 'move-1' && a.label === 'Vacation' && a.key === '1'));
|
||||||
|
assert.ok(actions.some((a) => a.id === 'move-7' && a.key === '7'));
|
||||||
|
assert.equal(actions.filter((a) => a.group === 'organize').length, 2);
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(destA, { recursive: true, force: true });
|
||||||
|
fs.rmSync(destB, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('move-N moves the file into the destination folder and undo restores it', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-'));
|
||||||
|
const adapter = new FolderAdapter({
|
||||||
|
folderPath: dir,
|
||||||
|
extensions: '',
|
||||||
|
destinations: { 2: { path: dest, label: 'Work' } },
|
||||||
|
});
|
||||||
|
await adapter.init();
|
||||||
|
const items = await adapter.list();
|
||||||
|
const target = items.find((i) => i.title === 'keep-me.txt');
|
||||||
|
const record = await adapter.applyAction(target, 'move-2');
|
||||||
|
assert.equal(record.type, 'move');
|
||||||
|
assert.equal(record.destKey, '2');
|
||||||
|
assert.ok(!fs.existsSync(path.join(dir, 'keep-me.txt')));
|
||||||
|
assert.ok(fs.existsSync(path.join(dest, 'keep-me.txt')));
|
||||||
|
await adapter.undo(record);
|
||||||
|
assert.ok(fs.existsSync(path.join(dir, 'keep-me.txt')));
|
||||||
|
assert.ok(!fs.existsSync(path.join(dest, 'keep-me.txt')));
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list() skips destination folders that sit inside the source tree', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
const nestedDest = path.join(dir, 'sorted');
|
||||||
|
fs.mkdirSync(nestedDest);
|
||||||
|
fs.writeFileSync(path.join(nestedDest, 'already-sorted.txt'), 'done');
|
||||||
|
const adapter = new FolderAdapter({
|
||||||
|
folderPath: dir,
|
||||||
|
extensions: '',
|
||||||
|
recursive: true,
|
||||||
|
destinations: { 0: { path: nestedDest, label: 'Sorted' } },
|
||||||
|
});
|
||||||
|
await adapter.init();
|
||||||
|
const titles = (await adapter.list()).map((i) => i.title);
|
||||||
|
assert.ok(!titles.includes('already-sorted.txt'));
|
||||||
|
assert.ok(titles.includes('keep-me.txt'));
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('move-N refuses an unconfigured key', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', destinations: {} });
|
||||||
|
await adapter.init();
|
||||||
|
const items = await adapter.list();
|
||||||
|
await assert.rejects(() => adapter.applyAction(items[0], 'move-3'), /No destination configured/);
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDetails() returns path, size, and type fields', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
|
||||||
|
await adapter.init();
|
||||||
|
const items = await adapter.list();
|
||||||
|
const target = items.find((i) => i.title === 'keep-me.txt');
|
||||||
|
const details = await adapter.getDetails(target.id);
|
||||||
|
const labels = details.fields.map((f) => f.label);
|
||||||
|
assert.ok(labels.includes('Path'));
|
||||||
|
assert.ok(labels.includes('Size'));
|
||||||
|
assert.ok(labels.includes('Type'));
|
||||||
|
assert.equal(details.path, path.join(dir, 'keep-me.txt'));
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uiHints() reflects showDetailsByDefault', async () => {
|
||||||
|
const dir = makeFixture();
|
||||||
|
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', showDetailsByDefault: true });
|
||||||
|
await adapter.init();
|
||||||
|
assert.deepEqual(adapter.uiHints(), { showDetailsByDefault: true, supportsDetails: true });
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|||||||
@@ -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