Skip to content

Commit 7f037d5

Browse files
committed
feat: Implement conversion history, persistent settings, and image conversion capabilities.
1 parent be4d225 commit 7f037d5

19 files changed

Lines changed: 3186 additions & 251 deletions

bun.lock

Lines changed: 304 additions & 86 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist-electron/main.js

Lines changed: 615 additions & 60 deletions
Large diffs are not rendered by default.

dist-electron/preload.js

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,42 @@
1-
import { contextBridge as t, ipcRenderer as e } from "electron";
2-
t.exposeInMainWorld("electronAPI", {
1+
import { contextBridge, ipcRenderer } from "electron";
2+
contextBridge.exposeInMainWorld("electronAPI", {
33
// Window controls
4-
minimize: () => e.send("window-minimize"),
5-
maximize: () => e.send("window-maximize"),
6-
close: () => e.send("window-close"),
7-
toggleDevTools: () => e.send("toggle-dev-tools"),
4+
minimize: () => ipcRenderer.send("window-minimize"),
5+
maximize: () => ipcRenderer.send("window-maximize"),
6+
close: () => ipcRenderer.send("window-close"),
7+
toggleDevTools: () => ipcRenderer.send("toggle-dev-tools"),
88
// Window state listener
9-
onMaximizeChange: (i) => {
10-
const n = (d, o) => i(o);
11-
return e.on("window-maximized-changed", n), () => e.removeListener("window-maximized-changed", n);
9+
onMaximizeChange: (callback) => {
10+
const handler = (_event, isMaximized) => callback(isMaximized);
11+
ipcRenderer.on("window-maximized-changed", handler);
12+
return () => ipcRenderer.removeListener("window-maximized-changed", handler);
1213
},
1314
// File operations
14-
openFileDialog: () => e.invoke("open-file-dialog"),
15-
selectOutputDir: () => e.invoke("select-output-dir"),
16-
getFileInfo: (i) => e.invoke("get-file-info", i),
17-
convertFiles: (i) => e.invoke("convert-files", i)
15+
openFileDialog: () => ipcRenderer.invoke("open-file-dialog"),
16+
selectOutputDir: () => ipcRenderer.invoke("select-output-dir"),
17+
getFileInfo: (filePath) => ipcRenderer.invoke("get-file-info", filePath),
18+
// Conversion
19+
convertFiles: (payload) => ipcRenderer.invoke("convert-files", payload),
20+
onConversionProgress: (callback) => {
21+
const handler = (_event, data) => callback(data);
22+
ipcRenderer.on("conversion-progress", handler);
23+
return () => ipcRenderer.removeListener("conversion-progress", handler);
24+
},
25+
// Thumbnails
26+
generateThumbnail: (filePath) => ipcRenderer.invoke("generate-thumbnail", filePath),
27+
// History
28+
getHistory: (options) => ipcRenderer.invoke("get-history", options),
29+
getHistoryStats: () => ipcRenderer.invoke("get-history-stats"),
30+
deleteHistoryItem: (id) => ipcRenderer.invoke("delete-history-item", id),
31+
clearHistory: () => ipcRenderer.invoke("clear-history"),
32+
showInFolder: (filePath) => ipcRenderer.invoke("show-in-folder", filePath),
33+
// Settings
34+
getSettings: () => ipcRenderer.invoke("get-settings"),
35+
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
36+
updateSetting: (key, value) => ipcRenderer.invoke("update-setting", key, value),
37+
resetSettings: () => ipcRenderer.invoke("reset-settings"),
38+
// Utility
39+
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
40+
getAppPath: (name) => ipcRenderer.invoke("get-app-path", name),
41+
openExternal: (url) => ipcRenderer.invoke("open-external", url)
1842
});
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import sharp from 'sharp'
2+
import path from 'path'
3+
import fs from 'fs/promises'
4+
5+
export interface ImageConvertOptions {
6+
sourcePath: string
7+
outputDir: string
8+
targetFormat: string
9+
quality: number // 1-100
10+
overwriteBehavior: 'skip' | 'rename' | 'overwrite'
11+
}
12+
13+
export interface ConvertResult {
14+
success: boolean
15+
outputPath: string
16+
error?: string
17+
durationMs: number
18+
}
19+
20+
// Formats natively supported by Sharp
21+
const SHARP_FORMATS = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif', 'tiff', 'tif', 'jxl'] as const
22+
23+
// Formats we can read but need special handling
24+
const SPECIAL_INPUT_FORMATS = ['svg', 'ico', 'bmp'] as const
25+
26+
/**
27+
* Check if a format is supported by our image converter
28+
*/
29+
export function isImageFormat(ext: string): boolean {
30+
const lower = ext.toLowerCase()
31+
return [...SHARP_FORMATS, ...SPECIAL_INPUT_FORMATS].includes(lower as typeof SHARP_FORMATS[number] | typeof SPECIAL_INPUT_FORMATS[number])
32+
}
33+
34+
/**
35+
* Generate a unique output file path, handling conflicts based on overwrite behavior.
36+
*/
37+
async function resolveOutputPath(
38+
outputDir: string,
39+
baseName: string,
40+
targetFormat: string,
41+
overwriteBehavior: 'skip' | 'rename' | 'overwrite'
42+
): Promise<{ path: string; skip: boolean }> {
43+
const ext = targetFormat === 'jpg' ? 'jpg' : targetFormat
44+
const outputPath = path.join(outputDir, `${baseName}.${ext}`)
45+
46+
try {
47+
await fs.access(outputPath)
48+
// File exists
49+
if (overwriteBehavior === 'overwrite') {
50+
return { path: outputPath, skip: false }
51+
}
52+
if (overwriteBehavior === 'skip') {
53+
return { path: outputPath, skip: true }
54+
}
55+
// rename: find next available name
56+
let counter = 1
57+
let newPath: string
58+
do {
59+
newPath = path.join(outputDir, `${baseName} (${counter}).${ext}`)
60+
counter++
61+
try {
62+
await fs.access(newPath)
63+
} catch {
64+
// File doesn't exist, use this path
65+
return { path: newPath, skip: false }
66+
}
67+
} while (counter < 10000)
68+
69+
return { path: newPath, skip: false }
70+
} catch {
71+
// File doesn't exist, use original path
72+
return { path: outputPath, skip: false }
73+
}
74+
}
75+
76+
/**
77+
* Get the base name of a file without its extension
78+
*/
79+
function getBaseName(filePath: string): string {
80+
const name = path.basename(filePath)
81+
const lastDot = name.lastIndexOf('.')
82+
if (lastDot === -1) return name
83+
return name.substring(0, lastDot)
84+
}
85+
86+
/**
87+
* Convert an image file to the target format using Sharp.
88+
*/
89+
export async function convertImage(options: ImageConvertOptions): Promise<ConvertResult> {
90+
const startTime = Date.now()
91+
const { sourcePath, outputDir, targetFormat, quality, overwriteBehavior } = options
92+
93+
try {
94+
// Validate source file exists
95+
await fs.access(sourcePath)
96+
97+
// Ensure output directory exists
98+
await fs.mkdir(outputDir, { recursive: true })
99+
100+
// Resolve output path
101+
const baseName = getBaseName(sourcePath)
102+
const { path: outputPath, skip } = await resolveOutputPath(
103+
outputDir,
104+
baseName,
105+
targetFormat,
106+
overwriteBehavior
107+
)
108+
109+
if (skip) {
110+
return {
111+
success: true,
112+
outputPath,
113+
durationMs: Date.now() - startTime,
114+
}
115+
}
116+
117+
// Create Sharp pipeline
118+
let pipeline = sharp(sourcePath, {
119+
animated: false, // Don't process animated frames for conversion
120+
failOn: 'none', // Don't fail on minor image issues
121+
})
122+
123+
// Apply format-specific conversion
124+
const format = targetFormat.toLowerCase()
125+
switch (format) {
126+
case 'png':
127+
pipeline = pipeline.png({
128+
quality,
129+
compressionLevel: quality >= 90 ? 6 : quality >= 60 ? 7 : 9,
130+
})
131+
break
132+
133+
case 'jpg':
134+
case 'jpeg':
135+
pipeline = pipeline.jpeg({
136+
quality,
137+
mozjpeg: true, // Better compression
138+
})
139+
break
140+
141+
case 'webp':
142+
pipeline = pipeline.webp({
143+
quality,
144+
lossless: quality >= 100,
145+
})
146+
break
147+
148+
case 'avif':
149+
pipeline = pipeline.avif({
150+
quality,
151+
lossless: quality >= 100,
152+
})
153+
break
154+
155+
case 'gif':
156+
pipeline = pipeline.gif()
157+
break
158+
159+
case 'tiff':
160+
case 'tif':
161+
pipeline = pipeline.tiff({
162+
quality,
163+
compression: 'lzw',
164+
})
165+
break
166+
167+
case 'jxl':
168+
pipeline = pipeline.jxl({
169+
quality,
170+
lossless: quality >= 100,
171+
})
172+
break
173+
174+
case 'bmp':
175+
// Sharp doesn't support BMP output natively;
176+
// convert to PNG as a raw bitmap alternative
177+
pipeline = pipeline.png({ compressionLevel: 0 })
178+
break
179+
180+
case 'ico':
181+
// ICO: resize to 256x256 max, output as PNG (common ICO approach)
182+
pipeline = pipeline
183+
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
184+
.png()
185+
break
186+
187+
case 'pdf':
188+
// For image-to-PDF: not supported by Sharp alone
189+
// We would need a separate PDF library
190+
return {
191+
success: false,
192+
outputPath: '',
193+
error: 'Image to PDF conversion is not yet supported. A document converter is required.',
194+
durationMs: Date.now() - startTime,
195+
}
196+
197+
default:
198+
return {
199+
success: false,
200+
outputPath: '',
201+
error: `Unsupported target format: ${format}`,
202+
durationMs: Date.now() - startTime,
203+
}
204+
}
205+
206+
// Execute conversion
207+
await pipeline.toFile(outputPath)
208+
209+
return {
210+
success: true,
211+
outputPath,
212+
durationMs: Date.now() - startTime,
213+
}
214+
} catch (err) {
215+
const errorMessage = err instanceof Error ? err.message : String(err)
216+
console.error(`[image-converter] Failed to convert ${sourcePath}:`, errorMessage)
217+
218+
return {
219+
success: false,
220+
outputPath: '',
221+
error: errorMessage,
222+
durationMs: Date.now() - startTime,
223+
}
224+
}
225+
}
226+
227+
/**
228+
* Generate a thumbnail for an image file.
229+
* Returns a base64 data URL.
230+
*/
231+
export async function generateThumbnail(
232+
filePath: string,
233+
size: number = 128
234+
): Promise<string | null> {
235+
try {
236+
const buffer = await sharp(filePath, {
237+
failOn: 'none',
238+
})
239+
.resize(size, size, {
240+
fit: 'cover',
241+
position: 'centre',
242+
})
243+
.png({ quality: 70, compressionLevel: 9 })
244+
.toBuffer()
245+
246+
return `data:image/png;base64,${buffer.toString('base64')}`
247+
} catch (err) {
248+
console.error(`[image-converter] Failed to generate thumbnail for ${filePath}:`, err)
249+
return null
250+
}
251+
}

electron/converters/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export { convertImage, isImageFormat, generateThumbnail } from './image-converter'
2+
export type { ImageConvertOptions, ConvertResult } from './image-converter'
3+
4+
// Category detection for routing to the right converter
5+
export type ConverterCategory = 'image' | 'document' | 'video' | 'audio'
6+
7+
const IMAGE_FORMATS = new Set([
8+
'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'tiff', 'tif', 'svg', 'ico', 'jxl',
9+
])
10+
11+
const DOCUMENT_FORMATS = new Set([
12+
'pdf', 'epub', 'xps', 'cbz', 'mobi', 'fb2', 'docx', 'txt', 'rtf', 'odt',
13+
])
14+
15+
const VIDEO_FORMATS = new Set([
16+
'mp4', 'mkv', 'avi', 'mov', 'webm', '3gp', 'flv', 'wmv',
17+
])
18+
19+
const AUDIO_FORMATS = new Set([
20+
'mp3', 'wav', 'aac', 'ogg', 'flac', 'wma', 'm4a',
21+
])
22+
23+
export function getConverterCategory(ext: string): ConverterCategory | null {
24+
const lower = ext.toLowerCase()
25+
if (IMAGE_FORMATS.has(lower)) return 'image'
26+
if (DOCUMENT_FORMATS.has(lower)) return 'document'
27+
if (VIDEO_FORMATS.has(lower)) return 'video'
28+
if (AUDIO_FORMATS.has(lower)) return 'audio'
29+
return null
30+
}

0 commit comments

Comments
 (0)