pulp-image 0.1.8 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -17,36 +17,74 @@ See [Roadmap](ROADMAP.md) for upcoming features (v0.2.0: UI Redesign, Rotate, Fl
17
17
  ### Changed
18
18
  - Streamlined README with quick links and features grid
19
19
  - Added public roadmap and changelog
20
- - Updated project slogan
20
+ - Improved documentation and help content
21
21
 
22
22
  ---
23
23
 
24
24
  ## [0.1.7] - 2026-01-12
25
25
 
26
- ### Added
27
- - Auto suffix option to add dimensions to filenames
28
- - Custom suffix support for output filenames
29
- - Rename patterns with tokens {name}, {ext}, {index} (UI only)
26
+ ### Fixed
27
+ - Custom output directory filter not working correctly in UI
28
+
29
+ ### Changed
30
+ - Improved Help tab content and styling
31
+
32
+ ---
33
+
34
+ ## [0.1.6] - 2026-01-11
35
+
36
+ ### Changed
37
+ - Updated README and package.json homepage
38
+
39
+ ---
40
+
41
+ ## [0.1.5] - 2026-01-11
42
+
43
+ ### Fixed
44
+ - UI color contrast for WCAG AA accessibility
45
+
46
+ ---
47
+
48
+ ## [0.1.4] - 2026-01-10
49
+
50
+ ### Fixed
51
+ - Terminal banner (ASCII art) width now dynamic
52
+
53
+ ---
54
+
55
+ ## [0.1.3] - 2026-01-10
56
+
57
+ ### Fixed
58
+ - Dynamic version display in banner
59
+
60
+ ---
61
+
62
+ ## [0.1.2] - 2026-01-09
30
63
 
31
64
  ### Fixed
32
- - Minor bug fixes and improvements
65
+ - npm bin configuration for global install
33
66
 
34
67
  ---
35
68
 
36
- ## [0.1.0] - Initial Release
69
+ ## [0.1.1] - 2026-01-09 (Initial Release)
37
70
 
38
71
  ### Added
39
72
  - **CLI Tool**: Full command-line interface for image processing
40
73
  - **Browser UI**: Launch with `pulp ui`, works offline, no uploads
41
- - **Format Conversion**: PNG, JPG, WebP, AVIF, GIF, TIFF
42
- - **Resize**: By width, height, or exact dimensions
43
- - **Quality Control**: Compression quality 1-100
74
+ - **Format Conversion**: PNG, JPG, WebP, AVIF
75
+ - **Resize**: By width, height, or exact dimensions with aspect ratio preservation
76
+ - **Quality Control**: Compression quality 1-100 for lossy formats
44
77
  - **Lossless Mode**: For WebP and AVIF
45
78
  - **Transparency Handling**: Flatten with custom background color
46
- - **Batch Processing**: Process entire folders
79
+ - **Batch Processing**: Process entire folders at once
80
+ - **Auto Suffix**: Add dimensions to filenames (-800w, -600h, -800x600)
81
+ - **Custom Suffix**: Add custom text to output filenames
82
+ - **Rename Patterns**: Tokens {name}, {ext}, {index} for output naming (UI only)
47
83
  - **Custom Output Directory**: Save files anywhere
48
84
  - **Overwrite Protection**: Skip or overwrite existing files
49
85
  - **Delete Originals**: Remove source files after processing (CLI only)
86
+ - **Verbose Mode**: Detailed per-file output (CLI only)
87
+ - **Statistics**: Original size, final size, bytes saved, percentage reduction
50
88
 
51
89
  ---
52
90
 
package/ROADMAP.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  | Feature | Description |
10
10
  |---------|-------------|
11
- | ~~**Format Conversion**~~ | ~~Convert between PNG, JPG, WebP, AVIF, GIF, TIFF~~ |
11
+ | ~~**Format Conversion**~~ | ~~Convert between PNG, JPG, WebP, AVIF~~ |
12
12
  | ~~**Resize**~~ | ~~Resize by width, height, or both with auto aspect ratio~~ |
13
13
  | ~~**Quality Control**~~ | ~~Adjust compression quality (1-100)~~ |
14
14
  | ~~**Lossless Mode**~~ | ~~Preserve full quality for WebP and AVIF~~ |
@@ -27,6 +27,7 @@
27
27
  | Feature | Description |
28
28
  |---------|-------------|
29
29
  | **UI Redesign** | Modern tabbed interface with dark mode |
30
+ | **Results Redesign** | Clearer output with operation summary (CLI and UI) |
30
31
  | **Rotate** | Rotate images by 90°, 180°, 270°, or custom angle |
31
32
  | **Flip / Mirror** | Flip vertically or horizontally |
32
33
  | **Grayscale** | Convert images to black and white |
package/bin/pulp.js CHANGED
@@ -19,15 +19,33 @@ import { planTasks } from '../src/planTasks.js';
19
19
  import { Reporter } from '../src/reporter.js';
20
20
  import { runJob } from '../src/runJob.js';
21
21
  import { startUIServer } from '../src/uiServer.js';
22
+ import { checkForUpdate, formatUpdateMessage } from '../src/updateCheck.js';
22
23
  import { statSync, existsSync } from 'fs';
23
24
 
24
25
  const program = new Command();
25
26
  const banner = getBanner(pkg.version);
26
27
 
28
+ // Format update message with colors for CLI
29
+ function formatUpdateBox(updateMsg) {
30
+ if (!updateMsg) return null;
31
+
32
+ const lines = [
33
+ '',
34
+ chalk.bgYellow.black(' UPDATE AVAILABLE '),
35
+ '',
36
+ ` ${chalk.gray('Current:')} ${chalk.white(updateMsg.current)}`,
37
+ ` ${chalk.gray('Latest:')} ${chalk.green.bold(updateMsg.latest)}`,
38
+ '',
39
+ ` ${chalk.cyan('npm update -g pulp-image')}`,
40
+ ''
41
+ ];
42
+
43
+ return lines.join('\n');
44
+ }
45
+
27
46
  program
28
47
  .name('pulp')
29
48
  .description('Full-featured image processing CLI with a browser UI. 100% local.')
30
- .version(pkg.version)
31
49
  .addHelpText('before', chalk.cyan(banner))
32
50
  .argument('[input]', 'Input file or directory')
33
51
  .option('-w, --width <number>', 'Output width in pixels')
@@ -73,6 +91,9 @@ Compression Behavior:
73
91
  // Display banner
74
92
  console.log(chalk.cyan(banner));
75
93
 
94
+ // Start update check early (runs in background while processing)
95
+ const updateCheckPromise = checkForUpdate(pkg.version).catch(() => null);
96
+
76
97
  // Normalize config
77
98
  const config = {
78
99
  input: input || null,
@@ -212,6 +233,15 @@ Compression Behavior:
212
233
  results.failed.forEach(f => reporter.recordFailed(f.filePath, new Error(f.error)));
213
234
  reporter.printSummary(config.verbose);
214
235
  }
236
+
237
+ // Show update notification at the end (if available)
238
+ const updateInfo = await updateCheckPromise;
239
+ if (updateInfo) {
240
+ const updateMsg = formatUpdateMessage(updateInfo);
241
+ if (updateMsg) {
242
+ console.log(formatUpdateBox(updateMsg));
243
+ }
244
+ }
215
245
  });
