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/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
+