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/src/uiServer.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join, resolve } from 'path';
|
|
4
|
+
import { mkdirSync, writeFileSync, unlinkSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { tmpdir, homedir } from 'os';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
import multer from 'multer';
|
|
8
|
+
import open from 'open';
|
|
9
|
+
import { runJob } from './runJob.js';
|
|
10
|
+
import { exec, spawn } from 'child_process';
|
|
11
|
+
import { promisify } from 'util';
|
|
12
|
+
|
|
13
|
+
const execAsync = promisify(exec);
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Starts the UI server and opens browser
|
|
20
|
+
* @param {number} port - Port to run server on (default: 3000)
|
|
21
|
+
* @returns {Promise<object>} Server instance and URL
|
|
22
|
+
*/
|
|
23
|
+
export async function startUIServer(port = 3000) {
|
|
24
|
+
const app = express();
|
|
25
|
+
|
|
26
|
+
// Get UI directory path (ui/ folder in project root)
|
|
27
|
+
const projectRoot = join(__dirname, '..');
|
|
28
|
+
const uiDir = join(projectRoot, 'ui');
|
|
29
|
+
|
|
30
|
+
// JSON body parser for API
|
|
31
|
+
app.use(express.json());
|
|
32
|
+
|
|
33
|
+
// Configure multer for file uploads with filename preservation
|
|
34
|
+
const uploadDir = join(tmpdir(), 'pulp-image-uploads');
|
|
35
|
+
if (!existsSync(uploadDir)) {
|
|
36
|
+
mkdirSync(uploadDir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const storage = multer.diskStorage({
|
|
40
|
+
destination: (req, file, cb) => {
|
|
41
|
+
cb(null, uploadDir);
|
|
42
|
+
},
|
|
43
|
+
filename: (req, file, cb) => {
|
|
44
|
+
// Preserve original filename, sanitize for security
|
|
45
|
+
const originalName = file.originalname || 'file';
|
|
46
|
+
// Remove path traversal attempts (../)
|
|
47
|
+
let sanitized = originalName.replace(/\.\./g, '_');
|
|
48
|
+
// Remove leading/trailing dots and slashes
|
|
49
|
+
sanitized = sanitized.replace(/^[.\/]+|[.\/]+$/g, '');
|
|
50
|
+
// Remove null bytes and other dangerous characters
|
|
51
|
+
sanitized = sanitized.replace(/\0/g, '');
|
|
52
|
+
// Keep most characters but remove truly dangerous ones
|
|
53
|
+
// Allow: letters, numbers, dots, hyphens, underscores, spaces
|
|
54
|
+
sanitized = sanitized.replace(/[<>:"|?*\x00-\x1f]/g, '_');
|
|
55
|
+
// Ensure we have a valid filename
|
|
56
|
+
const safeName = sanitized || `file_${Date.now()}`;
|
|
57
|
+
cb(null, safeName);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const upload = multer({
|
|
62
|
+
storage: storage,
|
|
63
|
+
limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// API endpoint for processing images
|
|
67
|
+
app.post('/api/run', upload.array('files'), async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
if (!req.files || req.files.length === 0) {
|
|
70
|
+
return res.status(400).json({ error: 'No files uploaded' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Parse config from form data
|
|
74
|
+
let config;
|
|
75
|
+
try {
|
|
76
|
+
config = JSON.parse(req.body.config || '{}');
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return res.status(400).json({ error: 'Invalid config JSON' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle default output directory
|
|
82
|
+
if (config.useDefaultOutput !== false) {
|
|
83
|
+
// Generate timestamp-based output directory
|
|
84
|
+
const now = new Date();
|
|
85
|
+
const year = now.getFullYear();
|
|
86
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
87
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
88
|
+
const hours = String(now.getHours()).padStart(2, '0');
|
|
89
|
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
90
|
+
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
91
|
+
const timestamp = `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
|
|
92
|
+
config.out = join(homedir(), 'pulp-image-results', timestamp);
|
|
93
|
+
} else if (config.out) {
|
|
94
|
+
// Expand ~ to home directory
|
|
95
|
+
if (config.out.startsWith('~')) {
|
|
96
|
+
config.out = join(homedir(), config.out.slice(1));
|
|
97
|
+
}
|
|
98
|
+
// Resolve to absolute path
|
|
99
|
+
config.out = resolve(config.out);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Determine if single file or directory
|
|
103
|
+
// For browser uploads, we treat multiple files as a directory
|
|
104
|
+
const isDirectory = req.files.length > 1;
|
|
105
|
+
let inputPath;
|
|
106
|
+
const tempPaths = [];
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
if (isDirectory) {
|
|
110
|
+
// Create a temp directory and move all files there
|
|
111
|
+
const tempDir = join(tmpdir(), 'pulp-image-temp', randomUUID());
|
|
112
|
+
mkdirSync(tempDir, { recursive: true });
|
|
113
|
+
|
|
114
|
+
for (const file of req.files) {
|
|
115
|
+
// Use originalname from multer (which we sanitized but preserved)
|
|
116
|
+
const originalName = file.originalname || file.filename;
|
|
117
|
+
const destPath = join(tempDir, originalName);
|
|
118
|
+
writeFileSync(destPath, readFileSync(file.path));
|
|
119
|
+
unlinkSync(file.path); // Remove from upload dir
|
|
120
|
+
tempPaths.push(destPath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
inputPath = tempDir;
|
|
124
|
+
} else {
|
|
125
|
+
// Single file - preserve original filename
|
|
126
|
+
const file = req.files[0];
|
|
127
|
+
const originalName = file.originalname || file.filename;
|
|
128
|
+
// If multer saved with a different name, rename it
|
|
129
|
+
if (file.filename !== originalName) {
|
|
130
|
+
const newPath = join(uploadDir, originalName);
|
|
131
|
+
writeFileSync(newPath, readFileSync(file.path));
|
|
132
|
+
unlinkSync(file.path);
|
|
133
|
+
inputPath = newPath;
|
|
134
|
+
tempPaths.push(newPath);
|
|
135
|
+
} else {
|
|
136
|
+
inputPath = file.path;
|
|
137
|
+
tempPaths.push(file.path);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Run the job
|
|
142
|
+
const results = await runJob(inputPath, config);
|
|
143
|
+
|
|
144
|
+
// Add resolved output path to results
|
|
145
|
+
results.outputPath = config.out;
|
|
146
|
+
|
|
147
|
+
// Always clean up temp files in UI mode
|
|
148
|
+
// Browser security prevents UI from deleting original user files,
|
|
149
|
+
// so temp upload files must always be cleaned up after processing
|
|
150
|
+
try {
|
|
151
|
+
if (isDirectory) {
|
|
152
|
+
// Remove entire temp directory
|
|
153
|
+
rmSync(inputPath, { recursive: true, force: true });
|
|
154
|
+
} else {
|
|
155
|
+
// Remove single temp file
|
|
156
|
+
tempPaths.forEach(path => {
|
|
157
|
+
if (existsSync(path)) {
|
|
158
|
+
unlinkSync(path);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
} catch (cleanupError) {
|
|
163
|
+
console.warn('Warning: Failed to clean up temp files:', cleanupError);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Return results
|
|
167
|
+
res.json(results);
|
|
168
|
+
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Clean up on error
|
|
171
|
+
try {
|
|
172
|
+
if (isDirectory && inputPath) {
|
|
173
|
+
rmSync(inputPath, { recursive: true, force: true });
|
|
174
|
+
} else {
|
|
175
|
+
tempPaths.forEach(path => {
|
|
176
|
+
if (existsSync(path)) {
|
|
177
|
+
unlinkSync(path);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} catch (cleanupError) {
|
|
182
|
+
console.warn('Warning: Failed to clean up temp files after error:', cleanupError);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error('API Error:', error);
|
|
190
|
+
res.status(500).json({
|
|
191
|
+
error: error.message || 'Processing failed',
|
|
192
|
+
message: error.message
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// API endpoint to resolve output path
|
|
198
|
+
app.post('/api/resolve-output-path', (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const { useDefault, timestamp, customPath } = req.body;
|
|
201
|
+
|
|
202
|
+
if (useDefault && timestamp) {
|
|
203
|
+
const resolvedPath = join(homedir(), 'pulp-image-results', timestamp);
|
|
204
|
+
res.json({ path: resolvedPath });
|
|
205
|
+
} else if (customPath) {
|
|
206
|
+
let resolved = customPath;
|
|
207
|
+
if (resolved.startsWith('~')) {
|
|
208
|
+
resolved = join(homedir(), resolved.slice(1));
|
|
209
|
+
}
|
|
210
|
+
resolved = resolve(resolved);
|
|
211
|
+
res.json({ path: resolved });
|
|
212
|
+
} else {
|
|
213
|
+
res.status(400).json({ error: 'Invalid request' });
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
res.status(500).json({ error: error.message });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// API endpoint to validate output path
|
|
221
|
+
app.post('/api/validate-output-path', (req, res) => {
|
|
222
|
+
try {
|
|
223
|
+
const { path } = req.body;
|
|
224
|
+
if (!path) {
|
|
225
|
+
return res.status(400).json({ error: 'Path required' });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let resolvedPath = path;
|
|
229
|
+
if (resolvedPath.startsWith('~')) {
|
|
230
|
+
resolvedPath = join(homedir(), resolvedPath.slice(1));
|
|
231
|
+
}
|
|
232
|
+
resolvedPath = resolve(resolvedPath);
|
|
233
|
+
|
|
234
|
+
const exists = existsSync(resolvedPath);
|
|
235
|
+
const willCreate = !exists; // Will be created if it doesn't exist
|
|
236
|
+
|
|
237
|
+
res.json({ exists, willCreate, path: resolvedPath });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
res.status(500).json({ error: error.message });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// API endpoint to open folder in file manager
|
|
244
|
+
app.post('/api/open-folder', async (req, res) => {
|
|
245
|
+
try {
|
|
246
|
+
const { path } = req.body;
|
|
247
|
+
if (!path) {
|
|
248
|
+
return res.status(400).json({ error: 'Path required' });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Determine OS and use appropriate command
|
|
252
|
+
const platform = process.platform;
|
|
253
|
+
let command;
|
|
254
|
+
let options = {};
|
|
255
|
+
|
|
256
|
+
if (platform === 'darwin') {
|
|
257
|
+
// macOS
|
|
258
|
+
command = `open "${path}"`;
|
|
259
|
+
} else if (platform === 'win32') {
|
|
260
|
+
// Windows - use explorer to open in foreground
|
|
261
|
+
command = `explorer "${path}"`;
|
|
262
|
+
} else {
|
|
263
|
+
// Linux and others - suppress stderr to avoid Wayland/Gnome noise
|
|
264
|
+
command = `xdg-open "${path}" 2>/dev/null || true`;
|
|
265
|
+
// Use shell to properly handle stderr redirection
|
|
266
|
+
options = { shell: '/bin/bash' };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
// Suppress stderr on Linux to avoid noisy output
|
|
271
|
+
if (platform === 'linux' || (platform !== 'darwin' && platform !== 'win32')) {
|
|
272
|
+
// Use spawn with stderr redirected instead of execAsync for better control
|
|
273
|
+
await new Promise((resolve, reject) => {
|
|
274
|
+
const child = spawn('xdg-open', [path], {
|
|
275
|
+
stdio: ['ignore', 'ignore', 'ignore'], // Suppress all output
|
|
276
|
+
detached: true
|
|
277
|
+
});
|
|
278
|
+
child.unref(); // Allow parent to exit independently
|
|
279
|
+
// Don't wait for child to exit - xdg-open may spawn other processes
|
|
280
|
+
setTimeout(resolve, 100); // Give it a moment to start
|
|
281
|
+
});
|
|
282
|
+
} else {
|
|
283
|
+
await execAsync(command, options);
|
|
284
|
+
}
|
|
285
|
+
res.json({ success: true });
|
|
286
|
+
} catch (execError) {
|
|
287
|
+
// Don't expose raw OS errors to user
|
|
288
|
+
console.error('Error opening folder:', execError);
|
|
289
|
+
res.status(500).json({
|
|
290
|
+
error: 'Could not open folder automatically.',
|
|
291
|
+
path: path // Include path so UI can display it
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
} catch (error) {
|
|
296
|
+
// Don't expose raw errors to user
|
|
297
|
+
console.error('Error opening folder:', error);
|
|
298
|
+
const { path } = req.body;
|
|
299
|
+
res.status(500).json({
|
|
300
|
+
error: 'Could not open folder automatically.',
|
|
301
|
+
path: path || null // Include path if available
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Version endpoint - reads from package.json (single source of truth)
|
|
307
|
+
// Must be before static middleware to avoid conflicts
|
|
308
|
+
app.get('/api/version', (req, res) => {
|
|
309
|
+
try {
|
|
310
|
+
const packageJsonPath = join(projectRoot, 'package.json');
|
|
311
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
312
|
+
res.json({ version: packageJson.version });
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error('Error reading version from package.json:', error);
|
|
315
|
+
res.status(500).json({ error: 'Failed to read version' });
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Serve static files from ui directory
|
|
320
|
+
app.use(express.static(uiDir));
|
|
321
|
+
|
|
322
|
+
// Fallback: serve placeholder if ui/index.html doesn't exist
|
|
323
|
+
// (static middleware serves it if it exists, so this only runs if missing)
|
|
324
|
+
app.get('/', (req, res) => {
|
|
325
|
+
// Placeholder page
|
|
326
|
+
res.send(`<!DOCTYPE html>
|
|
327
|
+
<html lang="en">
|
|
328
|
+
<head>
|
|
329
|
+
<meta charset="UTF-8">
|
|
330
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
331
|
+
<title>Pulp Image UI</title>
|
|
332
|
+
<style>
|
|
333
|
+
body {
|
|
334
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
335
|
+
display: flex;
|
|
336
|
+
justify-content: center;
|
|
337
|
+
align-items: center;
|
|
338
|
+
min-height: 100vh;
|
|
339
|
+
margin: 0;
|
|
340
|
+
background: linear-gradient(135deg, #fff5e6 0%, #ffe6cc 100%);
|
|
341
|
+
color: #333;
|
|
342
|
+
}
|
|
343
|
+
.container {
|
|
344
|
+
text-align: center;
|
|
345
|
+
padding: 2rem;
|
|
346
|
+
background: white;
|
|
347
|
+
border-radius: 12px;
|
|
348
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
349
|
+
max-width: 600px;
|
|
350
|
+
}
|
|
351
|
+
h1 {
|
|
352
|
+
color: #ff8c42;
|
|
353
|
+
margin-top: 0;
|
|
354
|
+
}
|
|
355
|
+
.brand {
|
|
356
|
+
font-size: 0.9em;
|
|
357
|
+
color: #666;
|
|
358
|
+
margin-top: 1rem;
|
|
359
|
+
}
|
|
360
|
+
</style>
|
|
361
|
+
</head>
|
|
362
|
+
<body>
|
|
363
|
+
<div class="container">
|
|
364
|
+
<h1>š Pulp Image UI</h1>
|
|
365
|
+
<p>UI is starting up...</p>
|
|
366
|
+
<p class="brand">Pulp Image by Rebellion Geeks</p>
|
|
367
|
+
</div>
|
|
368
|
+
</body>
|
|
369
|
+
</html>`);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Start server
|
|
373
|
+
return new Promise((resolve, reject) => {
|
|
374
|
+
const server = app.listen(port, 'localhost', () => {
|
|
375
|
+
const url = `http://localhost:${port}`;
|
|
376
|
+
console.log(`\nš Pulp Image UI is running\n`);
|
|
377
|
+
console.log(`Open in your browser:\n${url}\n`);
|
|
378
|
+
console.log('Tip: Ctrl + Click the link above if it doesn\'t open automatically.\n');
|
|
379
|
+
console.log('Press Ctrl+C to stop\n');
|
|
380
|
+
|
|
381
|
+
// Open browser automatically (best-effort)
|
|
382
|
+
open(url).catch(() => {
|
|
383
|
+
// Silently fail - user can use Ctrl+Click or copy URL
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
resolve({ server, url, app });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
server.on('error', (err) => {
|
|
390
|
+
if (err.code === 'EADDRINUSE') {
|
|
391
|
+
reject(new Error(`Port ${port} is already in use. Please try a different port.`));
|
|
392
|
+
} else {
|
|
393
|
+
reject(err);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|