216
246
 
217
247
  // UI command
@@ -227,6 +257,14 @@ program
227
257
  process.exit(1);
228
258
  }
229
259
 
260
+ // Check for updates when starting UI
261
+ checkForUpdate(pkg.version).then(updateInfo => {
262
+ const updateMsg = formatUpdateMessage(updateInfo);
263
+ if (updateMsg) {
264
+ console.log(formatUpdateBox(updateMsg));
265
+ }
266
+ }).catch(() => {}); // Silently ignore errors
267
+
230
268
  try {
231
269
  const { server } = await startUIServer(port);
232
270
 
@@ -248,5 +286,25 @@ program
248
286
  }
249
287
  });
250
288
 
251
- program.parse();
289
+ // Handle --version manually (before Commander parses) to include update check
290
+ if (process.argv.includes('--version') || process.argv.includes('-V')) {
291
+ (async () => {
292
+ console.log(pkg.version);
293
+
294
+ // Check for updates
295
+ try {
296
+ const updateInfo = await checkForUpdate(pkg.version);
297
+ const updateMsg = formatUpdateMessage(updateInfo);
298
+ if (updateMsg) {
299
+ console.log(formatUpdateBox(updateMsg));
300
+ }
301
+ } catch {
302
+ // Silently ignore errors
303
+ }
304
+
305
+ process.exit(0);
306
+ })();
307
+ } else {
308
+ program.parse();
309
+ }
252
310
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulp-image",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Full-featured image processing CLI with a browser UI. 100% local.",
5
5
  "type": "module",
