diff --git a/CLAUDE.md b/CLAUDE.md index ed56ce0..bbe434a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,14 +1,17 @@ # Filesmith A desktop **file toolkit**: drop files and run operations — convert, compress, resize, -upscale, remove background, and PDF tools (extract text, PDF→images, compress) — across -images, video, audio, and PDFs, with batch queues, thumbnails, and live progress. +upscale, remove background, PDF tools (extract text, PDF→images, compress) and archive +tools (repack CBZ/CBR/CB7/CBT, extract, archive↔PDF) — across images, video, audio, +PDFs, documents and archives, with batch queues, thumbnails, and live progress. ## What it is Electron + TypeScript desktop app. Renderer is React + TypeScript + Vite + Tailwind v4. The heavy lifting is done by external CLI tools (ffmpeg, ImageMagick, mutool, CaesiumCLT, -Real-ESRGAN, rembg); the app orchestrates them. Windows-first, unsigned installer via +7-Zip, Real-ESRGAN, rembg); the app orchestrates them. Writing RAR/CBR is the one +operation that needs a tool we cannot bundle (WinRAR's Rar.exe), so it is detected at +runtime and the target is greyed out when absent. Windows-first, unsigned installer via GitHub Releases (mirrors the sibling RCMM project's distribution). Origin: the operations are ported from RCMM's audited `rcmm-convert/compress/upscale/removebg` @@ -32,7 +35,7 @@ without design ceremony. 3. **Media + PDF** — video/audio convert & compress (ffmpeg), PDF extract-text / images / compress (mutool). -Dependencies: bundle the core tools (ffmpeg, ImageMagick, mutool, CaesiumCLT) in +Dependencies: bundle the core tools (ffmpeg, ImageMagick, mutool, CaesiumCLT, 7-Zip) in `resources/bin` so images/PDF work offline out of the box; fetch the AI tools on demand. Each phase is its own checkpoint — confirm scope before starting the next. @@ -43,7 +46,7 @@ Each phase is its own checkpoint — confirm scope before starting the next. src/ main/ Node/Electron main process — the engine index.ts app + window bootstrap - (planned) tools/{convert,compress,resize,upscale,removebg,pdf}.ts — one per operation + tools/{convert,compress,resize,upscale,removebg,pdf,archive}.ts — one per operation (planned) toolResolver.ts find bundled/PATH binaries; on-demand AI-tool download (planned) jobQueue.ts batch queue: spawn, stream progress, cancel (planned) output.ts collision-safe output naming (ported from RCMM Get-UniqueOutPath) diff --git a/README.md b/README.md index d569683..5888af3 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ A local file toolkit for Windows. Drop files, pick a tool, get results. ## What it is -Filesmith puts the everyday file jobs behind one clean window: convert, compress, resize, and more, across **images, video, audio, PDFs, and documents**. Drop a pile of files, pick a tool, and it runs the batch with thumbnails, live per-file progress, rich previews, and collision-safe output that never overwrites your originals. +Filesmith puts the everyday file jobs behind one clean window: convert, compress, resize, and more, across **images, video, audio, PDFs, documents, and archives**. Drop a pile of files, pick a tool, and it runs the batch with thumbnails, live per-file progress, rich previews, and collision-safe output that never overwrites your originals. Everything runs **locally on your machine**, and your queue and produced files are **remembered across restarts**, so you can close the app mid-batch and pick up where you left off. @@ -39,8 +39,9 @@ Everything runs **locally on your machine**, and your queue and produced files a - **Images** — convert (PNG / WebP / AVIF / JPG / JXL / TIFF / BMP / GIF / ICO), compress, resize, remove background, upscale (2–4×), and **generate** from a text prompt. - **Video / Audio** — convert (container / codec) and compress, with live `ffmpeg` progress and a resolution preview. -- **PDF** — extract text, PDF to images, and compress. +- **PDF** — extract text, PDF to images, merge, split, burst, extract images, compress, and pack pages into a comic archive. - **Documents** — convert office documents to PDF. +- **Archives** — convert between ZIP / RAR / 7z / TAR and the comic formats CBZ / CBR / CB7 / CBT, extract to a folder, and turn a comic archive into a PDF. Writing CBR/RAR needs WinRAR installed (it cannot be bundled); everything else works offline. Plus batch queues, thumbnails for every kind (images, video frames, audio cover art), a **built-in viewer** for images, video, and audio (with a live circular visualizer), per-file progress with ETA, and cancel. @@ -65,7 +66,7 @@ Download **`Filesmith-Setup-x64-.exe`** from the [latest release](https ## Use 1. Open **Filesmith** from the Start menu. -2. Pick a file type in the left rail (Images, Video, Audio, PDF, Documents). +2. Pick a file type in the left rail (Images, Video, Audio, PDF, Documents, Archives). 3. Choose a tool from the top-right (Convert, Compress, Resize, …), set its options. 4. Drop files onto the drop zone (or click **browse**), then run. @@ -85,7 +86,7 @@ npm run binaries # populate resources/ with the bundled tools (once, before pa npm run package # build the Windows installer into dist/ ``` -Stack: Electron + TypeScript, with a React + Vite + Tailwind renderer. Operations are performed by external tools (ffmpeg, ImageMagick, mutool, Ghostscript, CaesiumCLT, LibreOffice, Real-ESRGAN, rembg, ComfyUI) that the app orchestrates; the core tools are bundled and the AI tools are resolved from your machine or fetched on first use. See `CLAUDE.md` for architecture and scope. +Stack: Electron + TypeScript, with a React + Vite + Tailwind renderer. Operations are performed by external tools (ffmpeg, ImageMagick, mutool, Ghostscript, CaesiumCLT, 7-Zip, LibreOffice, Real-ESRGAN, rembg, ComfyUI) that the app orchestrates; the core tools are bundled and the AI tools are resolved from your machine or fetched on first use. See `CLAUDE.md` for architecture and scope. ## License diff --git a/docs/superpowers/plans/2026-08-31-archive-conversion.md b/docs/superpowers/plans/2026-08-31-archive-conversion.md new file mode 100644 index 0000000..82119b9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-archive-conversion.md @@ -0,0 +1,1259 @@ +# Archive Conversion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an Archives category to Filesmith that converts between `.zip` / `.rar` / `.7z` / `.tar` / `.cbz` / `.cbr` / `.cb7` / `.cbt`, extracts archives to a folder, and bridges comic archives to and from PDF. + +**Architecture:** A new `archive` tool module owns the format catalog and every 7-Zip / WinRAR argument builder as pure functions; `registry.ts` gains an `archiveTool` that dispatches four ops through temp directories and the existing collision-safe output helpers. 7-Zip ships bundled in `resources/bin`; WinRAR is detected at runtime and only unlocks RAR output. + +**Tech Stack:** Electron + TypeScript, React renderer, Vitest unit tests, Playwright `_electron` e2e, 7-Zip CLI, ImageMagick, mutool. + +**Spec:** `docs/superpowers/specs/2026-08-31-archive-conversion-design.md` + +## Global Constraints + +- TypeScript strict. Main-process code is Node; the renderer never touches `fs` or `child_process`. +- Never overwrite a source or an existing output. Every output path comes from `reserveOutPath` or `uniqueOutDir` in `src/main/output.ts`. +- Tool modules own their own format catalog and argument builders, and are independently unit-testable as pure functions. +- All spawns go through `run()` in `src/main/run.ts` with an argument array, never a shell string. +- Paths containing `%` must not reach magick or mutool unescaped: route outputs through the `runToOutput` `argsFor` pattern already used in `registry.ts`. +- Extensions are lowercased with a leading dot, single-component only (`.cbz`, not `.tar.gz`). +- No em-dashes in user-facing copy. +- Unit tests live in `test/*.test.ts`; e2e specs live in `e2e/*.spec.ts`. + +--- + +### Task 1: Archive format catalog and container mapping + +**Files:** + +- Create: `src/shared/archive.ts` +- Test: `test/archive-catalog.test.ts` + +**Interfaces:** + +- Consumes: `FormatOption` from `src/shared/convert.ts`. +- Produces: `type ArchiveContainer = 'zip' | '7z' | 'tar' | 'rar'`; `ARCHIVE_FORMATS: FormatOption[]`; `CONTAINER_OF: Record`; `archiveTargets(sourceExt: string, hasRar: boolean): FormatOption[]`; `containerOf(ext: string): ArchiveContainer | null`; `needsRar(ext: string): boolean`. + +This file is in `shared/` (not `main/tools/`) because the renderer needs `archiveTargets` to build the target chips, and the engine needs the identical list. Mirrors how `src/shared/convert.ts` is shared. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/archive-catalog.test.ts +import { describe, expect, it } from 'vitest' +import { archiveTargets, containerOf, needsRar, ARCHIVE_FORMATS } from '@shared/archive' + +describe('archive catalog', () => { + it('maps every comic extension to its real container', () => { + expect(containerOf('.cbz')).toBe('zip') + expect(containerOf('.cbr')).toBe('rar') + expect(containerOf('.cb7')).toBe('7z') + expect(containerOf('.cbt')).toBe('tar') + expect(containerOf('.zip')).toBe('zip') + expect(containerOf('.7z')).toBe('7z') + expect(containerOf('.tar')).toBe('tar') + expect(containerOf('.rar')).toBe('rar') + }) + + it('returns null for a non-archive extension', () => { + expect(containerOf('.png')).toBeNull() + }) + + it('offers eight formats in total', () => { + expect(ARCHIVE_FORMATS).toHaveLength(8) + }) + + it('drops the source format from the target list', () => { + const exts = archiveTargets('.cbz', true).map((f) => f.ext) + expect(exts).not.toContain('.cbz') + expect(exts).toContain('.cb7') + }) + + it('drops rar targets when WinRAR is absent', () => { + const without = archiveTargets('.cbz', false).map((f) => f.ext) + expect(without).not.toContain('.cbr') + expect(without).not.toContain('.rar') + const with_ = archiveTargets('.cbz', true).map((f) => f.ext) + expect(with_).toContain('.cbr') + expect(with_).toContain('.rar') + }) + + it('knows which extensions need WinRAR', () => { + expect(needsRar('.cbr')).toBe(true) + expect(needsRar('.rar')).toBe(true) + expect(needsRar('.cbz')).toBe(false) + }) + + it('is case-insensitive on the source extension', () => { + expect(archiveTargets('.CBZ', true).map((f) => f.ext)).not.toContain('.cbz') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/archive-catalog.test.ts` +Expected: FAIL, cannot resolve `@shared/archive`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/shared/archive.ts +import type { FormatOption } from './convert' + +// Comic archives are ordinary containers with a renamed extension: .cbz is a +// zip, .cbr a rar, .cb7 a 7z, .cbt a tar. Converting between them is +// extract-and-repack, so the only thing that varies is the container 7-Zip is +// told to write. +export type ArchiveContainer = 'zip' | '7z' | 'tar' | 'rar' + +export const CONTAINER_OF: Record = { + '.cbz': 'zip', + '.zip': 'zip', + '.cb7': '7z', + '.7z': '7z', + '.cbt': 'tar', + '.tar': 'tar', + '.cbr': 'rar', + '.rar': 'rar' +} + +// Comic formats first: they are the reason this category exists. +export const ARCHIVE_FORMATS: FormatOption[] = [ + { label: 'CBZ', ext: '.cbz' }, + { label: 'CBR', ext: '.cbr' }, + { label: 'CB7', ext: '.cb7' }, + { label: 'CBT', ext: '.cbt' }, + { label: 'ZIP', ext: '.zip' }, + { label: 'RAR', ext: '.rar' }, + { label: '7Z', ext: '.7z' }, + { label: 'TAR', ext: '.tar' } +] + +const norm = (ext: string): string => ext.toLowerCase() + +export function containerOf(ext: string): ArchiveContainer | null { + return CONTAINER_OF[norm(ext)] ?? null +} + +/** True when writing this format requires WinRAR's Rar.exe (which cannot be + * bundled: it is proprietary, and 7-Zip's unRAR licence forbids using its RAR + * code to build a compressor). */ +export function needsRar(ext: string): boolean { + return containerOf(ext) === 'rar' +} + +/** Targets offered for a source archive: everything but its own format, with + * RAR formats removed when WinRAR is not installed. */ +export function archiveTargets(sourceExt: string, hasRar: boolean): FormatOption[] { + const src = norm(sourceExt) + return ARCHIVE_FORMATS.filter((f) => f.ext !== src && (hasRar || !needsRar(f.ext))) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/archive-catalog.test.ts` +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/archive.ts test/archive-catalog.test.ts +git commit -m "feat(archive): add archive format catalog and container mapping" +``` + +--- + +### Task 2: File-kind classification for archives + +**Files:** + +- Modify: `src/shared/types.ts` (the `FileKind` and `ToolId` unions) +- Modify: `src/shared/fileKind.ts` +- Test: `test/archive-kind.test.ts` + +**Interfaces:** + +- Consumes: `containerOf` from Task 1 is not used here; this task only extends the classifier. +- Produces: `ARCHIVE_EXTS: string[]` exported from `src/shared/fileKind.ts`; `FileKind` includes `'archive'`; `ToolId` includes `'archive'`. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/archive-kind.test.ts +import { describe, expect, it } from 'vitest' +import { ARCHIVE_EXTS, fileKind } from '@shared/fileKind' + +describe('archive file kind', () => { + it('classifies every archive extension as archive', () => { + for (const e of ARCHIVE_EXTS) expect(fileKind(e)).toBe('archive') + }) + + it('covers the eight supported extensions', () => { + expect([...ARCHIVE_EXTS].sort()).toEqual( + ['.7z', '.cb7', '.cbr', '.cbt', '.cbz', '.rar', '.tar', '.zip'].sort() + ) + }) + + it('accepts an extension without a leading dot, and any case', () => { + expect(fileKind('cbz')).toBe('archive') + expect(fileKind('.CBR')).toBe('archive') + }) + + it('leaves other kinds alone', () => { + expect(fileKind('.png')).toBe('image') + expect(fileKind('.pdf')).toBe('pdf') + expect(fileKind('.xyz')).toBe('other') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/archive-kind.test.ts` +Expected: FAIL, `ARCHIVE_EXTS` is not exported. + +- [ ] **Step 3: Write minimal implementation** + +In `src/shared/types.ts`, extend both unions: + +```ts +export type ToolId = + 'convert' | 'compress' | 'resize' | 'upscale' | 'removebg' | 'pdf' | 'generate' | 'archive' + +export type FileKind = + 'image' | 'video' | 'audio' | 'pdf' | 'document' | 'text' | 'archive' | 'other' +``` + +In `src/shared/fileKind.ts`, add the extension set above the `fileKind` function: + +```ts +// Archive containers, including the comic variants (.cbz/.cbr/.cb7/.cbt are +// zip/rar/7z/tar with a renamed extension). Compound extensions (.tar.gz) are +// deliberately out: FileInfo.ext is a single lowercased extension. +export const ARCHIVE_EXTS = ['.zip', '.rar', '.7z', '.tar', '.cbz', '.cbr', '.cb7', '.cbt'] +``` + +and add the branch inside `fileKind`, before the `DOC_EXTS` check: + +```ts +if (ARCHIVE_EXTS.includes(e)) return 'archive' +``` + +- [ ] **Step 4: Run tests and typecheck** + +Run: `npx vitest run test/archive-kind.test.ts && npm run typecheck` +Expected: tests PASS. Typecheck may report non-exhaustive switches on `FileKind` in the renderer; fix each by adding an `archive` case that falls through to the existing generic/`other` behaviour. Do not add archive-specific visuals here. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/types.ts src/shared/fileKind.ts test/archive-kind.test.ts +git commit -m "feat(archive): classify archive extensions as a new file kind" +``` + +--- + +### Task 3: 7-Zip and WinRAR argument builders + +**Files:** + +- Create: `src/main/tools/archive.ts` +- Test: `test/archive-args.test.ts` + +**Interfaces:** + +- Consumes: `ArchiveContainer` from `src/shared/archive.ts` (Task 1). +- Produces: `buildExtractArgs(input: string, outDir: string): string[]`; `buildPackArgs(output: string, container: ArchiveContainer, store: boolean): string[]`; `buildRarPackArgs(output: string): string[]`; `parse7zProgress(chunk: string): number | undefined`; `naturalSort(names: string[]): string[]`; `batchImages(paths: string[], budget: number): string[][]`; `IMAGE_ENTRY_EXTS: string[]`. + +`-bsp2` routes 7-Zip's progress stream to stderr so it arrives through the existing `RunOptions.onStderr` hook and `run.ts` needs no change. + +Pack and rar commands run with `cwd` set to the extracted temp directory and reference `*` / `.`, so the archive holds the files themselves rather than a wrapper folder. A wrapper folder breaks comic readers. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/archive-args.test.ts +import { describe, expect, it } from 'vitest' +import { + batchImages, + buildExtractArgs, + buildPackArgs, + buildRarPackArgs, + naturalSort, + parse7zProgress +} from '../src/main/tools/archive' + +describe('7-Zip arguments', () => { + it('extracts into a target directory without prompting', () => { + expect(buildExtractArgs('C:\\in.cbr', 'C:\\tmp\\x')).toEqual([ + 'x', + 'C:\\in.cbr', + '-oC:\\tmp\\x', + '-y', + '-bsp2' + ]) + }) + + it('packs the directory contents, not a wrapper folder', () => { + const args = buildPackArgs('C:\\out.cbz', 'zip', true) + expect(args).toEqual(['a', '-tzip', '-mx0', 'C:\\out.cbz', '*', '-y', '-bsp2']) + expect(args).not.toContain('C:\\tmp\\x') + }) + + it('uses normal compression when store is off', () => { + expect(buildPackArgs('C:\\out.7z', '7z', false)).toContain('-mx5') + }) + + it('builds a WinRAR command that strips the leading path', () => { + expect(buildRarPackArgs('C:\\out.cbr')).toEqual(['a', '-ep1', '-r', '-y', 'C:\\out.cbr', '.']) + }) +}) + +describe('parse7zProgress', () => { + it('reads the percent counter', () => { + expect(parse7zProgress(' 47% 12 - page-012.jpg')).toBe(47) + }) + + it('takes the last percent in a multi-line chunk', () => { + expect(parse7zProgress(' 10% a\r 62% b\r')).toBe(62) + }) + + it('returns undefined when there is no counter', () => { + expect(parse7zProgress('Scanning the drive for archives')).toBeUndefined() + }) + + it('clamps a bogus value into range', () => { + expect(parse7zProgress(' 340% x')).toBe(100) + }) +}) + +describe('naturalSort', () => { + it('orders page 2 before page 10', () => { + expect(naturalSort(['page10.jpg', 'page2.jpg', 'page1.jpg'])).toEqual([ + 'page1.jpg', + 'page2.jpg', + 'page10.jpg' + ]) + }) + + it('is stable across mixed case and nested folders', () => { + expect(naturalSort(['B/p2.png', 'a/p10.png', 'a/p2.png'])).toEqual([ + 'a/p2.png', + 'a/p10.png', + 'B/p2.png' + ]) + }) +}) + +describe('batchImages', () => { + it('keeps one batch when everything fits', () => { + expect(batchImages(['a.jpg', 'b.jpg'], 1000)).toEqual([['a.jpg', 'b.jpg']]) + }) + + it('splits when the joined length exceeds the budget', () => { + const paths = Array.from({ length: 6 }, (_, i) => `C:\\tmp\\page-${i}.jpg`) + const batches = batchImages(paths, 40) + expect(batches.length).toBeGreaterThan(1) + expect(batches.flat()).toEqual(paths) + for (const b of batches) expect(b.join(' ').length).toBeLessThanOrEqual(40) + }) + + it('never drops a path longer than the whole budget', () => { + const long = 'C:\\' + 'x'.repeat(100) + '.jpg' + expect(batchImages([long], 10)).toEqual([[long]]) + }) + + it('returns nothing for an empty list', () => { + expect(batchImages([], 100)).toEqual([]) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/archive-args.test.ts` +Expected: FAIL, module `../src/main/tools/archive` not found. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/main/tools/archive.ts +import type { ArchiveContainer } from '@shared/archive' + +// Archive operations via 7-Zip (extract + pack) and, when installed, WinRAR +// (pack only: nothing else can write RAR). Every function here is pure so the +// argument shapes are testable without spawning anything. + +/** Image entries considered comic pages when converting an archive to PDF. */ +export const IMAGE_ENTRY_EXTS = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.tif', '.tiff'] + +/** `7z x -o -y -bsp2` — extract everything, overwriting inside our own + * temp dir. `-bsp2` puts the progress stream on stderr, where run.ts already + * listens. */ +export function buildExtractArgs(input: string, outDir: string): string[] { + return ['x', input, '-o' + outDir, '-y', '-bsp2'] +} + +/** + * `7z a -t -mx * -y -bsp2` — pack the CURRENT DIRECTORY's + * contents. Run this with cwd set to the extracted temp dir: passing the dir + * path instead would nest everything under a wrapper folder, which comic + * readers show as an empty book. 7-Zip expands `*` itself, so no shell is + * involved. + * + * `store` (-mx0) is the default for comic archives: the pages are already + * compressed images, so deflate costs time and saves nothing. + */ +export function buildPackArgs( + output: string, + container: ArchiveContainer, + store: boolean +): string[] { + return ['a', '-t' + container, store ? '-mx0' : '-mx5', output, '*', '-y', '-bsp2'] +} + +/** `rar a -ep1 -r -y .` — WinRAR packing the cwd. `-ep1` strips the base + * path so entries are not prefixed with `./`. */ +export function buildRarPackArgs(output: string): string[] { + return ['a', '-ep1', '-r', '-y', output, '.'] +} + +/** Last percent counter in a 7-Zip progress chunk, clamped to 0..100. */ +export function parse7zProgress(chunk: string): number | undefined { + const matches = [...chunk.matchAll(/(\d{1,3})%/g)] + if (matches.length === 0) return undefined + const n = Number(matches[matches.length - 1][1]) + return Math.max(0, Math.min(100, n)) +} + +const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }) + +/** Sort entry names the way a reader expects pages: page2 before page10. Plain + * lexicographic order scrambles every comic with more than nine pages. */ +export function naturalSort(names: string[]): string[] { + return [...names].sort((a, b) => collator.compare(a, b)) +} + +/** + * Split an image list into groups whose joined command line stays under + * `budget` characters. Windows caps a command line at 32767, and a 400-page + * comic blows past it. A single path longer than the budget still gets its own + * batch rather than being dropped. + */ +export function batchImages(paths: string[], budget: number): string[][] { + const batches: string[][] = [] + let current: string[] = [] + let len = 0 + for (const p of paths) { + const add = current.length === 0 ? p.length : p.length + 1 + if (current.length > 0 && len + add > budget) { + batches.push(current) + current = [] + len = 0 + } + current.push(p) + len += current.length === 1 ? p.length : p.length + 1 + } + if (current.length > 0) batches.push(current) + return batches +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/archive-args.test.ts` +Expected: PASS, 14 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/tools/archive.ts test/archive-args.test.ts +git commit -m "feat(archive): add 7-Zip and WinRAR argument builders" +``` + +--- + +### Task 4: Bundle 7-Zip and detect WinRAR + +**Files:** + +- Modify: `src/main/toolResolver.ts` +- Modify: `scripts/fetch-binaries.mjs` +- Test: `test/archive-resolvers.test.ts` + +**Interfaces:** + +- Consumes: nothing from earlier tasks. +- Produces: `resolveRar(): string | null` and `resolveSevenZip(): string` exported from `src/main/toolResolver.ts`. + +Read `test/resolvers.test.ts` first: it already establishes how `app` from `electron` is mocked for resolver tests. Follow that mock rather than inventing a new one. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/archive-resolvers.test.ts +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: false, getAppPath: () => 'C:\\app' } +})) + +const exists = vi.hoisted(() => ({ set: new Set() })) +vi.mock('fs', async (orig) => { + const real = await orig() + return { ...real, existsSync: (p: string) => exists.set.has(String(p)) } +}) + +import { resolveRar, resolveSevenZip } from '../src/main/toolResolver' + +describe('resolveSevenZip', () => { + beforeEach(() => exists.set.clear()) + + it('prefers the bundled binary', () => { + exists.set.add('C:\\app\\resources\\bin\\7z.exe') + expect(resolveSevenZip()).toBe('C:\\app\\resources\\bin\\7z.exe') + }) + + it('falls back to the bare name so PATH can resolve it', () => { + expect(resolveSevenZip()).toBe('7z') + }) +}) + +describe('resolveRar', () => { + beforeEach(() => exists.set.clear()) + + it('returns null when WinRAR is not installed', () => { + expect(resolveRar()).toBeNull() + }) + + it('finds WinRAR under Program Files', () => { + exists.set.add('C:\\Program Files\\WinRAR\\Rar.exe') + expect(resolveRar()).toBe('C:\\Program Files\\WinRAR\\Rar.exe') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/archive-resolvers.test.ts` +Expected: FAIL, `resolveRar` is not exported. + +- [ ] **Step 3: Write minimal implementation** + +Add to `src/main/toolResolver.ts`, next to `resolveGhostscript`: + +```ts +/** 7-Zip's console binary. Bundled in resources/bin like ffmpeg and magick; + * falls back to the bare name so a PATH install still works in dev. */ +export function resolveSevenZip(): string { + return resolveTool('7z') +} + +/** + * WinRAR's `Rar.exe`, the only thing that can WRITE a rar (and therefore a + * .cbr). It is proprietary and cannot be bundled, so this returns null when + * absent and the UI greys out the RAR targets. Reading rar needs none of this: + * bundled 7-Zip handles it. + */ +export function resolveRar(): string | null { + if (process.platform !== 'win32') return null + for (const root of programFilesRoots()) { + const p = join(root, 'WinRAR', 'Rar.exe') + if (existsSync(p)) return p + } + return null +} +``` + +In `scripts/fetch-binaries.mjs`, add a step alongside the magick and caesium copy steps. The 7-Zip install path is already located for the Ghostscript step near line 245; reuse that lookup: + +```js +function sevenZip() { + const candidates = ['C:\\Program Files\\7-Zip', 'C:\\Program Files (x86)\\7-Zip'] + const dir = candidates.find((d) => existsSync(join(d, '7z.exe'))) + if (!dir) { + log('7-Zip: not found. Install it (winget install 7zip.7zip) and re-run.') + return + } + // 7z.exe needs 7z.dll beside it; License.txt travels with the binary because + // 7-Zip is LGPL with the unRAR restriction. + for (const f of ['7z.exe', '7z.dll', 'License.txt']) { + const src = join(dir, f) + if (existsSync(src)) copyFileSync(src, join(BIN, f)) + } + log(`7-Zip: copied from ${dir} (${mb(join(BIN, '7z.exe'))} MB)`) +} +``` + +Call `sevenZip()` from the script's main sequence next to the other copy steps. + +- [ ] **Step 4: Run the tests and the fetch script** + +Run: `npx vitest run test/archive-resolvers.test.ts` +Expected: PASS, 4 tests. + +Run: `node scripts/fetch-binaries.mjs` +Expected: a 7-Zip line in the output, and `resources/bin/7z.exe` plus `7z.dll` on disk. Confirm with `ls resources/bin/7z*`. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/toolResolver.ts scripts/fetch-binaries.mjs test/archive-resolvers.test.ts +git commit -m "feat(archive): bundle 7-Zip and detect an installed WinRAR" +``` + +--- + +### Task 5: The archive tool in the engine registry + +**Files:** + +- Modify: `src/main/tools/registry.ts` +- Test: `test/archive-tool.test.ts` + +**Interfaces:** + +- Consumes: everything produced by Tasks 1, 3 and 4. +- Produces: `archiveTool: ToolModule` registered under the `archive` key in the registry's tool map; job options `{ op, format, store, dpi, pageFormat, quality }`. + +Read `pdfTool` in `src/main/tools/registry.ts:302` first and mirror its op-switch shape, its use of `runToOutput`, and its temp-directory handling. + +Op contract: + +| `op` | Input | Output | Options used | +| ---------- | ------- | --------- | ---------------------------------------- | +| `repack` | archive | archive | `format`, `store` | +| `extract` | archive | directory | none | +| `to-pdf` | archive | `.pdf` | none | +| `from-pdf` | `.pdf` | archive | `format`, `dpi`, `pageFormat`, `quality` | + +- [ ] **Step 1: Write the failing test** + +```ts +// test/archive-tool.test.ts +import { describe, expect, it } from 'vitest' +import { needsRar } from '@shared/archive' +import { IMAGE_ENTRY_EXTS, naturalSort } from '../src/main/tools/archive' + +// The tool's spawn behaviour is covered end to end in e2e/workflows.spec.ts. +// Here we pin the decisions the tool makes before it spawns anything. +describe('archive tool preconditions', () => { + it('treats a rar target as needing WinRAR', () => { + expect(needsRar('.cbr')).toBe(true) + }) + + it('selects page images by extension in natural order', () => { + const entries = ['cover.png', 'p10.jpg', 'p2.jpg', 'notes.txt', 'thumbs.db'] + const pages = naturalSort( + entries.filter((e) => IMAGE_ENTRY_EXTS.some((x) => e.toLowerCase().endsWith(x))) + ) + expect(pages).toEqual(['cover.png', 'p2.jpg', 'p10.jpg']) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/archive-tool.test.ts` +Expected: FAIL, `IMAGE_ENTRY_EXTS` import resolves only once Task 3 is merged; if Task 3 is in place this test passes immediately and you proceed to Step 3 for the engine work, which the e2e in Task 9 verifies. + +- [ ] **Step 3: Write the implementation** + +Add to `src/main/tools/registry.ts`. Imports first: + +```ts +import { containerOf, needsRar } from '@shared/archive' +import { + batchImages, + buildExtractArgs, + buildPackArgs, + buildRarPackArgs, + IMAGE_ENTRY_EXTS, + naturalSort, + parse7zProgress +} from './archive' +import { resolveRar, resolveSevenZip } from '../toolResolver' +``` + +Then the tool: + +```ts +/** Walk a directory tree and return every file path, relative to `root`. */ +function listFilesRec(root: string, rel = ''): string[] { + const out: string[] = [] + for (const e of readdirSync(join(root, rel), { withFileTypes: true })) { + const r = rel ? join(rel, e.name) : e.name + if (e.isDirectory()) out.push(...listFilesRec(root, r)) + else out.push(r) + } + return out +} + +/** Extract `input` into a fresh temp dir and return its path. Caller removes it. */ +async function extractToTemp(input: string, ctx: ToolContext): Promise { + const dir = mkdtempSync(join(tmpdir(), 'filesmith-arc-')) + const res = await run(resolveSevenZip(), buildExtractArgs(input, dir), { + signal: ctx.signal, + onStderr: (c) => { + const p = parse7zProgress(c) + if (p !== undefined) ctx.onProgress(p, 'Extracting') + } + }) + if (res.code !== 0) { + rmSync(dir, { recursive: true, force: true }) + // 7-Zip exits non-zero on the password prompt rather than blocking. + if (/wrong password|encrypted|Cannot open encrypted/i.test(res.stderr + res.stdout)) + throw new Error('Archive is password-protected') + throw new Error(res.stderr.trim() || 'Could not read this archive') + } + return dir +} + +/** Pack the contents of `dir` into `output`, choosing 7-Zip or WinRAR by target. */ +async function packDir( + dir: string, + output: string, + targetExt: string, + store: boolean, + ctx: ToolContext +): Promise { + const container = containerOf(targetExt) + if (!container) throw new Error(`Unsupported archive format: ${targetExt}`) + + const onStderr = (c: string): void => { + const p = parse7zProgress(c) + if (p !== undefined) ctx.onProgress(p, 'Packing') + } + + if (container === 'rar') { + const rar = resolveRar() + // Guarded in the UI too, but session-restored options can still land here. + if (!rar) throw new Error('WinRAR not found. CBR output needs WinRAR installed.') + const res = await run(rar, buildRarPackArgs(output), { signal: ctx.signal, cwd: dir, onStderr }) + if (res.code !== 0) throw new Error(res.stderr.trim() || 'WinRAR could not write the archive') + return + } + + const res = await run(resolveSevenZip(), buildPackArgs(output, container, store), { + signal: ctx.signal, + cwd: dir, + onStderr + }) + if (res.code !== 0) throw new Error(res.stderr.trim() || '7-Zip could not write the archive') +} + +const archiveTool: ToolModule = { + async run(file, options, ctx) { + const op = String(options.op ?? 'repack') + + if (op === 'extract') { + const dir = uniqueOutDir(dirname(file.path), basename(file.path, extname(file.path))) + const res = await run(resolveSevenZip(), buildExtractArgs(file.path, dir), { + signal: ctx.signal, + onStderr: (c) => { + const p = parse7zProgress(c) + if (p !== undefined) ctx.onProgress(p, 'Extracting') + } + }) + if (res.code !== 0) { + rmSync(dir, { recursive: true, force: true }) + throw new Error(res.stderr.trim() || 'Could not read this archive') + } + return dir + } + + if (op === 'repack') { + const targetExt = normalizeExt(String(options.format ?? '.cbz')) + if (needsRar(targetExt) && !resolveRar()) + throw new Error('WinRAR not found. CBR output needs WinRAR installed.') + const store = options.store !== false + const temp = await extractToTemp(file.path, ctx) + const output = reserveOutPath(file.path, targetExt, 'converted') + try { + await packDir(temp, output, targetExt, store, ctx) + return output + } catch (e) { + rmSync(output, { force: true }) + throw e + } finally { + rmSync(temp, { recursive: true, force: true }) + } + } + + if (op === 'to-pdf') { + const temp = await extractToTemp(file.path, ctx) + try { + const pages = naturalSort( + listFilesRec(temp).filter((p) => + IMAGE_ENTRY_EXTS.some((x) => p.toLowerCase().endsWith(x)) + ) + ).map((p) => join(temp, p)) + if (pages.length === 0) throw new Error('No images found in this archive') + + const output = reserveOutPath(file.path, '.pdf', 'converted') + // Windows caps a command line at 32767 chars; leave room for the exe + // and the output path. + const batches = batchImages(pages, 30000) + try { + if (batches.length === 1) { + await runToOutput(resolveTool('magick'), (o) => [...batches[0], o], output, ctx) + } else { + const parts: string[] = [] + for (const [i, b] of batches.entries()) { + const part = join(temp, `part-${i}.pdf`) + await runToOutput(resolveTool('magick'), (o) => [...b, o], part, ctx) + parts.push(part) + ctx.onProgress(Math.round(((i + 1) / batches.length) * 90), 'Building PDF') + } + await runToOutput( + resolveTool('mutool'), + (o) => buildPdfMergeArgs(parts, o), + output, + ctx + ) + } + return output + } catch (e) { + rmSync(output, { force: true }) + throw e + } + } finally { + rmSync(temp, { recursive: true, force: true }) + } + } + + if (op === 'from-pdf') { + const targetExt = normalizeExt(String(options.format ?? '.cbz')) + if (needsRar(targetExt) && !resolveRar()) + throw new Error('WinRAR not found. CBR output needs WinRAR installed.') + const dpi = Number(options.dpi ?? 150) + const pageFormat = String(options.pageFormat ?? 'jpg') + const quality = Number(options.quality ?? 85) + + const temp = mkdtempSync(join(tmpdir(), 'filesmith-arc-')) + const output = reserveOutPath(file.path, targetExt, 'converted') + try { + ctx.onProgress(undefined, 'Rendering pages') + const draw = await run(resolveTool('mutool'), buildPdfImagesArgs(file.path, temp, dpi), { + signal: ctx.signal + }) + if (draw.code !== 0) throw new Error(draw.stderr.trim() || 'Could not render this PDF') + + if (pageFormat === 'jpg') { + // mutool draw has no jpeg writer, so convert the rendered PNGs in one + // mogrify pass. A 200-page PNG comic runs to hundreds of megabytes. + ctx.onProgress(50, 'Compressing pages') + const mog = await run( + resolveTool('magick'), + ['mogrify', '-format', 'jpg', '-quality', String(quality), '*.png'], + { signal: ctx.signal, cwd: temp } + ) + if (mog.code !== 0) throw new Error(mog.stderr.trim() || 'Could not compress the pages') + for (const f of readdirSync(temp)) + if (f.toLowerCase().endsWith('.png')) rmSync(join(temp, f), { force: true }) + } + + await packDir(temp, output, targetExt, true, ctx) + return output + } catch (e) { + rmSync(output, { force: true }) + throw e + } finally { + rmSync(temp, { recursive: true, force: true }) + } + } + + throw new Error(`Unknown archive operation: ${op}`) + } +} +``` + +Register it in the tool map at the bottom of the file, next to `pdf: pdfTool`: + +```ts +archive: archiveTool +``` + +- [ ] **Step 4: Run the unit suite and typecheck** + +Run: `npm test && npm run typecheck` +Expected: all green. If `runToOutput`'s signature differs from the sketch above, adapt the calls to the real one rather than changing `runToOutput`; the `argsFor` callback shape is what keeps `%`-in-path safe. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/tools/registry.ts test/archive-tool.test.ts +git commit -m "feat(archive): add the archive tool with repack, extract and PDF bridges" +``` + +--- + +### Task 6: WinRAR status over IPC + +**Files:** + +- Modify: `src/main/ipc.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/renderer/src/env.d.ts` (the `window.filesmith` type, if the API surface is typed there) +- Create: `src/renderer/src/components/useArchiveStatus.ts` + +**Interfaces:** + +- Consumes: `resolveRar` from Task 4. +- Produces: `window.filesmith.archiveStatus(): Promise<{ rar: boolean }>`; `useArchiveStatus(): { rar: boolean }`. + +Read `usePidStatus.ts` first and follow its shape exactly. + +- [ ] **Step 1: Add the IPC handler** + +In `src/main/ipc.ts`, next to `removebg:status`: + +```ts +ipcMain.handle('archive:status', () => ({ rar: resolveRar() !== null })) +``` + +with `resolveRar` added to the existing `../toolResolver` import. + +- [ ] **Step 2: Expose it in the preload bridge** + +In `src/preload/index.ts`, next to `removebgStatus`: + +```ts + archiveStatus: (): Promise<{ rar: boolean }> => ipcRenderer.invoke('archive:status'), +``` + +- [ ] **Step 3: Add the renderer hook** + +```ts +// src/renderer/src/components/useArchiveStatus.ts +import { useEffect, useState } from 'react' + +/** Whether WinRAR is installed. Only RAR/CBR *output* depends on it: reading a + * .cbr always works through bundled 7-Zip. */ +export function useArchiveStatus(): { rar: boolean } { + const [rar, setRar] = useState(false) + useEffect(() => { + let live = true + window.filesmith.archiveStatus().then((s) => { + if (live) setRar(s.rar) + }) + return () => { + live = false + } + }, []) + return { rar } +} +``` + +- [ ] **Step 4: Typecheck** + +Run: `npm run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/ipc.ts src/preload/index.ts src/renderer/src/env.d.ts src/renderer/src/components/useArchiveStatus.ts +git commit -m "feat(archive): report WinRAR availability to the renderer" +``` + +--- + +### Task 7: Catalog entries for the Archives category + +**Files:** + +- Modify: `src/shared/catalog.ts` +- Modify: `src/renderer/src/components/Icon.tsx` +- Test: `test/archive-catalog-nav.test.ts` + +**Interfaces:** + +- Consumes: `ToolId` including `'archive'` (Task 2). +- Produces: `CategoryId` includes `'archives'`; `OPERATIONS.archives` with ids `convert`, `extract`, `to-pdf`; `OPERATIONS.pdf` gains id `to-cbz`. + +Note: the archive rail icon and colour are the one new visual asset in this feature, and the project rule sends anything touching the look through the owner's mockup process. Use the placeholder below only after the owner has signed off on the glyph and colour. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/archive-catalog-nav.test.ts +import { describe, expect, it } from 'vitest' +import { acceptsKind, defaultOperation, findOperation, operationsFor } from '@shared/catalog' + +describe('archives category', () => { + it('accepts archive files and nothing else', () => { + expect(acceptsKind('archives', 'archive')).toBe(true) + expect(acceptsKind('archives', 'image')).toBe(false) + }) + + it('lands on Convert', () => { + expect(defaultOperation('archives')).toBe('convert') + }) + + it('offers convert, extract and to-pdf', () => { + expect(operationsFor('archives').map((o) => o.id)).toEqual(['convert', 'extract', 'to-pdf']) + }) + + it('routes every archives operation to the archive tool', () => { + for (const o of operationsFor('archives')) expect(o.tool).toBe('archive') + }) + + it('adds a To CBZ card under PDF that runs the archive tool', () => { + const card = findOperation('pdf', 'to-cbz') + expect(card?.tool).toBe('archive') + expect(card?.opKey).toBe('from-pdf') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/archive-catalog-nav.test.ts` +Expected: FAIL, `'archives'` is not a `CategoryId`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/shared/catalog.ts`: + +```ts +export type CategoryId = 'images' | 'video' | 'audio' | 'pdf' | 'documents' | 'archives' +``` + +Append to `CATEGORIES`: + +```ts + { id: 'archives', label: 'Archives', kinds: ['archive'], color: '#a16207', icon: 'archive' } +``` + +Append to `OPERATIONS`: + +```ts +archives: [ + { + id: 'convert', + label: 'Convert', + desc: 'Repack as CBZ, CBR, CB7 or ZIP', + color: '#5b5bd6', + icon: 'convert', + tool: 'archive', + opKey: 'repack' + }, + { + id: 'extract', + label: 'Extract', + desc: 'Unpack into a folder', + color: '#22b364', + icon: 'resize', + tool: 'archive', + opKey: 'extract' + }, + { + id: 'to-pdf', + label: 'To PDF', + desc: 'Turn a comic archive into a PDF', + color: '#ef4444', + icon: 'pdf', + tool: 'archive', + opKey: 'to-pdf' + } +] +``` + +Append to the existing `pdf` operations array: + +```ts + { + id: 'to-cbz', + label: 'To CBZ', + desc: 'Pack the pages as a comic archive', + color: '#a16207', + icon: 'archive', + tool: 'archive', + opKey: 'from-pdf' + } +``` + +In `src/renderer/src/components/Icon.tsx`, add the `archive` glyph next to `pdf` (a lidded box): + +```tsx + archive: ( + + ), +``` + +- [ ] **Step 4: Run test and launch the app** + +Run: `npx vitest run test/archive-catalog-nav.test.ts && npm run typecheck` +Expected: PASS. + +Run: `npm run dev` +Expected: an Archives entry in the category rail with three operation cards, and a To CBZ card on the PDF category. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/catalog.ts src/renderer/src/components/Icon.tsx test/archive-catalog-nav.test.ts +git commit -m "feat(archive): add the Archives category and its operation cards" +``` + +--- + +### Task 8: Options panel for archive operations + +**Files:** + +- Modify: `src/renderer/src/components/OptionsPanel.tsx` +- Test: manual, through `npm run dev` + +**Interfaces:** + +- Consumes: `archiveTargets`, `needsRar` (Task 1); `useArchiveStatus` (Task 6); operation `opKey` values (Task 7). +- Produces: job options `{ op, format, store, dpi, pageFormat, quality }` matching the contract in Task 5. + +Read how the PDF branch of `OptionsPanel` builds its controls and reuse the same chip and select components. Introduce no new visual vocabulary. + +- [ ] **Step 1: Add the archive branch** + +For `tool === 'archive'`: + +- `op` is always the current operation's `opKey`. +- **Convert (`repack`)**: target chips from `archiveTargets(file.ext, rar)` — but render the full `ARCHIVE_FORMATS` list minus the source, and when `needsRar(f.ext) && !rar` render the chip `disabled` with `title="WinRAR not found"` so hovering explains it, instead of hiding the option. Plus a Compression control: `Store` (default, `store: true`) and `Normal` (`store: false`), with the helper line "Comic pages are already compressed, so Store is faster and the same size." +- **Extract**: no options. Show the standard "no options" state the panel already uses for option-free operations. +- **To PDF (`to-pdf`)**: no options. +- **To CBZ (`from-pdf`)**: a DPI control matching the existing Pages-to-PNG card (`dpi`, default 150); a page format toggle `JPEG` / `PNG` (`pageFormat`, default `'jpg'`); a quality slider shown only for JPEG (`quality`, default 85); and target chips limited to the comic formats `.cbz` / `.cb7` / `.cbt` / `.cbr`, with the same disabled-plus-tooltip treatment for `.cbr`. + +- [ ] **Step 2: Verify the greyed-out CBR chip by hand** + +Run: `npm run dev` + +With WinRAR **not** installed: open Archives, drop a `.cbz`, confirm the CBR and RAR chips are visibly disabled, are not selectable, and show `WinRAR not found` on hover. + +If WinRAR is installed on this machine, temporarily make `resolveRar` return `null` to check the disabled state, then revert. + +- [ ] **Step 3: Verify the enabled path** + +With WinRAR installed, confirm the CBR chip is selectable and a `.cbz` converts to a `.cbr` that opens in a comic reader. + +- [ ] **Step 4: Lint and typecheck** + +Run: `npm run lint && npm run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/components/OptionsPanel.tsx +git commit -m "feat(archive): add archive options with a WinRAR-gated CBR target" +``` + +--- + +### Task 9: End-to-end coverage + +**Files:** + +- Modify: `e2e/workflows.spec.ts` +- Create: `e2e/fixtures/sample.cbz` (three tiny PNG pages named `p1.png`, `p2.png`, `p10.png`) +- Create: `e2e/fixtures/sample-pages.pdf` (a 3-page PDF) + +**Interfaces:** + +- Consumes: the full chain from Tasks 1 through 8. +- Produces: two e2e cases proving the preload / IPC / engine path unit tests cannot reach. + +- [ ] **Step 1: Build the fixtures** + +```bash +mkdir -p e2e/fixtures/_pages +npx --yes magick -size 200x300 xc:white e2e/fixtures/_pages/p1.png +npx --yes magick -size 200x300 xc:gray e2e/fixtures/_pages/p2.png +npx --yes magick -size 200x300 xc:black e2e/fixtures/_pages/p10.png +"resources/bin/7z.exe" a -tzip e2e/fixtures/sample.cbz ./e2e/fixtures/_pages/* +"resources/bin/magick.exe" e2e/fixtures/_pages/p1.png e2e/fixtures/_pages/p2.png e2e/fixtures/_pages/p10.png e2e/fixtures/sample-pages.pdf +rm -rf e2e/fixtures/_pages +``` + +- [ ] **Step 2: Write the failing e2e cases** + +Follow the existing structure in `e2e/workflows.spec.ts` for launching the app, adding a file and running a job. Add: + +```ts +test('converts a CBZ to a CB7', async () => { + // Add e2e/fixtures/sample.cbz, choose Archives > Convert, pick CB7, Run. + // Assert: a "sample (converted).cb7" appears next to the fixture, is + // non-empty, and the queue row reports done. +}) + +test('converts a PDF to a CBZ', async () => { + // Add e2e/fixtures/sample-pages.pdf, choose PDF > To CBZ, Run. + // Assert: "sample-pages (converted).cbz" exists and `7z l` on it lists 3 + // entries. +}) +``` + +- [ ] **Step 3: Run them and watch them fail** + +Run: `npm run build && npm run test:e2e` +Expected: FAIL until the assertions are filled in against the real UI selectors. + +- [ ] **Step 4: Fill in the assertions and go green** + +Run: `npm run build && npm run test:e2e` +Expected: PASS, both new cases. + +- [ ] **Step 5: Run the full gate and commit** + +```bash +npm test && npm run typecheck && npm run lint && npm run build && npm run test:e2e +git add e2e/workflows.spec.ts e2e/fixtures/sample.cbz e2e/fixtures/sample-pages.pdf +git commit -m "test(archive): cover CBZ to CB7 and PDF to CBZ end to end" +``` + +--- + +### Task 10: Version bump, docs and PR + +**Files:** + +- Modify: `package.json` (minor version bump: this is a feature) +- Modify: `CLAUDE.md` (project layout section: note `tools/archive.ts` and the bundled 7-Zip) +- Modify: `README.md` (the operations list) + +- [ ] **Step 1: Bump the minor version** + +`0.2.2` becomes `0.3.0`. Features take the minor number. + +- [ ] **Step 2: Update the project docs** + +In `CLAUDE.md`, add `archive.ts` to the `src/main/tools/` list and add 7-Zip to the bundled-tools sentence. In `README.md`, add archive conversion to the operations the app performs. + +- [ ] **Step 3: Run the full PR gate** + +Run: `npm test && npm run typecheck && npm run lint && npm run build && npm run test:e2e` +Expected: all green. + +- [ ] **Step 4: Package and install locally** + +Run: `npm run package` +Install the built `Filesmith-Setup-x64-0.3.0.exe` and confirm a real `.cbr` from disk converts to `.cbz` and opens in a reader. + +- [ ] **Step 5: Open the PR** + +```bash +git push -u origin feat/archive-conversion +gh pr create --title "feat: archive conversion (CBZ, CBR, CB7, CBT) with PDF bridges" --body "Implements docs/superpowers/specs/2026-08-31-archive-conversion-design.md" +``` + +--- + +## Self-Review + +**Spec coverage.** Formats read: Task 2. Formats written and the RAR gate: Tasks 1, 4, 5, 8. The four operations: Tasks 5 and 7. 7-Zip bundling: Task 4. `archive.ts` pure functions: Tasks 1 and 3. `archiveTool`: Task 5. `resolveRar`: Task 4. IPC and preload: Task 6. Shared types: Task 2. Renderer: Tasks 7 and 8. Error handling: Task 5 (password, no-images, WinRAR guard, output cleanup on failure). Testing: unit coverage in Tasks 1 through 4, e2e in Task 9. Deferred items are not implemented, as intended. + +**Type consistency.** `containerOf` / `needsRar` / `archiveTargets` (Task 1) are used unchanged in Tasks 5 and 8. `buildExtractArgs` / `buildPackArgs` / `buildRarPackArgs` / `parse7zProgress` / `naturalSort` / `batchImages` / `IMAGE_ENTRY_EXTS` (Task 3) are used unchanged in Task 5. `resolveSevenZip` / `resolveRar` (Task 4) are used unchanged in Tasks 5 and 6. The job-option names in Task 5's table match the options Task 8 emits. + +**Open dependency.** Task 7 introduces the Archives rail icon and colour, the one new visual asset. Under the project's design rule that needs the owner's sign-off before the placeholder glyph and `#a16207` are treated as final. diff --git a/docs/superpowers/specs/2026-08-31-archive-conversion-design.md b/docs/superpowers/specs/2026-08-31-archive-conversion-design.md new file mode 100644 index 0000000..02a2f49 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-archive-conversion-design.md @@ -0,0 +1,185 @@ +# Archive conversion (comic archives) — design + +Date: 2026-08-31 +Status: proposed + +## Goal + +Filesmith converts between archive containers, with comic archives (`.cbz` / `.cbr` / +`.cb7` / `.cbt`) as the driving use case, and bridges those archives to and from PDF. + +## Why it fits + +`.cbz` / `.cbr` / `.cb7` / `.cbt` are ZIP / RAR / 7z / tar with a renamed extension and +images inside, ordered by filename. So "convert" here is extract-and-repack, not +transcoding. Filesmith already orchestrates external CLIs, already owns collision-safe +output naming, and already bundles the two tools the PDF bridges need (mutool, +ImageMagick). The only new dependency is 7-Zip. + +## The RAR asymmetry + +Reading RAR is free. Writing RAR is not: only WinRAR's `Rar.exe` creates RAR archives, +it is proprietary, and it cannot be bundled. 7-Zip's own licence carries the unRAR +restriction that forbids using its RAR code to build a RAR compressor. + +Resolution: RAR/CBR is always readable. RAR/CBR **output** is enabled only when +`Rar.exe` is found on the machine. When it is not, the CBR and RAR target chips render +greyed out with a `WinRAR not found` tooltip on hover, rather than disappearing — a user +looking for CBR gets an answer instead of a missing option. + +## Scope + +### Formats read + +`.zip` `.rar` `.7z` `.tar` `.cbz` `.cbr` `.cb7` `.cbt` + +Deliberately out of v1: `.tar.gz` / `.tgz` / `.tar.bz2`. The codebase models `ext` as a +single lowercased extension (`FileInfo.ext`, `extname`), so compound extensions need a +classifier change, and gzipped tars need a two-pass extract. Neither serves comics. +Deferred, not forgotten. + +### Formats written + +| Target | Container | Availability | +| ------ | --------- | -------------------------- | +| `.cbz` | zip | always | +| `.zip` | zip | always | +| `.cb7` | 7z | always | +| `.7z` | 7z | always | +| `.cbt` | tar | always | +| `.tar` | tar | always | +| `.cbr` | rar | only with WinRAR installed | +| `.rar` | rar | only with WinRAR installed | + +### Operations + +New `archives` category, three operation cards: + +1. **Convert** (`repack`) — archive to archive. Extract to a temp dir, repack into the + target container. +2. **Extract** (`extract`) — archive to a collision-free folder next to the source. +3. **To PDF** (`to-pdf`) — comic archive to PDF, images in natural page order. + +One new card on the existing **PDF** category: + +4. **To CBZ** (`from-pdf`) — render pages, pack them into a comic archive. + +Cards 3 and 4 both carry `tool: 'archive'`; the catalog already supports a category +hosting a card from another tool, so no catalog structure change is needed. + +## Architecture + +### New dependency: 7-Zip + +`7z.exe` + `7z.dll` (~2 MB) copied into `resources/bin` by `scripts/fetch-binaries.mjs`, +which already locates a local 7-Zip install (`scripts/fetch-binaries.mjs:245-247`) for the +Ghostscript step. This follows the existing magick / caesium "copy from a local install, +print install guidance if absent" pattern; only ffmpeg downloads. `electron-builder` +already packs `resources/bin`, so packaging needs no change. 7-Zip's licence text is +copied alongside the binary. + +Standalone `7zr.exe` is not an option: it handles only the 7z format, and this feature +needs zip, rar and tar. + +### `src/main/tools/archive.ts` — the tool module + +Owns its format catalog and every argument builder, and is independently testable in the +way `pdf.ts` and `convert.ts` are. Pure functions only: + +- `ARCHIVE_FORMATS: FormatOption[]` and `CONTAINER_OF: Record` mapping + each extension to `'zip' | '7z' | 'tar' | 'rar'`. +- `archiveTargets(sourceExt, hasRar): FormatOption[]` — every target except the source's + own format, RAR entries dropped when `hasRar` is false. Shared with the renderer so the + UI and the engine cannot drift. +- `buildExtractArgs(input, outDir)` gives `['x', input, '-o' + outDir, '-y', '-bsp2']` +- `buildPackArgs(output, container, level)` gives `['a', '-t' + container, '-mx' + level, +output, '*', '-y', '-bsp2']`, run with `cwd` set to the temp dir so the archive holds + the contents, not a wrapper folder. A wrapper folder breaks comic readers. +- `buildRarPackArgs(output, srcDir)` gives WinRAR's `['a', '-ep1', '-r', '-y', output, '.']` +- `parse7zProgress(chunk): number | undefined` — the `47%` counter. `-bsp2` sends the + progress stream to stderr, so it arrives through `RunOptions.onStderr` and `run.ts` + needs no change. +- `naturalSort(names): string[]` — `page2` before `page10`. Comic page order is the whole + point of the feature; plain lexicographic sort scrambles it. +- `batchImages(paths, budget): string[][]` — splits an image list into command lines that + stay under the Windows 32767-character limit. + +### `registry.ts` — `archiveTool: ToolModule` + +An op switch mirroring `pdfTool`. Every op extracts to `mkdtempSync` and removes the temp +tree in a `finally`, so a cancel or failure leaves nothing behind. + +- `repack` — extract, then pack. Output `reserveOutPath(source, targetExt, 'converted')`. +- `extract` — `uniqueOutDir(dirname(source), basename)`, extract straight into it. +- `to-pdf` — extract, collect image entries, `naturalSort`, then ImageMagick. When the + file list exceeds the command-line budget, `batchImages` splits it into part PDFs and + `buildPdfMergeArgs` (already wired for PDF merge) joins them. Output + `reserveOutPath(source, '.pdf', 'converted')`. +- `from-pdf` — `buildPdfImagesArgs` renders `page-%d.png` at the chosen DPI into a temp + dir; when the target page format is JPEG, one `magick mogrify -format jpg -quality N` + pass converts them and the PNGs are dropped; then pack to the chosen comic container. + JPEG is the default: a 200-page PNG CBZ runs to hundreds of megabytes. + +Guard: an op targeting `.cbr` / `.rar` with no `Rar.exe` fails the job with +`WinRAR not found — CBR output needs WinRAR installed`. The UI already prevents this, but +options can arrive from a restored session, so the engine checks too. + +### `toolResolver.ts` — `resolveRar(): string | null` + +`Program Files\WinRAR\Rar.exe`, then the x86 root, then bare `Rar` on PATH. Returns +`null` when absent, in the shape `resolveRembg()` already uses for an optional tool. + +### IPC and preload + +`archive:status` returns `{ rar: boolean }`, exposed as `archiveStatus()`, consumed by a +`useArchiveStatus` hook alongside `usePidStatus` / `useGenerateStatus`. + +### Shared types + +- `ToolId` gains `'archive'`. +- `FileKind` gains `'archive'`. +- `fileKind.ts` gains `ARCHIVE_EXTS` and the classifier branch. +- `catalog.ts` gains the `archives` category and its operations, plus the To CBZ card + under `pdf`. + +### Renderer + +`OptionsPanel` gains an archive branch: target chips (RAR entries greyed with the +tooltip), and a compression control for repack — **Store** (default; comic pages are +already-compressed images, so deflate buys nothing and costs time) or **Normal**. Extract +has no options. From-PDF reuses the DPI control the existing Pages-to-PNG card already +has, plus page format and JPEG quality. + +Everything else reuses existing components. The one genuinely new visual asset is the +archive category's rail icon and colour, which needs the owner's sign-off under the +project's design rule. + +## Error handling + +- Missing 7-Zip gives `ToolMissingError`, surfaced through the existing + `toolMissingMessage`. +- Encrypted archive: 7-Zip exits non-zero on the password prompt; the job fails with + `Archive is password-protected`. No password UI in v1. +- `to-pdf` on an archive with no images fails with `No images found in this archive` + rather than writing an empty PDF. +- Never overwrite: every output goes through `reserveOutPath` / `uniqueOutDir`, per the + project's hard rule. +- Path handling routes through `runToOutput`'s `argsFor` pattern, so the known + `%`-in-path expansion gotcha for magick and mutool stays covered. + +## Testing + +Unit (vitest, matching the arg-builder / catalog / collision focus): +container mapping; `archiveTargets` gating on `hasRar`; extract, pack and rar arg +builders; `parse7zProgress`; `naturalSort` including the page2/page10 case; +`batchImages` boundary behaviour; `fileKind` on every archive extension. + +E2E (Playwright `_electron`): a small fixture `.cbz` converts to `.cb7` and the output +exists and is non-empty; a fixture PDF converts to `.cbz` and the archive lists the +expected page count. + +## Deferred + +`.tar.gz` / `.tgz`; flattening a single wrapper folder during repack; password-protected +archives; per-archive thumbnails drawn from the first page inside; browsing an archive's +contents in the preview window. diff --git a/e2e/helpers.ts b/e2e/helpers.ts index f4cc691..084bc53 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -9,6 +9,7 @@ export const MAGICK = join(BIN, 'magick.exe') export const FFMPEG = join(BIN, 'ffmpeg.exe') export const FFPROBE = join(BIN, 'ffprobe.exe') export const MUTOOL = join(BIN, 'mutool.exe') +export const SEVENZIP = join(BIN, '7z.exe') /** Env so the bundled modules-build magick finds its coder DLLs when the specs * call it directly (the app sets the same env for its own spawns). */ diff --git a/e2e/workflows.spec.ts b/e2e/workflows.spec.ts index 8958dcf..0fb1733 100644 --- a/e2e/workflows.spec.ts +++ b/e2e/workflows.spec.ts @@ -1,10 +1,18 @@ import { _electron, type ElectronApplication, type Page } from 'playwright' import { test, expect } from '@playwright/test' import { execFileSync } from 'child_process' -import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'fs' import { tmpdir } from 'os' import { basename, join } from 'path' -import { FFMPEG, FFPROBE, MAGICK, MAIN, MUTOOL, ROOT, magickEnv, runJob } from './helpers' +import { FFMPEG, FFPROBE, MAGICK, MAIN, MUTOOL, ROOT, SEVENZIP, magickEnv, runJob } from './helpers' // The full workflow matrix, run against the REAL built app: every operation of // every category, through the actual preload bridge -> IPC -> queue -> engine @@ -12,7 +20,7 @@ import { FFMPEG, FFPROBE, MAGICK, MAIN, MUTOOL, ROOT, magickEnv, runJob } from ' // synthesized into one temp dir (outputs land beside their sources, so the // teardown sweep removes everything the suite produced). -const binariesPresent = [MAGICK, FFMPEG, FFPROBE, MUTOOL].every(existsSync) +const binariesPresent = [MAGICK, FFMPEG, FFPROBE, MUTOOL, SEVENZIP].every(existsSync) let app: ElectronApplication let page: Page @@ -31,6 +39,16 @@ const ffprobeJson = (args: string[]): unknown => encoding: 'utf-8' }) ) +const sevenZip = (args: string[], cwd?: string): string => + execFileSync(SEVENZIP, args, { cwd, encoding: 'utf-8' }) +/** Entry names inside an archive, via `7z l -slt` (the first Path is the + * archive itself). */ +const entriesOf = (archive: string): string[] => + sevenZip(['l', '-slt', archive]) + .split('\n') + .filter((l) => l.startsWith('Path = ')) + .map((l) => l.slice(7).trim()) + .slice(1) const identify = (fmt: string, file: string): string => magick(['identify', '-format', fmt, file]).trim() const pageCount = (pdf: string): number => { @@ -121,6 +139,14 @@ test.beforeAll(async () => { join(dir, 'pages3.pdf') ]) + // Comic archive: pages named so a lexicographic sort would scramble them + // (p10 before p2), which is exactly what the natural ordering has to fix. + const comicSrc = join(dir, 'comic-src') + mkdirSync(comicSrc) + for (const n of ['p1.png', 'p2.png', 'p10.png']) + magick(['-size', '120x160', 'gradient:white-black', join(comicSrc, n)]) + sevenZip(['a', '-tzip', join(dir, 'comic.cbz'), '*', '-y'], comicSrc) + // Document: real prose so extract-text has something to find. writeFileSync( join(dir, 'notes.txt'), @@ -148,8 +174,18 @@ test('every category opens its workspace with the right operations', async () => Images: ['Convert', 'Compress', 'Resize', 'Remove Background', 'Upscale', 'Generate'], Video: ['Convert', 'Compress'], Audio: ['Convert', 'Compress'], - PDF: ['Extract text', 'Pages to PNG', 'Merge', 'Split', 'Burst', 'Extract images', 'Compress'], - Documents: ['Convert'] + PDF: [ + 'Extract text', + 'Pages to PNG', + 'Merge', + 'Split', + 'Burst', + 'Extract images', + 'To CBZ', + 'Compress' + ], + Documents: ['Convert'], + Archives: ['Convert', 'Extract', 'To PDF'] } for (const [category, ops] of Object.entries(expected)) { await page.locator(`button:has-text("${category}")`).first().click() @@ -482,3 +518,83 @@ test('generate: one 512px image through headless ComfyUI, then delete it', async // suite's temp dir (the app writes to the user's output folder). for (const p of produced.paths) fs.rmSync(p, { force: true }) }) + +// --- Archives ---------------------------------------------------------------- + +test('archive convert: cbz -> cb7, contents flat and page order intact', async () => { + const e = await runJob(page, 'archive', join(dir, 'comic.cbz'), { + op: 'repack', + format: '.cb7', + store: true + }) + expect(e.status).toBe('done') + expect(e.outputPath!.endsWith('.cb7')).toBe(true) + expect(statSync(e.outputPath!).size).toBeGreaterThan(0) + // No wrapper folder: a nested path here shows as an empty book in a reader. + expect(entriesOf(e.outputPath!).sort()).toEqual(['p1.png', 'p10.png', 'p2.png']) +}) + +test('archive convert: cbz -> zip and -> cbt', async () => { + for (const format of ['.zip', '.cbt']) { + const e = await runJob(page, 'archive', join(dir, 'comic.cbz'), { op: 'repack', format }) + expect(e.status, format).toBe('done') + expect(entriesOf(e.outputPath!)).toHaveLength(3) + } +}) + +test('archive extract: unpacks into its own folder', async () => { + const e = await runJob(page, 'archive', join(dir, 'comic.cbz'), { op: 'extract' }) + expect(e.status).toBe('done') + expect(readdirSync(e.outputPath!).sort()).toEqual(['p1.png', 'p10.png', 'p2.png']) +}) + +test('archive to-pdf: one page per image, in reading order', async () => { + const e = await runJob(page, 'archive', join(dir, 'comic.cbz'), { op: 'to-pdf' }) + expect(e.status).toBe('done') + expect(pageCount(e.outputPath!)).toBe(3) +}) + +test('archive to-pdf: an archive with no images fails instead of writing an empty PDF', async () => { + const src = join(dir, 'noimg-src') + mkdirSync(src) + writeFileSync(join(src, 'readme.txt'), 'no pages here') + sevenZip(['a', '-tzip', join(dir, 'noimg.cbz'), '*', '-y'], src) + const e = await runJob(page, 'archive', join(dir, 'noimg.cbz'), { op: 'to-pdf' }) + expect(e.status).toBe('failed') + expect(e.error).toMatch(/No images/i) +}) + +test('pdf to-cbz: pages are zero-padded jpegs so a reader orders them correctly', async () => { + const e = await runJob(page, 'archive', join(dir, 'pages3.pdf'), { + op: 'from-pdf', + format: '.cbz', + dpi: 72, + pageFormat: 'jpg', + quality: 80 + }) + expect(e.status).toBe('done') + expect(e.outputPath!.endsWith('.cbz')).toBe(true) + expect(entriesOf(e.outputPath!).sort()).toEqual([ + 'page-0001.jpg', + 'page-0002.jpg', + 'page-0003.jpg' + ]) +}) + +test('pdf to-cbz: PNG pages when asked', async () => { + const e = await runJob(page, 'archive', join(dir, 'pages3.pdf'), { + op: 'from-pdf', + format: '.cb7', + dpi: 72, + pageFormat: 'png' + }) + expect(e.status).toBe('done') + expect(entriesOf(e.outputPath!).every((n) => n.endsWith('.png'))).toBe(true) +}) + +test('archive collision safety: the same conversion twice yields two files', async () => { + const a = await runJob(page, 'archive', join(dir, 'comic.cbz'), { op: 'repack', format: '.cb7' }) + const b = await runJob(page, 'archive', join(dir, 'comic.cbz'), { op: 'repack', format: '.cb7' }) + expect(a.outputPath).not.toBe(b.outputPath) + expect(existsSync(a.outputPath!) && existsSync(b.outputPath!)).toBe(true) +}) diff --git a/package.json b/package.json index f23e133..3a8d8f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filesmith", - "version": "0.2.2", + "version": "0.3.0", "description": "A desktop file toolkit: convert, compress, resize, upscale, remove backgrounds, and PDF tools.", "author": "Max", "license": "MIT", diff --git a/scripts/fetch-binaries.mjs b/scripts/fetch-binaries.mjs index f075a93..80cebd9 100644 --- a/scripts/fetch-binaries.mjs +++ b/scripts/fetch-binaries.mjs @@ -106,6 +106,31 @@ function bundleCaesium() { log(` ✓ CaesiumCLT: caesiumclt.exe (${mb(join(BIN, 'caesiumclt.exe'))} MB)`) } +/** 7-Zip: 7z.exe plus its 7z.dll. Handles every archive container the Archives + * category reads and writes (zip, 7z, tar, and READING rar). Copied from a + * local install like ImageMagick and Caesium; the standalone 7zr.exe is not an + * option because it only speaks 7z. */ +function bundleSevenZip() { + const dirs = [ + process.env.ProgramW6432 && join(process.env.ProgramW6432, '7-Zip'), + process.env.ProgramFiles && join(process.env.ProgramFiles, '7-Zip'), + join('C:', 'Program Files', '7-Zip'), + join('C:', 'Program Files (x86)', '7-Zip') + ].filter(Boolean) + const dir = dirs.find((d) => existsSync(join(d, '7z.exe'))) + if (!dir) { + log(' ! 7-Zip not found — skip (winget install 7zip.7zip)') + return + } + // 7z.exe needs 7z.dll beside it. License.txt travels with them: 7-Zip is + // LGPL with the unRAR restriction, so the licence has to ship too. + for (const f of ['7z.exe', '7z.dll', 'License.txt']) { + const src = join(dir, f) + if (existsSync(src)) copyFileSync(src, join(BIN, f === 'License.txt' ? '7-Zip-License.txt' : f)) + } + log(` ✓ 7-Zip: 7z.exe (${mb(join(BIN, '7z.exe'))} MB)`) +} + /** ffmpeg: download the smaller "essentials" static build and extract ffmpeg.exe. */ async function bundleFfmpeg() { const url = 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip' @@ -406,13 +431,21 @@ function markSkipped(id) { function assertBundled() { const missing = [] - const required = ['magick.exe', 'ffmpeg.exe', 'ffprobe.exe', 'caesiumclt.exe', 'mutool.exe'] + const required = [ + 'magick.exe', + 'ffmpeg.exe', + 'ffprobe.exe', + 'caesiumclt.exe', + 'mutool.exe', + '7z.exe', + '7z.dll' + ] for (const f of required) if (!existsSync(join(BIN, f))) missing.push({ id: f, - what: 'core convert/compress/PDF', - how: 'winget install ImageMagick.ImageMagick Gyan.FFmpeg ArtifexSoftware.mutool SaeraSoft.CaesiumCLT' + what: 'core convert/compress/PDF/archive', + how: 'winget install ImageMagick.ImageMagick Gyan.FFmpeg ArtifexSoftware.mutool SaeraSoft.CaesiumCLT 7zip.7zip' }) // The essentials ffmpeg is ~90 MB; the local "full" static build is ~227 MB @@ -476,6 +509,7 @@ if (process.argv.includes('--lo-only')) { log('Populating resources/bin …') bundleImageMagick() bundleCaesium() + bundleSevenZip() await bundleFfmpeg() bundleMutool() if (!SKIPPED.has('ghostscript')) await bundleGhostscript() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index c181fd1..a91ec47 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -9,10 +9,10 @@ import type { PreviewItem, PreviewPayload } from '@shared/types' -import { AUDIO_EXTS, DOC_EXTS, IMAGE_EXTS, TEXT_EXTS, VIDEO_EXTS } from '@shared/fileKind' +import { imageFilters, pickerFilters } from './pickerFilters' import { JobQueue } from './jobQueue' import { fileInfoFromPath } from './fileInfo' -import { removebgStatus } from './toolResolver' +import { removebgStatus, resolveRar } from './toolResolver' import { ensureUserNcnnDir, listNcnnModels, userNcnnDir } from './tools/ncnnModels' import { probeDimensions, probeImageDimensions } from './probe' import { makeThumbnail } from './thumbnail' @@ -45,7 +45,6 @@ import type { ComfyStatus, PidStatus } from '@shared/ipc' // Only files Filesmith can actually act on. Everything else (exe, zip, docs, …) // is hidden from the picker and dropped from drag-and-drop. -const bare = (exts: string[]): string[] => exts.map((e) => e.replace('.', '')) function isSupported(f: FileInfo): boolean { return f.kind !== 'other' } @@ -146,6 +145,9 @@ export function registerGlobalIpc(): JobQueue { return true }) ipcMain.handle('removebg:status', () => removebgStatus()) + // Only WRITING rar/cbr needs WinRAR (it cannot be bundled). Reading one uses + // the bundled 7-Zip, so this gates the target chips and nothing else. + ipcMain.handle('archive:status', () => ({ rar: resolveRar() !== null })) // The AI upscalers actually present on disk (bundled + the user's overlay), // so the picker reflects what is installed instead of a build-time literal. ipcMain.handle('upscale:models', () => { @@ -169,31 +171,14 @@ export function registerGlobalIpc(): JobQueue { ipcMain.handle('image:pick', async (e) => { const r = await openDialog(e, { properties: ['openFile'], - filters: [{ name: 'Images', extensions: bare(IMAGE_EXTS) }] + filters: imageFilters() }) return r.canceled || !r.filePaths.length ? null : r.filePaths[0] }) ipcMain.handle('files:pick', async (e) => { const r = await openDialog(e, { properties: ['openFile', 'multiSelections'], - filters: [ - { - name: 'All supported', - extensions: bare([ - ...IMAGE_EXTS, - ...VIDEO_EXTS, - ...AUDIO_EXTS, - '.pdf', - ...DOC_EXTS, - ...TEXT_EXTS - ]) - }, - { name: 'Images', extensions: bare(IMAGE_EXTS) }, - { name: 'Video', extensions: bare(VIDEO_EXTS) }, - { name: 'Audio', extensions: bare(AUDIO_EXTS) }, - { name: 'Documents', extensions: bare(['.pdf', ...DOC_EXTS]) }, - { name: 'Text', extensions: bare(TEXT_EXTS) } - ] + filters: pickerFilters() }) return r.canceled ? [] : r.filePaths.map(fileInfoFromPath).filter(isSupported) }) diff --git a/src/main/pickerFilters.ts b/src/main/pickerFilters.ts new file mode 100644 index 0000000..9012856 --- /dev/null +++ b/src/main/pickerFilters.ts @@ -0,0 +1,48 @@ +import { + ARCHIVE_EXTS, + AUDIO_EXTS, + DOC_EXTS, + IMAGE_EXTS, + TEXT_EXTS, + VIDEO_EXTS +} from '@shared/fileKind' + +/** Electron's dialog filters want bare extensions ("png"), not ".png". */ +const bare = (exts: string[]): string[] => exts.map((e) => e.replace(/^\./, '')) + +export interface PickerFilter { + name: string + extensions: string[] +} + +/** + * The Open dialog's filter list. Split out of ipc.ts so it can be tested: this + * list was silently missing a whole category once (archives shipped as a + * category while the picker still refused to show a .cbz), and the only thing + * that catches that is asserting the filters against the extension sets. + */ +export function pickerFilters(): PickerFilter[] { + const everything = [ + ...IMAGE_EXTS, + ...VIDEO_EXTS, + ...AUDIO_EXTS, + '.pdf', + ...DOC_EXTS, + ...TEXT_EXTS, + ...ARCHIVE_EXTS + ] + return [ + { name: 'All supported', extensions: bare(everything) }, + { name: 'Images', extensions: bare(IMAGE_EXTS) }, + { name: 'Video', extensions: bare(VIDEO_EXTS) }, + { name: 'Audio', extensions: bare(AUDIO_EXTS) }, + { name: 'Documents', extensions: bare(['.pdf', ...DOC_EXTS]) }, + { name: 'Text', extensions: bare(TEXT_EXTS) }, + { name: 'Archives', extensions: bare(ARCHIVE_EXTS) } + ] +} + +/** Just the image filter, for the Remove Background backdrop picker. */ +export function imageFilters(): PickerFilter[] { + return [{ name: 'Images', extensions: bare(IMAGE_EXTS) }] +} diff --git a/src/main/toolResolver.ts b/src/main/toolResolver.ts index a9811ba..30e364a 100644 --- a/src/main/toolResolver.ts +++ b/src/main/toolResolver.ts @@ -120,6 +120,35 @@ export function resolveGhostscript(): string { return exe.replace('.exe', '') // hope it's on PATH } +/** 7-Zip's console binary: bundled in resources/bin like ffmpeg and magick, + * falling back to the bare name so a PATH install still works in dev. Reading + * a rar needs nothing beyond this; only WRITING one needs WinRAR. */ +export function resolveSevenZip(): string { + return resolveTool('7z') +} + +/** First `/WinRAR/Rar.exe` that exists, or null. Split out from + * resolveRar so the search itself is testable against temp fixtures instead of + * whatever happens to be installed on the machine running the suite. */ +export function findRarIn(roots: string[]): string | null { + for (const root of roots) { + const p = join(root, 'WinRAR', 'Rar.exe') + if (existsSync(p)) return p + } + return null +} + +/** + * WinRAR's Rar.exe, the only thing that can WRITE a rar (and therefore a .cbr). + * It is proprietary and cannot be bundled, and 7-Zip's unRAR licence forbids + * using its RAR code to build a compressor. So this returns null when WinRAR is + * absent and the UI greys the RAR targets out with a reason. + */ +export function resolveRar(): string | null { + if (process.platform !== 'win32') return null + return findRarIn(programFilesRoots()) +} + /** * Resolve Real-ESRGAN's binary. Like Ghostscript it's a small tree (exe + dlls * + models/), so it lives in resources/realesrgan and the binary finds its diff --git a/src/main/tools/archive.ts b/src/main/tools/archive.ts new file mode 100644 index 0000000..cc8729d --- /dev/null +++ b/src/main/tools/archive.ts @@ -0,0 +1,81 @@ +import type { ArchiveContainer } from '@shared/archive' + +// Archive operations via 7-Zip (extract + pack) and, when installed, WinRAR +// (pack only: nothing else can write RAR). Every function here is pure so the +// argument shapes are testable without spawning anything, matching how +// convert.ts and pdf.ts are structured. + +/** Image entries treated as comic pages when converting an archive to PDF. */ +export const IMAGE_ENTRY_EXTS = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.tif', '.tiff'] + +/** `7z x -o -y -bsp2` — extract everything, overwriting inside our own + * temp dir. `-bsp2` puts the progress stream on stderr, where run.ts already + * listens, so no change to the spawn helper is needed. */ +export function buildExtractArgs(input: string, outDir: string): string[] { + return ['x', input, '-o' + outDir, '-y', '-bsp2'] +} + +/** + * `7z a -t -mx * -y -bsp2` — pack the CURRENT DIRECTORY's + * contents. Run this with cwd set to the extracted temp dir: passing the dir + * path instead would nest everything under a wrapper folder, which comic + * readers show as an empty book. 7-Zip expands `*` itself, so no shell is + * involved and the no-shell spawn rule still holds. + * + * `store` (-mx0) is the default for comic archives: the pages are already + * compressed images, so deflate costs time and saves nothing. + */ +export function buildPackArgs( + output: string, + container: ArchiveContainer, + store: boolean +): string[] { + return ['a', '-t' + container, store ? '-mx0' : '-mx5', output, '*', '-y', '-bsp2'] +} + +/** `rar a -ep1 -r -y .` — WinRAR packing the cwd. `-ep1` strips the base + * path so entries are not prefixed with `./`. */ +export function buildRarPackArgs(output: string): string[] { + return ['a', '-ep1', '-r', '-y', output, '.'] +} + +/** Last percent counter in a 7-Zip progress chunk, clamped to 0..100. */ +export function parse7zProgress(chunk: string): number | undefined { + const matches = [...chunk.matchAll(/(\d{1,3})%/g)] + if (matches.length === 0) return undefined + const n = Number(matches[matches.length - 1][1]) + return Math.max(0, Math.min(100, n)) +} + +const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }) + +/** Sort entry names the way a reader expects pages: page2 before page10. Plain + * lexicographic order scrambles every comic with more than nine pages, which is + * the whole point of this feature. */ +export function naturalSort(names: string[]): string[] { + return [...names].sort((a, b) => collator.compare(a, b)) +} + +/** + * Split an image list into groups whose joined command line stays under + * `budget` characters. Windows caps a command line at 32767 and a 400-page + * comic blows past it. A single path longer than the budget still gets its own + * batch rather than being dropped. + */ +export function batchImages(paths: string[], budget: number): string[][] { + const batches: string[][] = [] + let current: string[] = [] + let len = 0 + for (const p of paths) { + const add = current.length === 0 ? p.length : p.length + 1 + if (current.length > 0 && len + add > budget) { + batches.push(current) + current = [] + len = 0 + } + len += current.length === 0 ? p.length : p.length + 1 + current.push(p) + } + if (current.length > 0) batches.push(current) + return batches +} diff --git a/src/main/tools/pdf.ts b/src/main/tools/pdf.ts index 009a7fc..0855c65 100644 --- a/src/main/tools/pdf.ts +++ b/src/main/tools/pdf.ts @@ -52,9 +52,18 @@ export function normalizePageRange(input: string): string | null { return cleaned } -/** `mutool draw -F png -r -o /page-%d.png ` — render each page. */ -export function buildPdfImagesArgs(input: string, outDir: string, dpi: number): string[] { - return ['draw', '-F', 'png', '-r', String(dpi), '-o', join(outDir, 'page-%d.png'), input] +/** `mutool draw -F png -r -o /page-%d.png ` — render each page. + * `pattern` is a printf template mutool expands per page. The default keeps the + * unpadded names the Pages-to-PNG folder has always used; packing pages into a + * comic archive passes a zero-padded one instead, because a reader sorts entries + * by name and `page-10.png` would otherwise land before `page-2.png`. */ +export function buildPdfImagesArgs( + input: string, + outDir: string, + dpi: number, + pattern = 'page-%d.png' +): string[] { + return ['draw', '-F', 'png', '-r', String(dpi), '-o', join(outDir, pattern), input] } /** `mutool clean -gggg -z ` — garbage-collect + compress streams diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts index 92166f0..782a7fd 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -6,6 +6,7 @@ import { mkdtempSync, readdirSync, readFileSync, + renameSync, rmSync, statSync, writeFileSync @@ -15,13 +16,15 @@ import type { FileInfo, ToolId } from '@shared/types' import type { AudioCodec, ImageFormat, PdfLevel, UpscaleModel, VideoCodec } from '@shared/compress' import { resolveGhostscript, + resolveRar, + resolveSevenZip, resolveRealesrgan, resolveRembg, resolveSoffice, resolveTool, toolMissingMessage } from '../toolResolver' -import { run, ToolMissingError } from '../run' +import { run, ToolMissingError, type RunResult } from '../run' import { estimateProgress, estimateSecForBytes } from './estimate' import { reserveOutPath, uniqueOutDir } from '../output' import { ffmpegProgress, probeDuration, probeImageDimensions } from '../probe' @@ -33,6 +36,16 @@ import { pidInstalled } from '../pid/paths' import { spandrelSidecar } from '../comfy/sidecar' import { comfyModelByPath } from '../comfy/store' import type { ToolContext, ToolModule } from './tool' +import { containerOf, needsRar } from '@shared/archive' +import { + batchImages, + buildExtractArgs, + buildPackArgs, + buildRarPackArgs, + IMAGE_ENTRY_EXTS, + naturalSort, + parse7zProgress +} from './archive' import { buildFfmpegArgs, buildMagickArgs, @@ -1033,13 +1046,323 @@ function describeRembgError(stderr: string, code: number): string { return describeToolError(stderr, 'rembg', code) } +/** Every file under `root`, as paths relative to it (archives nest folders). */ +function listFilesRec(root: string, rel = ''): string[] { + const out: string[] = [] + for (const e of readdirSync(join(root, rel), { withFileTypes: true })) { + const r = rel ? join(rel, e.name) : e.name + if (e.isDirectory()) out.push(...listFilesRec(root, r)) + else out.push(r) + } + return out +} + +/** 7-Zip exits non-zero rather than blocking when an archive wants a password, + * which is the one archive failure worth naming for the user. */ +function describeArchiveError(res: RunResult, fallback: string): string { + const all = res.stderr + res.stdout + if (/wrong password|encrypted|Cannot open encrypted/i.test(all)) + return 'This archive is password-protected.' + return describeToolError(res.stderr, '7-Zip', res.code) || fallback +} + +/** Extract `input` into a fresh temp dir and return its path. The caller owns + * the directory and must remove it. */ +async function extractToTemp(input: string, ctx: ToolContext): Promise { + const dir = mkdtempSync(join(tmpdir(), 'filesmith-arc-')) + try { + const res = await run(resolveSevenZip(), buildExtractArgs(input, dir), { + signal: ctx.signal, + onStderr: (c) => { + const p = parse7zProgress(c) + if (p !== undefined) ctx.onProgress(p, 'Reading archive…') + } + }) + if (res.code !== 0) throw new Error(describeArchiveError(res, 'Could not read this archive')) + return dir + } catch (e) { + rmSync(dir, { recursive: true, force: true }) + throw e instanceof ToolMissingError ? new Error(toolMissingMessage(e.tool), { cause: e }) : e + } +} + +/** + * Pack the CONTENTS of `dir` into `output`, choosing 7-Zip or WinRAR by target + * format. Both are run with cwd set to `dir` so nothing is nested under a + * wrapper folder, which comic readers show as an empty book. + * + * The archive is built in its own temp dir and then copied onto `output`. + * reserveOutPath leaves a 0-byte placeholder to hold the name, and `7z a` / + * `rar a` are ADD commands: handed an existing empty file they try to update it + * and die with "Incorrect function" / "Bad archive". Building elsewhere also + * keeps a half-written archive from ever appearing at the final path, and keeps + * the archive out of the very directory being packed. + */ +async function packDir( + dir: string, + output: string, + targetExt: string, + store: boolean, + ctx: ToolContext +): Promise { + const container = containerOf(targetExt) + if (!container) throw new Error(`Unsupported archive format: ${targetExt}`) + + const onStderr = (c: string): void => { + const p = parse7zProgress(c) + if (p !== undefined) ctx.onProgress(p, 'Writing archive…') + } + + const outTmp = mkdtempSync(join(tmpdir(), 'filesmith-arcout-')) + const built = join(outTmp, 'out' + targetExt) + try { + if (container === 'rar') { + const rar = resolveRar() + // Also guarded in the UI, but session-restored options can still land a + // CBR target here on a machine where WinRAR has since been removed. + if (!rar) throw new Error(RAR_MISSING) + const res = await run(rar, buildRarPackArgs(built), { signal: ctx.signal, cwd: dir }) + if (res.code !== 0) + throw new Error(describeToolError(res.stderr, 'WinRAR', res.code) || 'WinRAR failed') + } else { + const res = await run(resolveSevenZip(), buildPackArgs(built, container, store), { + signal: ctx.signal, + cwd: dir, + onStderr + }) + if (res.code !== 0) throw new Error(describeArchiveError(res, '7-Zip failed')) + } + if (!existsSync(built) || statSync(built).size === 0) + throw new Error('The archive tool reported success but wrote no output') + copyFileSync(built, output) + } catch (e) { + throw e instanceof ToolMissingError ? new Error(toolMissingMessage(e.tool), { cause: e }) : e + } finally { + rmSync(outTmp, { recursive: true, force: true }) + } +} + +/** Refuse a RAR target early when WinRAR is absent, before any work is done. */ +function assertRarTarget(targetExt: string): void { + if (needsRar(targetExt) && !resolveRar()) throw new Error(RAR_MISSING) +} + +const RAR_MISSING = 'WinRAR not found. CBR and RAR output need WinRAR installed.' + +/** + * Archive operations. Comic archives are the driving case: .cbz/.cbr/.cb7/.cbt + * are zip/rar/7z/tar with a renamed extension, so converting between them is + * extract-and-repack. Reading every format is free; only WRITING rar needs + * WinRAR, which cannot be bundled. + */ +const archiveTool: ToolModule = { + async run(file, options, ctx) { + const op = String(options.op ?? 'repack') + + // Unpack into a new folder next to the source. + if (op === 'extract') { + const dir = uniqueOutDir( + dirname(file.path), + basename(file.path, extname(file.path)) + ' (extracted)' + ) + mkdirSync(dir, { recursive: true }) + ctx.onProgress(undefined, 'Extracting…') + try { + const res = await run(resolveSevenZip(), buildExtractArgs(file.path, dir), { + signal: ctx.signal, + onStderr: (c) => { + const p = parse7zProgress(c) + if (p !== undefined) ctx.onProgress(p, 'Extracting…') + } + }) + if (res.code !== 0) + throw new Error(describeArchiveError(res, 'Could not read this archive')) + return dir + } catch (e) { + // Never strand a half-extracted folder: the next run would make + // "name (extracted) (2)" and leave the broken one behind forever. + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + /* best effort */ + } + throw e instanceof ToolMissingError + ? new Error(toolMissingMessage(e.tool), { cause: e }) + : e + } + } + + // Repack into another container (the Convert card). + if (op === 'repack') { + const targetExt = normalizeExt(String(options.format ?? '.cbz')) + assertRarTarget(targetExt) + const store = options.store !== false + const temp = await extractToTemp(file.path, ctx) + const output = reserveOutPath(file.path, targetExt, 'converted') + try { + await packDir(temp, output, targetExt, store, ctx) + if (!existsSync(output) || statSync(output).size === 0) + throw new Error('The archive tool reported success but wrote no output') + return output + } catch (e) { + try { + rmSync(output, { force: true }) + } catch { + /* best effort */ + } + throw e + } finally { + rmSync(temp, { recursive: true, force: true }) + } + } + + // Comic archive to PDF, pages in natural reading order. + if (op === 'to-pdf') { + const temp = await extractToTemp(file.path, ctx) + try { + const entries = naturalSort( + listFilesRec(temp).filter((p) => IMAGE_ENTRY_EXTS.includes(extname(p).toLowerCase())) + ) + if (entries.length === 0) throw new Error('No images found in this archive') + + // Rename to neutral, zero-padded names in a flat folder: an entry like + // `100%off.jpg` would otherwise be printf-expanded by ImageMagick, and + // the flat list is what fixes page order for good. + const pagesDir = join(temp, '__pages') + mkdirSync(pagesDir, { recursive: true }) + const width = String(entries.length).length + const pages = entries.map((e, i) => { + const p = join(pagesDir, `p${String(i + 1).padStart(width, '0')}${extname(e)}`) + renameSync(join(temp, e), p) + return p + }) + + const output = reserveOutPath(file.path, '.pdf', 'converted') + const magick = resolveTool('magick') + // Windows caps a command line at 32767 characters and a long comic + // blows past it, so build part PDFs and let mutool merge join them. + const batches = batchImages(pages, 30000) + try { + if (batches.length === 1) { + return await runToOutput( + magick, + (out) => [...batches[0], out], + output, + ctx, + 'ImageMagick', + true, + undefined, + estimateSecForBytes(file.size, 0.2) + ) + } + const parts: string[] = [] + for (const [i, b] of batches.entries()) { + if (ctx.signal.aborted) throw new Error('Canceled') + ctx.onProgress( + Math.round((i / batches.length) * 90), + `Building PDF ${i + 1}/${batches.length}…` + ) + const part = join(temp, `part-${i}.pdf`) + const res = await run(magick, [...b, part], { signal: ctx.signal }) + if (res.code !== 0 || !existsSync(part)) + throw new Error(describeToolError(res.stderr, 'ImageMagick', res.code)) + parts.push(part) + } + ctx.onProgress(95, 'Joining pages…') + return await runToOutput( + resolveTool('mutool'), + (out) => buildPdfMergeArgs(parts, out), + output, + ctx, + 'mutool' + ) + } catch (e) { + try { + rmSync(output, { force: true }) + } catch { + /* best effort */ + } + throw e + } + } finally { + rmSync(temp, { recursive: true, force: true }) + } + } + + // PDF to comic archive: render the pages, then pack them. + if (op === 'from-pdf') { + const targetExt = normalizeExt(String(options.format ?? '.cbz')) + assertRarTarget(targetExt) + const dpi = Math.max(36, Math.min(600, Number(options.dpi ?? 150))) + const pageFormat = String(options.pageFormat ?? 'jpg') + const quality = Math.max(1, Math.min(100, Number(options.quality ?? 85))) + + // Always a neutral temp dir, so mutool draw's printf `-o` pattern can + // never expand a `%` inherited from the source file's name. + const temp = mkdtempSync(join(tmpdir(), 'filesmith-arc-')) + const output = reserveOutPath(file.path, targetExt, 'converted') + const est = estimateProgress(estimateSecForBytes(file.size, 0.15), (p) => + ctx.onProgress(Math.min(p, 90)) + ) + try { + ctx.onProgress(undefined, `Rendering pages @ ${dpi} DPI…`) + // Zero-padded, because a reader sorts entries by name: page-10 must not + // come before page-2. + const draw = await run( + resolveTool('mutool'), + buildPdfImagesArgs(file.path, temp, dpi, 'page-%04d.png'), + { signal: ctx.signal } + ) + if (draw.code !== 0) throw new Error(describeToolError(draw.stderr, 'mutool', draw.code)) + if (readdirSync(temp).length === 0) throw new Error('This PDF has no pages to render') + + if (pageFormat === 'jpg') { + // mutool draw has no JPEG writer, so convert the rendered PNGs in one + // mogrify pass. A 200-page PNG comic runs to hundreds of megabytes. + est.stop() + ctx.onProgress(60, 'Compressing pages…') + const mog = await run( + resolveTool('magick'), + ['mogrify', '-format', 'jpg', '-quality', String(quality), '*.png'], + { signal: ctx.signal, cwd: temp } + ) + if (mog.code !== 0) + throw new Error(describeToolError(mog.stderr, 'ImageMagick', mog.code)) + for (const f of readdirSync(temp)) + if (f.toLowerCase().endsWith('.png')) rmSync(join(temp, f), { force: true }) + } + + await packDir(temp, output, targetExt, true, ctx) + if (!existsSync(output) || statSync(output).size === 0) + throw new Error('The archive tool reported success but wrote no output') + return output + } catch (e) { + try { + rmSync(output, { force: true }) + } catch { + /* best effort */ + } + throw e instanceof ToolMissingError + ? new Error(toolMissingMessage(e.tool), { cause: e }) + : e + } finally { + est.stop() + rmSync(temp, { recursive: true, force: true }) + } + } + + throw new Error(`Unknown archive operation: ${op}`) + } +} + const TOOLS: Partial> = { convert: convertTool, compress: compressTool, resize: resizeTool, upscale: upscaleTool, removebg: removebgTool, - pdf: pdfTool + pdf: pdfTool, + archive: archiveTool } export function getTool(id: ToolId): ToolModule | undefined { diff --git a/src/preload/index.ts b/src/preload/index.ts index 41b08c9..35a8e34 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -188,6 +188,9 @@ const api = { return () => ipcRenderer.removeListener('generate:image', listener) }, + // Whether WinRAR is installed, which is the only way to WRITE a rar/cbr. + archiveStatus: (): Promise<{ rar: boolean }> => ipcRenderer.invoke('archive:status'), + // Remove Background availability (discloses the AI model + one-time download). removebgStatus: (): Promise<{ ready: boolean; uvAvailable: boolean }> => ipcRenderer.invoke('removebg:status'), diff --git a/src/renderer/src/components/Icon.tsx b/src/renderer/src/components/Icon.tsx index 42c35a1..792cd45 100644 --- a/src/renderer/src/components/Icon.tsx +++ b/src/renderer/src/components/Icon.tsx @@ -7,6 +7,7 @@ export type IconName = | 'upscale' | 'removebg' | 'pdf' + | 'archive' | 'upload' | 'check' | 'clock' @@ -58,6 +59,7 @@ const PATHS: Record = { ), pdf: , + archive: , upload: , check: , clock: ( diff --git a/src/renderer/src/components/OptionsPanel.tsx b/src/renderer/src/components/OptionsPanel.tsx index ba708c6..507191b 100644 --- a/src/renderer/src/components/OptionsPanel.tsx +++ b/src/renderer/src/components/OptionsPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react' import type { FileKind, JobOptions } from '@shared/types' -import { familyFormats, isSameFormat } from '@shared/convert' +import { familyFormats, isSameFormat, type FormatOption } from '@shared/convert' import { AUDIO_BITRATES, AUDIO_CODECS, @@ -17,6 +17,7 @@ import { type Choice, type UpscaleModel } from '@shared/compress' +import { ARCHIVE_FORMATS, COMIC_FORMATS, needsRar } from '@shared/archive' import { RESIZE_FITS, type ResizeFit } from '@shared/resize' import { BG_DEFAULTS, BG_FILLS, type BgFill } from '@shared/removebg' import { GEN_SIZES, GEN_STYLES, GEN_MAX_COUNT, clampDim } from '@shared/generate' @@ -29,6 +30,7 @@ import { usePidStatus } from './usePidStatus' import { ComfyImportCard } from './ComfyImport' import { useComfyModels } from './useComfyModels' import { useGenerateStatus } from './useGenerateStatus' +import { useArchiveStatus } from './useArchiveStatus' /** A live "input → output" resolution row for the video resolution preview. */ export interface VideoOutputRow { @@ -851,6 +853,167 @@ function PdfOptions({ ) } +/** Target-format chips shared by the two archive operations that write one. + * A format is greyed rather than hidden when it needs WinRAR: a user looking + * for CBR gets an answer on hover instead of a missing option. */ +function ArchiveTargets({ + formats, + value, + srcExts, + hasRar, + set +}: { + formats: FormatOption[] + value: string + srcExts: string[] + hasRar: boolean + set: (k: string, v: string | number | boolean) => void +}): JSX.Element { + return ( +
+ +
+ {formats.map((f) => { + // .zip and .cbz are the same container but not the same file, so the + // comparison is by extension, not by container: repacking a .zip as + // a .cbz is exactly what a comic reader needs. + const isSource = srcExts.includes(f.ext) + const noRar = needsRar(f.ext) && !hasRar + const disabled = isSource || noRar + const sel = value === f.ext && !disabled + return ( + + ) + })} +
+
+ ) +} + +function ArchiveOptions({ + options, + srcExts, + set +}: { + options: JobOptions + srcExts: string[] + set: (k: string, v: string | number | boolean) => void +}): JSX.Element { + const op = String(options.op ?? 'repack') + const { rar } = useArchiveStatus() + const format = String(options.format ?? '.cbz') + + if (op === 'extract') { + return ( +

+ Each archive is unpacked into its own folder next to it. +

+ ) + } + + if (op === 'to-pdf') { + return ( +

+ Pages are ordered by filename, the way a reader shows them. +

+ ) + } + + if (op === 'from-pdf') { + const pageFormat = String(options.pageFormat ?? 'jpg') + return ( + <> + +
+
+ + Resolution + + + {Number(options.dpi ?? 150)} DPI + +
+ set('dpi', Number(e.target.value))} + className="w-full accent-accent" + /> +
+
+ + set('pageFormat', v)} + options={[ + { value: 'jpg', label: 'JPEG' }, + { value: 'png', label: 'PNG' } + ]} + /> +

+ {pageFormat === 'jpg' + ? 'Much smaller files, the usual choice for comics.' + : 'Lossless, but a long comic runs to hundreds of megabytes.'} +

+
+ {pageFormat === 'jpg' && } + + ) + } + + // repack + return ( + <> + +
+ + set('store', v === 'store')} + options={[ + { value: 'store', label: 'Store' }, + { value: 'normal', label: 'Normal' } + ]} + /> +

