pulp-image 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +332 -0
- package/bin/pulp.js +251 -0
- package/package.json +37 -0
- package/src/banner.js +10 -0
- package/src/buildOutputPath.js +67 -0
- package/src/formats.js +54 -0
- package/src/planTasks.js +42 -0
- package/src/processImage.js +216 -0
- package/src/reporter.js +115 -0
- package/src/runJob.js +97 -0
- package/src/stats.js +30 -0
- package/src/uiServer.js +398 -0
- package/ui/app.js +1153 -0
- package/ui/assets/pulp-logo-white.svg +13 -0
- package/ui/index.html +475 -0
- package/ui/styles.css +929 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
// Pulp Image UI - Main Application Logic
|
|
2
|
+
|
|
3
|
+
// Format information
|
|
4
|
+
const FORMAT_INFO = {
|
|
5
|
+
png: { name: 'PNG', supportsLossless: true, supportsQuality: false, defaultQuality: null, supportsTransparency: true },
|
|
6
|
+
jpg: { name: 'JPG', supportsLossless: false, supportsQuality: true, defaultQuality: 80, supportsTransparency: false },
|
|
7
|
+
webp: { name: 'WebP', supportsLossless: true, supportsQuality: true, defaultQuality: 80, supportsTransparency: true },
|
|
8
|
+
avif: { name: 'AVIF', supportsLossless: true, supportsQuality: true, defaultQuality: 50, supportsTransparency: true }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// State
|
|
12
|
+
let selectedFiles = [];
|
|
13
|
+
let ignoredFiles = []; // Store ignored non-image files
|
|
14
|
+
let ignoredFilesCount = 0; // Track ignored non-image files
|
|
15
|
+
let showIgnoredFiles = false; // Toggle state for showing ignored files
|
|
16
|
+
let processing = false;
|
|
17
|
+
let resolvedOutputPath = null; // Store resolved output path to prevent clearing
|
|
18
|
+
|
|
19
|
+
// DOM Elements
|
|
20
|
+
const form = document.getElementById('process-form');
|
|
21
|
+
const inputModeRadios = document.querySelectorAll('input[name="input-mode"]');
|
|
22
|
+
const inputSourceFiles = document.getElementById('input-source-files');
|
|
23
|
+
const inputSourceFolder = document.getElementById('input-source-folder');
|
|
24
|
+
const outputDir = document.getElementById('output-dir');
|
|
25
|
+
const useCustomOutput = document.getElementById('use-custom-output');
|
|
26
|
+
const openResultsFolderBtn = document.getElementById('open-results-folder');
|
|
27
|
+
const openResultsFolderResultsBtn = document.getElementById('open-results-folder-results');
|
|
28
|
+
const formatSelect = document.getElementById('format');
|
|
29
|
+
const widthInput = document.getElementById('width');
|
|
30
|
+
const heightInput = document.getElementById('height');
|
|
31
|
+
const qualitySlider = document.getElementById('quality');
|
|
32
|
+
const qualityValue = document.getElementById('quality-value');
|
|
33
|
+
const losslessToggle = document.getElementById('lossless');
|
|
34
|
+
const alphaModeRadios = document.querySelectorAll('input[name="alpha-mode"]');
|
|
35
|
+
const backgroundColor = document.getElementById('background');
|
|
36
|
+
const backgroundText = document.getElementById('background-text');
|
|
37
|
+
const renamePatternInput = document.getElementById('rename-pattern');
|
|
38
|
+
const suffixInput = document.getElementById('suffix');
|
|
39
|
+
const autoSuffixToggle = document.getElementById('auto-suffix');
|
|
40
|
+
const overwriteCheckbox = document.getElementById('overwrite');
|
|
41
|
+
// Delete original checkbox removed - not available in browser UI due to security restrictions
|
|
42
|
+
const processButton = document.getElementById('process-button');
|
|
43
|
+
const resetButton = document.getElementById('reset-button');
|
|
44
|
+
const inputPreview = document.getElementById('input-preview');
|
|
45
|
+
const fileListSummary = document.getElementById('file-list-summary-text');
|
|
46
|
+
const toggleFileListBtn = document.getElementById('toggle-file-list');
|
|
47
|
+
const fileListDetail = document.getElementById('file-list-detail');
|
|
48
|
+
const toggleIgnoredFilesBtn = document.getElementById('toggle-ignored-files');
|
|
49
|
+
const ignoredFilesList = document.getElementById('ignored-files-list');
|
|
50
|
+
const renamePreview = document.getElementById('rename-preview');
|
|
51
|
+
const suffixPreview = document.getElementById('suffix-preview');
|
|
52
|
+
const autoSuffixPreview = document.getElementById('auto-suffix-preview');
|
|
53
|
+
const resultsSection = document.getElementById('results-section');
|
|
54
|
+
const resultsContent = document.getElementById('results-content');
|
|
55
|
+
const inputHelper = document.getElementById('input-helper');
|
|
56
|
+
const outputDirHelper = document.getElementById('output-dir-helper');
|
|
57
|
+
const openResultsFolderHelper = document.getElementById('open-results-folder-helper');
|
|
58
|
+
const openResultsFolderResultsHelper = document.getElementById('open-results-folder-results-helper');
|
|
59
|
+
const openResultsFolderFallback = document.getElementById('open-results-folder-fallback');
|
|
60
|
+
const openResultsFolderResultsFallback = document.getElementById('open-results-folder-results-fallback');
|
|
61
|
+
|
|
62
|
+
// Initialize
|
|
63
|
+
document.addEventListener('DOMContentLoaded', async () => {
|
|
64
|
+
setupTabs();
|
|
65
|
+
setupForm();
|
|
66
|
+
setupValidation();
|
|
67
|
+
await loadVersion(); // Load version from backend
|
|
68
|
+
await updateOutputDirectory(); // Initialize output directory
|
|
69
|
+
await updateUI();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Load version from backend (single source of truth)
|
|
73
|
+
async function loadVersion() {
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch('/api/version');
|
|
76
|
+
if (response.ok) {
|
|
77
|
+
const data = await response.json();
|
|
78
|
+
const versionElement = document.getElementById('version');
|
|
79
|
+
if (versionElement && data.version) {
|
|
80
|
+
versionElement.textContent = `v${data.version}`;
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
console.warn('Failed to load version from server');
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.warn('Error loading version:', error);
|
|
87
|
+
// Keep default version display if fetch fails
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Tab Switching
|
|
92
|
+
function setupTabs() {
|
|
93
|
+
const tabButtons = document.querySelectorAll('.tab-button');
|
|
94
|
+
const tabContents = document.querySelectorAll('.tab-content');
|
|
95
|
+
|
|
96
|
+
tabButtons.forEach(button => {
|
|
97
|
+
button.addEventListener('click', () => {
|
|
98
|
+
const tabName = button.dataset.tab;
|
|
99
|
+
|
|
100
|
+
// Update buttons
|
|
101
|
+
tabButtons.forEach(btn => btn.classList.remove('active'));
|
|
102
|
+
button.classList.add('active');
|
|
103
|
+
|
|
104
|
+
// Update contents
|
|
105
|
+
tabContents.forEach(content => content.classList.remove('active'));
|
|
106
|
+
document.getElementById(`${tabName}-tab`).classList.add('active');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Form Setup
|
|
112
|
+
function setupForm() {
|
|
113
|
+
// Input mode switching
|
|
114
|
+
inputModeRadios.forEach(radio => {
|
|
115
|
+
radio.addEventListener('change', (e) => {
|
|
116
|
+
const mode = e.target.value;
|
|
117
|
+
const privacyNotice = document.getElementById('folder-privacy-notice');
|
|
118
|
+
if (mode === 'files') {
|
|
119
|
+
inputSourceFiles.style.display = 'block';
|
|
120
|
+
inputSourceFolder.style.display = 'none';
|
|
121
|
+
if (privacyNotice) privacyNotice.style.display = 'none';
|
|
122
|
+
inputHelper.textContent = 'Select one or more image files. Supports PNG, JPG, WebP, AVIF. Multiple files will be processed as a batch.';
|
|
123
|
+
} else {
|
|
124
|
+
inputSourceFiles.style.display = 'none';
|
|
125
|
+
inputSourceFolder.style.display = 'block';
|
|
126
|
+
if (privacyNotice) privacyNotice.style.display = 'block';
|
|
127
|
+
inputHelper.textContent = 'Choose a folder containing image files. Folder picker support depends on browser (Chrome/Edge recommended).';
|
|
128
|
+
}
|
|
129
|
+
// Clear selection when switching modes
|
|
130
|
+
selectedFiles = [];
|
|
131
|
+
inputSourceFiles.value = '';
|
|
132
|
+
inputSourceFolder.value = '';
|
|
133
|
+
updateInputPreview();
|
|
134
|
+
updateUI().catch(console.error);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Supported image formats (matching backend)
|
|
139
|
+
const SUPPORTED_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp', 'avif'];
|
|
140
|
+
|
|
141
|
+
// Filter files to only include supported image formats
|
|
142
|
+
function filterImageFiles(files) {
|
|
143
|
+
return Array.from(files).filter(file => {
|
|
144
|
+
const ext = file.name.split('.').pop()?.toLowerCase();
|
|
145
|
+
return ext && SUPPORTED_IMAGE_EXTENSIONS.includes(ext);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// File input
|
|
150
|
+
inputSourceFiles.addEventListener('change', (e) => {
|
|
151
|
+
const allFiles = Array.from(e.target.files);
|
|
152
|
+
selectedFiles = filterImageFiles(allFiles);
|
|
153
|
+
ignoredFiles = Array.from(allFiles).filter(file => !selectedFiles.includes(file));
|
|
154
|
+
ignoredFilesCount = ignoredFiles.length;
|
|
155
|
+
if (ignoredFilesCount > 0) {
|
|
156
|
+
console.log(`Ignored ${ignoredFilesCount} non-image file(s)`);
|
|
157
|
+
}
|
|
158
|
+
updateInputPreview();
|
|
159
|
+
updateUI().catch(console.error);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Folder input
|
|
163
|
+
inputSourceFolder.addEventListener('change', (e) => {
|
|
164
|
+
const allFiles = Array.from(e.target.files);
|
|
165
|
+
selectedFiles = filterImageFiles(allFiles);
|
|
166
|
+
ignoredFiles = Array.from(allFiles).filter(file => !selectedFiles.includes(file));
|
|
167
|
+
ignoredFilesCount = ignoredFiles.length;
|
|
168
|
+
if (ignoredFilesCount > 0) {
|
|
169
|
+
console.log(`Ignored ${ignoredFilesCount} non-image file(s)`);
|
|
170
|
+
}
|
|
171
|
+
updateInputPreview();
|
|
172
|
+
updateUI().catch(console.error);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Toggle ignored files
|
|
176
|
+
if (toggleIgnoredFilesBtn) {
|
|
177
|
+
toggleIgnoredFilesBtn.addEventListener('click', () => {
|
|
178
|
+
showIgnoredFiles = !showIgnoredFiles;
|
|
179
|
+
updateInputPreview();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Custom output directory toggle
|
|
184
|
+
useCustomOutput.addEventListener('change', async () => {
|
|
185
|
+
if (useCustomOutput.checked) {
|
|
186
|
+
outputDir.readOnly = false;
|
|
187
|
+
outputDir.style.background = 'white';
|
|
188
|
+
outputDirHelper.textContent = 'Specify a custom output directory. Use ~ for home directory.';
|
|
189
|
+
await validateCustomOutputPath();
|
|
190
|
+
} else {
|
|
191
|
+
outputDir.readOnly = true;
|
|
192
|
+
outputDir.style.background = '';
|
|
193
|
+
await updateOutputDirectory();
|
|
194
|
+
outputDirHelper.textContent = 'Files will be saved in a new folder inside your home directory.';
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Validate custom output path on input
|
|
199
|
+
outputDir.addEventListener('blur', async () => {
|
|
200
|
+
if (useCustomOutput.checked) {
|
|
201
|
+
await validateCustomOutputPath();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Open results folder button (near output directory) - with null check
|
|
206
|
+
if (openResultsFolderBtn) {
|
|
207
|
+
openResultsFolderBtn.addEventListener('click', async (e) => {
|
|
208
|
+
// Prevent clicks when disabled
|
|
209
|
+
if (e.target.disabled === true) {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
e.stopPropagation();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const outputPath = outputDir ? outputDir.value : null;
|
|
215
|
+
if (outputPath) {
|
|
216
|
+
await openResultsFolder(outputPath, 'output');
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
console.warn('open-results-folder button not found in DOM');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Open results folder button (near results) - with null check
|
|
224
|
+
if (openResultsFolderResultsBtn) {
|
|
225
|
+
openResultsFolderResultsBtn.addEventListener('click', async (e) => {
|
|
226
|
+
// Prevent clicks when disabled
|
|
227
|
+
if (e.target.disabled === true) {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
e.stopPropagation();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const outputPath = outputDir ? outputDir.value : null;
|
|
233
|
+
if (outputPath) {
|
|
234
|
+
await openResultsFolder(outputPath, 'results');
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
} else {
|
|
238
|
+
console.warn('open-results-folder-results button not found in DOM');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Helper function to open results folder
|
|
242
|
+
async function openResultsFolder(path, buttonLocation = 'output') {
|
|
243
|
+
// Hide any previous fallback messages
|
|
244
|
+
if (openResultsFolderFallback) {
|
|
245
|
+
openResultsFolderFallback.style.display = 'none';
|
|
246
|
+
}
|
|
247
|
+
if (openResultsFolderResultsFallback) {
|
|
248
|
+
openResultsFolderResultsFallback.style.display = 'none';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch('/api/open-folder', {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'Content-Type': 'application/json' },
|
|
255
|
+
body: JSON.stringify({ path: path })
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!response.ok) {
|
|
259
|
+
const errorData = await response.json().catch(() => ({}));
|
|
260
|
+
// Show friendly fallback message with path
|
|
261
|
+
const fallbackElement = buttonLocation === 'results' ? openResultsFolderResultsFallback : openResultsFolderFallback;
|
|
262
|
+
const messageElement = fallbackElement?.querySelector('.folder-open-fallback-message');
|
|
263
|
+
const pathElement = fallbackElement?.querySelector('.folder-open-fallback-path');
|
|
264
|
+
|
|
265
|
+
if (fallbackElement && messageElement && pathElement) {
|
|
266
|
+
messageElement.textContent = 'We couldn\'t open the results folder automatically on this system.\nYou can open it manually using the path below.';
|
|
267
|
+
pathElement.textContent = errorData.path || path || 'Path not available';
|
|
268
|
+
fallbackElement.style.display = 'block';
|
|
269
|
+
}
|
|
270
|
+
return; // Don't throw - this is expected behavior
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Success - ensure fallback is hidden
|
|
274
|
+
if (openResultsFolderFallback) {
|
|
275
|
+
openResultsFolderFallback.style.display = 'none';
|
|
276
|
+
}
|
|
277
|
+
if (openResultsFolderResultsFallback) {
|
|
278
|
+
openResultsFolderResultsFallback.style.display = 'none';
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
// Network or other errors - show fallback with path
|
|
282
|
+
const fallbackElement = buttonLocation === 'results' ? openResultsFolderResultsFallback : openResultsFolderFallback;
|
|
283
|
+
const messageElement = fallbackElement?.querySelector('.folder-open-fallback-message');
|
|
284
|
+
const pathElement = fallbackElement?.querySelector('.folder-open-fallback-path');
|
|
285
|
+
|
|
286
|
+
if (fallbackElement && messageElement && pathElement) {
|
|
287
|
+
messageElement.textContent = 'We couldn\'t open the results folder automatically on this system.\nYou can open it manually using the path below.';
|
|
288
|
+
pathElement.textContent = path || 'Path not available';
|
|
289
|
+
fallbackElement.style.display = 'block';
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Rename pattern input
|
|
295
|
+
renamePatternInput.addEventListener('input', () => {
|
|
296
|
+
updateRenamePreview();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Format change - triggers rename preview update
|
|
300
|
+
formatSelect.addEventListener('change', () => {
|
|
301
|
+
updateFormatDependencies();
|
|
302
|
+
updateRenamePreview();
|
|
303
|
+
updateUI().catch(console.error);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Quality slider
|
|
307
|
+
qualitySlider.addEventListener('input', (e) => {
|
|
308
|
+
qualityValue.textContent = e.target.value;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Lossless toggle
|
|
312
|
+
losslessToggle.addEventListener('change', () => {
|
|
313
|
+
updateFormatDependencies();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Background color
|
|
317
|
+
backgroundColor.addEventListener('input', (e) => {
|
|
318
|
+
backgroundText.value = e.target.value;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
backgroundText.addEventListener('input', (e) => {
|
|
322
|
+
if (/^#[0-9A-F]{6}$/i.test(e.target.value)) {
|
|
323
|
+
backgroundColor.value = e.target.value;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Suffix inputs
|
|
328
|
+
suffixInput.addEventListener('input', updateSuffixPreview);
|
|
329
|
+
autoSuffixToggle.addEventListener('change', updateSuffixPreview);
|
|
330
|
+
widthInput.addEventListener('input', updateSuffixPreview);
|
|
331
|
+
heightInput.addEventListener('input', updateSuffixPreview);
|
|
332
|
+
formatSelect.addEventListener('change', () => {
|
|
333
|
+
updateFormatDependencies();
|
|
334
|
+
updateSuffixPreview();
|
|
335
|
+
updateRenamePreview(); // Update rename preview when format changes
|
|
336
|
+
updateUI().catch(console.error);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Warnings
|
|
340
|
+
overwriteCheckbox.addEventListener('change', () => {
|
|
341
|
+
document.getElementById('overwrite-warning').style.display =
|
|
342
|
+
overwriteCheckbox.checked ? 'block' : 'none';
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Delete original checkbox removed - not available in browser UI
|
|
346
|
+
|
|
347
|
+
// Form submission
|
|
348
|
+
form.addEventListener('submit', handleSubmit);
|
|
349
|
+
|
|
350
|
+
// Reset button
|
|
351
|
+
resetButton.addEventListener('click', resetForm);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Validation Setup
|
|
355
|
+
function setupValidation() {
|
|
356
|
+
// Real-time validation
|
|
357
|
+
const inputs = form.querySelectorAll('input, select');
|
|
358
|
+
inputs.forEach(input => {
|
|
359
|
+
input.addEventListener('blur', validateForm);
|
|
360
|
+
input.addEventListener('input', validateForm);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Update Format Dependencies
|
|
365
|
+
function updateFormatDependencies() {
|
|
366
|
+
const format = formatSelect.value;
|
|
367
|
+
const formatInfo = format ? FORMAT_INFO[format] : null;
|
|
368
|
+
|
|
369
|
+
// Update quality slider
|
|
370
|
+
if (formatInfo) {
|
|
371
|
+
if (formatInfo.supportsQuality && !losslessToggle.checked) {
|
|
372
|
+
qualitySlider.disabled = false;
|
|
373
|
+
if (formatInfo.defaultQuality && qualitySlider.value === '80') {
|
|
374
|
+
qualitySlider.value = formatInfo.defaultQuality;
|
|
375
|
+
qualityValue.textContent = formatInfo.defaultQuality;
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
qualitySlider.disabled = true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Update lossless toggle
|
|
382
|
+
if (formatInfo.supportsLossless) {
|
|
383
|
+
losslessToggle.disabled = false;
|
|
384
|
+
} else {
|
|
385
|
+
losslessToggle.disabled = true;
|
|
386
|
+
losslessToggle.checked = false;
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
qualitySlider.disabled = false;
|
|
390
|
+
losslessToggle.disabled = false;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Update helper texts
|
|
394
|
+
updateHelperTexts();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Update Helper Texts
|
|
398
|
+
function updateHelperTexts() {
|
|
399
|
+
const format = formatSelect.value;
|
|
400
|
+
const formatInfo = format ? FORMAT_INFO[format] : null;
|
|
401
|
+
const formatHelper = document.getElementById('format-helper');
|
|
402
|
+
const qualityHelper = document.getElementById('quality-helper');
|
|
403
|
+
const losslessHelper = document.getElementById('lossless-helper');
|
|
404
|
+
|
|
405
|
+
if (formatInfo) {
|
|
406
|
+
formatHelper.textContent = `${formatInfo.name}: ${formatInfo.supportsTransparency ? 'Supports transparency' : 'No transparency support'}. ${formatInfo.supportsLossless ? 'Supports lossless' : 'Lossy only'}.`;
|
|
407
|
+
|
|
408
|
+
if (losslessToggle.checked && formatInfo.supportsLossless) {
|
|
409
|
+
qualityHelper.textContent = 'Quality is disabled when lossless compression is enabled.';
|
|
410
|
+
} else if (formatInfo.supportsQuality) {
|
|
411
|
+
qualityHelper.textContent = `Quality for ${formatInfo.name} (1-100). Default: ${formatInfo.defaultQuality}. Higher = better quality but larger file.`;
|
|
412
|
+
} else {
|
|
413
|
+
qualityHelper.textContent = `${formatInfo.name} is always lossless (no quality setting).`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (formatInfo.supportsLossless) {
|
|
417
|
+
losslessHelper.textContent = `Use lossless compression for ${formatInfo.name}. When enabled, quality setting is ignored.`;
|
|
418
|
+
} else {
|
|
419
|
+
losslessHelper.textContent = `${formatInfo.name} does not support lossless compression.`;
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
formatHelper.textContent = 'Select output format or keep original.';
|
|
423
|
+
qualityHelper.textContent = 'Quality for lossy formats (1-100). Defaults: JPG=80, WebP=80, AVIF=50.';
|
|
424
|
+
losslessHelper.textContent = 'Use lossless compression where supported (PNG, WebP, AVIF). PNG is always lossless.';
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Update Input Preview
|
|
429
|
+
function updateInputPreview() {
|
|
430
|
+
if (selectedFiles.length === 0 && ignoredFilesCount === 0) {
|
|
431
|
+
fileListSummary.textContent = 'No files selected';
|
|
432
|
+
toggleFileListBtn.style.display = 'none';
|
|
433
|
+
fileListDetail.style.display = 'none';
|
|
434
|
+
if (toggleIgnoredFilesBtn) toggleIgnoredFilesBtn.style.display = 'none';
|
|
435
|
+
if (ignoredFilesList) ignoredFilesList.style.display = 'none';
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Update summary - show filtered count and ignored count if any
|
|
440
|
+
const filteredCount = selectedFiles.length;
|
|
441
|
+
let summaryText = `${filteredCount} image file${filteredCount === 1 ? '' : 's'} selected`;
|
|
442
|
+
if (ignoredFilesCount > 0) {
|
|
443
|
+
summaryText += ` (${ignoredFilesCount} non-image ${ignoredFilesCount === 1 ? 'file' : 'files'} ignored)`;
|
|
444
|
+
}
|
|
445
|
+
fileListSummary.textContent = summaryText;
|
|
446
|
+
|
|
447
|
+
// Always show toggle button (even for single file)
|
|
448
|
+
toggleFileListBtn.style.display = 'inline-block';
|
|
449
|
+
toggleFileListBtn.textContent = fileListDetail.style.display === 'none' ? 'Show files' : 'Hide files';
|
|
450
|
+
|
|
451
|
+
// Update file list detail - always render list (only filtered image files)
|
|
452
|
+
fileListDetail.innerHTML = '';
|
|
453
|
+
selectedFiles.forEach((file, index) => {
|
|
454
|
+
const item = document.createElement('div');
|
|
455
|
+
item.className = 'file-list-item';
|
|
456
|
+
const size = file.size ? formatBytes(file.size) : 'Unknown size';
|
|
457
|
+
item.innerHTML = `
|
|
458
|
+
<span class="file-list-item-name">${escapeHtml(file.name)}</span>
|
|
459
|
+
<span class="file-list-item-size">${size}</span>
|
|
460
|
+
`;
|
|
461
|
+
fileListDetail.appendChild(item);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Update ignored files section
|
|
465
|
+
if (ignoredFilesCount > 0 && toggleIgnoredFilesBtn && ignoredFilesList) {
|
|
466
|
+
toggleIgnoredFilesBtn.style.display = 'inline-block';
|
|
467
|
+
toggleIgnoredFilesBtn.textContent = showIgnoredFiles ? 'Hide ignored files' : 'Show ignored files';
|
|
468
|
+
|
|
469
|
+
if (showIgnoredFiles) {
|
|
470
|
+
ignoredFilesList.style.display = 'block';
|
|
471
|
+
ignoredFilesList.innerHTML = '';
|
|
472
|
+
ignoredFiles.forEach((file) => {
|
|
473
|
+
const item = document.createElement('div');
|
|
474
|
+
item.className = 'file-list-item ignored-file';
|
|
475
|
+
item.innerHTML = `
|
|
476
|
+
<span class="file-list-item-name">${escapeHtml(file.name)}</span>
|
|
477
|
+
<span class="file-list-item-badge">Ignored</span>
|
|
478
|
+
`;
|
|
479
|
+
ignoredFilesList.appendChild(item);
|
|
480
|
+
});
|
|
481
|
+
} else {
|
|
482
|
+
ignoredFilesList.style.display = 'none';
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
if (toggleIgnoredFilesBtn) toggleIgnoredFilesBtn.style.display = 'none';
|
|
486
|
+
if (ignoredFilesList) ignoredFilesList.style.display = 'none';
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Toggle file list
|
|
491
|
+
toggleFileListBtn.addEventListener('click', () => {
|
|
492
|
+
if (fileListDetail.style.display === 'none') {
|
|
493
|
+
fileListDetail.style.display = 'block';
|
|
494
|
+
toggleFileListBtn.textContent = 'Hide files';
|
|
495
|
+
} else {
|
|
496
|
+
fileListDetail.style.display = 'none';
|
|
497
|
+
toggleFileListBtn.textContent = 'Show files';
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Update Output Directory
|
|
502
|
+
async function updateOutputDirectory() {
|
|
503
|
+
// Don't update if we're processing or if path is already resolved
|
|
504
|
+
if (processing) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Don't update if outputDir element is missing
|
|
509
|
+
if (!outputDir) {
|
|
510
|
+
console.warn('output-dir element not found in DOM');
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!useCustomOutput || !useCustomOutput.checked) {
|
|
515
|
+
// Generate default path: ~/pulp-image-results/YYYY-MM-DD_HH-mm-ss
|
|
516
|
+
const now = new Date();
|
|
517
|
+
const year = now.getFullYear();
|
|
518
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
519
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
520
|
+
const hours = String(now.getHours()).padStart(2, '0');
|
|
521
|
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
522
|
+
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
523
|
+
const timestamp = `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
|
|
524
|
+
|
|
525
|
+
// Get resolved path from server
|
|
526
|
+
try {
|
|
527
|
+
const response = await fetch('/api/resolve-output-path', {
|
|
528
|
+
method: 'POST',
|
|
529
|
+
headers: { 'Content-Type': 'application/json' },
|
|
530
|
+
body: JSON.stringify({
|
|
531
|
+
useDefault: true,
|
|
532
|
+
timestamp: timestamp
|
|
533
|
+
})
|
|
534
|
+
});
|
|
535
|
+
if (response.ok) {
|
|
536
|
+
const data = await response.json();
|
|
537
|
+
resolvedOutputPath = data.path;
|
|
538
|
+
outputDir.value = data.path;
|
|
539
|
+
} else {
|
|
540
|
+
// Fallback to placeholder if server call fails
|
|
541
|
+
const fallbackPath = `~/pulp-image-results/${timestamp}`;
|
|
542
|
+
resolvedOutputPath = fallbackPath;
|
|
543
|
+
outputDir.value = fallbackPath;
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
// Fallback to placeholder if server call fails
|
|
547
|
+
const fallbackPath = `~/pulp-image-results/${timestamp}`;
|
|
548
|
+
resolvedOutputPath = fallbackPath;
|
|
549
|
+
outputDir.value = fallbackPath;
|
|
550
|
+
}
|
|
551
|
+
openResultsFolderBtn.style.display = 'none'; // Will show after processing
|
|
552
|
+
} else if (outputDir.value) {
|
|
553
|
+
// Validate custom path exists
|
|
554
|
+
await validateCustomOutputPath();
|
|
555
|
+
// Store custom path
|
|
556
|
+
resolvedOutputPath = outputDir.value;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Validate custom output directory
|
|
561
|
+
async function validateCustomOutputPath() {
|
|
562
|
+
const customPath = outputDir.value.trim();
|
|
563
|
+
if (!customPath) return;
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
const response = await fetch('/api/validate-output-path', {
|
|
567
|
+
method: 'POST',
|
|
568
|
+
headers: { 'Content-Type': 'application/json' },
|
|
569
|
+
body: JSON.stringify({ path: customPath })
|
|
570
|
+
});
|
|
571
|
+
if (response.ok) {
|
|
572
|
+
const data = await response.json();
|
|
573
|
+
if (!data.exists && !data.willCreate) {
|
|
574
|
+
// Show friendly message
|
|
575
|
+
const helper = document.getElementById('output-dir-helper');
|
|
576
|
+
if (helper) {
|
|
577
|
+
helper.textContent = 'Directory does not exist. It will be created.';
|
|
578
|
+
helper.style.color = 'var(--text-secondary)';
|
|
579
|
+
}
|
|
580
|
+
} else {
|
|
581
|
+
const helper = document.getElementById('output-dir-helper');
|
|
582
|
+
if (helper) {
|
|
583
|
+
helper.textContent = useCustomOutput.checked
|
|
584
|
+
? 'Specify a custom output directory. Use ~ for home directory.'
|
|
585
|
+
: 'Files will be saved in a new folder inside your home directory.';
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
} catch (error) {
|
|
590
|
+
// Silently fail validation
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Update Rename Preview
|
|
595
|
+
function updateRenamePreview() {
|
|
596
|
+
if (!renamePreview) {
|
|
597
|
+
return; // Element not found, skip silently
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const pattern = renamePatternInput ? renamePatternInput.value.trim() : '';
|
|
601
|
+
|
|
602
|
+
// Show placeholder if no pattern or no files
|
|
603
|
+
if (!pattern) {
|
|
604
|
+
renamePreview.style.display = 'none';
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (selectedFiles.length === 0) {
|
|
609
|
+
renamePreview.textContent = 'Preview: (select files to see preview)';
|
|
610
|
+
renamePreview.style.display = 'block';
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Get first file for preview
|
|
615
|
+
const firstFile = selectedFiles[0];
|
|
616
|
+
const nameWithoutExt = firstFile.name.replace(/\.[^/.]+$/, '');
|
|
617
|
+
|
|
618
|
+
// Get output extension - use format if set, otherwise keep original extension
|
|
619
|
+
let ext = '';
|
|
620
|
+
if (formatSelect && formatSelect.value) {
|
|
621
|
+
ext = formatSelect.value;
|
|
622
|
+
} else {
|
|
623
|
+
const fileExt = firstFile.name.split('.').pop();
|
|
624
|
+
ext = fileExt || 'png';
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Build preview using tokens
|
|
628
|
+
let preview = pattern
|
|
629
|
+
.replace(/{name}/g, nameWithoutExt)
|
|
630
|
+
.replace(/{ext}/g, ext)
|
|
631
|
+
.replace(/{index}/g, '1');
|
|
632
|
+
|
|
633
|
+
// Ensure extension is included if pattern doesn't have one
|
|
634
|
+
if (!preview.includes('.')) {
|
|
635
|
+
preview += `.${ext}`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
renamePreview.textContent = `Preview (first file): ${preview}`;
|
|
639
|
+
renamePreview.style.display = 'block';
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Update Suffix Preview
|
|
643
|
+
function updateSuffixPreview() {
|
|
644
|
+
const suffix = suffixInput.value.trim();
|
|
645
|
+
const autoSuffix = autoSuffixToggle.checked;
|
|
646
|
+
const width = widthInput.value;
|
|
647
|
+
const height = heightInput.value;
|
|
648
|
+
const format = formatSelect.value;
|
|
649
|
+
|
|
650
|
+
// Build preview filename
|
|
651
|
+
let preview = 'example';
|
|
652
|
+
|
|
653
|
+
// Auto suffix
|
|
654
|
+
if (autoSuffix) {
|
|
655
|
+
const suffixParts = [];
|
|
656
|
+
if (width && height) {
|
|
657
|
+
suffixParts.push(`${width}x${height}`);
|
|
658
|
+
} else if (width) {
|
|
659
|
+
suffixParts.push(`${width}w`);
|
|
660
|
+
} else if (height) {
|
|
661
|
+
suffixParts.push(`${height}h`);
|
|
662
|
+
}
|
|
663
|
+
if (suffixParts.length > 0) {
|
|
664
|
+
preview += `-${suffixParts.join('-')}`;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Custom suffix
|
|
669
|
+
if (suffix) {
|
|
670
|
+
preview += `-${suffix}`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Extension
|
|
674
|
+
const ext = format || 'png';
|
|
675
|
+
preview += `.${ext}`;
|
|
676
|
+
|
|
677
|
+
if (autoSuffix || suffix) {
|
|
678
|
+
suffixPreview.textContent = `Preview: ${preview}`;
|
|
679
|
+
suffixPreview.style.display = 'block';
|
|
680
|
+
} else {
|
|
681
|
+
suffixPreview.style.display = 'none';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (autoSuffix) {
|
|
685
|
+
autoSuffixPreview.textContent = `Auto suffix will add: ${width && height ? `${width}x${height}` : width ? `${width}w` : height ? `${height}h` : 'size'}`;
|
|
686
|
+
autoSuffixPreview.style.display = 'block';
|
|
687
|
+
} else {
|
|
688
|
+
autoSuffixPreview.style.display = 'none';
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Update UI State
|
|
693
|
+
async function updateUI() {
|
|
694
|
+
updateFormatDependencies();
|
|
695
|
+
updateSuffixPreview();
|
|
696
|
+
updateRenamePreview();
|
|
697
|
+
if (!useCustomOutput.checked && !processing) {
|
|
698
|
+
await updateOutputDirectory();
|
|
699
|
+
}
|
|
700
|
+
validateForm();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Validate Form
|
|
704
|
+
function validateForm() {
|
|
705
|
+
const hasInput = selectedFiles.length > 0;
|
|
706
|
+
const isValid = hasInput;
|
|
707
|
+
|
|
708
|
+
processButton.disabled = !isValid || processing;
|
|
709
|
+
|
|
710
|
+
return isValid;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Handle Form Submit
|
|
714
|
+
async function handleSubmit(e) {
|
|
715
|
+
e.preventDefault();
|
|
716
|
+
|
|
717
|
+
// Prevent submission if button is disabled
|
|
718
|
+
if (processButton && processButton.disabled === true) {
|
|
719
|
+
e.preventDefault();
|
|
720
|
+
e.stopPropagation();
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Prevent double submission
|
|
725
|
+
if (processing) {
|
|
726
|
+
console.warn('Processing already in progress');
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Validate files selected
|
|
731
|
+
if (!selectedFiles || selectedFiles.length === 0) {
|
|
732
|
+
alert('Error: Please select at least one image file to process.');
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Validate output directory before processing
|
|
737
|
+
let outputPath = resolvedOutputPath || (outputDir ? outputDir.value : null);
|
|
738
|
+
if (!outputPath || outputPath.trim() === '') {
|
|
739
|
+
alert('Error: Output directory is not set. Please wait for the directory path to be resolved, or specify a custom output directory.');
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Validate form
|
|
744
|
+
if (!validateForm()) {
|
|
745
|
+
alert('Error: Please check your form inputs and try again.');
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
processing = true;
|
|
750
|
+
if (processButton) {
|
|
751
|
+
processButton.disabled = true;
|
|
752
|
+
processButton.textContent = 'Processing...';
|
|
753
|
+
}
|
|
754
|
+
if (resultsSection) {
|
|
755
|
+
resultsSection.style.display = 'none';
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
// Use stored resolved path or current value
|
|
760
|
+
// Expand ~ to home directory (will be done on server, but prepare it)
|
|
761
|
+
if (outputPath.startsWith('~')) {
|
|
762
|
+
outputPath = outputPath.replace('~', '');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const config = {
|
|
766
|
+
width: widthInput.value ? parseInt(widthInput.value, 10) : null,
|
|
767
|
+
height: heightInput.value ? parseInt(heightInput.value, 10) : null,
|
|
768
|
+
format: formatSelect.value || null,
|
|
769
|
+
out: outputPath,
|
|
770
|
+
renamePattern: renamePatternInput.value.trim() || null,
|
|
771
|
+
suffix: suffixInput.value.trim() || null,
|
|
772
|
+
autoSuffix: autoSuffixToggle.checked,
|
|
773
|
+
quality: qualitySlider.disabled ? null : parseInt(qualitySlider.value, 10),
|
|
774
|
+
lossless: losslessToggle.checked,
|
|
775
|
+
background: backgroundColor.value,
|
|
776
|
+
alphaMode: document.querySelector('input[name="alpha-mode"]:checked').value,
|
|
777
|
+
overwrite: overwriteCheckbox.checked,
|
|
778
|
+
deleteOriginal: false, // Always false in UI mode - browser security prevents file deletion
|
|
779
|
+
useDefaultOutput: !useCustomOutput.checked
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// Prepare file data
|
|
783
|
+
// Files are sent to server for local processing (no internet upload)
|
|
784
|
+
|
|
785
|
+
const formData = new FormData();
|
|
786
|
+
selectedFiles.forEach(file => {
|
|
787
|
+
formData.append('files', file);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// Add config as JSON
|
|
791
|
+
formData.append('config', JSON.stringify(config));
|
|
792
|
+
|
|
793
|
+
const response = await fetch('/api/run', {
|
|
794
|
+
method: 'POST',
|
|
795
|
+
body: formData
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
if (!response.ok) {
|
|
799
|
+
let errorMessage = 'Processing failed';
|
|
800
|
+
try {
|
|
801
|
+
const errorData = await response.json();
|
|
802
|
+
errorMessage = errorData.message || errorData.error || errorMessage;
|
|
803
|
+
} catch (e) {
|
|
804
|
+
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
|
805
|
+
}
|
|
806
|
+
throw new Error(errorMessage);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const results = await response.json();
|
|
810
|
+
|
|
811
|
+
// Update output directory with resolved path
|
|
812
|
+
if (results.outputPath) {
|
|
813
|
+
if (outputDir) {
|
|
814
|
+
outputDir.value = results.outputPath;
|
|
815
|
+
}
|
|
816
|
+
resolvedOutputPath = results.outputPath; // Keep it stable
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
displayResults(results);
|
|
820
|
+
|
|
821
|
+
// Hide any fallback messages from previous attempts
|
|
822
|
+
if (openResultsFolderFallback) {
|
|
823
|
+
openResultsFolderFallback.style.display = 'none';
|
|
824
|
+
}
|
|
825
|
+
if (openResultsFolderResultsFallback) {
|
|
826
|
+
openResultsFolderResultsFallback.style.display = 'none';
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Enable/disable "Open results folder" buttons based on whether any files were processed
|
|
830
|
+
const hasProcessedFiles = results.processed && results.processed.length > 0;
|
|
831
|
+
const explanationText = 'No output files were created.\nFix the errors above and process again to enable this action.';
|
|
832
|
+
|
|
833
|
+
if (openResultsFolderBtn) {
|
|
834
|
+
if (hasProcessedFiles) {
|
|
835
|
+
openResultsFolderBtn.style.display = 'inline-block';
|
|
836
|
+
openResultsFolderBtn.disabled = false;
|
|
837
|
+
if (openResultsFolderHelper) {
|
|
838
|
+
openResultsFolderHelper.style.display = 'none';
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
openResultsFolderBtn.style.display = 'inline-block';
|
|
842
|
+
openResultsFolderBtn.disabled = true;
|
|
843
|
+
if (openResultsFolderHelper) {
|
|
844
|
+
openResultsFolderHelper.textContent = explanationText;
|
|
845
|
+
openResultsFolderHelper.style.display = 'block';
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (openResultsFolderResultsBtn) {
|
|
850
|
+
if (hasProcessedFiles) {
|
|
851
|
+
openResultsFolderResultsBtn.style.display = 'inline-block';
|
|
852
|
+
openResultsFolderResultsBtn.disabled = false;
|
|
853
|
+
if (openResultsFolderResultsHelper) {
|
|
854
|
+
openResultsFolderResultsHelper.style.display = 'none';
|
|
855
|
+
}
|
|
856
|
+
} else {
|
|
857
|
+
openResultsFolderResultsBtn.style.display = 'inline-block';
|
|
858
|
+
openResultsFolderResultsBtn.disabled = true;
|
|
859
|
+
if (openResultsFolderResultsHelper) {
|
|
860
|
+
openResultsFolderResultsHelper.textContent = explanationText;
|
|
861
|
+
openResultsFolderResultsHelper.style.display = 'block';
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
} catch (error) {
|
|
867
|
+
console.error('Processing error:', error);
|
|
868
|
+
// Sanitize error message for UI
|
|
869
|
+
const rawError = error.message || 'An unexpected error occurred during processing.';
|
|
870
|
+
const sanitizedError = sanitizeErrorMessage(rawError);
|
|
871
|
+
alert(`Error: ${sanitizedError}`);
|
|
872
|
+
|
|
873
|
+
// Show error in results section if available
|
|
874
|
+
if (resultsSection && resultsContent) {
|
|
875
|
+
resultsContent.innerHTML = `
|
|
876
|
+
<div class="result-item error">
|
|
877
|
+
<div class="result-item-header">
|
|
878
|
+
<div class="result-item-title">Processing Failed</div>
|
|
879
|
+
<div class="result-item-status error">Error</div>
|
|
880
|
+
</div>
|
|
881
|
+
<div class="result-item-details">
|
|
882
|
+
<div class="result-detail">${escapeHtml(sanitizedError)}</div>
|
|
883
|
+
</div>
|
|
884
|
+
</div>
|
|
885
|
+
`;
|
|
886
|
+
resultsSection.style.display = 'block';
|
|
887
|
+
}
|
|
888
|
+
} finally {
|
|
889
|
+
processing = false;
|
|
890
|
+
if (processButton) {
|
|
891
|
+
processButton.disabled = false;
|
|
892
|
+
processButton.textContent = 'Process Images';
|
|
893
|
+
}
|
|
894
|
+
// Do NOT reset resolvedOutputPath here - keep it stable for subsequent runs
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Display Results
|
|
899
|
+
function displayResults(results) {
|
|
900
|
+
resultsContent.innerHTML = '';
|
|
901
|
+
|
|
902
|
+
// Per-file results
|
|
903
|
+
if (results.processed && results.processed.length > 0) {
|
|
904
|
+
results.processed.forEach(result => {
|
|
905
|
+
const item = createResultItem(result, 'success');
|
|
906
|
+
resultsContent.appendChild(item);
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (results.skipped && results.skipped.length > 0) {
|
|
911
|
+
results.skipped.forEach(skipped => {
|
|
912
|
+
const item = document.createElement('div');
|
|
913
|
+
item.className = 'result-item';
|
|
914
|
+
const fileName = extractFileName(skipped.filePath);
|
|
915
|
+
const sanitizedReason = sanitizeErrorMessage(skipped.reason);
|
|
916
|
+
item.innerHTML = `
|
|
917
|
+
<div class="result-item-header">
|
|
918
|
+
<div class="result-item-title">${escapeHtml(fileName)}</div>
|
|
919
|
+
<div class="result-item-status">Skipped</div>
|
|
920
|
+
</div>
|
|
921
|
+
<div class="result-item-details">
|
|
922
|
+
<div class="result-detail">${escapeHtml(sanitizedReason)}</div>
|
|
923
|
+
</div>
|
|
924
|
+
`;
|
|
925
|
+
resultsContent.appendChild(item);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (results.failed && results.failed.length > 0) {
|
|
930
|
+
results.failed.forEach(failed => {
|
|
931
|
+
const item = document.createElement('div');
|
|
932
|
+
item.className = 'result-item error';
|
|
933
|
+
// Sanitize error message and file path
|
|
934
|
+
const sanitizedError = sanitizeErrorMessage(failed.error);
|
|
935
|
+
const fileName = extractFileName(failed.filePath);
|
|
936
|
+
item.innerHTML = `
|
|
937
|
+
<div class="result-item-header">
|
|
938
|
+
<div class="result-item-title">${escapeHtml(fileName)}</div>
|
|
939
|
+
<div class="result-item-status error">Failed</div>
|
|
940
|
+
</div>
|
|
941
|
+
<div class="result-item-details">
|
|
942
|
+
<div class="result-detail">${escapeHtml(sanitizedError)}</div>
|
|
943
|
+
</div>
|
|
944
|
+
`;
|
|
945
|
+
resultsContent.appendChild(item);
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Summary
|
|
950
|
+
if (results.totals) {
|
|
951
|
+
const summary = createSummary(results.totals);
|
|
952
|
+
resultsContent.appendChild(summary);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
resultsSection.style.display = 'block';
|
|
956
|
+
resultsSection.scrollIntoView({ behavior: 'smooth' });
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Create Result Item
|
|
960
|
+
function createResultItem(result, status) {
|
|
961
|
+
const item = document.createElement('div');
|
|
962
|
+
item.className = `result-item ${status}`;
|
|
963
|
+
|
|
964
|
+
const originalSize = formatBytes(result.originalSize);
|
|
965
|
+
const finalSize = formatBytes(result.finalSize);
|
|
966
|
+
const saved = formatBytes(result.bytesSaved);
|
|
967
|
+
const percent = result.percentSaved.toFixed(2);
|
|
968
|
+
|
|
969
|
+
item.innerHTML = `
|
|
970
|
+
<div class="result-item-header">
|
|
971
|
+
<div class="result-item-title">${escapeHtml(extractFileName(result.outputPath))}</div>
|
|
972
|
+
<div class="result-item-status ${status}">Processed</div>
|
|
973
|
+
</div>
|
|
974
|
+
<div class="result-item-details">
|
|
975
|
+
<div class="result-detail">
|
|
976
|
+
<strong>Original:</strong> ${originalSize} (${result.metadata.width}×${result.metadata.height})
|
|
977
|
+
</div>
|
|
978
|
+
<div class="result-detail">
|
|
979
|
+
<strong>Final:</strong> ${finalSize}
|
|
980
|
+
</div>
|
|
981
|
+
<div class="result-detail">
|
|
982
|
+
<strong>Saved:</strong> ${saved} (${percent}%)
|
|
983
|
+
</div>
|
|
984
|
+
</div>
|
|
985
|
+
`;
|
|
986
|
+
|
|
987
|
+
return item;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Create Summary
|
|
991
|
+
function createSummary(totals) {
|
|
992
|
+
const summary = document.createElement('div');
|
|
993
|
+
summary.className = 'result-summary';
|
|
994
|
+
|
|
995
|
+
const totalOriginal = formatBytes(totals.totalOriginal);
|
|
996
|
+
const totalFinal = formatBytes(totals.totalFinal);
|
|
997
|
+
const totalSaved = formatBytes(totals.totalSaved);
|
|
998
|
+
const percentSaved = totals.percentSaved.toFixed(2);
|
|
999
|
+
|
|
1000
|
+
summary.innerHTML = `
|
|
1001
|
+
<h3 class="result-summary-title">Summary</h3>
|
|
1002
|
+
<div class="result-summary-stats">
|
|
1003
|
+
<div class="summary-stat">
|
|
1004
|
+
<div class="summary-stat-value">${totals.processedCount}</div>
|
|
1005
|
+
<div class="summary-stat-label">Files Processed</div>
|
|
1006
|
+
</div>
|
|
1007
|
+
<div class="summary-stat">
|
|
1008
|
+
<div class="summary-stat-value">${totalOriginal}</div>
|
|
1009
|
+
<div class="summary-stat-label">Total Original Size</div>
|
|
1010
|
+
</div>
|
|
1011
|
+
<div class="summary-stat">
|
|
1012
|
+
<div class="summary-stat-value">${totalFinal}</div>
|
|
1013
|
+
<div class="summary-stat-label">Total Final Size</div>
|
|
1014
|
+
</div>
|
|
1015
|
+
<div class="summary-stat">
|
|
1016
|
+
<div class="summary-stat-value">${totalSaved}</div>
|
|
1017
|
+
<div class="summary-stat-label">Total Saved (${percentSaved}%)</div>
|
|
1018
|
+
</div>
|
|
1019
|
+
${totals.skippedCount > 0 ? `
|
|
1020
|
+
<div class="summary-stat">
|
|
1021
|
+
<div class="summary-stat-value">${totals.skippedCount}</div>
|
|
1022
|
+
<div class="summary-stat-label">Files Skipped</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
` : ''}
|
|
1025
|
+
${totals.failedCount > 0 ? `
|
|
1026
|
+
<div class="summary-stat">
|
|
1027
|
+
<div class="summary-stat-value">${totals.failedCount}</div>
|
|
1028
|
+
<div class="summary-stat-label">Files Failed</div>
|
|
1029
|
+
</div>
|
|
1030
|
+
` : ''}
|
|
1031
|
+
</div>
|
|
1032
|
+
`;
|
|
1033
|
+
|
|
1034
|
+
return summary;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Format Bytes
|
|
1038
|
+
function formatBytes(bytes) {
|
|
1039
|
+
if (bytes === 0) return '0 B';
|
|
1040
|
+
const k = 1024;
|
|
1041
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
1042
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1043
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Escape HTML
|
|
1047
|
+
function escapeHtml(text) {
|
|
1048
|
+
const div = document.createElement('div');
|
|
1049
|
+
div.textContent = text;
|
|
1050
|
+
return div.innerHTML;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Sanitize error messages for UI display
|
|
1054
|
+
function sanitizeErrorMessage(errorMsg) {
|
|
1055
|
+
if (!errorMsg) return 'An error occurred.';
|
|
1056
|
+
|
|
1057
|
+
let sanitized = errorMsg;
|
|
1058
|
+
|
|
1059
|
+
// Remove temp paths
|
|
1060
|
+
sanitized = sanitized.replace(/\/tmp\/[^\s]+/g, '');
|
|
1061
|
+
sanitized = sanitized.replace(/[^\s]*pulp-image-[^\s]+/g, '');
|
|
1062
|
+
|
|
1063
|
+
// Remove CLI flags and technical jargon
|
|
1064
|
+
sanitized = sanitized.replace(/--[a-z-]+/g, '');
|
|
1065
|
+
sanitized = sanitized.replace(/Use --alpha-mode flatten/g, '');
|
|
1066
|
+
sanitized = sanitized.replace(/Use --overwrite/g, '');
|
|
1067
|
+
|
|
1068
|
+
// Transform common error messages to user-friendly versions
|
|
1069
|
+
if (sanitized.includes('transparency') && sanitized.includes('does not support')) {
|
|
1070
|
+
const formatMatch = sanitized.match(/format (\w+)/i);
|
|
1071
|
+
const format = formatMatch ? formatMatch[1].toUpperCase() : 'this format';
|
|
1072
|
+
return `This image contains transparency, but ${format} does not support transparency.\n\nChoose a format like PNG or WebP, or enable background flattening.`;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (sanitized.includes('already exists')) {
|
|
1076
|
+
return 'This file already exists. Enable "Overwrite Existing Files" to replace it.';
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (sanitized.includes('Input and output paths are the same')) {
|
|
1080
|
+
return 'Input and output are the same file. Enable "Overwrite Existing Files" to process in place.';
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (sanitized.includes('Input file not found')) {
|
|
1084
|
+
return 'The selected file could not be found. Please select the file again.';
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (sanitized.includes('Unsupported output format')) {
|
|
1088
|
+
return 'The selected output format is not supported. Please choose PNG, JPG, WebP, or AVIF.';
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Clean up extra whitespace
|
|
1092
|
+
sanitized = sanitized.replace(/\s+/g, ' ').trim();
|
|
1093
|
+
|
|
1094
|
+
// If message is too technical or empty, provide generic message
|
|
1095
|
+
if (sanitized.length === 0 || sanitized.includes('Error:') || sanitized.includes('at ')) {
|
|
1096
|
+
return 'An error occurred while processing this image. Please check the image file and try again.';
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
return sanitized;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Extract just the filename from a path
|
|
1103
|
+
function extractFileName(filePath) {
|
|
1104
|
+
if (!filePath) return 'Unknown file';
|
|
1105
|
+
// Remove temp paths
|
|
1106
|
+
if (filePath.includes('/tmp/') || filePath.includes('pulp-image-')) {
|
|
1107
|
+
// Try to extract original filename from path
|
|
1108
|
+
const parts = filePath.split('/');
|
|
1109
|
+
return parts[parts.length - 1] || 'File';
|
|
1110
|
+
}
|
|
1111
|
+
// Return just the filename
|
|
1112
|
+
const parts = filePath.split(/[/\\]/);
|
|
1113
|
+
return parts[parts.length - 1] || filePath;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Reset Form
|
|
1117
|
+
function resetForm() {
|
|
1118
|
+
form.reset();
|
|
1119
|
+
selectedFiles = [];
|
|
1120
|
+
ignoredFiles = [];
|
|
1121
|
+
ignoredFilesCount = 0;
|
|
1122
|
+
showIgnoredFiles = false;
|
|
1123
|
+
inputSourceFiles.value = '';
|
|
1124
|
+
inputSourceFolder.value = '';
|
|
1125
|
+
qualityValue.textContent = '80';
|
|
1126
|
+
qualitySlider.value = '80';
|
|
1127
|
+
backgroundText.value = '#ffffff';
|
|
1128
|
+
backgroundColor.value = '#ffffff';
|
|
1129
|
+
useCustomOutput.checked = false;
|
|
1130
|
+
outputDir.readOnly = true;
|
|
1131
|
+
outputDir.style.background = '';
|
|
1132
|
+
openResultsFolderBtn.style.display = 'none';
|
|
1133
|
+
openResultsFolderBtn.disabled = true;
|
|
1134
|
+
if (openResultsFolderHelper) {
|
|
1135
|
+
openResultsFolderHelper.style.display = 'none';
|
|
1136
|
+
}
|
|
1137
|
+
if (openResultsFolderFallback) {
|
|
1138
|
+
openResultsFolderFallback.style.display = 'none';
|
|
1139
|
+
}
|
|
1140
|
+
if (openResultsFolderResultsBtn) {
|
|
1141
|
+
openResultsFolderResultsBtn.style.display = 'none';
|
|
1142
|
+
openResultsFolderResultsBtn.disabled = true;
|
|
1143
|
+
}
|
|
1144
|
+
if (openResultsFolderResultsHelper) {
|
|
1145
|
+
openResultsFolderResultsHelper.style.display = 'none';
|
|
1146
|
+
}
|
|
1147
|
+
if (openResultsFolderResultsFallback) {
|
|
1148
|
+
openResultsFolderResultsFallback.style.display = 'none';
|
|
1149
|
+
}
|
|
1150
|
+
updateUI().catch(console.error);
|
|
1151
|
+
resultsSection.style.display = 'none';
|
|
1152
|
+
}
|
|
1153
|
+
|