6
6
  "main": "bin/pulp.js",
package/src/uiServer.js CHANGED
@@ -7,6 +7,7 @@ import { randomUUID } from 'crypto';
7
7
  import multer from 'multer';
8
8
  import open from 'open';
9
9
  import { runJob } from './runJob.js';
10
+ import { checkForUpdate } from './updateCheck.js';
10
11
  import { exec, spawn } from 'child_process';
11
12
  import { promisify } from 'util';
12
13
 
@@ -321,6 +322,23 @@ export async function startUIServer(port = 3000) {
321
322
  res.status(500).json({ error: 'Failed to read version' });
322
323
  }
323
324
  });
325
+
326
+ // Update check endpoint - checks npm registry for newer versions
327
+ app.get('/api/check-update', async (req, res) => {
328
+ try {
329
+ const packageJsonPath = join(projectRoot, 'package.json');
330
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
331
+ const currentVersion = packageJson.version;
332
+
333
+ const force = req.query.force === 'true';
334
+ const updateInfo = await checkForUpdate(currentVersion, { force });
335
+
336
+ res.json(updateInfo);
337
+ } catch (error) {
338
+ console.error('Error checking for updates:', error);
339
+ res.status(500).json({ error: 'Failed to check for updates' });
340
+ }
341
+ });
324
342
 
325
343
  // Serve static files from ui directory
