diff --git a/CLAUDE.md b/CLAUDE.md index ad8200e..e91d551 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,8 @@ strict monochrome. Rules and feedback trail: `docs/design/redesign-direction.md` **View sizes (2026-10-05):** `docs/mockups/view-zoom/04-explorer-style.html`; spec `docs/superpowers/specs/2026-10-05-view-sizes-design.md`. +**Output options (2026-10-26):** `docs/mockups/output-options/01-output-options.html`. + **Console (2026-10-06):** `docs/mockups/console/01-bottom-panel.html`; spec `docs/superpowers/specs/2026-10-06-console-design.md`. **The look is designed collaboratively with the owner. Make NO visual assumptions.** @@ -103,7 +105,9 @@ e2e/ Playwright specs test/ Vitest scripts/ fetch, verify, - Tool modules own their own format catalog + argument builder and are independently testable. - Never overwrite a user's source or an existing output file. Always resolve a collision-free name (`name.ext`, then `name (converted).ext`, `name (converted 2).ext`; folders get ` (2)`). - This is a hard rule ported from RCMM. + This is a hard rule ported from RCMM. One owner-approved exception (2026-10-26): opt-in + **Replace originals** sends the source to the Recycle Bin (never a permanent delete) only after + the new file is fully written (`src/main/finishOutput.ts`). - No secrets in the repo; unsigned build is expected. ## Working with the owner diff --git a/README.md b/README.md index 9750d24..7ddcccd 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Filesmith is a Windows desktop toolkit for everyday file jobs: convert, compress, resize, upscale, remove backgrounds, generate images, and work with PDFs and archives. Drop in a batch of files, pick -the options and run it. Outputs go next to the sources by default and never overwrite anything. The same +the options and run it. Outputs go next to the sources by default and never overwrite anything (unless you tick Replace originals, which sends the original to the Recycle Bin). The same engine is available as the `filesmith` command line, from a built-in console or any terminal. ![Filesmith converting a batch of images, with one job running](docs/screenshots/convert.png) diff --git a/docs/cli.md b/docs/cli.md index 8fc4572..0303dda 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -55,6 +55,11 @@ filesmith [args] [options] `convert` requires `--to`. `filesmith formats [verb]` lists every target, value and model. - **Never overwrite, and dry runs.** Output names follow the app's rule: `name.ext` if free, else `name (tag).ext`, else `name (tag 2).ext`; folders `base`, `base (2)`. There is no `--force`. + `--replace` (convert, compress, resize, upscale, removebg) is the one exception, and it is opt-in: + the new file takes the original's place and the original goes to the Recycle Bin, only after the + new file is written; on a drive without a Recycle Bin the original is kept. `--rename "{name}-web"` names outputs by a pattern (`{name}`, `{n}`, `{date}`), + `--dates today` gives new files the current time instead of the source's date, and + `--strip-metadata` removes GPS and camera data from images and video. `--dry-run` validates everything, prints the planned outputs and writes and downloads nothing. Planned names are predictions; the real names are in the `done` events. `--out` creates the folder (with parents) when it is missing; `generate` writes to the current folder unless `--out` is given. diff --git a/docs/design/redesign-direction.md b/docs/design/redesign-direction.md index 39c03bf..08b2310 100644 --- a/docs/design/redesign-direction.md +++ b/docs/design/redesign-direction.md @@ -37,5 +37,10 @@ Only the chosen mockups are kept in `docs/mockups/`; the rejected rounds named b `docs/superpowers/specs/2026-10-05-view-sizes-design.md`. - View menu (2026-10-05, #41): View menu = 3 sizes (Details, Tiles, Extra large icons), no icons, no shortcuts; the other sizes via Ctrl+wheel. +- Output options (2026-10-26): `docs/mockups/output-options/01-output-options.html`. Location is ONE + dropdown (Next to source, Downloads, Desktop, Pictures, recent folders, "choose folder…" at the end), + no separate Choose folder button, and a line under it shows where files land. Then ORIGINALS + (Replace originals, to the Recycle Bin), NAMING (Rename pattern), METADATA (Keep original dates, + Remove photo metadata). On Convert, Compress, Resize, Upscale and Remove BG. - Console (2026-10-06, #39, feedback on 0.8.0): the totals row is removed; the bottom strip of the centre column holds only the Console button (Ctrl+`), and the open console sits above it. diff --git a/docs/getting-started.md b/docs/getting-started.md index 8d0909e..c552b1d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -121,9 +121,18 @@ row's Cancel action. ## Outputs never overwrite -Filesmith never overwrites your source or any existing file. - -- Results go next to the source by default, or to the folder set under Options, Output location. +Filesmith never overwrites your source or any existing file, unless you tick **Replace originals**. + +- Results go next to the source by default. **Location** (Options, OUTPUT) also offers Downloads, + Desktop, Pictures, your recent folders and **choose folder…**; the line under it shows where the + files will land. +- **Replace originals** puts the new file where the original was and sends the original to the + Recycle Bin, only after the new file is written OK. You can restore it from there. On a drive + without a Recycle Bin (USB stick, SD card, network share) the original is kept and the row says so. +- **Rename** names the results by a pattern: `{name}` the original name, `{n}` 1, 2, 3…, `{date}` + today. `{name}-web` turns `photo.png` into `photo-web.jpg`. A taken name gets ` (2)`. +- **Keep original dates** (on by default) gives results the source's modified date. **Remove photo + metadata** strips GPS location and camera data from images and video. - If the plain name is free and differs from the source, it is used: `photo.png` converts to `photo.jpg`. - Otherwise a tag is added: `photo (converted).jpg`, then `photo (converted 2).jpg`, and so on. Other operations use their own tag: `(compressed)`, `(resized)`, `(upscaled)`, `(no-bg)`. diff --git a/docs/mockups/README.md b/docs/mockups/README.md index 3d3669e..009e6c3 100644 --- a/docs/mockups/README.md +++ b/docs/mockups/README.md @@ -18,6 +18,7 @@ against. Open the `.html` files directly in a browser. The design rules and feed | `console/shots/01-bottom-panel-*.png` | Screenshots of that mockup, one per state. | | `console/shots/impl-*.png` | The built console. Written by `e2e/visual.spec.ts`. | | `generate-empty/*.png` | The Generate screen with no model, before and after, plus Settings. `after.png` and `settings.png` are written by `e2e/generate-empty.spec.ts`. | +| `output-options/01-output-options.html` | The chosen output options (2026-10-26): Location dropdown with the folder picker inside, Replace originals, Rename, Metadata. Interactive. | | `cli-settings-skill.png` | Settings with the CLI and Claude skill section. Written by `e2e/skill.spec.ts`. | The `impl-*` and spec-written shots are refreshed with `FILESMITH_SHOTS=1 npm run test:e2e` after diff --git a/docs/mockups/output-options/01-output-options.html b/docs/mockups/output-options/01-output-options.html new file mode 100644 index 0000000..8c00e84 --- /dev/null +++ b/docs/mockups/output-options/01-output-options.html @@ -0,0 +1,159 @@ + + + + + +Output options + + + + +

Output options – A, final

+

Clickable: open Location (the folder picker is "Choose folder…" inside it), tick Replace originals and Rename to see what appears.

+
+
+
A · final, with your picks
+
+
Convert6 selected
+
+
OUTPUT
+
+
Location
+
Where the converted files go.
+
+ + +
+
→C:\Users\Admin\DownloadsOpen
+
+
ORIGINALS
+ + +
NAMING
+ + +
METADATA
+ + +
estimate   8.5 MB → ~384 KB  -96%
+
+
+
+ +
+ + + diff --git a/docs/operations.md b/docs/operations.md index 4c86d0f..a87b167 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -5,7 +5,11 @@ Options tab, their defaults, and what it writes. Labels below match the UI. ## Output naming (all verbs) -Filesmith never overwrites a source or an existing file. +Filesmith never overwrites a source or an existing file. The one exception is opt-in: with +**Replace originals** ticked (Convert, Compress, Resize, Upscale, Remove BG) the new file takes the +original's place and the original goes to the Recycle Bin. The same verbs offer Location, Rename, +Keep original dates and, for images and video on Convert/Compress/Resize, Remove photo metadata; +see [getting-started](getting-started.md#outputs-never-overwrite). - A file is written next to its source as `name.` when that name is free. - If it is taken, a tag is added: `name (converted).ext`, then `name (converted 2).ext`, and so on. diff --git a/e2e/workflows.spec.ts b/e2e/workflows.spec.ts index d66a65d..8b1c561 100644 --- a/e2e/workflows.spec.ts +++ b/e2e/workflows.spec.ts @@ -8,6 +8,7 @@ import { readdirSync, rmSync, statSync, + utimesSync, writeFileSync } from 'fs' import { tmpdir } from 'os' @@ -624,3 +625,36 @@ test('archive collision safety: the same conversion twice yields two files', asy expect(a.outputPath).not.toBe(b.outputPath) expect(existsSync(a.outputPath!) && existsSync(b.outputPath!)).toBe(true) }) + +test('output options: rename, keep dates and strip metadata on a real convert', async () => { + const src = join(dir, 'tagged.jpg') + magick(['-size', '64x48', 'xc:gray', '-set', 'comment', 'secret-gps', src]) + const old = new Date(2020, 0, 2) + utimesSync(src, old, old) + const r = await runJob(page, 'convert', src, { + format: '.png', + rename: true, + renamePattern: '{name}-clean', + keepDates: true, + stripMetadata: true + }) + expect(r.status).toBe('done') + expect(basename(r.outputPath!)).toBe('tagged-clean.png') + expect(Math.abs(statSync(r.outputPath!).mtimeMs - old.getTime())).toBeLessThan(2000) + expect(identify('%c', r.outputPath!)).toBe('') +}) + +test('output options: replace sends the original to the Recycle Bin', async () => { + const src = join(dir, 'replace-me.png') + magick(['-size', '64x48', 'xc:gray', src]) + const r = await runJob(page, 'resize', src, { + mode: 'percent', + percent: 50, + replaceOriginals: true + }) + expect(r.status).toBe('done') + // The new file took the original's name; the original is gone from the folder. + expect(r.outputPath).toBe(src) + expect(identify('%wx%h', src)).toBe('32x24') + expect(readdirSync(dir).filter((n) => n.startsWith('replace-me'))).toEqual(['replace-me.png']) +}) diff --git a/package-lock.json b/package-lock.json index 543c727..569e25a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filesmith", - "version": "0.8.3", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filesmith", - "version": "0.8.3", + "version": "0.9.0", "hasInstallScript": true, "license": "MIT", "devDependencies": { diff --git a/package.json b/package.json index 4782a72..2a9e63e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filesmith", - "version": "0.8.3", + "version": "0.9.0", "description": "A desktop file toolkit: convert, compress, resize, upscale, remove backgrounds, and PDF tools.", "author": "Max", "license": "MIT", diff --git a/resources/skill/filesmith/SKILL.md b/resources/skill/filesmith/SKILL.md index 729ec93..e402c46 100644 --- a/resources/skill/filesmith/SKILL.md +++ b/resources/skill/filesmith/SKILL.md @@ -1,6 +1,6 @@ --- name: filesmith -description: Use when the user wants to do something to files on this PC, even phrased casually ("compress this image", "shrink this video", "make this a webp", "convert to jpg/pdf/mp4", "resize to 1920", "upscale this", "remove the background", "make me an image of...", "merge/split these PDFs", "pull the text out of this PDF"). Runs the local Filesmith CLI; never overwrites originals. +description: Use when the user wants to do something to files on this PC, even phrased casually ("compress this image", "shrink this video", "make this a webp", "convert to jpg/pdf/mp4", "resize to 1920", "upscale this", "remove the background", "make me an image of...", "merge/split these PDFs", "pull the text out of this PDF"). Runs the local Filesmith CLI; never overwrites originals unless the user asks to replace them (--replace, to the Recycle Bin). metadata: filesmith-version: {{VERSION}} --- diff --git a/resources/skill/filesmith/reference.md b/resources/skill/filesmith/reference.md index 3497874..8495883 100644 --- a/resources/skill/filesmith/reference.md +++ b/resources/skill/filesmith/reference.md @@ -17,6 +17,15 @@ | `--json` | NDJSON events on stdout | | `-h`, `--help` | help | +## Finished files (convert, compress, resize, upscale, removebg) + +| Flag | Meaning | +| ---- | ------- | +| `--replace` | the new file takes the original's place; the original goes to the Recycle Bin after the new file is written. Not with `--out` or `--rename`. On a drive without a Recycle Bin (USB, SD card, network share) the original is kept. Use only when the user asked to replace the originals | +| `--rename ` | new names from a pattern: `{name}` original name, `{n}` 1, 2, 3..., `{date}` today. A taken name gets ` (2)` | +| `--dates keep\|today` | `keep` (default): new files get the source's modified date | +| `--strip-metadata` | remove GPS, camera and EXIF data (images and video; convert, compress, resize only) | + ## Commands ### convert `--to ` (required) diff --git a/src/cli/catalog.ts b/src/cli/catalog.ts index 5e9ed0b..47aa885 100644 --- a/src/cli/catalog.ts +++ b/src/cli/catalog.ts @@ -118,6 +118,39 @@ const JSON_FLAG: FlagSpec = { } const FILE_COMMON = [OUT, RECURSIVE, DRY, JSON_FLAG] +// What happens to each finished file (the app's ORIGINALS / NAMING / METADATA). +const REPLACE: FlagSpec = { + name: 'replace', + type: 'bool', + key: 'replaceOriginals', + help: 'Replace each original: the new file takes its place, the original goes to the Recycle Bin' +} +const RENAME: FlagSpec = { + name: 'rename', + type: 'text', + key: 'renamePattern', + valueName: '', + help: 'Name the new files by a pattern: {name} original name, {n} 1, 2, 3..., {date} today' +} +// An enum, not a bool: dates are kept by default, as in the app, and a bool +// flag cannot be switched off. +const DATES: FlagSpec = { + name: 'dates', + type: 'enum', + values: ['keep', 'today'], + def: 'keep', + key: 'keepDates', + help: "keep: new files get the source's modified date; today: they get the time they were made" +} +const STRIP_METADATA: FlagSpec = { + name: 'strip-metadata', + type: 'bool', + key: 'stripMetadata', + help: 'Remove photo metadata (GPS, camera, EXIF) from images and video' +} +const FINISH = [REPLACE, RENAME, DATES] +const FINISH_STRIP = [...FINISH, STRIP_METADATA] + const tabDesc = (id: string): string => TABS.find((t) => t.id === id)?.desc ?? '' const cardDesc = (op: string): string => TOOL_CARDS.find((c) => c.opKey === op)?.desc ?? '' @@ -211,6 +244,7 @@ export const COMMANDS: CommandSpec[] = [ group: 'PAGES', help: 'PDF to comic: jpg page quality' }, + ...FINISH_STRIP, ...FILE_COMMON ], examples: [ @@ -298,6 +332,7 @@ export const COMMANDS: CommandSpec[] = [ }, LEVEL, GREYSCALE, + ...FINISH_STRIP, ...FILE_COMMON ], examples: [ @@ -362,6 +397,7 @@ export const COMMANDS: CommandSpec[] = [ group: 'SIZE', help: 'Inferred from --percent or --width/--height; rarely needed' }, + ...FINISH_STRIP, ...FILE_COMMON ], examples: ['filesmith resize *.png --percent 25', 'filesmith resize hero.jpg --width 1920'] @@ -404,6 +440,7 @@ export const COMMANDS: CommandSpec[] = [ group: 'PERFORMANCE', help: 'GPU mode: full, or balanced to leave room for other apps' }, + ...FINISH, ...FILE_COMMON ], examples: [ @@ -445,6 +482,7 @@ export const COMMANDS: CommandSpec[] = [ valueName: '', help: 'Background image, cover fit; implies --fill image' }, + ...FINISH, ...FILE_COMMON ], examples: [ diff --git a/src/cli/options.ts b/src/cli/options.ts index bdfb96f..bba7c07 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -277,6 +277,20 @@ export function buildOptions(cmd: CommandSpec, given: RawFlags, ctx: BuildContex default: throw new Error(`buildOptions does not handle ${cmd.id}`) } + if (get('replace') === true) { + if (outDir) usage('--replace puts each new file where its original was; leave out --out.') + if (get('rename') !== undefined) + usage('--replace keeps the original names; leave out --rename.') + options.replaceOriginals = true + } + const rename = get('rename') + if (typeof rename === 'string') { + if (!rename.trim()) usage('--rename needs a pattern, e.g. --rename "{name}-web".') + options.rename = true + options.renamePattern = rename + } + if (cmd.flags.some((f) => f.name === 'dates')) options.keepDates = val('dates') !== 'today' + if (get('strip-metadata') === true) options.stripMetadata = true return { options, warnings, outDir, explicit, codecFor } } diff --git a/src/cli/plan.ts b/src/cli/plan.ts index 9807769..1477173 100644 --- a/src/cli/plan.ts +++ b/src/cli/plan.ts @@ -4,6 +4,7 @@ import { needsRar } from '@shared/archive' import { tabById } from '@shared/tabs' import { baseName } from '@shared/fileKind' import { planOutput, type PlannedOutput } from '../main/tools/plan' +import { canReplace, finishOptionsFor, planFinish } from '../main/finishOutput' import type { Readiness } from '../main/tools/readiness' import { CliError, type ErrorCode } from './exit' import type { CommandId } from './catalog' @@ -90,14 +91,22 @@ function planOne( message }) const ready = (t: ToolId, op: string, options: JobOptions): PlannedJob => { - const full: JobOptions = env.outDir ? { ...options, outDir: env.outDir } : options + const full: JobOptions = { + ...options, + ...(env.outDir ? { outDir: env.outDir } : {}), + // The Rename pattern's {n}: this file's position in the run. + seq: index + 1 + } try { + const fo = finishOptionsFor(t, full) + const replace = fo.replace && canReplace(t, full) + const planned = planOutput(t, f, full, replace ? undefined : env.outDir, claimed) return { ...base, tool: t, op, options: full, - output: planOutput(t, f, full, env.outDir, claimed), + output: { ...planned, path: planFinish(f.path, planned, fo, replace, claimed) }, state: 'ready' } } catch (e) { diff --git a/src/main/atomicOutput.ts b/src/main/atomicOutput.ts index a331e29..e4e3163 100644 --- a/src/main/atomicOutput.ts +++ b/src/main/atomicOutput.ts @@ -107,7 +107,7 @@ function partFileFor(final: string): string { /** Windows can briefly refuse a rename while an antivirus scanner or the * search indexer holds the just-closed file. */ -function renameRetrying(from: string, to: string): void { +export function renameRetrying(from: string, to: string): void { for (let attempt = 0; ; attempt++) { try { renameSync(from, to) diff --git a/src/main/env.ts b/src/main/env.ts index f1b9932..c371f8c 100644 --- a/src/main/env.ts +++ b/src/main/env.ts @@ -18,6 +18,9 @@ export interface EngineEnv { * fetch in the CLI (the shims add --use-system-ca and NODE_USE_ENV_PROXY=1). */ fetch: (url: string, init?: RequestInit) => Promise host: 'app' | 'cli' + /** Send a path to the Recycle Bin (Replace originals). The app passes + * shell.trashItem; absent, the engine uses recycle.ts's PowerShell route. */ + trash?: (path: string) => Promise } let current: EngineEnv | null = null diff --git a/src/main/finishOutput.ts b/src/main/finishOutput.ts new file mode 100644 index 0000000..cc0f276 --- /dev/null +++ b/src/main/finishOutput.ts @@ -0,0 +1,188 @@ +import { closeSync, existsSync, openSync, rmSync, statSync, utimesSync } from 'fs' +import { basename, dirname, extname, join, resolve } from 'path' +import type { JobOptions } from '@shared/types' +import { renderOutputName } from '@shared/outputNaming' +import { renameRetrying } from './atomicOutput' +import { hasRecycleBin, tooBigForRecycleBin } from './recycle' + +// What happens to a finished output before the job reports done (2026-10-26): +// Rename to the user's pattern, or Replace the original (the original goes to +// the Recycle Bin, never a permanent delete, and only after the new file is +// fully written), then optionally copy the source's dates onto it. The +// never-overwrite rule still holds for every other file: a taken name gets +// ` (2)`, ` (3)`, ... + +export interface FinishOptions { + replace: boolean + /** The rename pattern, or null when names stay as the tool chose them. */ + rename: string | null + keepDates: boolean + /** 1-based position in the run, for `{n}`. */ + seq: number +} + +export function finishOptions(o: JobOptions): FinishOptions { + const replace = o.replaceOriginals === true + const pattern = typeof o.renamePattern === 'string' ? o.renamePattern.trim() : '' + const seq = Number(o.seq) + return { + replace, + // Replace wins: the new file takes the original's name. + rename: !replace && o.rename === true && pattern ? pattern : null, + keepDates: o.keepDates === true, + seq: Number.isFinite(seq) && seq >= 1 ? Math.floor(seq) : 1 + } +} + +/** The tools that show these options. Elsewhere (archive routes on Convert, + * pdf, generate) they are hidden, so a value left in the options bag from an + * earlier target must not act. */ +export const FINISH_TOOLS: readonly string[] = [ + 'convert', + 'compress', + 'resize', + 'upscale', + 'removebg' +] + +/** Finish options for this tool, or all-off where the tool does not offer them. */ +export function finishOptionsFor(tool: string, o: JobOptions): FinishOptions { + const fo = finishOptions(o) + return FINISH_TOOLS.includes(tool) + ? fo + : { ...fo, replace: false, rename: null, keepDates: false } +} + +/** Replace only makes sense for one source in, one file out: never for merge + * (many in) or a tool without the option. Folder outputs are excluded at run time. */ +export function canReplace(tool: string, options: JobOptions): boolean { + return FINISH_TOOLS.includes(tool) && !Array.isArray(options.mergeInputs) +} + +/** File names in collision order without a tag: `name.ext`, `name (2).ext`, ... */ +function* plainCandidates(dir: string, name: string, ext: string): Generator { + yield join(dir, name + ext) + for (let n = 2; ; n++) yield join(dir, `${name} (${n})${ext}`) +} + +const same = (a: string, b: string): boolean => + resolve(a).toLowerCase() === resolve(b).toLowerCase() + +/** Move `from` to the first free `name.ext` candidate in `dir`, claiming the + * name with an exclusive create first so a concurrent job cannot take it. + * Landing on its own current name is a no-op. */ +function moveToFreeName(from: string, dir: string, name: string, ext: string): string { + for (const cand of plainCandidates(dir, name, ext)) { + if (same(cand, from)) return from + try { + closeSync(openSync(cand, 'wx')) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') continue + throw err + } + try { + renameRetrying(from, cand) + } catch (err) { + // Never leave an empty file under a real name (it would sit where the + // recycled original was). + rmSync(cand, { force: true }) + throw err + } + return cand + } + throw new Error('unreachable') +} + +const stem = (p: string): string => basename(p, extname(p)) + +/** + * Apply Rename / Replace / Keep dates to a finished output. `trash` sends a + * path to the Recycle Bin. A failure to recycle the original is not a failed + * job (the new file is fine): the original stays and `note` says why. + */ +export async function finishOutput( + source: string, + output: string, + fo: FinishOptions, + replaceAllowed: boolean, + trash: (p: string) => Promise, + now: Date = new Date(), + recyclable: (p: string) => Promise = hasRecycleBin +): Promise<{ path: string; note?: string }> { + if (!fo.replace && !fo.rename && !fo.keepDates) return { path: output } + let out: ReturnType + try { + out = statSync(output) + } catch { + return { path: output } + } + if (!out.isFile()) return { path: output } + const src = existsSync(source) ? statSync(source) : null + let path = output + let note: string | undefined + + if (fo.replace && replaceAllowed && src && !same(source, output)) { + if (tooBigForRecycleBin({ bytes: src.size, files: 1 })) { + note = 'Original kept: too large for the Recycle Bin.' + } else if (!(await recyclable(source))) { + // Windows deletes for good on drives without a Recycle Bin (USB sticks, + // SD cards, network shares): never risk that. + note = 'Original kept: this drive has no Recycle Bin.' + } else { + try { + await trash(source) + } catch (e) { + note = `Original kept: ${(e as Error).message}` + } + if (!note) path = moveToFreeName(output, dirname(source), stem(source), extname(output)) + } + } else if (fo.rename) { + const name = renderOutputName(fo.rename, stem(source), fo.seq, now) + path = moveToFreeName(output, dirname(output), name, extname(output)) + } + + if (fo.keepDates && src) { + try { + utimesSync(path, src.atime, src.mtime) + } catch { + /* best effort: the file itself is fine */ + } + } + return { path, note } +} + +/** The path finishOutput WOULD end on, without touching disk (CLI dry run). + * `planned` is the tool's predicted output; `claimed` holds lower-cased + * paths already predicted in this run. */ +export function planFinish( + source: string, + planned: { path: string; kind: 'file' | 'dir' }, + fo: FinishOptions, + replaceAllowed: boolean, + claimed: Set, + now: Date = new Date() +): string { + if (planned.kind !== 'file') return planned.path + let dir: string + let name: string + if (fo.replace && replaceAllowed && !same(source, planned.path)) { + dir = dirname(source) + name = stem(source) + } else if (fo.rename) { + dir = dirname(planned.path) + name = renderOutputName(fo.rename, stem(source), fo.seq, now) + } else return planned.path + const ext = extname(planned.path) + claimed.delete(planned.path.toLowerCase()) + for (const cand of plainCandidates(dir, name, ext)) { + const key = cand.toLowerCase() + // The source is about to go to the Recycle Bin, so its name counts as free. + const free = + (!existsSync(cand) || same(cand, planned.path) || (fo.replace && same(cand, source))) && + !claimed.has(key) + if (!free) continue + claimed.add(key) + return cand + } + throw new Error('unreachable') +} diff --git a/src/main/index.ts b/src/main/index.ts index fa59b9b..78f7458 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,7 +28,8 @@ setEngineEnv({ resourcesDir: app.isPackaged ? process.resourcesPath : join(app.getAppPath(), 'resources'), downloadsDir: app.getPath('downloads'), fetch: (url, init) => net.fetch(url, init), - host: 'app' + host: 'app', + trash: (p) => shell.trashItem(p) }) // A private scheme the renderer uses to load local media (generated-image diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e7ef9d1..c507d6f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -200,6 +200,12 @@ export function registerGlobalIpc(): JobQueue { }) return r.canceled || !r.filePaths.length ? null : r.filePaths[0] }) + // The fixed choices in the output Location dropdown. + ipcMain.handle('files:known-folders', () => ({ + downloads: app.getPath('downloads'), + desktop: app.getPath('desktop'), + pictures: app.getPath('pictures') + })) // Video DISPLAY dimensions (rotation-aware) for the compress scale preview. ipcMain.handle('video:dimensions', (_e, p: string) => probeDimensions(p)) // Images go through ImageMagick instead: ffprobe rejects very large ones and diff --git a/src/main/jobQueue.ts b/src/main/jobQueue.ts index a975dc7..0568c5d 100644 --- a/src/main/jobQueue.ts +++ b/src/main/jobQueue.ts @@ -1,8 +1,11 @@ import { cpus } from 'os' import { statSync } from 'fs' import type { JobEvent, JobRequest, ToolId } from '@shared/types' +import { engineEnv } from './env' import { fileInfoFromPath } from './fileInfo' +import { canReplace, finishOptionsFor, finishOutput } from './finishOutput' import { resolveOutDir } from './output' +import { moveToRecycleBin } from './recycle' import { getTool } from './tools/registry' import { ToolMissingError } from './run' import { toolMissingMessage } from './toolResolver' @@ -90,8 +93,11 @@ export class JobQueue { this.emit({ id: req.id, status: 'running' }) try { const file = fileInfoFromPath(req.input) - const outDir = resolveOutDir(req.options.outDir) - const output = await tool.run(file, req.options, { + const fo = finishOptionsFor(req.tool, req.options) + const replace = fo.replace && canReplace(req.tool, req.options) + // Replace puts the new file where the original was, so Location is moot. + const outDir = replace ? undefined : resolveOutDir(req.options.outDir) + const produced = await tool.run(file, req.options, { signal: ctrl.signal, outDir, allowDownload: this.opts.allowDownload ?? true, @@ -104,6 +110,13 @@ export class JobQueue { etaSec: etaSec ?? undefined }) }) + const { path: output, note } = await finishOutput( + file.path, + produced, + fo, + replace, + engineEnv().trash ?? moveToRecycleBin + ) // Report the produced size so the UI can show the result and how much it // shrank. Directory outputs (pages-to-images, split) have no single size. let outputSize: number | undefined @@ -113,7 +126,14 @@ export class JobQueue { } catch { /* best effort */ } - this.emit({ id: req.id, status: 'done', percent: 100, outputPath: output, outputSize }) + this.emit({ + id: req.id, + status: 'done', + percent: 100, + outputPath: output, + outputSize, + message: note + }) } catch (err) { const aborted = ctrl.signal.aborted this.emit({ diff --git a/src/main/recycle.ts b/src/main/recycle.ts index 8f60770..4d6fea5 100644 --- a/src/main/recycle.ts +++ b/src/main/recycle.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync, statSync, statfsSync } from 'fs' -import { dirname, join } from 'path' +import { dirname, join, parse } from 'path' import { run } from './run' /** Beyond this Windows deletes "to the Recycle Bin" permanently, so we refuse @@ -74,3 +74,40 @@ export async function moveToRecycleBin(path: string): Promise { `Could not move ${path} to the Recycle Bin. ${r.stderr.trim().split('\n').pop() ?? ''}`.trim() ) } + +const binByRoot = new Map>() + +/** True when `path` is on a local fixed drive, where Windows really recycles. + * On removable drives and network shares a "send to the Recycle Bin" silently + * deletes for good, so Replace originals refuses there. Cached per drive. */ +export function hasRecycleBin(path: string): Promise { + const root = parse(path).root.toLowerCase() + if (!/^[a-z]:\\$/.test(root)) return Promise.resolve(false) // UNC share or relative + let p = binByRoot.get(root) + if (!p) { + p = driveIsFixed(root).catch(() => false) + binByRoot.set(root, p) + } + return p +} + +async function driveIsFixed(root: string): Promise { + const ps = join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + const r = await run( + ps, + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '[System.IO.DriveInfo]::new($env:FILESMITH_DRIVE).DriveType' + ], + { env: { ...process.env, FILESMITH_DRIVE: root } } + ) + return r.code === 0 && r.stdout.trim() === 'Fixed' +} diff --git a/src/main/tools/compress.ts b/src/main/tools/compress.ts index d3f83a5..978ce84 100644 --- a/src/main/tools/compress.ts +++ b/src/main/tools/compress.ts @@ -30,13 +30,25 @@ export function buildCompressArgs(input: string, outDir: string, quality: number * the target format (webp/avif/…); `-quality` drives lossy encoding. A * single-frame target reads only `input[0]` (so a multi-frame source doesn't * split); a multi-frame target keeps every frame (animated GIF -> animated WebP). */ -export function buildMagickCompressArgs(input: string, output: string, quality: number): string[] { +export function buildMagickCompressArgs( + input: string, + output: string, + quality: number, + strip = false +): string[] { const e = normalizeExt(extname(output)) const src = MULTIFRAME_TARGETS.includes(e) ? input : magickFrame(input) // TIFF: without an explicit -compress, re-encoding DROPPED the source's Zip // compression entirely (measured +59% on a Zip TIFF at every quality). const extra = e === '.tiff' ? ['-compress', 'Zip'] : [] - return [src, '-quality', String(quality), ...extra, output] + return [ + src, + ...(strip ? ['-auto-orient', '-strip'] : []), + '-quality', + String(quality), + ...extra, + output + ] } // --- Video --------------------------------------------------------------------- @@ -62,6 +74,8 @@ export interface VideoOpts { quality: number /** Output size as a percentage of the source (100 = original). */ scale: number + /** Drop container metadata (GPS, device, dates). */ + strip?: boolean } /** @@ -88,6 +102,7 @@ export function buildVideoCompressArgs(input: string, output: string, o: VideoOp args.push('-crf', String(crfForCodec(o.codec, o.quality))) if (o.codec === 'h265') args.push('-tag:v', 'hvc1') // Apple/QuickTime compatibility args.push('-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart') + if (o.strip) args.push('-map_metadata', '-1') args.push(output) return args } diff --git a/src/main/tools/convert.ts b/src/main/tools/convert.ts index 99e0465..4fc7b6a 100644 --- a/src/main/tools/convert.ts +++ b/src/main/tools/convert.ts @@ -33,6 +33,18 @@ export function magickFrame(path: string, frame = 0): string { return `${path.replace(/%/g, '%%')}[${frame}]` } +/** Flags that drop photo/video metadata (GPS, camera, device) when the user + * ticks Remove photo metadata. Images via ImageMagick, video via ffmpeg; audio + * keeps its tags (artist and title are not what this option is for). */ +export function stripArgs(kind: FileKind, strip: unknown): string[] { + if (strip !== true) return [] + // -strip also drops the EXIF Orientation tag, so turn the pixels upright + // first or a portrait phone photo comes out sideways. + if (kind === 'image') return ['-auto-orient', '-strip'] + if (kind === 'video') return ['-map_metadata', '-1'] + return [] +} + /** ffmpeg args: `ffmpeg -y -i [extra...] `. */ export function buildFfmpegArgs(input: string, output: string, extra: string[] = []): string[] { return ['-y', '-i', input, ...extra, output] diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts index 33c2b2a..725c3a9 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -59,6 +59,7 @@ import { magickFrame, magickQualityArgs, normalizeExt, + stripArgs, toolForKind } from './convert' import { buildResizeArgs, buildResizeSpec, isValidResizeSpec } from './resize' @@ -294,7 +295,11 @@ const convertTool: ToolModule = { if (duration) ctx.onProgress(0, 'Converting…') return runToOutput( resolveTool(kindTool), - (out) => buildFfmpegArgs(file.path, out, ffmpegExtraFor(file.kind, targetExt)), + (out) => + buildFfmpegArgs(file.path, out, [ + ...ffmpegExtraFor(file.kind, targetExt), + ...stripArgs(file.kind, options.stripMetadata) + ]), output, ctx, kindTool, @@ -302,7 +307,11 @@ const convertTool: ToolModule = { ffmpegProgress(duration, (pct, eta) => ctx.onProgress(pct, 'Converting…', eta)) ) } - const extra = [...magickExtraFor(targetExt), ...magickQualityArgs(targetExt, options.quality)] + const extra = [ + ...stripArgs(file.kind, options.stripMetadata), + ...magickExtraFor(targetExt), + ...magickQualityArgs(targetExt, options.quality) + ] // Single-frame target: read only the first frame so a multi-frame source // doesn't split into out-0/out-1/… and leave the exact output path empty. const src = MULTIFRAME_TARGETS.includes(normalizeExt(targetExt)) @@ -488,7 +497,7 @@ const resizeTool: ToolModule = { const animated = normalizeExt(file.ext) === '.gif' return runToOutput( resolveTool('magick'), - (out) => buildResizeArgs(file.path, out, spec, animated), + (out) => buildResizeArgs(file.path, out, spec, animated, options.stripMetadata === true), output, ctx, 'magick', @@ -548,7 +557,13 @@ const compressTool: ToolModule = { ctx.onProgress(duration ? 0 : undefined, `Compressing video (${codec})…`) return runToOutput( resolveTool('ffmpeg'), - (out) => buildVideoCompressArgs(file.path, out, { codec, quality, scale }), + (out) => + buildVideoCompressArgs(file.path, out, { + codec, + quality, + scale, + strip: options.stripMetadata === true + }), output, ctx, 'ffmpeg', @@ -590,7 +605,13 @@ const compressTool: ToolModule = { const imageFormat = String(options.imageFormat ?? 'keep') as ImageFormat ctx.onProgress(undefined, `Compressing image (q${quality})…`) - if (imageFormat === 'keep' && CAESIUM_EXTS.includes(normalizeExt(file.ext))) { + // Remove photo metadata goes through ImageMagick, which can turn the pixels + // upright before dropping the Orientation tag; Caesium cannot. + if ( + imageFormat === 'keep' && + CAESIUM_EXTS.includes(normalizeExt(file.ext)) && + options.stripMetadata !== true + ) { const tmp = mkdtempSync(join(tmpdir(), 'filesmith-')) const est = estimateProgress(estimateSecForBytes(file.size, 0.08), (p, eta) => ctx.onProgress(p, undefined, eta) @@ -627,7 +648,7 @@ const compressTool: ToolModule = { const output = reserveOutput(file.path, outExt, 'compressed', ctx.outDir) return runToOutput( resolveTool('magick'), - (out) => buildMagickCompressArgs(file.path, out, quality), + (out) => buildMagickCompressArgs(file.path, out, quality, options.stripMetadata === true), output, ctx, 'magick', diff --git a/src/main/tools/resize.ts b/src/main/tools/resize.ts index 712a0aa..a9ab2ed 100644 --- a/src/main/tools/resize.ts +++ b/src/main/tools/resize.ts @@ -57,8 +57,14 @@ export function buildResizeArgs( input: string, output: string, spec: string, - animated = false + animated = false, + strip = false ): string[] { - if (animated) return [input, '-coalesce', '-resize', spec, '-layers', 'optimize', output] - return [input, '-resize', spec, output] + // Upright before resizing (a WxH box is meant for the image as seen), then + // drop the metadata, Orientation tag included. + const o = strip ? ['-auto-orient'] : [] + const s = strip ? ['-strip'] : [] + if (animated) + return [input, '-coalesce', ...o, '-resize', spec, ...s, '-layers', 'optimize', output] + return [input, ...o, '-resize', spec, ...s, output] } diff --git a/src/preload/index.ts b/src/preload/index.ts index bad16e1..20d4720 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,6 +30,9 @@ const api = { pickImage: (): Promise => ipcRenderer.invoke('image:pick'), /** Folder picker for the output location; resolves to the path or null. */ pickFolder: (): Promise => ipcRenderer.invoke('files:pick-folder'), + /** Downloads / Desktop / Pictures, for the output Location dropdown. */ + knownFolders: (): Promise<{ downloads: string; desktop: string; pictures: string }> => + ipcRenderer.invoke('files:known-folders'), classify: (paths: string[]): Promise => ipcRenderer.invoke('files:classify', paths), /** Resolve the absolute path of a dropped File (Electron removed File.path). */ pathForFile: (file: File): string => webUtils.getPathForFile(file), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5399b0e..b17e376 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -884,10 +884,13 @@ export default function App(): JSX.Element { } if (newSources.length) dispatch({ type: 'addSources', items: newSources, key: qKey }) if (!targets.length) return - dispatch({ type: 'markQueued', ids: targets.map((t) => t.id), options: opts }) - for (const t of targets) { - void window.filesmith.runJob({ id: t.id, tool, input: t.path, options: opts }) - } + // `seq` is the Rename pattern's {n}: the file's position in this run. It + // is stored in runOptions too, so Retry keeps the same number. + targets.forEach((t, i) => { + const o = { ...opts, seq: i + 1 } + dispatch({ type: 'markQueued', ids: [t.id], options: o }) + void window.filesmith.runJob({ id: t.id, tool, input: t.path, options: o }) + }) } // Merge needs 2+ PDFs before it can run; every other op runs per selected file. @@ -1348,6 +1351,7 @@ export default function App(): JSX.Element { } srcExts={srcExts} sourceExt={sourceExt} + sampleName={selEff[0]?.name ?? null} runCount={runCount} videoOutputs={videoOutputs} resizeOutputs={resizeOutputs} diff --git a/src/renderer/src/components/options/ConvertSettings.tsx b/src/renderer/src/components/options/ConvertSettings.tsx index d9c4d22..0739f2b 100644 --- a/src/renderer/src/components/options/ConvertSettings.tsx +++ b/src/renderer/src/components/options/ConvertSettings.tsx @@ -6,7 +6,6 @@ import { Select } from '../ui/Select' import type { SelectOption } from '../ui/selectNav' import { Segmented } from '../ui/Segmented' import { Setting, SettingGroup } from '../ui/Setting' -import { OutputSettings } from './OutputSettings' import { PageRenderSettings } from './PageRenderSettings' import type { SetOption } from './types' @@ -88,7 +87,6 @@ export function ConvertSettings({ )} {verb === 'from-pdf' && } - {!verb && } ) } diff --git a/src/renderer/src/components/options/OptionsPane.tsx b/src/renderer/src/components/options/OptionsPane.tsx index 4695725..0695516 100644 --- a/src/renderer/src/components/options/OptionsPane.tsx +++ b/src/renderer/src/components/options/OptionsPane.tsx @@ -7,6 +7,7 @@ import { ArchiveSettings } from './ArchiveSettings' import { CompressSettings } from './CompressSettings' import { ConvertSettings } from './ConvertSettings' import { GenerateSettings } from './generate/GenerateSettings' +import { OutputSettings } from './OutputSettings' import { useArchiveStatus } from './hooks/useArchiveStatus' import { PdfSettings } from './PdfSettings' import { RemoveBgSettings } from './RemoveBgSettings' @@ -23,6 +24,7 @@ export function OptionsPane({ kind, srcExts, sourceExt, + sampleName, runCount, videoOutputs, resizeOutputs, @@ -36,6 +38,8 @@ export function OptionsPane({ kind: FileKind srcExts: string[] sourceExt: string | null + /** The first file in scope, for the Rename example. */ + sampleName: string | null runCount: number videoOutputs: SizeRow[] resizeOutputs: SizeRow[] @@ -80,10 +84,42 @@ export function OptionsPane({ return null } } + // Output options (Location, Replace, Rename, Metadata) for the one-in, + // one-file-out verbs. Archive routes on Convert keep their own outputs. + const fileOut = + (tab === 'convert' && tool !== 'archive') || + tool === 'compress' || + tool === 'resize' || + tool === 'upscale' || + tool === 'removebg' + const canStrip = + (kind === 'image' || kind === 'video') && + (tool === 'convert' || tool === 'compress' || tool === 'resize') + const stem = (sampleName ?? 'photo.png').replace(/\.[^.]+$/, '') + const outExt = exampleExt(tool, kind, options, sourceExt ?? '.png') return (
{body()} + {fileOut && ( + + )} {estimate && }
) } + +/** The extension a run would most likely produce, for the Rename example only. */ +function exampleExt(tool: ToolId, kind: FileKind, o: JobOptions, src: string): string { + if (tool === 'convert') return String(o.format ?? src) + if (tool === 'upscale' || tool === 'removebg') return '.png' + if (tool === 'compress') { + if (kind === 'video') return '.mp4' + if (kind === 'image' && o.imageFormat && o.imageFormat !== 'keep') return `.${o.imageFormat}` + } + return src +} diff --git a/src/renderer/src/components/options/OutputSettings.tsx b/src/renderer/src/components/options/OutputSettings.tsx index 4d22a48..d242b08 100644 --- a/src/renderer/src/components/options/OutputSettings.tsx +++ b/src/renderer/src/components/options/OutputSettings.tsx @@ -1,68 +1,176 @@ -import type { JSX } from 'react' +import { useEffect, useState, type JSX } from 'react' import type { JobOptions } from '@shared/types' -import { SmallButton } from '../ui/Button' +import { renderOutputName } from '@shared/outputNaming' import { Select } from '../ui/Select' -import { Setting, SettingGroup } from '../ui/Setting' +import type { SelectOption } from '../ui/selectNav' +import { CheckSetting, Setting, SettingGroup } from '../ui/Setting' +import { TextField } from '../ui/TextField' +import { addRecentFolder, loadRecentFolders } from './recentFolders' import type { SetOption } from './types' const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p +const parentOf = (p: string): string => p.replace(/[\\/][^\\/]+[\\/]?$/, '') +const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase() -/** OUTPUT > Location (engine support: Task 3) and FILES > If file exists, which - * is fixed because the never-overwrite rule leaves one possible value (O7). */ +const PICK = '\u0000pick' +type Known = { downloads: string; desktop: string; pictures: string } + +/** + * OUTPUT > Location, ORIGINALS > Replace originals, NAMING > Rename and + * METADATA (signed off 2026-10-26, docs/mockups/output-options/). The folder + * picker is the last entry of the Location dropdown, not a separate button. + */ export function OutputSettings({ options, - set + set, + canStrip, + example }: { options: JobOptions set: SetOption + /** Remove photo metadata applies (images and video). */ + canStrip: boolean + /** A source name and output extension for the Rename example line. */ + example: { name: string; ext: string } }): JSX.Element { const dir = typeof options.outDir === 'string' ? options.outDir : '' + const replace = options.replaceOriginals === true + const renameOn = options.rename === true + const pattern = typeof options.renamePattern === 'string' ? options.renamePattern : '{name}' + const [known, setKnown] = useState(null) + const [recent, setRecent] = useState(loadRecentFolders) + + useEffect(() => { + let live = true + void window.filesmith.knownFolders().then((k) => live && setKnown(k)) + return () => { + live = false + } + }, []) + async function choose(): Promise { const p = await window.filesmith.pickFolder() - if (p) set('outDir', p) + if (!p) return + set('outDir', p) + setRecent(addRecentFolder(p)) } + + const fixed: SelectOption[] = known + ? [ + { value: known.downloads, label: 'downloads' }, + { value: known.desktop, label: 'desktop' }, + { value: known.pictures, label: 'pictures' } + ] + : [] + const isFixed = (p: string): boolean => fixed.some((f) => same(f.value, p)) + const recents = recent.filter((p) => !isFixed(p)) + // A folder chosen earlier that is neither fixed nor recent still shows as itself. + const extra = dir && !isFixed(dir) && !recents.some((p) => same(p, dir)) ? [dir] : [] + const choices: SelectOption[] = [ + { value: '', label: 'next to source' }, + ...fixed.map((f, i) => (i === 0 ? { ...f, sep: true } : f)), + ...[...extra, ...recents].map((p) => ({ + value: p, + label: baseName(p), + reason: parentOf(p), + group: 'recent' + })), + { value: PICK, label: 'choose folder…', sep: true } + ] + const current = choices.find((c) => c.value !== PICK && same(c.value, dir))?.value ?? dir + + const exampleName = renderOutputName(pattern, example.name, 1, new Date()) + return ( <> {dir} : 'Files are written next to each source.'} + warn={replace} + desc={ + replace + ? 'Ignored while Replace originals is on: each new file lands where its original was.' + : 'Where the new files go.' + } > -
- undefined} + label="Location" + value={current} + options={choices} + disabled={replace} + onChange={(v) => { + if (v === PICK) void choose() + else set('outDir', v) + }} /> + {!replace && ( +
+ → + + {dir || 'same folder as each file'} + + {dir && ( + + )} +
+ )} + + set('replaceOriginals', v)} + label="Replace originals" + sub="The new file takes the original's place. The original goes to the Recycle Bin, only after the new file is written OK." + /> + + + set('rename', v)} + label="Rename" + sub={replace ? 'Off while Replace originals is on.' : 'Pattern for the new names.'} + /> + {renameOn && !replace && ( +
+ set('renamePattern', e.target.value)} + /> +
+ {'{name}'} original name · {'{n}'} 1, 2, 3… · {'{date}'} today +
+
+ e.g. + + {example.name} → {exampleName} + {example.ext} + +
+
+ )} +
+ + set('keepDates', v)} + label="Keep original dates" + sub="New files keep the source's modified date." + /> + set('stripMetadata', v)} + label="Remove photo metadata" + sub={ + canStrip ? 'Strips GPS location, camera and other EXIF data.' : 'Images and video only.' + } + /> + ) } diff --git a/src/renderer/src/components/options/recentFolders.ts b/src/renderer/src/components/options/recentFolders.ts new file mode 100644 index 0000000..20bfd94 --- /dev/null +++ b/src/renderer/src/components/options/recentFolders.ts @@ -0,0 +1,28 @@ +// Folders the user picked for output, newest first. A per-viewer convenience, +// so localStorage; it can be empty or throw (private window), never required. + +export const RECENT_KEY = 'filesmith.output.recent' +export const RECENT_MAX = 5 + +export function loadRecentFolders(): string[] { + try { + const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') + return Array.isArray(v) ? v.filter((p): p is string => typeof p === 'string') : [] + } catch { + return [] + } +} + +export function pushRecent(list: string[], path: string): string[] { + return [path, ...list.filter((p) => p.toLowerCase() !== path.toLowerCase())].slice(0, RECENT_MAX) +} + +export function addRecentFolder(path: string): string[] { + const next = pushRecent(loadRecentFolders(), path) + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(next)) + } catch { + /* not stored; the dropdown still works */ + } + return next +} diff --git a/src/renderer/src/components/ui/Select.tsx b/src/renderer/src/components/ui/Select.tsx index 8e2b993..1acbfd8 100644 --- a/src/renderer/src/components/ui/Select.tsx +++ b/src/renderer/src/components/ui/Select.tsx @@ -127,6 +127,8 @@ export function Select({ {o.value === value && } ) + if (o.sep && !head) + return [
  • , opt] return head ? [
  • diff --git a/src/renderer/src/components/ui/Setting.tsx b/src/renderer/src/components/ui/Setting.tsx index 1dfcfd6..4991270 100644 --- a/src/renderer/src/components/ui/Setting.tsx +++ b/src/renderer/src/components/ui/Setting.tsx @@ -62,22 +62,26 @@ export function CheckSetting({ checked, onChange, label, - sub + sub, + disabled }: { checked: boolean onChange: (v: boolean) => void label: string sub?: string + /** Greyed out and inert; `sub` says why. */ + disabled?: boolean }): JSX.Element { + const toggle = (): void => { + if (!disabled) onChange(!checked) + } return ( -
    onChange(!checked)}> - onChange(!checked)} - label={label} - size="md" - focusable - /> +
    + {label} {sub && {sub}} diff --git a/src/renderer/src/components/ui/selectNav.ts b/src/renderer/src/components/ui/selectNav.ts index 0011486..1c7ed1e 100644 --- a/src/renderer/src/components/ui/selectNav.ts +++ b/src/renderer/src/components/ui/selectNav.ts @@ -7,6 +7,8 @@ export interface SelectOption { reason?: string /** Option-group heading (Generate's architectures). */ group?: string + /** Draw a divider line above this option. */ + sep?: boolean } /** Keyboard movement inside the select popup: arrows skip disabled items and diff --git a/src/renderer/src/state.ts b/src/renderer/src/state.ts index f6cb567..f1fe3b0 100644 --- a/src/renderer/src/state.ts +++ b/src/renderer/src/state.ts @@ -91,7 +91,7 @@ export interface AppState { } export const DEFAULT_OPTIONS: Record = { - convert: { format: '.webp', quality: 'balanced' }, + convert: { format: '.webp', quality: 'balanced', keepDates: true }, compress: { quality: 80, imageFormat: 'keep', @@ -100,11 +100,12 @@ export const DEFAULT_OPTIONS: Record = { audioCodec: 'keep', audioBitrate: 192, pdfLevel: 'balanced', - pdfGray: false + pdfGray: false, + keepDates: true }, - resize: { mode: 'percent', percent: 50, fit: 'contain' }, - upscale: { upscaleFactor: 4, upscaleModel: 'photo' }, - removebg: { ...BG_DEFAULTS }, + resize: { mode: 'percent', percent: 50, fit: 'contain', keepDates: true }, + upscale: { upscaleFactor: 4, upscaleModel: 'photo', keepDates: true }, + removebg: { ...BG_DEFAULTS, keepDates: true }, pdf: { op: 'extract-text', dpi: 150, range: '' }, archive: { op: 'repack', diff --git a/src/renderer/src/theme/controls.css b/src/renderer/src/theme/controls.css index 3e3fc01..4eb7de4 100644 --- a/src/renderer/src/theme/controls.css +++ b/src/renderer/src/theme/controls.css @@ -78,6 +78,11 @@ color: var(--fg-disabled); cursor: default; } +.vs-pop-sep { + height: 1px; + margin: 4px 0; + background: var(--line); +} .vs-pop-gh { padding: 10px 8px 4px; font: 600 11px/1 var(--mono); diff --git a/src/renderer/src/theme/workbench.css b/src/renderer/src/theme/workbench.css index 718b515..95a307a 100644 --- a/src/renderer/src/theme/workbench.css +++ b/src/renderer/src/theme/workbench.css @@ -948,6 +948,63 @@ outline: 1px solid var(--focus); outline-offset: 2px; } +.vs-chk.dis { + cursor: default; +} +.vs-chk.dis .lt, +.vs-chk.dis .lt span { + color: var(--fg-disabled); +} +.vs-chk.dis .box, +.vs-chk.dis:hover .box { + border-color: var(--line); +} +/* where the files go: a quiet line under the Location select */ +.vs-dest { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + padding: 5px 8px; + border: 1px dashed var(--line-strong); + font: 12px var(--mono); + color: var(--fg2); +} +.vs-dest .p { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* an outlined mini button: plain underlined words did not read as buttons */ +.vs-dest button { + flex: none; + height: 20px; + padding: 0 8px; + font: 12px/1 var(--sans); + color: var(--fg1); + border: 1px solid var(--line-strong); +} +.vs-dest button:hover { + background: var(--hover); + border-color: var(--fg3); +} +.vs-dest button:active { + background: var(--inv-bg); +} +.vs-dest button:focus-visible { + outline: 1px solid var(--focus); + outline-offset: 2px; +} +.vs-sub { + margin: 6px 0 2px 26px; + display: grid; + gap: 4px; +} +.vs-sub .vs-d { + margin: 0; +} /* estimate: quiet summary block */ .vs-est { margin: 14px 16px 0; diff --git a/src/shared/outputNaming.ts b/src/shared/outputNaming.ts new file mode 100644 index 0000000..4b9f13e --- /dev/null +++ b/src/shared/outputNaming.ts @@ -0,0 +1,26 @@ +// The Rename pattern, shared by the engine and the options panel's live example. +// Tokens: {name} the source's name without extension, {n} 1-based position in +// the run, {date} today as YYYY-MM-DD. + +export const RENAME_TOKENS = ['{name}', '{n}', '{date}'] as const + +const pad = (n: number): string => String(n).padStart(2, '0') + +export function isoDate(d: Date): string { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +/** A safe file name (no extension) from the pattern. Characters Windows forbids + * become `_`; an empty or dots-only result falls back to the source name. */ +export function renderOutputName(pattern: string, name: string, seq: number, now: Date): string { + const raw = pattern + .replace(/\{name\}/gi, name) + .replace(/\{n\}/gi, String(seq)) + .replace(/\{date\}/gi, isoDate(now)) + const safe = raw + // eslint-disable-next-line no-control-regex + .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') + .replace(/[. ]+$/, '') + .trim() + return safe && !/^\.+$/.test(safe) ? safe : name +} diff --git a/test/cli-options.test.ts b/test/cli-options.test.ts index 2b75429..3c39bed 100644 --- a/test/cli-options.test.ts +++ b/test/cli-options.test.ts @@ -181,3 +181,18 @@ describe('generate', () => { expect(() => buildGenerateFlags(gen, { cfg: '7.3' }, ['x'], ctx)).toThrow(/in steps of 0.5/) }) }) + +describe('finished files', () => { + it('--replace, --rename, --dates and --strip-metadata reach the app keys', () => { + expect(build('compress', { replace: true }).options.replaceOriginals).toBe(true) + const r = build('convert', { to: 'webp', rename: '{name}-web' }).options + expect([r.rename, r.renamePattern]).toEqual([true, '{name}-web']) + expect(build('resize', {}).options.keepDates).toBe(true) + expect(build('resize', { dates: 'today' }).options.keepDates).toBe(false) + expect(build('resize', { 'strip-metadata': true }).options.stripMetadata).toBe(true) + }) + it('--replace refuses --out and --rename', () => { + fails('compress', { replace: true, out: 'x' }, /leave out --out/) + fails('compress', { replace: true, rename: 'x' }, /leave out --rename/) + }) +}) diff --git a/test/finish-output.test.ts b/test/finish-output.test.ts new file mode 100644 index 0000000..c078fa0 --- /dev/null +++ b/test/finish-output.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { hasRecycleBin } from '../src/main/recycle' +import { + canReplace, + finishOptions, + finishOptionsFor, + finishOutput, + planFinish, + type FinishOptions +} from '../src/main/finishOutput' +import { renderOutputName } from '../src/shared/outputNaming' +import { stripArgs } from '../src/main/tools/convert' +import { buildResizeArgs } from '../src/main/tools/resize' +import { buildMagickCompressArgs, buildVideoCompressArgs } from '../src/main/tools/compress' +import { pushRecent } from '../src/renderer/src/components/options/recentFolders' + +// Rename / Replace originals / Keep dates (2026-10-26). Replace is the one +// owner-approved exception to never-overwrite, so its safety is pinned here: +// the original only ever goes to the Recycle Bin, and only after the new file +// exists. + +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'filesmith-finish-')) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +const NOW = new Date(2026, 9, 26) +const fo = (o: Partial): FinishOptions => ({ + replace: false, + rename: null, + keepDates: false, + seq: 1, + ...o +}) +const file = (name: string, body = name): string => { + const p = join(dir, name) + writeFileSync(p, body) + return p +} +/** A fake Recycle Bin: records and removes, like the real one. */ +const bin = (): { trash: (p: string) => Promise; got: string[] } => { + const got: string[] = [] + return { + got, + trash: async (p) => { + got.push(p) + rmSync(p) + } + } +} + +describe('renderOutputName', () => { + it('fills the tokens', () => { + expect(renderOutputName('{name}-web', 'beach', 1, NOW)).toBe('beach-web') + expect(renderOutputName('{date} {name} {n}', 'beach', 3, NOW)).toBe('2026-10-26 beach 3') + }) + it('replaces characters Windows forbids and falls back when empty', () => { + expect(renderOutputName('a:b/c', 'x', 1, NOW)).toBe('a_b_c') + expect(renderOutputName('...', 'beach', 1, NOW)).toBe('beach') + expect(renderOutputName(' ', 'beach', 1, NOW)).toBe('beach') + }) +}) + +describe('finishOptions', () => { + it('rename needs both the box and a pattern, and Replace switches it off', () => { + expect(finishOptions({ rename: true, renamePattern: '{name}-x' }).rename).toBe('{name}-x') + expect(finishOptions({ rename: true, renamePattern: ' ' }).rename).toBeNull() + expect(finishOptions({ rename: false, renamePattern: '{name}' }).rename).toBeNull() + expect( + finishOptions({ replaceOriginals: true, rename: true, renamePattern: '{name}-x' }).rename + ).toBeNull() + }) + it('defaults: nothing on, seq 1', () => { + expect(finishOptions({})).toEqual({ replace: false, rename: null, keepDates: false, seq: 1 }) + }) + it('tools without the options ignore values left in the options bag', () => { + const o = { replaceOriginals: true, rename: true, renamePattern: 'x', keepDates: true } + expect(finishOptionsFor('archive', o)).toMatchObject({ + replace: false, + rename: null, + keepDates: false + }) + expect(finishOptionsFor('convert', o).replace).toBe(true) + expect(canReplace('archive', {})).toBe(false) + }) + it('merge and generate cannot replace', () => { + expect(canReplace('convert', {})).toBe(true) + expect(canReplace('pdf', { mergeInputs: ['a', 'b'] })).toBe(false) + expect(canReplace('generate', {})).toBe(false) + }) +}) + +describe('finishOutput', () => { + it('does nothing when no option is on', async () => { + const out = file('beach.jxl') + expect(await finishOutput(file('beach.png'), out, fo({}), true, bin().trash)).toEqual({ + path: out + }) + }) + + it('renames, and a taken name gets (2)', async () => { + const src = file('beach.png') + file('beach-web.jxl', 'someone else') + const out = file('beach.jxl', 'new') + const r = await finishOutput(src, out, fo({ rename: '{name}-web' }), true, bin().trash, NOW) + expect(r.path).toBe(join(dir, 'beach-web (2).jxl')) + expect(readFileSync(r.path, 'utf8')).toBe('new') + expect(readFileSync(join(dir, 'beach-web.jxl'), 'utf8')).toBe('someone else') + expect(existsSync(out)).toBe(false) + }) + + it('replace: the original goes to the bin, the new file takes its name', async () => { + const src = file('beach.png', 'old') + const out = file('beach (compressed).png', 'new') + const b = bin() + const r = await finishOutput(src, out, fo({ replace: true }), true, b.trash) + expect(b.got).toEqual([src]) + expect(r.path).toBe(src) + expect(readFileSync(src, 'utf8')).toBe('new') + expect(existsSync(out)).toBe(false) + }) + + it('replace across formats keeps the new extension', async () => { + const src = file('beach.png') + const out = file('beach.jxl', 'new') + const r = await finishOutput(src, out, fo({ replace: true }), true, bin().trash) + expect(r.path).toBe(out) + expect(existsSync(src)).toBe(false) + }) + + it('a failed recycle keeps the original and says so', async () => { + const src = file('beach.png', 'old') + const out = file('beach (compressed).png', 'new') + const r = await finishOutput(src, out, fo({ replace: true }), true, async () => { + throw new Error('access denied') + }) + expect(r).toEqual({ path: out, note: 'Original kept: access denied' }) + expect(readFileSync(src, 'utf8')).toBe('old') + }) + + it('a drive without a Recycle Bin keeps the original', async () => { + const src = file('beach.png', 'old') + const out = file('beach (compressed).png', 'new') + const b = bin() + const r = await finishOutput( + src, + out, + fo({ replace: true }), + true, + b.trash, + NOW, + async () => false + ) + expect(r).toEqual({ path: out, note: 'Original kept: this drive has no Recycle Bin.' }) + expect(b.got).toEqual([]) + expect(readFileSync(src, 'utf8')).toBe('old') + }) + + it('network shares never count as having a Recycle Bin; the system drive does', async () => { + expect(await hasRecycleBin('\\\\server\\share\\a.png')).toBe(false) + expect(await hasRecycleBin(join(dir, 'a.png'))).toBe(true) + }) + + it('replace is ignored where it is not allowed', async () => { + const src = file('a.pdf') + const out = file('a (merged).pdf') + const b = bin() + expect((await finishOutput(src, out, fo({ replace: true }), false, b.trash)).path).toBe(out) + expect(b.got).toEqual([]) + }) + + it('keeps the source date', async () => { + const src = file('beach.png') + const old = new Date(2020, 0, 2, 3, 4, 5) + utimesSync(src, old, old) + const out = file('beach.jxl') + await finishOutput(src, out, fo({ keepDates: true }), true, bin().trash) + expect(Math.abs(statSync(out).mtimeMs - old.getTime())).toBeLessThan(2000) + }) +}) + +describe('planFinish', () => { + it('predicts rename, replace and clashes in one run', () => { + const src = join(dir, 'beach.png') + const planned = { path: join(dir, 'beach (compressed).png'), kind: 'file' as const } + const claimed = new Set([planned.path.toLowerCase()]) + expect(planFinish(src, planned, fo({ replace: true }), true, claimed)).toBe(src) + const p2 = { path: join(dir, 'dog.jxl'), kind: 'file' as const } + const one = planFinish(join(dir, 'dog.png'), p2, fo({ rename: 'x' }), true, claimed, NOW) + const two = planFinish(join(dir, 'cat.png'), p2, fo({ rename: 'x' }), true, claimed, NOW) + expect([one, two]).toEqual([join(dir, 'x.jxl'), join(dir, 'x (2).jxl')]) + }) + it('leaves folder outputs alone', () => { + const d = { path: join(dir, 'a (pages)'), kind: 'dir' as const } + expect(planFinish(join(dir, 'a.pdf'), d, fo({ rename: 'x' }), true, new Set())).toBe(d.path) + }) +}) + +describe('Remove photo metadata', () => { + it('images get -strip, video drops container metadata, audio keeps its tags', () => { + expect(stripArgs('image', true)).toEqual(['-auto-orient', '-strip']) + expect(stripArgs('video', true)).toEqual(['-map_metadata', '-1']) + expect(stripArgs('audio', true)).toEqual([]) + expect(stripArgs('image', false)).toEqual([]) + }) + it('reaches every image and video builder', () => { + expect(buildResizeArgs('a.png', 'b.png', '50%', false, true)).toContain('-strip') + expect(buildMagickCompressArgs('a.png', 'b.webp', 80, true)).toContain('-strip') + expect(buildResizeArgs('a.png', 'b.png', '50%', false, true).slice(0, 3)).toEqual([ + 'a.png', + '-auto-orient', + '-resize' + ]) + const v = buildVideoCompressArgs('a.mp4', 'b.mp4', { + codec: 'h264', + quality: 60, + scale: 100, + strip: true + }) + expect(v.slice(-3)).toEqual(['-map_metadata', '-1', 'b.mp4']) + }) +}) + +describe('recent folders', () => { + it('newest first, no duplicates (any case), at most 5', () => { + let l: string[] = [] + for (const p of ['A', 'B', 'C', 'D', 'E', 'F']) l = pushRecent(l, p) + expect(l).toEqual(['F', 'E', 'D', 'C', 'B']) + expect(pushRecent(l, 'c')).toEqual(['c', 'F', 'E', 'D', 'B']) + }) +})