maistro 1.0.390
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 +15 -0
- package/README.md +107 -0
- package/dist/app.d.ts +247 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +4971 -0
- package/dist/app.js.map +1 -0
- package/dist/buildInfo.d.ts +5 -0
- package/dist/buildInfo.d.ts.map +1 -0
- package/dist/buildInfo.js +2 -0
- package/dist/buildInfo.js.map +1 -0
- package/dist/caffeinate.d.ts +72 -0
- package/dist/caffeinate.d.ts.map +1 -0
- package/dist/caffeinate.js +258 -0
- package/dist/caffeinate.js.map +1 -0
- package/dist/claudePath.d.ts +10 -0
- package/dist/claudePath.d.ts.map +1 -0
- package/dist/claudePath.js +34 -0
- package/dist/claudePath.js.map +1 -0
- package/dist/clipboard.d.ts +44 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +442 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/config.d.ts +211 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +933 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +50 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +81 -0
- package/dist/constants.js.map +1 -0
- package/dist/contextBuilder.d.ts +38 -0
- package/dist/contextBuilder.d.ts.map +1 -0
- package/dist/contextBuilder.js +113 -0
- package/dist/contextBuilder.js.map +1 -0
- package/dist/dependencyDetector.d.ts +57 -0
- package/dist/dependencyDetector.d.ts.map +1 -0
- package/dist/dependencyDetector.js +505 -0
- package/dist/dependencyDetector.js.map +1 -0
- package/dist/executor.d.ts +83 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +583 -0
- package/dist/executor.js.map +1 -0
- package/dist/git.d.ts +85 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +283 -0
- package/dist/git.js.map +1 -0
- package/dist/imageManager.d.ts +161 -0
- package/dist/imageManager.d.ts.map +1 -0
- package/dist/imageManager.js +674 -0
- package/dist/imageManager.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +437 -0
- package/dist/index.js.map +1 -0
- package/dist/input-visual-test.d.ts +9 -0
- package/dist/input-visual-test.d.ts.map +1 -0
- package/dist/input-visual-test.js +108 -0
- package/dist/input-visual-test.js.map +1 -0
- package/dist/inputBox.d.ts +228 -0
- package/dist/inputBox.d.ts.map +1 -0
- package/dist/inputBox.js +966 -0
- package/dist/inputBox.js.map +1 -0
- package/dist/logger.d.ts +136 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +347 -0
- package/dist/logger.js.map +1 -0
- package/dist/orchestrator.d.ts +149 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +821 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/planner.d.ts +86 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +830 -0
- package/dist/planner.js.map +1 -0
- package/dist/pty-test-runner.d.ts +87 -0
- package/dist/pty-test-runner.d.ts.map +1 -0
- package/dist/pty-test-runner.js +721 -0
- package/dist/pty-test-runner.js.map +1 -0
- package/dist/screen.d.ts +44 -0
- package/dist/screen.d.ts.map +1 -0
- package/dist/screen.js +152 -0
- package/dist/screen.js.map +1 -0
- package/dist/taskQueue.d.ts +70 -0
- package/dist/taskQueue.d.ts.map +1 -0
- package/dist/taskQueue.js +282 -0
- package/dist/taskQueue.js.map +1 -0
- package/dist/tui-test-harness.d.ts +216 -0
- package/dist/tui-test-harness.d.ts.map +1 -0
- package/dist/tui-test-harness.js +527 -0
- package/dist/tui-test-harness.js.map +1 -0
- package/dist/types.d.ts +257 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +46 -0
- package/dist/types.js.map +1 -0
- package/dist/ui-visual-test.d.ts +15 -0
- package/dist/ui-visual-test.d.ts.map +1 -0
- package/dist/ui-visual-test.js +141 -0
- package/dist/ui-visual-test.js.map +1 -0
- package/dist/ui.d.ts +272 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +1531 -0
- package/dist/ui.js.map +1 -0
- package/dist/validator.d.ts +53 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +491 -0
- package/dist/validator.js.map +1 -0
- package/dist/versionCheck.d.ts +63 -0
- package/dist/versionCheck.d.ts.map +1 -0
- package/dist/versionCheck.js +261 -0
- package/dist/versionCheck.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import { mkdir, copyFile, unlink, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, basename, extname } from 'node:path';
|
|
4
|
+
import { loadState, saveState } from './taskQueue.js';
|
|
5
|
+
import { DEFAULT_CONFIG } from './types.js';
|
|
6
|
+
import { getLogger } from './logger.js';
|
|
7
|
+
/**
|
|
8
|
+
* Supported image extensions
|
|
9
|
+
*/
|
|
10
|
+
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'];
|
|
11
|
+
/**
|
|
12
|
+
* MIME types for supported image formats
|
|
13
|
+
*/
|
|
14
|
+
const MIME_TYPES = {
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.jpg': 'image/jpeg',
|
|
17
|
+
'.jpeg': 'image/jpeg',
|
|
18
|
+
'.gif': 'image/gif',
|
|
19
|
+
'.webp': 'image/webp',
|
|
20
|
+
'.bmp': 'image/bmp',
|
|
21
|
+
'.svg': 'image/svg+xml',
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Get the images directory path for a project
|
|
25
|
+
*/
|
|
26
|
+
export function getImagesDir(projectPath) {
|
|
27
|
+
return join(projectPath, '.maistro', 'images');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Get the state file path for a project
|
|
31
|
+
*/
|
|
32
|
+
function getStatePath(projectPath) {
|
|
33
|
+
return join(projectPath, DEFAULT_CONFIG.statePath);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Sanitize an image ID for use as a filename
|
|
37
|
+
* - Lowercase
|
|
38
|
+
* - Replace spaces and special chars with hyphens
|
|
39
|
+
* - Remove consecutive hyphens
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizeImageId(id) {
|
|
42
|
+
return id
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9-_]/g, '-')
|
|
45
|
+
.replace(/-+/g, '-')
|
|
46
|
+
.replace(/^-|-$/g, '');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check if a string looks like an image file path
|
|
50
|
+
*/
|
|
51
|
+
export function looksLikeImagePath(input) {
|
|
52
|
+
const ext = extname(input).toLowerCase();
|
|
53
|
+
return IMAGE_EXTENSIONS.includes(ext);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Check if a string looks like an image URL
|
|
57
|
+
* Accepts any HTTP/HTTPS URL that:
|
|
58
|
+
* - Has an image extension in the path (before query params)
|
|
59
|
+
* - Includes common image-related path segments
|
|
60
|
+
* - Is from known image hosting services
|
|
61
|
+
* - Has an image extension anywhere in the URL (including query params)
|
|
62
|
+
*/
|
|
63
|
+
export function looksLikeImageUrl(input) {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(input);
|
|
66
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const pathname = url.pathname.toLowerCase();
|
|
70
|
+
const fullUrl = input.toLowerCase();
|
|
71
|
+
// Check if path ends with image extension
|
|
72
|
+
if (IMAGE_EXTENSIONS.some(ext => pathname.endsWith(ext))) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
// Check if URL (including query params) contains image extension
|
|
76
|
+
// This handles URLs like: image.png?token=xxx or ?file=photo.jpg
|
|
77
|
+
if (IMAGE_EXTENSIONS.some(ext => fullUrl.includes(ext))) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
// Check common image-related path patterns
|
|
81
|
+
if (pathname.includes('/image') || pathname.includes('/photo') ||
|
|
82
|
+
pathname.includes('/picture') || pathname.includes('/media') ||
|
|
83
|
+
pathname.includes('/upload') || pathname.includes('/asset')) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
// Check known image hosting services
|
|
87
|
+
const hostname = url.hostname.toLowerCase();
|
|
88
|
+
const imageHosts = [
|
|
89
|
+
'imgur.com', 'i.imgur.com',
|
|
90
|
+
'cloudinary.com', 'res.cloudinary.com',
|
|
91
|
+
'unsplash.com', 'images.unsplash.com',
|
|
92
|
+
'pexels.com', 'images.pexels.com',
|
|
93
|
+
'flickr.com', 'staticflickr.com',
|
|
94
|
+
'giphy.com', 'media.giphy.com',
|
|
95
|
+
'gravatar.com',
|
|
96
|
+
'googleusercontent.com',
|
|
97
|
+
'githubusercontent.com', 'raw.githubusercontent.com',
|
|
98
|
+
'drive.google.com',
|
|
99
|
+
'dropbox.com', 'dl.dropboxusercontent.com',
|
|
100
|
+
'ibb.co', 'i.ibb.co',
|
|
101
|
+
'imgbb.com',
|
|
102
|
+
'postimg.cc', 'i.postimg.cc',
|
|
103
|
+
'prnt.sc', 'prntscr.com',
|
|
104
|
+
's3.amazonaws.com',
|
|
105
|
+
'blob.core.windows.net',
|
|
106
|
+
'storage.googleapis.com',
|
|
107
|
+
];
|
|
108
|
+
if (imageHosts.some(host => hostname === host || hostname.endsWith('.' + host))) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Extract image placeholders from text
|
|
119
|
+
* Supports:
|
|
120
|
+
* - [image:name], [img:name] - generic image references
|
|
121
|
+
* - [task-X-image-Y] - task-specific image references (e.g., [task-1-image-1])
|
|
122
|
+
*/
|
|
123
|
+
export function extractImagePlaceholders(text) {
|
|
124
|
+
const patterns = [
|
|
125
|
+
/\[image:([a-zA-Z0-9_-]+)\]/gi,
|
|
126
|
+
/\[img:([a-zA-Z0-9_-]+)\]/gi,
|
|
127
|
+
/\[(task-[a-zA-Z0-9]+-image-\d+)\]/gi, // task-specific format
|
|
128
|
+
];
|
|
129
|
+
const refs = new Set();
|
|
130
|
+
for (const pattern of patterns) {
|
|
131
|
+
let match;
|
|
132
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
133
|
+
refs.add(match[1].toLowerCase());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Array.from(refs);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Extract image URLs from text
|
|
140
|
+
* Returns an array of unique image URLs found in the text
|
|
141
|
+
*/
|
|
142
|
+
export function extractImageUrls(text) {
|
|
143
|
+
// Match URLs that look like images
|
|
144
|
+
// Pattern matches http:// or https:// followed by URL characters
|
|
145
|
+
const urlPattern = /https?:\/\/[^\s<>"')\]]+/gi;
|
|
146
|
+
const matches = text.match(urlPattern) || [];
|
|
147
|
+
const urls = new Set();
|
|
148
|
+
for (const match of matches) {
|
|
149
|
+
// Clean up trailing punctuation that might have been captured
|
|
150
|
+
let url = match.replace(/[.,;:!?]+$/, '');
|
|
151
|
+
// Also remove trailing parentheses if not balanced
|
|
152
|
+
if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
|
|
153
|
+
url = url.slice(0, -1);
|
|
154
|
+
}
|
|
155
|
+
if (looksLikeImageUrl(url)) {
|
|
156
|
+
urls.add(url);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return Array.from(urls);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Process a task description to download image URLs and replace them with placeholders
|
|
163
|
+
* Returns the updated description and any image metadata that was created
|
|
164
|
+
*/
|
|
165
|
+
export async function processDescriptionImageUrls(projectPath, description, taskId) {
|
|
166
|
+
const logger = getLogger();
|
|
167
|
+
const urls = extractImageUrls(description);
|
|
168
|
+
const downloadedImages = [];
|
|
169
|
+
const errors = [];
|
|
170
|
+
let updatedDescription = description;
|
|
171
|
+
if (urls.length === 0) {
|
|
172
|
+
return { updatedDescription, downloadedImages, errors };
|
|
173
|
+
}
|
|
174
|
+
logger?.info('Processing image URLs in description', {
|
|
175
|
+
taskId,
|
|
176
|
+
urlCount: urls.length,
|
|
177
|
+
urls,
|
|
178
|
+
});
|
|
179
|
+
// Get the next available image index for this task
|
|
180
|
+
let imageIndex = await getNextTaskImageIndex(projectPath, taskId);
|
|
181
|
+
for (const url of urls) {
|
|
182
|
+
const imageId = getTaskImageId(taskId, imageIndex);
|
|
183
|
+
const result = await addImageFromUrl(projectPath, url, imageId);
|
|
184
|
+
if (result.success && result.metadata) {
|
|
185
|
+
// Replace the URL with the placeholder
|
|
186
|
+
const placeholder = getImagePlaceholder(taskId, imageIndex);
|
|
187
|
+
updatedDescription = updatedDescription.replace(url, placeholder);
|
|
188
|
+
downloadedImages.push(result.metadata);
|
|
189
|
+
imageIndex++;
|
|
190
|
+
logger?.info('Image downloaded and replaced', {
|
|
191
|
+
url,
|
|
192
|
+
imageId,
|
|
193
|
+
placeholder,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const error = `Failed to download image from ${url}: ${result.error}`;
|
|
198
|
+
errors.push(error);
|
|
199
|
+
logger?.warn('Failed to download image', { url, error: result.error });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { updatedDescription, downloadedImages, errors };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Get the storage path for an image
|
|
206
|
+
*/
|
|
207
|
+
export function getImageStoragePath(imageId, extension) {
|
|
208
|
+
return `${sanitizeImageId(imageId)}${extension}`;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Get the absolute path to an image file
|
|
212
|
+
*/
|
|
213
|
+
export function getImageAbsolutePath(projectPath, storagePath) {
|
|
214
|
+
return join(getImagesDir(projectPath), storagePath);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Add an image from a local file path
|
|
218
|
+
*/
|
|
219
|
+
export async function addImageFromFile(projectPath, filePath, imageId, description) {
|
|
220
|
+
try {
|
|
221
|
+
// Validate file exists
|
|
222
|
+
if (!existsSync(filePath)) {
|
|
223
|
+
return { success: false, error: `File not found: ${filePath}` };
|
|
224
|
+
}
|
|
225
|
+
// Validate extension
|
|
226
|
+
const ext = extname(filePath).toLowerCase();
|
|
227
|
+
if (!IMAGE_EXTENSIONS.includes(ext)) {
|
|
228
|
+
return { success: false, error: `Unsupported image format: ${ext}` };
|
|
229
|
+
}
|
|
230
|
+
// Get file stats
|
|
231
|
+
const stats = await stat(filePath);
|
|
232
|
+
// Ensure images directory exists
|
|
233
|
+
const imagesDir = getImagesDir(projectPath);
|
|
234
|
+
await mkdir(imagesDir, { recursive: true });
|
|
235
|
+
// Create storage path
|
|
236
|
+
const storagePath = getImageStoragePath(imageId, ext);
|
|
237
|
+
const destPath = join(imagesDir, storagePath);
|
|
238
|
+
// Copy file
|
|
239
|
+
await copyFile(filePath, destPath);
|
|
240
|
+
// Create metadata
|
|
241
|
+
const metadata = {
|
|
242
|
+
id: sanitizeImageId(imageId),
|
|
243
|
+
originalName: basename(filePath),
|
|
244
|
+
storagePath,
|
|
245
|
+
mimeType: MIME_TYPES[ext] || 'application/octet-stream',
|
|
246
|
+
size: stats.size,
|
|
247
|
+
description,
|
|
248
|
+
source: 'file',
|
|
249
|
+
createdAt: new Date().toISOString(),
|
|
250
|
+
};
|
|
251
|
+
// Save to state
|
|
252
|
+
await saveImageMetadata(projectPath, metadata);
|
|
253
|
+
return { success: true, metadata };
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
return { success: false, error: String(err) };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Add an image from a URL (download)
|
|
261
|
+
*/
|
|
262
|
+
export async function addImageFromUrl(projectPath, url, imageId, description) {
|
|
263
|
+
try {
|
|
264
|
+
// Validate URL
|
|
265
|
+
const parsedUrl = new URL(url);
|
|
266
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
267
|
+
return { success: false, error: 'Only HTTP/HTTPS URLs are supported' };
|
|
268
|
+
}
|
|
269
|
+
// Download the image
|
|
270
|
+
const response = await fetch(url);
|
|
271
|
+
if (!response.ok) {
|
|
272
|
+
return { success: false, error: `Failed to download: ${response.status} ${response.statusText}` };
|
|
273
|
+
}
|
|
274
|
+
// Get content type and determine extension
|
|
275
|
+
const contentType = response.headers.get('content-type') || '';
|
|
276
|
+
let ext = '.png'; // Default
|
|
277
|
+
for (const [extension, mime] of Object.entries(MIME_TYPES)) {
|
|
278
|
+
if (contentType.includes(mime.split('/')[1])) {
|
|
279
|
+
ext = extension;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// If content-type didn't help, try URL path
|
|
284
|
+
const urlExt = extname(parsedUrl.pathname).toLowerCase();
|
|
285
|
+
if (IMAGE_EXTENSIONS.includes(urlExt)) {
|
|
286
|
+
ext = urlExt;
|
|
287
|
+
}
|
|
288
|
+
// Get image data
|
|
289
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
290
|
+
// Ensure images directory exists
|
|
291
|
+
const imagesDir = getImagesDir(projectPath);
|
|
292
|
+
await mkdir(imagesDir, { recursive: true });
|
|
293
|
+
// Create storage path
|
|
294
|
+
const storagePath = getImageStoragePath(imageId, ext);
|
|
295
|
+
const destPath = join(imagesDir, storagePath);
|
|
296
|
+
// Write file
|
|
297
|
+
await writeFile(destPath, buffer);
|
|
298
|
+
// Create metadata
|
|
299
|
+
const metadata = {
|
|
300
|
+
id: sanitizeImageId(imageId),
|
|
301
|
+
originalName: basename(parsedUrl.pathname) || `${imageId}${ext}`,
|
|
302
|
+
storagePath,
|
|
303
|
+
mimeType: MIME_TYPES[ext] || contentType,
|
|
304
|
+
size: buffer.length,
|
|
305
|
+
description,
|
|
306
|
+
source: 'url',
|
|
307
|
+
sourceUrl: url,
|
|
308
|
+
createdAt: new Date().toISOString(),
|
|
309
|
+
};
|
|
310
|
+
// Save to state
|
|
311
|
+
await saveImageMetadata(projectPath, metadata);
|
|
312
|
+
return { success: true, metadata };
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
return { success: false, error: String(err) };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Get all images for a project
|
|
320
|
+
*/
|
|
321
|
+
export async function getImages(projectPath) {
|
|
322
|
+
const statePath = getStatePath(projectPath);
|
|
323
|
+
const state = await loadState(statePath);
|
|
324
|
+
return state?.images || {};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Get a single image by ID
|
|
328
|
+
*/
|
|
329
|
+
export async function getImage(projectPath, imageId) {
|
|
330
|
+
const images = await getImages(projectPath);
|
|
331
|
+
return images[sanitizeImageId(imageId)] || null;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Delete an image
|
|
335
|
+
*/
|
|
336
|
+
export async function deleteImage(projectPath, imageId) {
|
|
337
|
+
try {
|
|
338
|
+
const sanitizedId = sanitizeImageId(imageId);
|
|
339
|
+
const images = await getImages(projectPath);
|
|
340
|
+
const metadata = images[sanitizedId];
|
|
341
|
+
if (!metadata) {
|
|
342
|
+
return { success: false, error: `Image not found: ${imageId}` };
|
|
343
|
+
}
|
|
344
|
+
// Delete the file
|
|
345
|
+
const filePath = getImageAbsolutePath(projectPath, metadata.storagePath);
|
|
346
|
+
if (existsSync(filePath)) {
|
|
347
|
+
await unlink(filePath);
|
|
348
|
+
}
|
|
349
|
+
// Remove from state
|
|
350
|
+
await removeImageMetadata(projectPath, sanitizedId);
|
|
351
|
+
return { success: true };
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
return { success: false, error: String(err) };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Save image metadata to project state
|
|
359
|
+
*/
|
|
360
|
+
async function saveImageMetadata(projectPath, metadata) {
|
|
361
|
+
const statePath = getStatePath(projectPath);
|
|
362
|
+
const state = await loadState(statePath);
|
|
363
|
+
if (!state) {
|
|
364
|
+
throw new Error('No project state found. Initialize a project first.');
|
|
365
|
+
}
|
|
366
|
+
if (!state.images) {
|
|
367
|
+
state.images = {};
|
|
368
|
+
}
|
|
369
|
+
state.images[metadata.id] = metadata;
|
|
370
|
+
await saveState(statePath, state);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Remove image metadata from project state
|
|
374
|
+
*/
|
|
375
|
+
async function removeImageMetadata(projectPath, imageId) {
|
|
376
|
+
const statePath = getStatePath(projectPath);
|
|
377
|
+
const state = await loadState(statePath);
|
|
378
|
+
if (!state || !state.images)
|
|
379
|
+
return;
|
|
380
|
+
delete state.images[imageId];
|
|
381
|
+
await saveState(statePath, state);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Format image size for display
|
|
385
|
+
*/
|
|
386
|
+
export function formatImageSize(bytes) {
|
|
387
|
+
if (bytes < 1024) {
|
|
388
|
+
return `${bytes}B`;
|
|
389
|
+
}
|
|
390
|
+
else if (bytes < 1024 * 1024) {
|
|
391
|
+
return `${Math.round(bytes / 1024)}KB`;
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Generate a task-specific image ID
|
|
399
|
+
* Format: task-{taskId}-image-{index}
|
|
400
|
+
*/
|
|
401
|
+
export function getTaskImageId(taskId, imageIndex) {
|
|
402
|
+
return `task-${taskId}-image-${imageIndex}`;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Generate a placeholder string for a task image
|
|
406
|
+
* Format: [task-{taskId}-image-{index}]
|
|
407
|
+
*/
|
|
408
|
+
export function getImagePlaceholder(taskId, imageIndex) {
|
|
409
|
+
return `[${getTaskImageId(taskId, imageIndex)}]`;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Save an image for a specific task
|
|
413
|
+
* Convenience wrapper that uses task-specific naming convention
|
|
414
|
+
*/
|
|
415
|
+
export async function saveTaskImage(projectPath, sourcePath, taskId, imageIndex, description) {
|
|
416
|
+
const imageId = getTaskImageId(taskId, imageIndex);
|
|
417
|
+
const result = await addImageFromFile(projectPath, sourcePath, imageId, description);
|
|
418
|
+
if (result.success) {
|
|
419
|
+
return {
|
|
420
|
+
...result,
|
|
421
|
+
placeholder: getImagePlaceholder(taskId, imageIndex),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Get the next available image index for a task
|
|
428
|
+
*/
|
|
429
|
+
export async function getNextTaskImageIndex(projectPath, taskId) {
|
|
430
|
+
const images = await getImages(projectPath);
|
|
431
|
+
const prefix = `task-${taskId}-image-`;
|
|
432
|
+
let maxIndex = 0;
|
|
433
|
+
for (const id of Object.keys(images)) {
|
|
434
|
+
if (id.startsWith(prefix)) {
|
|
435
|
+
const indexStr = id.slice(prefix.length);
|
|
436
|
+
const index = parseInt(indexStr, 10);
|
|
437
|
+
if (!isNaN(index) && index >= maxIndex) {
|
|
438
|
+
maxIndex = index + 1;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return maxIndex;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Magic bytes for common image formats
|
|
446
|
+
* These are the first few bytes that identify image file types
|
|
447
|
+
*/
|
|
448
|
+
const IMAGE_MAGIC_BYTES = {
|
|
449
|
+
png: { bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], ext: '.png', mime: 'image/png' },
|
|
450
|
+
jpeg: { bytes: [0xFF, 0xD8, 0xFF], ext: '.jpg', mime: 'image/jpeg' },
|
|
451
|
+
gif: { bytes: [0x47, 0x49, 0x46, 0x38], ext: '.gif', mime: 'image/gif' },
|
|
452
|
+
webp: { bytes: [0x52, 0x49, 0x46, 0x46], ext: '.webp', mime: 'image/webp' }, // RIFF header, followed by WEBP
|
|
453
|
+
bmp: { bytes: [0x42, 0x4D], ext: '.bmp', mime: 'image/bmp' },
|
|
454
|
+
};
|
|
455
|
+
/**
|
|
456
|
+
* Detect if a string contains base64-encoded image data
|
|
457
|
+
*
|
|
458
|
+
* Supports:
|
|
459
|
+
* - Raw base64 data (detects by magic bytes after decoding)
|
|
460
|
+
* - Data URLs (data:image/png;base64,...)
|
|
461
|
+
* - iTerm2 inline image sequences (ESC]1337;File=...)
|
|
462
|
+
*
|
|
463
|
+
* @param input The string to check for image data
|
|
464
|
+
* @returns Detection result with buffer and format info if found
|
|
465
|
+
*/
|
|
466
|
+
export function detectPastedImage(input) {
|
|
467
|
+
const logger = getLogger();
|
|
468
|
+
// Skip short strings and obvious non-image content
|
|
469
|
+
if (!input || input.length < 20) {
|
|
470
|
+
return { detected: false };
|
|
471
|
+
}
|
|
472
|
+
// Check for data URL format: data:image/TYPE;base64,DATA
|
|
473
|
+
const dataUrlMatch = input.match(/^data:image\/([a-z+]+);base64,(.+)$/i);
|
|
474
|
+
if (dataUrlMatch) {
|
|
475
|
+
const mimeSubtype = dataUrlMatch[1].toLowerCase();
|
|
476
|
+
const base64Data = dataUrlMatch[2];
|
|
477
|
+
try {
|
|
478
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
479
|
+
if (buffer.length < 8) {
|
|
480
|
+
return { detected: false, error: 'Decoded data too small' };
|
|
481
|
+
}
|
|
482
|
+
// Find matching format
|
|
483
|
+
const format = detectFormatFromBuffer(buffer);
|
|
484
|
+
if (format) {
|
|
485
|
+
return {
|
|
486
|
+
detected: true,
|
|
487
|
+
buffer,
|
|
488
|
+
format: format.name,
|
|
489
|
+
extension: format.ext,
|
|
490
|
+
mimeType: format.mime,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
// Trust the data URL MIME type even if magic bytes don't match exactly
|
|
494
|
+
const ext = mimeSubtype === 'jpeg' || mimeSubtype === 'jpg' ? '.jpg' : `.${mimeSubtype}`;
|
|
495
|
+
return {
|
|
496
|
+
detected: true,
|
|
497
|
+
buffer,
|
|
498
|
+
format: mimeSubtype,
|
|
499
|
+
extension: ext,
|
|
500
|
+
mimeType: `image/${mimeSubtype}`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
logger?.warn('detectPastedImage: failed to decode data URL', { error: String(err) });
|
|
505
|
+
return { detected: false, error: 'Failed to decode base64 data' };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// Check for iTerm2 inline image protocol: ESC]1337;File=...
|
|
509
|
+
// Format: \x1b]1337;File=[params]:BASE64_DATA\x07 or \x1b]1337;File=[params]:BASE64_DATA\x1b\\
|
|
510
|
+
const iterm2Match = input.match(/\x1b\]1337;File=[^:]*:([A-Za-z0-9+/=]+)(?:\x07|\x1b\\)/);
|
|
511
|
+
if (iterm2Match) {
|
|
512
|
+
const base64Data = iterm2Match[1];
|
|
513
|
+
try {
|
|
514
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
515
|
+
if (buffer.length < 8) {
|
|
516
|
+
return { detected: false, error: 'Decoded data too small' };
|
|
517
|
+
}
|
|
518
|
+
const format = detectFormatFromBuffer(buffer);
|
|
519
|
+
if (format) {
|
|
520
|
+
return {
|
|
521
|
+
detected: true,
|
|
522
|
+
buffer,
|
|
523
|
+
format: format.name,
|
|
524
|
+
extension: format.ext,
|
|
525
|
+
mimeType: format.mime,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// Default to PNG if format detection fails
|
|
529
|
+
return {
|
|
530
|
+
detected: true,
|
|
531
|
+
buffer,
|
|
532
|
+
format: 'png',
|
|
533
|
+
extension: '.png',
|
|
534
|
+
mimeType: 'image/png',
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
logger?.warn('detectPastedImage: failed to decode iTerm2 data', { error: String(err) });
|
|
539
|
+
return { detected: false, error: 'Failed to decode iTerm2 image data' };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Check for raw base64 data (must look like valid base64 and decode to valid image)
|
|
543
|
+
// Only consider if the string is mostly base64 characters
|
|
544
|
+
const trimmed = input.trim();
|
|
545
|
+
if (/^[A-Za-z0-9+/=]+$/.test(trimmed) && trimmed.length >= 100) {
|
|
546
|
+
try {
|
|
547
|
+
const buffer = Buffer.from(trimmed, 'base64');
|
|
548
|
+
if (buffer.length < 8) {
|
|
549
|
+
return { detected: false };
|
|
550
|
+
}
|
|
551
|
+
const format = detectFormatFromBuffer(buffer);
|
|
552
|
+
if (format) {
|
|
553
|
+
return {
|
|
554
|
+
detected: true,
|
|
555
|
+
buffer,
|
|
556
|
+
format: format.name,
|
|
557
|
+
extension: format.ext,
|
|
558
|
+
mimeType: format.mime,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// Not valid base64
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return { detected: false };
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Detect image format from buffer by checking magic bytes
|
|
570
|
+
*/
|
|
571
|
+
function detectFormatFromBuffer(buffer) {
|
|
572
|
+
for (const [name, { bytes, ext, mime }] of Object.entries(IMAGE_MAGIC_BYTES)) {
|
|
573
|
+
if (buffer.length >= bytes.length) {
|
|
574
|
+
let matches = true;
|
|
575
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
576
|
+
if (buffer[i] !== bytes[i]) {
|
|
577
|
+
matches = false;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (matches) {
|
|
582
|
+
// For WebP, also check for 'WEBP' signature at offset 8
|
|
583
|
+
if (name === 'webp') {
|
|
584
|
+
if (buffer.length >= 12 &&
|
|
585
|
+
buffer[8] === 0x57 && // W
|
|
586
|
+
buffer[9] === 0x45 && // E
|
|
587
|
+
buffer[10] === 0x42 && // B
|
|
588
|
+
buffer[11] === 0x50) { // P
|
|
589
|
+
return { name, ext, mime };
|
|
590
|
+
}
|
|
591
|
+
continue; // Not actually WebP
|
|
592
|
+
}
|
|
593
|
+
return { name, ext, mime };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Add an image from pasted base64 data
|
|
601
|
+
*
|
|
602
|
+
* @param projectPath Project directory path
|
|
603
|
+
* @param pastedData The pasted string (base64, data URL, or escape sequence)
|
|
604
|
+
* @param imageId Identifier for the image
|
|
605
|
+
* @param description Optional description
|
|
606
|
+
* @returns Result with success status and metadata
|
|
607
|
+
*/
|
|
608
|
+
export async function addImageFromPaste(projectPath, pastedData, imageId, description) {
|
|
609
|
+
const detected = detectPastedImage(pastedData);
|
|
610
|
+
if (!detected.detected || !detected.buffer) {
|
|
611
|
+
return { success: false, error: detected.error || 'No image data detected in pasted content' };
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
// Ensure images directory exists
|
|
615
|
+
const imagesDir = getImagesDir(projectPath);
|
|
616
|
+
await mkdir(imagesDir, { recursive: true });
|
|
617
|
+
// Create storage path
|
|
618
|
+
const ext = detected.extension || '.png';
|
|
619
|
+
const storagePath = getImageStoragePath(imageId, ext);
|
|
620
|
+
const destPath = join(imagesDir, storagePath);
|
|
621
|
+
// Write file
|
|
622
|
+
await writeFile(destPath, detected.buffer);
|
|
623
|
+
// Create metadata
|
|
624
|
+
const metadata = {
|
|
625
|
+
id: sanitizeImageId(imageId),
|
|
626
|
+
originalName: `pasted-image${ext}`,
|
|
627
|
+
storagePath,
|
|
628
|
+
mimeType: detected.mimeType || 'application/octet-stream',
|
|
629
|
+
size: detected.buffer.length,
|
|
630
|
+
description,
|
|
631
|
+
source: 'paste',
|
|
632
|
+
createdAt: new Date().toISOString(),
|
|
633
|
+
};
|
|
634
|
+
// Save to state
|
|
635
|
+
await saveImageMetadata(projectPath, metadata);
|
|
636
|
+
return { success: true, metadata };
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
return { success: false, error: String(err) };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Check if input looks like it might contain pasted image data
|
|
644
|
+
* This is a quick check before doing full detection
|
|
645
|
+
*/
|
|
646
|
+
export function mightBePastedImage(input) {
|
|
647
|
+
if (!input || input.length < 50) {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
// Check for data URL
|
|
651
|
+
if (input.startsWith('data:image/')) {
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
// Check for iTerm2 sequence
|
|
655
|
+
if (input.includes('\x1b]1337;File=')) {
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
// Check for long base64-looking string (at least 100 chars of mostly base64 alphabet)
|
|
659
|
+
const trimmed = input.trim();
|
|
660
|
+
if (trimmed.length >= 100 && /^[A-Za-z0-9+/=]+$/.test(trimmed)) {
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Generate a unique image ID for pasted images
|
|
667
|
+
* Format: paste-{timestamp}-{random}
|
|
668
|
+
*/
|
|
669
|
+
export function generatePasteImageId() {
|
|
670
|
+
const timestamp = Date.now().toString(36);
|
|
671
|
+
const random = Math.random().toString(36).slice(2, 6);
|
|
672
|
+
return `paste-${timestamp}-${random}`;
|
|
673
|
+
}
|
|
674
|
+
//# sourceMappingURL=imageManager.js.map
|