+ Comic pages are already compressed images, so Store is faster at the same size. +

+
+ + ) +} + /** A checkpoint whose name marks it as restoration/refiner/inpaint — a valid * model but not text-to-image, so we never auto-select it as the default. */ function isRestoreName(name: string): boolean { @@ -1559,6 +1722,9 @@ export function OptionsPanel({ {operation.tool === 'pdf' && ( )} + {operation.tool === 'archive' && ( + + )} {operation.tool === 'generate' && } diff --git a/src/renderer/src/components/PreviewWindow.tsx b/src/renderer/src/components/PreviewWindow.tsx index 4eee7df..bc5119c 100644 --- a/src/renderer/src/components/PreviewWindow.tsx +++ b/src/renderer/src/components/PreviewWindow.tsx @@ -36,7 +36,7 @@ function forceDuration(m: HTMLMediaElement): boolean { /** * Centered fallback card shown when a file has no inline preview — the native - * kind for 'document'/'other', and the graceful landing spot when Chromium + * kind for 'document'/'archive'/'other', and the graceful landing spot when Chromium * can't decode an image/video/audio the file-kind heuristic still classified as * playable (e.g. .heic, .mkv, .wma). Single source of truth for that markup. */ @@ -417,7 +417,9 @@ function PreviewView({ {text && text.path === textPath ? text.text : ''} ))} - {(f.kind === 'document' || f.kind === 'other' || showFallback) && } + {(f.kind === 'document' || f.kind === 'archive' || f.kind === 'other' || showFallback) && ( + + )} {/* Non-video formats: a corner "expand" button to view fullscreen. */} {(f.kind === 'image' || f.kind === 'audio') && !showFallback && ( diff --git a/src/renderer/src/components/useArchiveStatus.ts b/src/renderer/src/components/useArchiveStatus.ts new file mode 100644 index 0000000..85a6180 --- /dev/null +++ b/src/renderer/src/components/useArchiveStatus.ts @@ -0,0 +1,24 @@ +import { useEffect, useRef, useState } from 'react' + +// Kept apart from OptionsPanel so that file keeps exporting only components +// (React Fast Refresh requires that), matching usePidStatus. + +/** + * Whether WinRAR is installed. Only RAR/CBR *output* depends on it: reading a + * .cbr always works through bundled 7-Zip. Fetched once on mount, which is + * enough because installing WinRAR mid-session is not a case worth polling for. + */ +export function useArchiveStatus(): { rar: boolean } { + const [rar, setRar] = useState(false) + const alive = useRef(true) + useEffect(() => { + alive.current = true + void window.filesmith.archiveStatus().then((s) => { + if (alive.current) setRar(s.rar) + }) + return () => { + alive.current = false + } + }, []) + return { rar } +} diff --git a/src/renderer/src/state.ts b/src/renderer/src/state.ts index 42d3bab..ef0742e 100644 --- a/src/renderer/src/state.ts +++ b/src/renderer/src/state.ts @@ -105,6 +105,7 @@ export const DEFAULT_OPTIONS: Record = { upscale: { upscaleFactor: 4, upscaleModel: 'photo' }, removebg: { ...BG_DEFAULTS }, pdf: { op: 'extract-text', dpi: 150, range: '' }, + archive: { op: 'repack', format: '.cbz', store: true, dpi: 150, pageFormat: 'jpg', quality: 85 }, generate: { prompt: '', model: '', @@ -122,8 +123,8 @@ export const DEFAULT_OPTIONS: Record = { export const emptyQueue = (): QueueState => ({ items: [], selected: [], anchor: null }) -/** A fresh workspace's options: its tool's defaults, plus the PDF verb when the - * operation is one of the several the pdf tool carries. */ +/** A fresh workspace's options: its tool's defaults, plus the operation's verb + * when its tool carries several (the pdf and archive tools both do). */ export function defaultOptionsFor(category: CategoryId, opId: string): JobOptions { const op = findOperation(category, opId) if (!op) return {} diff --git a/src/shared/archive.ts b/src/shared/archive.ts new file mode 100644 index 0000000..6291066 --- /dev/null +++ b/src/shared/archive.ts @@ -0,0 +1,57 @@ +import type { FormatOption } from './convert' + +// Comic archives are ordinary containers with a renamed extension: .cbz is a +// zip, .cbr a rar, .cb7 a 7z, .cbt a tar. Converting between them is +// extract-and-repack, so the only thing that varies is the container 7-Zip is +// told to write. Shared with the renderer so the target chips it draws and the +// formats the engine accepts can never drift apart. +export type ArchiveContainer = 'zip' | '7z' | 'tar' | 'rar' + +export const CONTAINER_OF: Record = { + '.cbz': 'zip', + '.zip': 'zip', + '.cb7': '7z', + '.7z': '7z', + '.cbt': 'tar', + '.tar': 'tar', + '.cbr': 'rar', + '.rar': 'rar' +} + +// Comic formats first: they are the reason this category exists. +export const ARCHIVE_FORMATS: FormatOption[] = [ + { label: 'CBZ', ext: '.cbz' }, + { label: 'CBR', ext: '.cbr' }, + { label: 'CB7', ext: '.cb7' }, + { label: 'CBT', ext: '.cbt' }, + { label: 'ZIP', ext: '.zip' }, + { label: 'RAR', ext: '.rar' }, + { label: '7Z', ext: '.7z' }, + { label: 'TAR', ext: '.tar' } +] + +/** The comic containers, the only sensible targets when packing rendered PDF + * pages (nobody wants a .tar of page scans). */ +export const COMIC_FORMATS: FormatOption[] = ARCHIVE_FORMATS.filter((f) => + ['.cbz', '.cbr', '.cb7', '.cbt'].includes(f.ext) +) + +const norm = (ext: string): string => (ext.startsWith('.') ? ext : '.' + ext).toLowerCase() + +export function containerOf(ext: string): ArchiveContainer | null { + return CONTAINER_OF[norm(ext)] ?? null +} + +/** True when writing this format requires WinRAR's Rar.exe, which cannot be + * bundled: it is proprietary, and 7-Zip's unRAR licence forbids using its RAR + * code to build a compressor. Reading a rar needs none of this. */ +export function needsRar(ext: string): boolean { + return containerOf(ext) === 'rar' +} + +/** Targets offered for a source archive: everything but its own format, with + * RAR formats removed when WinRAR is not installed. */ +export function archiveTargets(sourceExt: string, hasRar: boolean): FormatOption[] { + const src = norm(sourceExt) + return ARCHIVE_FORMATS.filter((f) => f.ext !== src && (hasRar || !needsRar(f.ext))) +} diff --git a/src/shared/catalog.ts b/src/shared/catalog.ts index 8edd1aa..4e03a1d 100644 --- a/src/shared/catalog.ts +++ b/src/shared/catalog.ts @@ -9,7 +9,7 @@ import type { FileKind, ToolId } from './types' // deserves its own card, while "Compress" maps to the compress tool under four // different categories. This table is the mapping between the two. -export type CategoryId = 'images' | 'video' | 'audio' | 'pdf' | 'documents' +export type CategoryId = 'images' | 'video' | 'audio' | 'pdf' | 'documents' | 'archives' export interface Category { id: CategoryId @@ -31,7 +31,8 @@ export const CATEGORIES: Category[] = [ kinds: ['document', 'text'], color: '#12b3a6', icon: 'doc' - } + }, + { id: 'archives', label: 'Archives', kinds: ['archive'], color: '#a16207', icon: 'archive' } ] export interface Operation { @@ -159,9 +160,47 @@ export const OPERATIONS: Record = { tool: 'pdf', opKey: 'extract-images' }, + { + id: 'to-cbz', + label: 'To CBZ', + desc: 'Pack the pages as a comic archive', + color: '#a16207', + icon: 'archive', + tool: 'archive', + opKey: 'from-pdf' + }, COMPRESS('Shrink file size') ], - documents: [CONVERT('Convert to PDF, DOCX, TXT')] + documents: [CONVERT('Convert to PDF, DOCX, TXT')], + archives: [ + { + id: 'convert', + label: 'Convert', + desc: 'Repack as CBZ, CBR, CB7 or ZIP', + color: '#5b5bd6', + icon: 'convert', + tool: 'archive', + opKey: 'repack' + }, + { + id: 'extract', + label: 'Extract', + desc: 'Unpack into a folder', + color: '#22b364', + icon: 'resize', + tool: 'archive', + opKey: 'extract' + }, + { + id: 'to-pdf', + label: 'To PDF', + desc: 'Turn a comic archive into a PDF', + color: '#ef4444', + icon: 'pdf', + tool: 'archive', + opKey: 'to-pdf' + } + ] } /** Every workspace is one (category, operation) pair, and that pair is its key. */ diff --git a/src/shared/fileKind.ts b/src/shared/fileKind.ts index fc6f8a0..f99df20 100644 --- a/src/shared/fileKind.ts +++ b/src/shared/fileKind.ts @@ -102,6 +102,11 @@ export const TEXT_EXTS = [ '.sh' ] +// Archive containers, including the comic variants (.cbz/.cbr/.cb7/.cbt are +// zip/rar/7z/tar with a renamed extension). Compound extensions (.tar.gz) are +// deliberately out: FileInfo.ext is a single lowercased extension. +export const ARCHIVE_EXTS = ['.zip', '.rar', '.7z', '.tar', '.cbz', '.cbr', '.cb7', '.cbt'] + /** Classify a file by its extension (lowercased, with or without a leading dot). */ export function fileKind(ext: string): FileKind { const e = (ext.startsWith('.') ? ext : '.' + ext).toLowerCase() @@ -109,6 +114,7 @@ export function fileKind(ext: string): FileKind { if (IMAGE_EXTS.includes(e)) return 'image' if (VIDEO_EXTS.includes(e)) return 'video' if (AUDIO_EXTS.includes(e)) return 'audio' + if (ARCHIVE_EXTS.includes(e)) return 'archive' if (TEXT_EXTS.includes(e)) return 'text' if (DOC_EXTS.includes(e)) return 'document' return 'other' diff --git a/src/shared/types.ts b/src/shared/types.ts index 959a0e1..ad2258c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,8 +1,10 @@ // Shared types crossing the main <-> renderer boundary. No Node or DOM imports. -export type ToolId = 'convert' | 'compress' | 'resize' | 'upscale' | 'removebg' | 'pdf' | 'generate' +export type ToolId = + 'convert' | 'compress' | 'resize' | 'upscale' | 'removebg' | 'pdf' | 'generate' | 'archive' -export type FileKind = 'image' | 'video' | 'audio' | 'pdf' | 'document' | 'text' | 'other' +export type FileKind = + 'image' | 'video' | 'audio' | 'pdf' | 'document' | 'text' | 'archive' | 'other' /** A file the user has added, as the renderer knows it. */ export interface FileInfo { diff --git a/test/archive-args.test.ts b/test/archive-args.test.ts new file mode 100644 index 0000000..338be35 --- /dev/null +++ b/test/archive-args.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + batchImages, + buildExtractArgs, + buildPackArgs, + buildRarPackArgs, + naturalSort, + parse7zProgress +} from '../src/main/tools/archive' + +describe('7-Zip arguments', () => { + it('extracts into a target directory without prompting', () => { + expect(buildExtractArgs('C:\\in.cbr', 'C:\\tmp\\x')).toEqual([ + 'x', + 'C:\\in.cbr', + '-oC:\\tmp\\x', + '-y', + '-bsp2' + ]) + }) + + it('packs the directory contents, not a wrapper folder', () => { + const args = buildPackArgs('C:\\out.cbz', 'zip', true) + expect(args).toEqual(['a', '-tzip', '-mx0', 'C:\\out.cbz', '*', '-y', '-bsp2']) + expect(args).not.toContain('C:\\tmp\\x') + }) + + it('uses normal compression when store is off', () => { + expect(buildPackArgs('C:\\out.7z', '7z', false)).toContain('-mx5') + }) + + it('builds a WinRAR command that strips the leading path', () => { + expect(buildRarPackArgs('C:\\out.cbr')).toEqual(['a', '-ep1', '-r', '-y', 'C:\\out.cbr', '.']) + }) +}) + +describe('parse7zProgress', () => { + it('reads the percent counter', () => { + expect(parse7zProgress(' 47% 12 - page-012.jpg')).toBe(47) + }) + + it('takes the last percent in a multi-line chunk', () => { + expect(parse7zProgress(' 10% a\r 62% b\r')).toBe(62) + }) + + it('returns undefined when there is no counter', () => { + expect(parse7zProgress('Scanning the drive for archives')).toBeUndefined() + }) + + it('clamps a bogus value into range', () => { + expect(parse7zProgress(' 340% x')).toBe(100) + }) +}) + +describe('naturalSort', () => { + it('orders page 2 before page 10', () => { + expect(naturalSort(['page10.jpg', 'page2.jpg', 'page1.jpg'])).toEqual([ + 'page1.jpg', + 'page2.jpg', + 'page10.jpg' + ]) + }) + + it('groups by folder, then by natural page order', () => { + expect(naturalSort(['b/p2.png', 'a/p10.png', 'a/p2.png'])).toEqual([ + 'a/p2.png', + 'a/p10.png', + 'b/p2.png' + ]) + }) +}) + +describe('batchImages', () => { + it('keeps one batch when everything fits', () => { + expect(batchImages(['a.jpg', 'b.jpg'], 1000)).toEqual([['a.jpg', 'b.jpg']]) + }) + + it('splits when the joined length exceeds the budget', () => { + const paths = Array.from({ length: 6 }, (_, i) => `C:\\tmp\\page-${i}.jpg`) + const batches = batchImages(paths, 40) + expect(batches.length).toBeGreaterThan(1) + expect(batches.flat()).toEqual(paths) + for (const b of batches) expect(b.join(' ').length).toBeLessThanOrEqual(40) + }) + + it('never drops a path longer than the whole budget', () => { + const long = 'C:\\' + 'x'.repeat(100) + '.jpg' + expect(batchImages([long], 10)).toEqual([[long]]) + }) + + it('returns nothing for an empty list', () => { + expect(batchImages([], 100)).toEqual([]) + }) +}) diff --git a/test/archive-catalog-nav.test.ts b/test/archive-catalog-nav.test.ts new file mode 100644 index 0000000..0f70173 --- /dev/null +++ b/test/archive-catalog-nav.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { acceptsKind, defaultOperation, findOperation, operationsFor } from '@shared/catalog' +import { defaultOptionsFor } from '../src/renderer/src/state' + +describe('archives category', () => { + it('accepts archive files and nothing else', () => { + expect(acceptsKind('archives', 'archive')).toBe(true) + expect(acceptsKind('archives', 'image')).toBe(false) + }) + + it('lands on Convert', () => { + expect(defaultOperation('archives')).toBe('convert') + }) + + it('offers convert, extract and to-pdf', () => { + expect(operationsFor('archives').map((o) => o.id)).toEqual(['convert', 'extract', 'to-pdf']) + }) + + it('routes every archives operation to the archive tool', () => { + for (const o of operationsFor('archives')) expect(o.tool).toBe('archive') + }) + + it('adds a To CBZ card under PDF that runs the archive tool', () => { + const card = findOperation('pdf', 'to-cbz') + expect(card?.tool).toBe('archive') + expect(card?.opKey).toBe('from-pdf') + }) + + it('seeds each archive workspace with its own op', () => { + expect(defaultOptionsFor('archives', 'extract').op).toBe('extract') + expect(defaultOptionsFor('archives', 'to-pdf').op).toBe('to-pdf') + expect(defaultOptionsFor('pdf', 'to-cbz').op).toBe('from-pdf') + }) +}) diff --git a/test/archive-catalog.test.ts b/test/archive-catalog.test.ts new file mode 100644 index 0000000..8e9e3dc --- /dev/null +++ b/test/archive-catalog.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { archiveTargets, containerOf, needsRar, ARCHIVE_FORMATS } from '@shared/archive' + +describe('archive catalog', () => { + it('maps every comic extension to its real container', () => { + expect(containerOf('.cbz')).toBe('zip') + expect(containerOf('.cbr')).toBe('rar') + expect(containerOf('.cb7')).toBe('7z') + expect(containerOf('.cbt')).toBe('tar') + expect(containerOf('.zip')).toBe('zip') + expect(containerOf('.7z')).toBe('7z') + expect(containerOf('.tar')).toBe('tar') + expect(containerOf('.rar')).toBe('rar') + }) + + it('returns null for a non-archive extension', () => { + expect(containerOf('.png')).toBeNull() + }) + + it('offers eight formats in total', () => { + expect(ARCHIVE_FORMATS).toHaveLength(8) + }) + + it('drops the source format from the target list', () => { + const exts = archiveTargets('.cbz', true).map((f) => f.ext) + expect(exts).not.toContain('.cbz') + expect(exts).toContain('.cb7') + }) + + it('drops rar targets when WinRAR is absent', () => { + const without = archiveTargets('.cbz', false).map((f) => f.ext) + expect(without).not.toContain('.cbr') + expect(without).not.toContain('.rar') + const withRar = archiveTargets('.cbz', true).map((f) => f.ext) + expect(withRar).toContain('.cbr') + expect(withRar).toContain('.rar') + }) + + it('knows which extensions need WinRAR', () => { + expect(needsRar('.cbr')).toBe(true) + expect(needsRar('.rar')).toBe(true) + expect(needsRar('.cbz')).toBe(false) + }) + + it('is case-insensitive on the source extension', () => { + expect(archiveTargets('.CBZ', true).map((f) => f.ext)).not.toContain('.cbz') + }) +}) diff --git a/test/archive-kind.test.ts b/test/archive-kind.test.ts new file mode 100644 index 0000000..aa4f63e --- /dev/null +++ b/test/archive-kind.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { ARCHIVE_EXTS, fileKind } from '@shared/fileKind' + +describe('archive file kind', () => { + it('classifies every archive extension as archive', () => { + for (const e of ARCHIVE_EXTS) expect(fileKind(e)).toBe('archive') + }) + + it('covers the eight supported extensions', () => { + expect([...ARCHIVE_EXTS].sort()).toEqual( + ['.7z', '.cb7', '.cbr', '.cbt', '.cbz', '.rar', '.tar', '.zip'].sort() + ) + }) + + it('accepts an extension without a leading dot, and any case', () => { + expect(fileKind('cbz')).toBe('archive') + expect(fileKind('.CBR')).toBe('archive') + }) + + it('leaves other kinds alone', () => { + expect(fileKind('.png')).toBe('image') + expect(fileKind('.pdf')).toBe('pdf') + expect(fileKind('.xyz')).toBe('other') + }) +}) diff --git a/test/archive-live.test.ts b/test/archive-live.test.ts new file mode 100644 index 0000000..4486539 --- /dev/null +++ b/test/archive-live.test.ts @@ -0,0 +1,126 @@ +// Integration coverage for the archive engine: unlike the rest of the unit +// suite these SPAWN the bundled binaries, so they are skipped when +// resources/bin has not been populated (CI, and a fresh clone before +// `npm run fetch-binaries`). They caught the reserveOutPath placeholder +// colliding with `7z a` / `rar a`, which no pure-function test could. +import { execFileSync } from 'child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'fs' +import { tmpdir } from 'os' +import { join, resolve } from 'path' +import { afterAll, describe, expect, it } from 'vitest' +import { getTool } from '../src/main/tools/registry' +import { resolveRar } from '../src/main/toolResolver' +import type { FileInfo } from '@shared/types' + +const SEVEN = resolve('resources/bin/7z.exe') +const MAGICK = resolve('resources/bin/magick.exe') +const root = mkdtempSync(join(tmpdir(), 'fs-live-')) +afterAll(() => rmSync(root, { recursive: true, force: true })) + +const ctx = { signal: new AbortController().signal, onProgress: (): void => {} } +const info = (p: string, ext: string): FileInfo => ({ + path: p, + name: p.split(/[\\/]/).pop()!, + ext, + kind: 'archive', + size: statSync(p).size +}) + +function makeCbz(name: string, pages: string[]): string { + const src = join(root, name + '-src') + mkdirSync(src, { recursive: true }) + for (const p of pages) execFileSync(MAGICK, ['-size', '80x120', 'xc:white', join(src, p)]) + const out = join(root, name + '.cbz') + execFileSync(SEVEN, ['a', '-tzip', out, '*', '-y'], { cwd: src }) + return out +} + +const list = (archive: string): string[] => + execFileSync(SEVEN, ['l', '-slt', archive], { encoding: 'utf8' }) + .split('\n') + .filter((l) => l.startsWith('Path = ')) + .map((l) => l.slice(7).trim()) + .slice(1) + +describe.skipIf(!existsSync(SEVEN) || !existsSync(MAGICK))('archive engine (live)', () => { + const tool = getTool('archive')! + + it('repacks a cbz into a cb7 with no wrapper folder', async () => { + const cbz = makeCbz('comic', ['p1.png', 'p2.png', 'p10.png']) + const out = await tool.run(info(cbz, '.cbz'), { op: 'repack', format: '.cb7' }, ctx) + expect(out.endsWith('.cb7')).toBe(true) + expect(statSync(out).size).toBeGreaterThan(0) + expect(list(out).sort()).toEqual(['p1.png', 'p10.png', 'p2.png']) + }) + + it('extracts into a collision-free folder', async () => { + const cbz = makeCbz('extractme', ['a.png', 'b.png']) + const dir = await tool.run(info(cbz, '.cbz'), { op: 'extract' }, ctx) + expect(readdirSync(dir).sort()).toEqual(['a.png', 'b.png']) + }) + + it('converts a comic archive to a PDF', async () => { + const cbz = makeCbz('topdf', ['p1.png', 'p2.png', 'p10.png']) + const out = await tool.run(info(cbz, '.cbz'), { op: 'to-pdf' }, ctx) + expect(out.endsWith('.pdf')).toBe(true) + const pages = execFileSync(resolve('resources/bin/mutool.exe'), ['info', out], { + encoding: 'utf8' + }) + expect(pages).toMatch(/Pages:\s*3/) + }) + + it('refuses an archive with no images', async () => { + const src = join(root, 'noimg-src') + mkdirSync(src, { recursive: true }) + writeFileSync(join(src, 'readme.txt'), 'hi') + const cbz = join(root, 'noimg.cbz') + execFileSync(SEVEN, ['a', '-tzip', cbz, '*', '-y'], { cwd: src }) + await expect(tool.run(info(cbz, '.cbz'), { op: 'to-pdf' }, ctx)).rejects.toThrow(/No images/) + }) + + it('converts a PDF to a cbz with zero-padded jpeg pages', async () => { + const cbz = makeCbz( + 'pdfsrc', + Array.from({ length: 12 }, (_, i) => `s${i}.png`) + ) + const pdf = await tool.run(info(cbz, '.cbz'), { op: 'to-pdf' }, ctx) + const out = await tool.run( + { ...info(pdf, '.pdf'), kind: 'pdf' }, + { op: 'from-pdf', format: '.cbz', dpi: 72, pageFormat: 'jpg', quality: 80 }, + ctx + ) + const entries = list(out).sort() + expect(entries).toHaveLength(12) + expect(entries[0]).toBe('page-0001.jpg') + expect(entries[1]).toBe('page-0002.jpg') + expect(entries).toContain('page-0012.jpg') + }) + + // Writing rar needs WinRAR, which cannot be bundled; reading one never does. + it.skipIf(!resolveRar())('round-trips a cbr through WinRAR and back', async () => { + const cbz = makeCbz('torar', ['p1.png', 'p2.png']) + const cbr = await tool.run(info(cbz, '.cbz'), { op: 'repack', format: '.cbr' }, ctx) + expect(cbr.endsWith('.cbr')).toBe(true) + expect(list(cbr).sort()).toEqual(['p1.png', 'p2.png']) + + // Reading it back needs only bundled 7-Zip. + const back = await tool.run(info(cbr, '.cbr'), { op: 'repack', format: '.cbz' }, ctx) + expect(list(back).sort()).toEqual(['p1.png', 'p2.png']) + }) + + it('never overwrites an existing output', async () => { + const cbz = makeCbz('twice', ['p1.png']) + const a = await tool.run(info(cbz, '.cbz'), { op: 'repack', format: '.cb7' }, ctx) + const b = await tool.run(info(cbz, '.cbz'), { op: 'repack', format: '.cb7' }, ctx) + expect(a).not.toBe(b) + expect(existsSync(a) && existsSync(b)).toBe(true) + }) +}) diff --git a/test/archive-resolvers.test.ts b/test/archive-resolvers.test.ts new file mode 100644 index 0000000..f79df0e --- /dev/null +++ b/test/archive-resolvers.test.ts @@ -0,0 +1,45 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterAll, describe, expect, it } from 'vitest' +import { findRarIn, resolveSevenZip } from '../src/main/toolResolver' + +// Same shape as resolvers.test.ts: real temp fixtures over the filesystem +// rather than fs mocks, because the whole point of these functions is what they +// find on a machine. +const root = mkdtempSync(join(tmpdir(), 'filesmith-rar-')) +afterAll(() => rmSync(root, { recursive: true, force: true })) + +describe('findRarIn', () => { + it('returns null when WinRAR is in none of the roots', () => { + expect(findRarIn([join(root, 'nothing-here')])).toBeNull() + }) + + it('finds Rar.exe under a Program Files root', () => { + const dir = join(root, 'pf', 'WinRAR') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'Rar.exe'), 'x') + expect(findRarIn([join(root, 'missing'), join(root, 'pf')])).toBe(join(dir, 'Rar.exe')) + }) + + it('takes the first root that has it', () => { + const first = join(root, 'a', 'WinRAR') + const second = join(root, 'b', 'WinRAR') + mkdirSync(first, { recursive: true }) + mkdirSync(second, { recursive: true }) + writeFileSync(join(first, 'Rar.exe'), 'x') + writeFileSync(join(second, 'Rar.exe'), 'x') + expect(findRarIn([join(root, 'a'), join(root, 'b')])).toBe(join(first, 'Rar.exe')) + }) + + it('returns null for an empty root list', () => { + expect(findRarIn([])).toBeNull() + }) +}) + +describe('resolveSevenZip', () => { + it('resolves to a 7z command either bundled or on PATH', () => { + const cmd = resolveSevenZip() + expect(cmd === '7z' || cmd.endsWith('7z.exe') || cmd.endsWith('7z')).toBe(true) + }) +}) diff --git a/test/archive-tool.test.ts b/test/archive-tool.test.ts new file mode 100644 index 0000000..5b7c3cf --- /dev/null +++ b/test/archive-tool.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { needsRar } from '@shared/archive' +import { IMAGE_ENTRY_EXTS, naturalSort } from '../src/main/tools/archive' +import { getTool } from '../src/main/tools/registry' + +// The spawn behaviour is covered end to end in e2e/workflows.spec.ts. These pin +// the decisions the tool makes before it spawns anything. +describe('archive tool', () => { + it('is registered under the archive id', () => { + expect(getTool('archive')).toBeDefined() + }) + + it('rejects an unknown operation instead of guessing', async () => { + const tool = getTool('archive')! + await expect( + tool.run( + { path: 'C:\\x.cbz', name: 'x.cbz', ext: '.cbz', kind: 'archive', size: 1 }, + { op: 'nonsense' }, + { signal: new AbortController().signal, onProgress: () => {} } + ) + ).rejects.toThrow(/Unknown archive operation/) + }) + + it('treats a rar target as needing WinRAR', () => { + expect(needsRar('.cbr')).toBe(true) + }) + + it('selects page images by extension in natural order', () => { + const entries = ['cover.png', 'p10.jpg', 'p2.jpg', 'notes.txt', 'thumbs.db'] + const pages = naturalSort( + entries.filter((e) => IMAGE_ENTRY_EXTS.some((x) => e.toLowerCase().endsWith(x))) + ) + expect(pages).toEqual(['cover.png', 'p2.jpg', 'p10.jpg']) + }) +}) diff --git a/test/picker-filters.test.ts b/test/picker-filters.test.ts new file mode 100644 index 0000000..c23dada --- /dev/null +++ b/test/picker-filters.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { imageFilters, pickerFilters } from '../src/main/pickerFilters' +import { + ARCHIVE_EXTS, + AUDIO_EXTS, + DOC_EXTS, + IMAGE_EXTS, + TEXT_EXTS, + VIDEO_EXTS, + fileKind +} from '@shared/fileKind' + +// The Archives category shipped once with the Open dialog still refusing to +// list a .cbz: "All supported" was a hand-written union that nobody extended. +// These assert the filters against the extension sets themselves, so the next +// category cannot drift the same way. + +const all = (): string[] => pickerFilters()[0].extensions +const named = (name: string): string[] => + pickerFilters().find((f) => f.name === name)?.extensions ?? [] + +describe('open dialog filters', () => { + it('offers every category as its own filter', () => { + expect(pickerFilters().map((f) => f.name)).toEqual([ + 'All supported', + 'Images', + 'Video', + 'Audio', + 'Documents', + 'Text', + 'Archives' + ]) + }) + + it('lists archives, so a .cbz is selectable in the picker', () => { + expect(named('Archives')).toContain('cbz') + expect(named('Archives')).toContain('cbr') + expect(named('Archives').sort()).toEqual(ARCHIVE_EXTS.map((e) => e.slice(1)).sort()) + }) + + it('"All supported" covers every extension the app can classify', () => { + const every = [ + ...IMAGE_EXTS, + ...VIDEO_EXTS, + ...AUDIO_EXTS, + '.pdf', + ...DOC_EXTS, + ...TEXT_EXTS, + ...ARCHIVE_EXTS + ] + for (const ext of every) { + expect(fileKind(ext), ext).not.toBe('other') + expect(all(), ext).toContain(ext.slice(1)) + } + }) + + it('strips only the leading dot (.7z keeps its digit)', () => { + expect(named('Archives')).toContain('7z') + expect(named('Archives')).not.toContain('.7z') + }) + + it('the image picker offers images only', () => { + expect(imageFilters()).toHaveLength(1) + expect(imageFilters()[0].extensions.sort()).toEqual(IMAGE_EXTS.map((e) => e.slice(1)).sort()) + }) +})