326
344
  app.use(express.static(uiDir));
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Update checker for Pulp Image
3
+ * Checks npm registry for newer versions and caches results
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { homedir } from 'os';
9
+
10
+ const PACKAGE_NAME = 'pulp-image';
11
+ const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
12
+ const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
13
+
14
+ /**
15
+ * Get cache directory path
16
+ * @returns {string} Path to cache directory
17
+ */
18
+ function getCacheDir() {
19
+ return join(homedir(), '.config', 'pulp-image');
20
+ }
21
+
22
+ /**
23
+ * Get cache file path
24
+ * @returns {string} Path to cache file
25
+ */
26
+ function getCacheFile() {
27
+ return join(getCacheDir(), 'update-cache.json');
28
+ }
29
+
30
+ /**
31
+ * Read cached update check result
32
+ * @returns {object|null} Cached result or null if expired/missing
33
+ */
34
+ function readCache() {
35
+ try {
36
+ const cacheFile = getCacheFile();
37
+ if (!existsSync(cacheFile)) {
38
+ return null;
39
+ }
40
+
41
+ const cache = JSON.parse(readFileSync(cacheFile, 'utf-8'));
42
+ const now = Date.now();
43
+
44
+ // Check if cache is still valid
45
+ if (cache.timestamp && (now - cache.timestamp) < CACHE_DURATION_MS) {
46
+ return cache;
47
+ }
48
+
49
+ return null; // Cache expired
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Write update check result to cache
57
+ * @param {object} data - Data to cache
58
+ */
59
+ function writeCache(data) {
60
+ try {
61
+ const cacheDir = getCacheDir();
62
+ if (!existsSync(cacheDir)) {
63
+ mkdirSync(cacheDir, { recursive: true });
64
+ }
65
+
66
+ const cache = {
67
+ ...data,
68
+ timestamp: Date.now()
69
+ };
70
+
71
+ writeFileSync(getCacheFile(), JSON.stringify(cache, null, 2));
72
+ } catch {
73
+ // Silently fail - caching is optional
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Compare two semantic version strings
79
+ * @param {string} current - Current version (e.g., "0.1.8")
80
+ * @param {string} latest - Latest version (e.g., "0.2.0")
81
+ * @returns {boolean} True if latest is newer than current
82
+ */
83
+ function isNewerVersion(current, latest) {
84
+ const parseVersion = (v) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
85
+
86
+ const currentParts = parseVersion(current);
87
+ const latestParts = parseVersion(latest);
88
+
89
+ for (let i = 0; i < 3; i++) {
90
+ const curr = currentParts[i] || 0;
91
+ const lat = latestParts[i] || 0;
92
+
93
+ if (lat > curr) return true;
94
+ if (lat < curr) return false;
95
+ }
96
+
97
+ return false; // Versions are equal
98
+ }
99
+
100
+ /**
101
+ * Fetch latest version from npm registry
102
+ * @param {number} timeoutMs - Request timeout in milliseconds
103
+ * @returns {Promise<string|null>} Latest version or null on error
104
+ */
105
+ async function fetchLatestVersion(timeoutMs = 3000) {
106
+ try {
107
+ const controller = new AbortController();
108
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
109
+
110
+ const response = await fetch(NPM_REGISTRY_URL, {
111
+ signal: controller.signal,
112
+ headers: {
113
+ 'Accept': 'application/json'
114
+ }
115
+ });
116
+
117
+ clearTimeout(timeout);
118
+
119
+ if (!response.ok) {
120
+ return null;
121
+ }
122
+
123
+ const data = await response.json();
124
+ return data.version || null;
125
+ } catch {
126
+ // Network error, timeout, or offline - silently fail
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Check for updates (with caching)
133
+ * @param {string} currentVersion - Current installed version
134
+ * @param {object} options - Options
135
+ * @param {boolean} options.force - Force check, ignore cache
136
+ * @returns {Promise<object>} Update check result
137
+ */
138
+ export async function checkForUpdate(currentVersion, options = {}) {
139
+ const { force = false } = options;
140
+
141
+ // Check cache first (unless forced)
142
+ if (!force) {
143
+ const cached = readCache();
144
+ if (cached && cached.currentVersion === currentVersion) {
145
+ return {
146
+ updateAvailable: cached.updateAvailable,
147
+ currentVersion: cached.currentVersion,
148
+ latestVersion: cached.latestVersion,
149
+ cached: true
150
+ };
151
+ }
152
+ }
153
+
154
+ // Fetch latest version from npm
155
+ const latestVersion = await fetchLatestVersion();
156
+
157
+ if (!latestVersion) {
158
+ // Couldn't fetch - return no update (fail silently)
159
+ return {
160
+ updateAvailable: false,
161
+ currentVersion,
162
+ latestVersion: null,
163
+ error: true
164
+ };
165
+ }
166
+
167
+ const updateAvailable = isNewerVersion(currentVersion, latestVersion);
168
+
169
+ // Cache the result
170
+ const result = {
171
+ updateAvailable,
172
+ currentVersion,
173
+ latestVersion
174
+ };
175
+
176
+ writeCache(result);
177
+
178
+ return {
179
+ ...result,
180
+ cached: false
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Format update notification message for CLI
186
+ * @param {object} updateInfo - Result from checkForUpdate
187
+ * @returns {object|null} Message parts or null if no update
188
+ */
189
+ export function formatUpdateMessage(updateInfo) {
190
+ if (!updateInfo.updateAvailable || !updateInfo.latestVersion) {
191
+ return null;
192
+ }
193
+
194
+ return {
195
+ current: updateInfo.currentVersion,
196
+ latest: updateInfo.latestVersion,
197
+ // Simple text version for basic usage
198
+ text: `Update available: ${updateInfo.currentVersion} → ${updateInfo.latestVersion}\nRun: npm update -g pulp-image`
199
+ };
200
+ }
package/ui/app.js CHANGED
@@ -67,6 +67,7 @@ document.addEventListener('DOMContentLoaded', async () => {
67
67
  await loadVersion(); // Load version from backend
68
68
  await updateOutputDirectory(); // Initialize output directory
69
69
  await updateUI();
70
+ checkForUpdates(); // Check for updates (non-blocking)
70
71
  });
71
72
 
72
73
  // Load version from backend (single source of truth)
@@ -1316,6 +1317,61 @@ function setupHelpSubnav() {
1316
1317
  window.addEventListener('scroll', updateActiveOnScroll);
1317
1318
  }
1318
1319
 
1320
+ // Check for Updates
1321
+ async function checkForUpdates() {
1322
+ try {
1323
+ const response = await fetch('/api/check-update');
1324
+ if (!response.ok) return;
1325
+
1326
+ const updateInfo = await response.json();
1327
+
1328
+ if (updateInfo.updateAvailable && updateInfo.latestVersion) {
1329
+ showUpdateBanner(updateInfo.currentVersion, updateInfo.latestVersion);
1330
+ }
1331
+ } catch (error) {
1332
+ // Silently fail - update check is optional
1333
+ console.debug('Update check failed:', error);
1334
+ }
1335
+ }
1336
+
1337
+ // Show Update Banner
1338
+ function showUpdateBanner(currentVersion, latestVersion) {
1339
+ // Don't show if already shown
1340
+ if (document.getElementById('update-banner')) return;
1341
+
1342
+ const banner = document.createElement('div');
1343
+ banner.id = 'update-banner';
1344
+ banner.className = 'update-banner';
1345
+ banner.innerHTML = `
1346
+ <div class="update-banner-content">
1347
+ <span class="update-banner-icon">🎉</span>
1348
+ <div class="update-banner-text">
1349
+ <strong>Update available!</strong>
1350
+ <span class="update-banner-versions">v${currentVersion} → v${latestVersion}</span>
1351
+ </div>
1352
+ <div class="update-banner-actions">
1353
+ <span class="update-banner-hint">CLI users:</span>
1354
+ <code>npm update -g pulp-image</code>
1355
+ <span class="update-banner-separator">|</span>
1356
+ <span class="update-banner-hint">Portable:</span>
1357
+ <a href="https://pulp.run" target="_blank" rel="noopener">pulp.run</a>
1358
+ </div>
1359
+ </div>
1360
+ <button class="update-banner-close" aria-label="Dismiss">&times;</button>
1361
+ `;
1362
+
1363
+ // Add close handler
1364
+ banner.querySelector('.update-banner-close').addEventListener('click', () => {
1365
+ banner.remove();
1366
+ });
1367
+
1368
+ // Insert at top of container
1369
+ const container = document.querySelector('.container');
1370
+ if (container) {
1371
+ container.insertBefore(banner, container.firstChild);
1372
+ }
1373
+ }
1374
+
1319
1375
  // Initialize when DOM is ready
1320
1376
  document.addEventListener('DOMContentLoaded', () => {
1321
1377
  setupTerminalCopyButtons();
package/ui/styles.css CHANGED
@@ -1220,3 +1220,131 @@ body {
1220
1220
  }
1221
1221
  }
1222
1222
 
1223
+ /* Update Banner */
1224
+ .update-banner {
1225
+ background: linear-gradient(135deg, #2e7d32 0%, #388e3c 100%);
1226
+ padding: 1rem 1.25rem;
1227
+ display: flex;
1228
+ align-items: center;
1229
+ justify-content: space-between;
1230
+ gap: 1rem;
1231
+ border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
1232
+ box-shadow: 0 2px 8px rgba(46, 125, 50, 0.3);
1233
+ }
1234
+
1235
+ .update-banner-content {
1236
+ display: flex;
1237
+ align-items: center;
1238
+ gap: 1rem;
1239
+ flex-wrap: wrap;
1240
+ }
1241
+
1242
+ .update-banner-icon {
1243
+ font-size: 1.5rem;
1244
+ }
1245
+
1246
+ .update-banner-text {
1247
+ display: flex;
1248
+ flex-direction: column;
1249
+ gap: 0.15rem;
1250
+ }
1251
+
1252
+ .update-banner-text strong {
1253
+ color: white;
1254
+ font-size: 1rem;
1255
+ }
1256
+
1257
+ .update-banner-versions {
1258
+ color: rgba(255, 255, 255, 0.9);
1259
+ font-size: 0.9rem;
1260
+ font-weight: 500;
1261
+ }
1262
+
1263
+ .update-banner-actions {
1264
+ display: flex;
1265
+ align-items: center;
1266
+ gap: 0.5rem;
1267
+ flex-wrap: wrap;
1268
+ color: rgba(255, 255, 255, 0.85);
1269
+ font-size: 0.85rem;
1270
+ }
1271
+
1272
+ .update-banner-hint {
1273
+ color: rgba(255, 255, 255, 0.7);
1274
+ }
1275
+
1276
+ .update-banner-separator {
1277
+ color: rgba(255, 255, 255, 0.4);
1278
+ margin: 0 0.25rem;
1279
+ }
1280
+
1281
+ .update-banner-actions code {
1282
+ background: rgba(255, 255, 255, 0.2);
1283
+ color: white;
1284
+ padding: 0.25rem 0.5rem;
1285
+ border-radius: 4px;
1286
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1287
+ font-size: 0.8rem;
1288
+ }
1289
+
1290
+ .update-banner-actions a {
1291
+ color: white;
1292
+ font-weight: 600;
1293
+ text-decoration: underline;
1294
+ text-decoration-thickness: 2px;
1295
+ text-underline-offset: 2px;
1296
+ }
1297
+
1298
+ .update-banner-actions a:hover {
1299
+ text-decoration-thickness: 3px;
1300
+ }
1301
+
1302
+ .update-banner-close {
1303
+ background: rgba(255, 255, 255, 0.2);
1304
+ border: none;
1305
+ font-size: 1.25rem;
1306
+ color: white;
1307
+ cursor: pointer;
1308
+ padding: 0.25rem 0.5rem;
1309
+ line-height: 1;
1310
+ border-radius: 4px;
1311
+ opacity: 0.8;
1312
+ transition: opacity 0.2s, background 0.2s;
1313
+ }
1314
+
1315
+ .update-banner-close:hover {
1316
+ opacity: 1;
1317
+ background: rgba(255, 255, 255, 0.3);
1318
+ }
1319
+
1320
+ @media (max-width: 700px) {
1321
+ .update-banner {
1322
+ flex-direction: column;
1323
+ align-items: flex-start;
1324
+ gap: 0.75rem;
1325
+ position: relative;
1326
+ padding-right: 3rem;
1327
+ }
1328
+
1329
+ .update-banner-content {
1330
+ flex-direction: column;
1331
+ align-items: flex-start;
1332
+ gap: 0.5rem;
1333
+ }
1334
+
1335
+ .update-banner-close {
1336
+ position: absolute;
1337
+ top: 0.75rem;
1338
+ right: 0.75rem;
1339
+ }
1340
+
1341
+ .update-banner-actions {
1342
+ flex-direction: column;
1343
+ align-items: flex-start;
1344
+ gap: 0.35rem;
1345
+ }
1346
+
1347
+ .update-banner-separator {
1348
+ display: none;
1349
+ }
1350
+ }