9 tasks covering mime.js, the ico/heic converter modules, a new iconSize column on ConversionJob, app.js/worker.js wiring, and the frontend size picker. Every code snippet (icojs encode/decode, the heic-convert ESM import, prisma db execute/migrate status flags) was verified against the actual installed/resolved packages rather than guessed.
1163 lines
43 KiB
Markdown
1163 lines
43 KiB
Markdown
# HEIC/HEIF/ICO Image Format Support 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 ICO (bidirectional) and HEIC/HEIF (source-only decode) as supported image conversion formats.
|
|
|
|
**Architecture:** Two new converter modules (`src/converters/ico.js`, `src/converters/heic.js`) register into the existing generic `sourceFormat -> targetFormat` registry, following the exact pattern `src/converters/imageToPdf.js` already uses. ICO conversion uses `icojs` (pure JS, both decode and encode). HEIC/HEIF decode uses `heic-convert` (pure JS wrapper around the WASM `libheif-js`) to produce a PNG buffer, which then flows through the same sharp pipeline as every other image conversion. A new nullable `iconSize` column on `ConversionJob` carries the user's chosen icon resolution through the job pipeline, parallel to the existing `quality` column.
|
|
|
|
**Tech Stack:** Node.js (ESM), Express, Prisma/MySQL, sharp, vitest+supertest, React (frontend).
|
|
|
|
## Global Constraints
|
|
|
|
- No dependency with native/compiled bindings unless it ships a prebuilt binary for o2switch's platform/arch — `icojs` and `heic-convert` (→ `heic-decode` → `libheif-js`, a WASM build) are pure JS/WASM with no compile step, so this is satisfied.
|
|
- HDR (Radiance `.hdr`) is explicitly **out of scope** — dropped during design (no viable library either direction).
|
|
- HEIC/HEIF are **source-only** — never register anything with `heic`/`heif` as a `targetFormat`.
|
|
- Any new converter registration must be added to **both** `src/app.js` and `src/worker.js` — they independently duplicate the registration call list.
|
|
- Local test runs must use `.env.local`-equivalent inline env vars, never load real `.env` prod credentials (see `CLAUDE.md`).
|
|
- Prisma schema changes use the shadow-db-free fallback (`--from-schema-datasource` / `--to-schema-datamodel`) documented in `CLAUDE.md` — `prisma migrate dev` does not work against the local dev DB.
|
|
|
|
---
|
|
|
|
### Task 1: `mime.js` — ICO output MIME type and HEIF→HEIC extension alias
|
|
|
|
**Files:**
|
|
- Modify: `src/mime.js:3-19` (OUTPUT_MIME_TYPES), `src/mime.js:41-43` (normalizeFormat)
|
|
- Test: `test/mime.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: nothing new.
|
|
- Produces: `outputMimeType('ico')` returns `'image/x-icon'`. `resolveInputFormat(path, 'heif')` now succeeds for real HEIC/HEIF-family content (whose detected `ext` is always `'heic'` per the installed `file-type` v22 source, regardless of container brand).
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Add to `test/mime.test.js`:
|
|
|
|
```js
|
|
describe('outputMimeType — ico', () => {
|
|
it('returns image/x-icon for ico', () => {
|
|
expect(outputMimeType('ico')).toBe('image/x-icon');
|
|
});
|
|
});
|
|
|
|
describe('resolveInputFormat — heic/heif alias', () => {
|
|
it('accepts a HEIC-family file declared as heic', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.heic');
|
|
const result = await resolveInputFormat(fixturePath, 'heic');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('accepts the same HEIC-family content declared as heif', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.heif');
|
|
const result = await resolveInputFormat(fixturePath, 'heif');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
(`test/fixtures/sample.heic` and `test/fixtures/sample.heif` already exist — added and committed earlier in this project's history, see `test/fixtures/HEIC_ATTRIBUTION.md`.)
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js`
|
|
Expected: the `ico` case fails with a thrown error (`No known MIME type for target format "ico"`); the `heif` case fails with `result.valid` being `false`.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
In `src/mime.js`, add `ico` to `OUTPUT_MIME_TYPES`:
|
|
|
|
```js
|
|
const OUTPUT_MIME_TYPES = {
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
png: 'image/png',
|
|
webp: 'image/webp',
|
|
gif: 'image/gif',
|
|
tiff: 'image/tiff',
|
|
avif: 'image/avif',
|
|
bmp: 'image/bmp',
|
|
ico: 'image/x-icon',
|
|
pdf: 'application/pdf',
|
|
html: 'text/html',
|
|
txt: 'text/plain',
|
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
md: 'text/markdown',
|
|
csv: 'text/csv',
|
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
};
|
|
```
|
|
|
|
Update `normalizeFormat`:
|
|
|
|
```js
|
|
function normalizeFormat(format) {
|
|
if (format === 'jpg') return 'jpeg';
|
|
if (format === 'heif') return 'heic';
|
|
return format;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/mime.js test/mime.test.js
|
|
git commit -m "feat: add ico MIME type and heif->heic extension alias"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: ICO converter (`src/converters/ico.js`)
|
|
|
|
**Files:**
|
|
- Modify: `src/converters/image.js:6-8` (export `sharpFormatName`, it is currently private)
|
|
- Create: `src/converters/ico.js`
|
|
- Test: `test/converters/ico.test.js`
|
|
- Modify: `package.json` (add `icojs` dependency)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `sharpFormatName(format)` and `buildFormatOptions(targetFormat, quality)`, both now exported from `src/converters/image.js`.
|
|
- Produces: `registerIcoConverter()` — registers `ico -> X` and `X -> ico` for every `X` in `['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif']`. `encodeIcoFromInput(input, size)` — exported helper, `input` may be a file path or a Buffer (anything `sharp()` accepts); `size` defaults to `DEFAULT_ICON_SIZE` (`256`). Also exports `DEFAULT_ICON_SIZE`.
|
|
|
|
- [ ] **Step 1: Install the dependency**
|
|
|
|
```bash
|
|
npm install icojs
|
|
```
|
|
|
|
Verify `package.json`'s `dependencies` now includes `"icojs"` at whatever version was resolved (do not hand-edit the version string — let `npm install` write it).
|
|
|
|
- [ ] **Step 2: Export `sharpFormatName` from `image.js`**
|
|
|
|
In `src/converters/image.js`, change:
|
|
```js
|
|
function sharpFormatName(format) {
|
|
```
|
|
to:
|
|
```js
|
|
export function sharpFormatName(format) {
|
|
```
|
|
|
|
- [ ] **Step 3: Write the failing tests**
|
|
|
|
Create `test/converters/ico.test.js`:
|
|
|
|
```js
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import sharp from 'sharp';
|
|
import { registerIcoConverter, encodeIcoFromInput, DEFAULT_ICON_SIZE } from '../../src/converters/ico.js';
|
|
import { resolve, listTargetFormats } from '../../src/converters/registry.js';
|
|
import { detectInputMime } from '../../src/mime.js';
|
|
|
|
let tmpDir;
|
|
|
|
beforeAll(async () => {
|
|
registerIcoConverter();
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-ico-'));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('ICO converter registration', () => {
|
|
it('registers ico as both a source and a target for every image format', () => {
|
|
expect(listTargetFormats('ico')).toContain('png');
|
|
expect(listTargetFormats('ico')).toContain('jpg');
|
|
expect(listTargetFormats('png')).toContain('ico');
|
|
expect(listTargetFormats('jpg')).toContain('ico');
|
|
});
|
|
});
|
|
|
|
describe('encodeIcoFromInput', () => {
|
|
it('defaults to a 256x256 icon when no size is given', async () => {
|
|
const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');
|
|
const icoBuffer = await encodeIcoFromInput(inputPath);
|
|
|
|
const outputPath = path.join(tmpDir, 'default-size.ico');
|
|
await fs.writeFile(outputPath, icoBuffer);
|
|
const detected = await detectInputMime(outputPath);
|
|
expect(detected.mime).toBe('image/x-icon');
|
|
});
|
|
|
|
it('produces an icon at the requested size', async () => {
|
|
const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');
|
|
const icoBuffer = await encodeIcoFromInput(inputPath, 32);
|
|
|
|
const { decodeIco } = await import('icojs');
|
|
const [image] = await decodeIco(icoBuffer, 'image/png');
|
|
expect(image.width).toBe(32);
|
|
expect(image.height).toBe(32);
|
|
});
|
|
});
|
|
|
|
describe('ico -> image conversion', () => {
|
|
it('converts an ICO fixture to PNG, picking the largest embedded image', async () => {
|
|
const source = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');
|
|
const icoPath = path.join(tmpDir, 'multi.ico');
|
|
|
|
const small = await sharp(source).resize(16, 16).png().toBuffer();
|
|
const large = await sharp(source).resize(48, 48).png().toBuffer();
|
|
const { encodeIco } = await import('icojs');
|
|
await fs.writeFile(icoPath, Buffer.from(await encodeIco([{ buffer: small }, { buffer: large }])));
|
|
|
|
const outputPath = path.join(tmpDir, 'from-ico.png');
|
|
const entry = resolve('ico', 'png');
|
|
await entry.convert(icoPath, outputPath);
|
|
|
|
const meta = await sharp(outputPath).metadata();
|
|
expect(meta.width).toBe(48);
|
|
expect(meta.height).toBe(48);
|
|
});
|
|
});
|
|
|
|
describe('image -> ico conversion', () => {
|
|
it('converts a PNG fixture to a valid ICO at the requested size', async () => {
|
|
const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');
|
|
const outputPath = path.join(tmpDir, 'output.ico');
|
|
const entry = resolve('png', 'ico');
|
|
|
|
await entry.convert(inputPath, outputPath, { iconSize: 48 });
|
|
|
|
const detected = await detectInputMime(outputPath);
|
|
expect(detected.mime).toBe('image/x-icon');
|
|
|
|
const { decodeIco } = await import('icojs');
|
|
const [image] = await decodeIco(await fs.readFile(outputPath), 'image/png');
|
|
expect(image.width).toBe(48);
|
|
});
|
|
|
|
it('defaults to 256px when no iconSize is given', async () => {
|
|
const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');
|
|
const outputPath = path.join(tmpDir, 'output-default.ico');
|
|
const entry = resolve('png', 'ico');
|
|
|
|
await entry.convert(inputPath, outputPath);
|
|
|
|
const { decodeIco } = await import('icojs');
|
|
const [image] = await decodeIco(await fs.readFile(outputPath), 'image/png');
|
|
expect(image.width).toBe(DEFAULT_ICON_SIZE);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they fail**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/ico.test.js`
|
|
Expected: FAIL — `Cannot find module '../../src/converters/ico.js'`
|
|
|
|
- [ ] **Step 5: Implement `src/converters/ico.js`**
|
|
|
|
```js
|
|
import fs from 'node:fs/promises';
|
|
import sharp from 'sharp';
|
|
import { decodeIco, encodeIco } from 'icojs';
|
|
import { register } from './registry.js';
|
|
import { sharpFormatName, buildFormatOptions } from './image.js';
|
|
|
|
const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif'];
|
|
export const DEFAULT_ICON_SIZE = 256;
|
|
|
|
export async function encodeIcoFromInput(input, size = DEFAULT_ICON_SIZE) {
|
|
const pngBuffer = await sharp(input).resize(size, size, { fit: 'contain' }).png().toBuffer();
|
|
return Buffer.from(await encodeIco([{ buffer: pngBuffer }]));
|
|
}
|
|
|
|
async function decodeIcoLargestImage(inputPath) {
|
|
const buffer = await fs.readFile(inputPath);
|
|
const images = await decodeIco(buffer, 'image/png');
|
|
return images.reduce((a, b) => (a.width * a.height >= b.width * b.height ? a : b));
|
|
}
|
|
|
|
export function registerIcoConverter() {
|
|
for (const format of IMAGE_FORMATS) {
|
|
register({
|
|
family: 'image',
|
|
sourceFormat: 'ico',
|
|
targetFormat: format,
|
|
convert: async (inputPath, outputPath, { quality } = {}) => {
|
|
const largest = await decodeIcoLargestImage(inputPath);
|
|
await sharp(Buffer.from(largest.buffer))
|
|
.toFormat(sharpFormatName(format), buildFormatOptions(format, quality))
|
|
.toFile(outputPath);
|
|
},
|
|
});
|
|
|
|
register({
|
|
family: 'image',
|
|
sourceFormat: format,
|
|
targetFormat: 'ico',
|
|
convert: async (inputPath, outputPath, { iconSize } = {}) => {
|
|
const icoBuffer = await encodeIcoFromInput(inputPath, iconSize ?? DEFAULT_ICON_SIZE);
|
|
await fs.writeFile(outputPath, icoBuffer);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run tests to verify they pass**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/ico.test.js`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add package.json package-lock.json src/converters/image.js src/converters/ico.js test/converters/ico.test.js
|
|
git commit -m "feat: add bidirectional ICO image converter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: HEIC/HEIF source-only converter (`src/converters/heic.js`)
|
|
|
|
**Files:**
|
|
- Create: `src/converters/heic.js`
|
|
- Test: `test/converters/heic.test.js`
|
|
- Modify: `package.json` (add `heic-convert` dependency)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `sharpFormatName`, `buildFormatOptions` from `src/converters/image.js` (Task 2); `encodeIcoFromInput`, `DEFAULT_ICON_SIZE` from `src/converters/ico.js` (Task 2).
|
|
- Produces: `registerHeicConverter()` — registers `heic -> X` and `heif -> X` for every `X` in `['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif', 'ico']`. Registers nothing with `heic`/`heif` as a `targetFormat`.
|
|
|
|
- [ ] **Step 1: Install the dependency**
|
|
|
|
```bash
|
|
npm install heic-convert
|
|
```
|
|
|
|
Verify `package.json`'s `dependencies` now includes `"heic-convert"`.
|
|
|
|
- [ ] **Step 2: Write the failing tests**
|
|
|
|
Create `test/converters/heic.test.js`:
|
|
|
|
```js
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import sharp from 'sharp';
|
|
import { registerHeicConverter } from '../../src/converters/heic.js';
|
|
import { resolve, listTargetFormats } from '../../src/converters/registry.js';
|
|
import { detectInputMime } from '../../src/mime.js';
|
|
|
|
let tmpDir;
|
|
|
|
beforeAll(async () => {
|
|
registerHeicConverter();
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-heic-'));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('HEIC/HEIF converter registration', () => {
|
|
it('registers heic and heif as source formats for every image target plus ico', () => {
|
|
expect(listTargetFormats('heic')).toEqual(
|
|
expect.arrayContaining(['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif', 'ico'])
|
|
);
|
|
expect(listTargetFormats('heif')).toEqual(
|
|
expect.arrayContaining(['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif', 'ico'])
|
|
);
|
|
});
|
|
|
|
it('never registers heic or heif as a target format', () => {
|
|
expect(listTargetFormats('png')).not.toContain('heic');
|
|
expect(listTargetFormats('png')).not.toContain('heif');
|
|
});
|
|
});
|
|
|
|
describe('heic -> image conversion', () => {
|
|
it('converts a HEIC fixture to PNG', async () => {
|
|
const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heic');
|
|
const outputPath = path.join(tmpDir, 'output.png');
|
|
const entry = resolve('heic', 'png');
|
|
|
|
await entry.convert(inputPath, outputPath);
|
|
|
|
const detected = await detectInputMime(outputPath);
|
|
expect(detected.mime).toBe('image/png');
|
|
});
|
|
|
|
it('converts a HEIC fixture to JPG', async () => {
|
|
const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heic');
|
|
const outputPath = path.join(tmpDir, 'output.jpg');
|
|
const entry = resolve('heic', 'jpg');
|
|
|
|
await entry.convert(inputPath, outputPath);
|
|
|
|
const detected = await detectInputMime(outputPath);
|
|
expect(detected.mime).toBe('image/jpeg');
|
|
});
|
|
|
|
it('converts a HEIC fixture to ICO at the requested size', async () => {
|
|
const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heic');
|
|
const outputPath = path.join(tmpDir, 'output.ico');
|
|
const entry = resolve('heic', 'ico');
|
|
|
|
await entry.convert(inputPath, outputPath, { iconSize: 32 });
|
|
|
|
const detected = await detectInputMime(outputPath);
|
|
expect(detected.mime).toBe('image/x-icon');
|
|
|
|
const { decodeIco } = await import('icojs');
|
|
const [image] = await decodeIco(await fs.readFile(outputPath), 'image/png');
|
|
expect(image.width).toBe(32);
|
|
});
|
|
});
|
|
|
|
describe('heif -> image conversion', () => {
|
|
it('converts a HEIF-declared fixture to PNG', async () => {
|
|
const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heif');
|
|
const outputPath = path.join(tmpDir, 'output-from-heif.png');
|
|
const entry = resolve('heif', 'png');
|
|
|
|
await entry.convert(inputPath, outputPath);
|
|
|
|
const detected = await detectInputMime(outputPath);
|
|
expect(detected.mime).toBe('image/png');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests to verify they fail**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/heic.test.js`
|
|
Expected: FAIL — `Cannot find module '../../src/converters/heic.js'`
|
|
|
|
- [ ] **Step 4: Implement `src/converters/heic.js`**
|
|
|
|
```js
|
|
import fs from 'node:fs/promises';
|
|
import sharp from 'sharp';
|
|
import convert from 'heic-convert';
|
|
import { register } from './registry.js';
|
|
import { sharpFormatName, buildFormatOptions } from './image.js';
|
|
import { encodeIcoFromInput, DEFAULT_ICON_SIZE } from './ico.js';
|
|
|
|
const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif'];
|
|
const HEIC_SOURCE_FORMATS = ['heic', 'heif'];
|
|
|
|
async function decodeToPng(inputPath) {
|
|
const buffer = await fs.readFile(inputPath);
|
|
return convert({ buffer, format: 'PNG' });
|
|
}
|
|
|
|
export function registerHeicConverter() {
|
|
for (const sourceFormat of HEIC_SOURCE_FORMATS) {
|
|
for (const targetFormat of IMAGE_FORMATS) {
|
|
register({
|
|
family: 'image',
|
|
sourceFormat,
|
|
targetFormat,
|
|
convert: async (inputPath, outputPath, { quality } = {}) => {
|
|
const pngBuffer = await decodeToPng(inputPath);
|
|
await sharp(pngBuffer)
|
|
.toFormat(sharpFormatName(targetFormat), buildFormatOptions(targetFormat, quality))
|
|
.toFile(outputPath);
|
|
},
|
|
});
|
|
}
|
|
|
|
register({
|
|
family: 'image',
|
|
sourceFormat,
|
|
targetFormat: 'ico',
|
|
convert: async (inputPath, outputPath, { iconSize } = {}) => {
|
|
const pngBuffer = await decodeToPng(inputPath);
|
|
const icoBuffer = await encodeIcoFromInput(pngBuffer, iconSize ?? DEFAULT_ICON_SIZE);
|
|
await fs.writeFile(outputPath, icoBuffer);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/heic.test.js`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add package.json package-lock.json src/converters/heic.js test/converters/heic.test.js
|
|
git commit -m "feat: add source-only HEIC/HEIF image converter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Prisma schema — add `iconSize` column
|
|
|
|
**Files:**
|
|
- Modify: `prisma/schema.prisma`
|
|
- Create: `prisma/migrations/<timestamp>_add_icon_size/migration.sql`
|
|
|
|
**Interfaces:**
|
|
- Produces: `ConversionJob.iconSize` (`Int?`, mapped to `icon_size` column, `UnsignedSmallInt`) — consumed by Task 5.
|
|
|
|
- [ ] **Step 1: Edit `prisma/schema.prisma`**
|
|
|
|
Add `iconSize` to the `ConversionJob` model, directly after `quality`:
|
|
|
|
```prisma
|
|
model ConversionJob {
|
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
|
uuid String @unique(map: "uniq_uuid") @db.Char(36)
|
|
status JobStatus @default(pending)
|
|
family String @db.VarChar(32)
|
|
sourceFormat String @map("source_format") @db.VarChar(16)
|
|
targetFormat String @map("target_format") @db.VarChar(16)
|
|
originalFilename String @map("original_filename") @db.VarChar(255)
|
|
inputPath String @map("input_path") @db.VarChar(255)
|
|
outputPath String? @map("output_path") @db.VarChar(255)
|
|
inputMimeType String @map("input_mime_type") @db.VarChar(128)
|
|
outputMimeType String? @map("output_mime_type") @db.VarChar(128)
|
|
inputSizeBytes Int @map("input_size_bytes") @db.UnsignedInt
|
|
outputSizeBytes Int? @map("output_size_bytes") @db.UnsignedInt
|
|
quality Int? @db.UnsignedSmallInt
|
|
iconSize Int? @map("icon_size") @db.UnsignedSmallInt
|
|
conversionDurationSeconds Decimal? @map("conversion_duration_seconds") @db.Decimal(10, 3)
|
|
errorMessage String? @map("error_message") @db.VarChar(255)
|
|
errorLog String? @map("error_log") @db.Text
|
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(0)
|
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(0)
|
|
expiresAt DateTime @map("expires_at") @db.DateTime(0)
|
|
cleanedAt DateTime? @map("cleaned_at") @db.DateTime(0)
|
|
|
|
@@index([status], map: "idx_status")
|
|
@@index([expiresAt], map: "idx_expires_at")
|
|
@@map("conversion_jobs")
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Generate the migration SQL (shadow-db-free)**
|
|
|
|
```bash
|
|
DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage \
|
|
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate diff \
|
|
--from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script > /tmp/migration.sql
|
|
cat /tmp/migration.sql
|
|
```
|
|
|
|
Expected output: a single `ALTER TABLE conversion_jobs ADD COLUMN icon_size SMALLINT UNSIGNED NULL;` statement (column name/type must match exactly what's declared in Step 1 — verify before proceeding).
|
|
|
|
- [ ] **Step 3: Create the migration folder**
|
|
|
|
```bash
|
|
mkdir -p "prisma/migrations/$(date +%Y%m%d%H%M%S)_add_icon_size"
|
|
```
|
|
|
|
Copy the contents of `/tmp/migration.sql` into `prisma/migrations/<that-folder>/migration.sql`, then delete `/tmp/migration.sql`:
|
|
|
|
```bash
|
|
rm /tmp/migration.sql
|
|
```
|
|
|
|
- [ ] **Step 4: Apply it to the local dev DB and mark it resolved**
|
|
|
|
```bash
|
|
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma db execute --schema prisma/schema.prisma --file "prisma/migrations/<that-folder>/migration.sql"
|
|
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate resolve --applied "<that-folder-name>"
|
|
```
|
|
|
|
- [ ] **Step 5: Regenerate the Prisma client**
|
|
|
|
```bash
|
|
npm run prisma-generate
|
|
```
|
|
|
|
- [ ] **Step 6: Verify**
|
|
|
|
```bash
|
|
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate status
|
|
```
|
|
|
|
Expected: no pending migrations; `<that-folder-name>` listed as applied.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add prisma/schema.prisma "prisma/migrations/<that-folder>"
|
|
git commit -m "feat: add iconSize column to ConversionJob"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: `jobRepository.js` — thread `iconSize` through job creation/retrieval
|
|
|
|
**Files:**
|
|
- Modify: `src/jobs/jobRepository.js:1-22` (jobSelect), `src/jobs/jobRepository.js:24-39` (createJob)
|
|
- Test: `test/jobs/jobRepository.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `ConversionJob.iconSize` (Task 4).
|
|
- Produces: `createJob(prisma, { ..., iconSize })` persists it; every query using `jobSelect` (`getJobByUuid`, `findPendingJobs`, `findExpiredJobs`) returns `job.iconSize`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `test/jobs/jobRepository.test.js`, after the existing `'stores and retrieves a numeric quality value'` test:
|
|
|
|
```js
|
|
it('stores and retrieves a numeric iconSize value', async () => {
|
|
await createJob(
|
|
prisma,
|
|
baseJob({ uuid: '77777777-7777-4777-8777-777777777777', targetFormat: 'ico', iconSize: 48 })
|
|
);
|
|
|
|
const job = await getJobByUuid(prisma, '77777777-7777-4777-8777-777777777777');
|
|
expect(job.iconSize).toBe(48);
|
|
});
|
|
```
|
|
|
|
Also add `expect(job.iconSize).toBeNull();` to the existing `'creates and retrieves a pending job'` test, next to the existing `expect(job.quality).toBeNull();` line.
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/jobs/jobRepository.test.js`
|
|
Expected: FAIL — `job.iconSize` is `undefined`, not `null`/`48`.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
In `src/jobs/jobRepository.js`, add `iconSize: true,` to `jobSelect` (next to `quality: true,`):
|
|
|
|
```js
|
|
const jobSelect = {
|
|
id: true,
|
|
uuid: true,
|
|
status: true,
|
|
family: true,
|
|
sourceFormat: true,
|
|
targetFormat: true,
|
|
originalFilename: true,
|
|
inputPath: true,
|
|
outputPath: true,
|
|
inputMimeType: true,
|
|
outputMimeType: true,
|
|
inputSizeBytes: true,
|
|
outputSizeBytes: true,
|
|
quality: true,
|
|
iconSize: true,
|
|
conversionDurationSeconds: true,
|
|
errorMessage: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
expiresAt: true,
|
|
cleanedAt: true,
|
|
};
|
|
```
|
|
|
|
Add `iconSize: job.iconSize ?? null,` to `createJob`'s `data` object (next to `quality: job.quality ?? null,`):
|
|
|
|
```js
|
|
export async function createJob(prisma, job) {
|
|
await prisma.conversionJob.create({
|
|
data: {
|
|
uuid: job.uuid,
|
|
family: job.family,
|
|
sourceFormat: job.sourceFormat,
|
|
targetFormat: job.targetFormat,
|
|
originalFilename: job.originalFilename,
|
|
inputPath: job.inputPath,
|
|
inputMimeType: job.inputMimeType,
|
|
inputSizeBytes: job.inputSizeBytes,
|
|
expiresAt: job.expiresAt,
|
|
quality: job.quality ?? null,
|
|
iconSize: job.iconSize ?? null,
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/jobs/jobRepository.test.js`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/jobs/jobRepository.js test/jobs/jobRepository.test.js
|
|
git commit -m "feat: thread iconSize through jobRepository"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: `app.js` — register converters, validate `iconSize`, reject `quality` for ico
|
|
|
|
**Files:**
|
|
- Modify: `src/app.js:16-32` (isValidQuality, registerAllConverters), `src/app.js:1-14` (imports), `src/app.js:68-151` (POST /api/jobs handler)
|
|
- Test: `test/api/jobs.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `registerIcoConverter` (Task 2), `registerHeicConverter` (Task 3).
|
|
- Produces: `POST /api/jobs` accepts an `iconSizes` form field (JSON array, same shape/validation pattern as `qualities`); per-file `iconSize` is validated against `VALID_ICON_SIZES = [16, 32, 48, 256, 512]` and passed to `createJob`.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Add to `test/api/jobs.test.js`, inside the `describe('POST /api/jobs', ...)` block:
|
|
|
|
```js
|
|
it('creates a pending job with an iconSize for an ico target', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');
|
|
|
|
const response = await request(app)
|
|
.post('/api/jobs')
|
|
.field('targetFormats', JSON.stringify(['ico']))
|
|
.field('iconSizes', JSON.stringify([48]))
|
|
.attach('files', fixturePath, 'photo.png');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.jobs[0].status).toBe('pending');
|
|
|
|
const job = await getJobByUuid(prisma, response.body.jobs[0].id);
|
|
expect(job.iconSize).toBe(48);
|
|
});
|
|
|
|
it('rejects an iconSize outside the fixed set', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');
|
|
|
|
const response = await request(app)
|
|
.post('/api/jobs')
|
|
.field('targetFormats', JSON.stringify(['ico']))
|
|
.field('iconSizes', JSON.stringify([100]))
|
|
.attach('files', fixturePath, 'photo.png');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.jobs[0].error).toMatch(/Invalid iconSize/);
|
|
});
|
|
|
|
it('rejects an iconSize for a non-ico target', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');
|
|
|
|
const response = await request(app)
|
|
.post('/api/jobs')
|
|
.field('targetFormats', JSON.stringify(['webp']))
|
|
.field('iconSizes', JSON.stringify([48]))
|
|
.attach('files', fixturePath, 'photo.png');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.jobs[0].error).toMatch(/Invalid iconSize/);
|
|
});
|
|
|
|
it('rejects a quality value for an ico target', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');
|
|
|
|
const response = await request(app)
|
|
.post('/api/jobs')
|
|
.field('targetFormats', JSON.stringify(['ico']))
|
|
.field('qualities', JSON.stringify([50]))
|
|
.attach('files', fixturePath, 'photo.png');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.jobs[0].error).toMatch(/Invalid quality/);
|
|
});
|
|
|
|
it('creates a pending job converting a HEIC upload to jpg', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heic');
|
|
|
|
const response = await request(app)
|
|
.post('/api/jobs')
|
|
.field('targetFormats', JSON.stringify(['jpg']))
|
|
.attach('files', fixturePath, 'photo.heic');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.jobs[0].status).toBe('pending');
|
|
|
|
const job = await getJobByUuid(prisma, response.body.jobs[0].id);
|
|
expect(job.sourceFormat).toBe('heic');
|
|
});
|
|
|
|
it('creates a pending job converting a HEIF-declared upload to png', async () => {
|
|
const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heif');
|
|
|
|
const response = await request(app)
|
|
.post('/api/jobs')
|
|
.field('targetFormats', JSON.stringify(['png']))
|
|
.attach('files', fixturePath, 'photo.heif');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.jobs[0].status).toBe('pending');
|
|
});
|
|
```
|
|
|
|
Add to `describe('GET /api/formats', ...)`:
|
|
|
|
```js
|
|
it('lists ico as a target for png, and does not list heic/heif as a target for anything', async () => {
|
|
const icoTargets = await request(app).get('/api/formats').query({ source: 'png' });
|
|
expect(icoTargets.body.targets).toContain('ico');
|
|
|
|
const heicTargets = await request(app).get('/api/formats').query({ source: 'heic' });
|
|
expect(heicTargets.body.targets).toContain('png');
|
|
|
|
const pngTargets = await request(app).get('/api/formats').query({ source: 'png' });
|
|
expect(pngTargets.body.targets).not.toContain('heic');
|
|
expect(pngTargets.body.targets).not.toContain('heif');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js`
|
|
Expected: FAIL — `ico`/`heic`/`heif` conversions are rejected with `Unsupported conversion` (converters not yet registered in `app.js`), and `iconSizes` is silently ignored (no validation exists yet).
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
In `src/app.js`, add imports (next to the existing converter imports):
|
|
|
|
```js
|
|
import { registerIcoConverter } from './converters/ico.js';
|
|
import { registerHeicConverter } from './converters/heic.js';
|
|
```
|
|
|
|
Update `registerAllConverters`:
|
|
|
|
```js
|
|
function registerAllConverters() {
|
|
if (convertersRegistered) return;
|
|
registerImageConverters();
|
|
registerImageToPdfConverter();
|
|
registerDocumentConverters();
|
|
registerIcoConverter();
|
|
registerHeicConverter();
|
|
convertersRegistered = true;
|
|
}
|
|
```
|
|
|
|
Update `isValidQuality` to reject `ico` (no quality knob), and add `isValidIconSize`:
|
|
|
|
```js
|
|
const VALID_ICON_SIZES = [16, 32, 48, 256, 512];
|
|
|
|
function isValidQuality(targetFormat, quality) {
|
|
if (quality === null || quality === undefined) return true;
|
|
if (!Number.isInteger(quality)) return false;
|
|
if (targetFormat === 'gif' || targetFormat === 'ico') return false;
|
|
if (targetFormat === 'png') return quality >= 0 && quality <= 9;
|
|
return quality >= 1 && quality <= 100;
|
|
}
|
|
|
|
function isValidIconSize(targetFormat, iconSize) {
|
|
if (iconSize === null || iconSize === undefined) return true;
|
|
if (targetFormat !== 'ico') return false;
|
|
return VALID_ICON_SIZES.includes(iconSize);
|
|
}
|
|
```
|
|
|
|
In the `POST /api/jobs` handler, parse `iconSizes` the same way `qualities` is parsed (add right after the existing `qualities` parsing block, before the per-file loop):
|
|
|
|
```js
|
|
let iconSizes;
|
|
try {
|
|
iconSizes = JSON.parse(req.body.iconSizes ?? '[]');
|
|
} catch {
|
|
return res.status(400).json({ error: 'iconSizes must be a JSON array' });
|
|
}
|
|
|
|
if (!Array.isArray(iconSizes)) {
|
|
return res.status(400).json({ error: 'iconSizes must be a JSON array' });
|
|
}
|
|
|
|
if (iconSizes.length > 0 && iconSizes.length !== req.files.length) {
|
|
return res.status(400).json({ error: 'iconSizes must have one entry per uploaded file, or be omitted' });
|
|
}
|
|
```
|
|
|
|
Inside the per-file loop, after the existing `requestedQuality`/`isValidQuality` block and before the `createJob` call, add:
|
|
|
|
```js
|
|
const requestedIconSize = iconSizes[i] ?? null;
|
|
if (!isValidIconSize(targetFormat, requestedIconSize)) {
|
|
await deleteIfExists(file.path);
|
|
results.push({
|
|
file: file.originalname,
|
|
error: `Invalid iconSize for target format ${targetFormat}`,
|
|
});
|
|
continue;
|
|
}
|
|
```
|
|
|
|
Add `iconSize: requestedIconSize,` to the `createJob(prisma, { ... })` call, next to `quality: requestedQuality,`.
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Run the full test suite to check for regressions**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run`
|
|
Expected: PASS, except the two pre-existing unrelated failures documented in `CLAUDE.md` (`test/cleanup.test.js` and `test/jobs/jobRepository.test.js`'s expired-jobs clock/timezone case) — verify against `main` first if any other new failure shows up.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/app.js test/api/jobs.test.js
|
|
git commit -m "feat: wire ICO/HEIC/HEIF converters and iconSize validation into the API"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: `worker.js` — register converters, pass `iconSize` through
|
|
|
|
**Files:**
|
|
- Modify: `src/worker.js:1-11` (imports), `src/worker.js:23-53` (processJob), `src/worker.js:70-81` (main)
|
|
- Test: `test/worker.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `registerIcoConverter`, `registerHeicConverter` (same as Task 6).
|
|
- Produces: `processJob` now calls `entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize })`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
`test/worker.test.js` already has a `createPendingImageJob(uuid, sourceFormat, targetFormat)` helper that copies `test/fixtures/sample.png` into place and calls `createJob` without a `quality`/`iconSize` field (see the existing `'converts a pending image job to done'` test). It doesn't support passing `iconSize`, so write this test using `createJob` directly, following the same manual-setup style already used by the file's `'passes the job quality through to the converter...'` test. Add this inside the existing `describe('processPendingJobs', ...)` block in `test/worker.test.js`:
|
|
|
|
```js
|
|
it('processes a png -> ico job honoring iconSize', async () => {
|
|
const { decodeIco } = await import('icojs');
|
|
const uuid = 'bbbbbbbb-1111-4bbb-8bbb-bbbbbbbbbbb1';
|
|
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');
|
|
const inputFilePath = uploadPath(config, uuid, 'png');
|
|
await fs.copyFile(fixturePath, inputFilePath);
|
|
const { size: inputSizeBytes } = await fs.stat(inputFilePath);
|
|
|
|
await createJob(prisma, {
|
|
uuid,
|
|
family: 'image',
|
|
sourceFormat: 'png',
|
|
targetFormat: 'ico',
|
|
originalFilename: 'photo.png',
|
|
inputPath: `${uuid}.png`,
|
|
inputMimeType: 'image/png',
|
|
inputSizeBytes,
|
|
expiresAt: new Date(Date.now() + 3600 * 1000),
|
|
iconSize: 32,
|
|
});
|
|
|
|
await processPendingJobs(prisma, config);
|
|
|
|
const job = await getJobByUuid(prisma, uuid);
|
|
expect(job.status).toBe('done');
|
|
|
|
const outputFilePath = outputPath(config, uuid, 'ico');
|
|
const [image] = await decodeIco(await fs.readFile(outputFilePath), 'image/png');
|
|
expect(image.width).toBe(32);
|
|
});
|
|
```
|
|
|
|
This test file's `beforeAll` only calls `registerImageConverters()` — it will need `registerIcoConverter()` too. Add that import and call to `test/worker.test.js`'s existing `beforeAll`:
|
|
|
|
```js
|
|
import { registerIcoConverter } from '../src/converters/ico.js';
|
|
```
|
|
|
|
```js
|
|
beforeAll(async () => {
|
|
registerImageConverters();
|
|
registerIcoConverter();
|
|
config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-worker-')) };
|
|
await ensureStorageDirs(config);
|
|
prisma = getPrismaClient(config);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/worker.test.js`
|
|
Expected: FAIL — `No converter registered for png -> ico`.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
In `src/worker.js`, add imports:
|
|
|
|
```js
|
|
import { registerIcoConverter } from './converters/ico.js';
|
|
import { registerHeicConverter } from './converters/heic.js';
|
|
```
|
|
|
|
In `processJob`, pass `iconSize` through:
|
|
|
|
```js
|
|
await withTimeout(
|
|
entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize }),
|
|
JOB_TIMEOUT_MS
|
|
);
|
|
```
|
|
|
|
In `main`, register the two new converters:
|
|
|
|
```js
|
|
registerImageConverters();
|
|
registerImageToPdfConverter();
|
|
registerDocumentConverters();
|
|
registerIcoConverter();
|
|
registerHeicConverter();
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/worker.test.js`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/worker.js test/worker.test.js
|
|
git commit -m "feat: register ICO/HEIC/HEIF converters in the worker and pass iconSize through"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Frontend — ICO size picker
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/App.jsx`
|
|
- Modify: `frontend/src/api.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: nothing new from the backend beyond what Task 6 already exposes (`/api/formats` already returns `ico` dynamically once registered).
|
|
- Produces: each `pendingFiles` item gains an `iconSize` field; `uploadFiles` sends an `iconSizes` array alongside `targetFormats`/`qualities`.
|
|
|
|
- [ ] **Step 1: Update `frontend/src/App.jsx`**
|
|
|
|
Add an icon-size constant near the top, alongside `QUALITY_FORMATS`:
|
|
|
|
```js
|
|
const ICON_SIZES = [16, 32, 48, 256, 512];
|
|
const DEFAULT_ICON_SIZE = 256;
|
|
```
|
|
|
|
Update `defaultQualityFor` usage sites so `iconSize` is set independently of `quality`. In `handleFilesSelected`, change the mapped object:
|
|
|
|
```js
|
|
const withTargets = await Promise.all(
|
|
files.map(async (file) => {
|
|
const targets = await fetchFormats(extensionOf(file.name));
|
|
const targetFormat = targets[0] ?? null;
|
|
return {
|
|
file,
|
|
targets,
|
|
targetFormat,
|
|
quality: defaultQualityFor(targetFormat),
|
|
iconSize: targetFormat === 'ico' ? DEFAULT_ICON_SIZE : null,
|
|
};
|
|
})
|
|
);
|
|
```
|
|
|
|
Update `updateTargetFormat` to reset `iconSize` the same way it resets `quality`:
|
|
|
|
```js
|
|
function updateTargetFormat(index, targetFormat) {
|
|
setPendingFiles((current) =>
|
|
current.map((item, i) =>
|
|
i === index
|
|
? {
|
|
...item,
|
|
targetFormat,
|
|
quality: defaultQualityFor(targetFormat),
|
|
iconSize: targetFormat === 'ico' ? DEFAULT_ICON_SIZE : null,
|
|
}
|
|
: item
|
|
)
|
|
);
|
|
}
|
|
```
|
|
|
|
Add an `updateIconSize` setter, mirroring `updateQuality`:
|
|
|
|
```js
|
|
function updateIconSize(index, iconSize) {
|
|
setPendingFiles((current) => current.map((item, i) => (i === index ? { ...item, iconSize } : item)));
|
|
}
|
|
```
|
|
|
|
Add a conditional control in the JSX, alongside the existing png/pdf blocks:
|
|
|
|
```jsx
|
|
{item.targetFormat === 'ico' && (
|
|
<label>
|
|
Taille de l'icône
|
|
<select
|
|
value={item.iconSize ?? DEFAULT_ICON_SIZE}
|
|
onChange={(event) => updateIconSize(index, Number(event.target.value))}
|
|
>
|
|
{ICON_SIZES.map((size) => (
|
|
<option key={size} value={size}>
|
|
{size}px
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 2: Update `frontend/src/api.js`**
|
|
|
|
```js
|
|
export async function uploadFiles(items) {
|
|
const formData = new FormData();
|
|
const targetFormats = [];
|
|
const qualities = [];
|
|
const iconSizes = [];
|
|
for (const item of items) {
|
|
formData.append('files', item.file);
|
|
targetFormats.push(item.targetFormat);
|
|
qualities.push(item.quality ?? null);
|
|
iconSizes.push(item.iconSize ?? null);
|
|
}
|
|
formData.append('targetFormats', JSON.stringify(targetFormats));
|
|
formData.append('qualities', JSON.stringify(qualities));
|
|
formData.append('iconSizes', JSON.stringify(iconSizes));
|
|
|
|
const response = await fetch('/api/jobs', { method: 'POST', body: formData });
|
|
const data = await response.json();
|
|
return data.jobs;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Manual browser verification**
|
|
|
|
Per `CLAUDE.md`, check for pre-existing `npm run dev` / `node src/server.js` / `node src/worker.js` processes before starting your own (`Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Select-Object ProcessId,CommandLine"`). If a pre-existing worker is running, it will not have this session's converter code loaded — either ask the user to restart their worker, or start your own throwaway worker instance for this check (do not kill/restart processes you didn't start yourself). Then:
|
|
- Upload a `.png`, select `ico` as the target, confirm the size dropdown appears and defaults to 256px, submit, confirm the download works and the file is a valid multi-... actually single-resolution `.ico` at the chosen size (open it or re-run the `icojs` decode check on the downloaded file).
|
|
- Upload a `.heic` file (e.g. `test/fixtures/sample.heic` renamed with a real `.heic` extension), confirm `heic` is not offered as a target format anywhere, and that converting it to `.jpg`/`.png` succeeds.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/App.jsx frontend/src/api.js
|
|
git commit -m "feat: add ICO size picker to the upload UI"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Full regression pass
|
|
|
|
**Files:** none (verification only)
|
|
|
|
- [ ] **Step 1: Run the full backend test suite**
|
|
|
|
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run`
|
|
Expected: PASS, except the two pre-existing unrelated failures documented in `CLAUDE.md`.
|
|
|
|
- [ ] **Step 2: Confirm no unintended target-format regressions**
|
|
|
|
This exact check (`GET /api/formats?source=heic`/`?source=heif` contains every image format plus `ico`; `?source=png` does not contain `heic`/`heif`) is already asserted automatically in the `test/api/jobs.test.js` test added in Task 6 (`'lists ico as a target for png, and does not list heic/heif as a target for anything'`), which Step 1's full run already covers — no separate manual check is needed here.
|
|
|
|
If a server is already running per the pre-existing-process check noted in Task 8 (check with `Get-CimInstance Win32_Process -Filter "Name='node.exe'"` first, per `CLAUDE.md`), a real-world spot check is still worthwhile: `curl localhost:3000/api/formats?source=heic` and confirm the JSON `targets` array matches expectations.
|
|
|
|
- [ ] **Step 3: Report to the user**
|
|
|
|
Summarize: all new tests passing, list of files changed, note that o2switch deployment will need `npm install` (to fetch `icojs`/`heic-convert`) and `npm run prisma-migration` (to apply the `icon_size` column) before the new formats work in production — per `CLAUDE.md`'s deployment process, do not run these against production yourself; hand off to the user.
|