pi-image-tools 1.0.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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/config/config.example.json +3 -0
- package/index.ts +3 -0
- package/package.json +52 -0
- package/src/clipboard.ts +117 -0
- package/src/commands.ts +79 -0
- package/src/image-preview.ts +469 -0
- package/src/index.ts +260 -0
- package/src/inline-user-preview.ts +345 -0
- package/src/keybindings.ts +15 -0
- package/src/recent-images.ts +437 -0
- package/src/temp-file.ts +82 -0
- package/src/types.ts +20 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { homedir, tmpdir } from "node:os";
|
|
12
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { ClipboardImage } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export const RECENT_IMAGE_ENV_VAR = "PI_IMAGE_TOOLS_RECENT_DIRS";
|
|
17
|
+
export const RECENT_IMAGE_CACHE_DIR_ENV_VAR = "PI_IMAGE_TOOLS_RECENT_CACHE_DIR";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MAX_RECENT_IMAGES = 30;
|
|
20
|
+
const DEFAULT_MAX_CACHE_FILES = 160;
|
|
21
|
+
|
|
22
|
+
const SCREENSHOT_NAME_PATTERNS: readonly RegExp[] = [
|
|
23
|
+
/^screenshot/i,
|
|
24
|
+
/^screen shot/i,
|
|
25
|
+
/^snip/i,
|
|
26
|
+
/^capture/i,
|
|
27
|
+
/^img_/i,
|
|
28
|
+
/^screenrecording/i,
|
|
29
|
+
/^屏幕截图/i,
|
|
30
|
+
/^スクリーンショット/i,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const EXTENSION_TO_MIME = new Map<string, string>([
|
|
34
|
+
[".png", "image/png"],
|
|
35
|
+
[".jpg", "image/jpeg"],
|
|
36
|
+
[".jpeg", "image/jpeg"],
|
|
37
|
+
[".webp", "image/webp"],
|
|
38
|
+
[".gif", "image/gif"],
|
|
39
|
+
[".bmp", "image/bmp"],
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
interface RecentImageSource {
|
|
43
|
+
path: string;
|
|
44
|
+
filterScreenshotNames: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RecentImageCandidate {
|
|
48
|
+
path: string;
|
|
49
|
+
name: string;
|
|
50
|
+
mimeType: string;
|
|
51
|
+
modifiedAtMs: number;
|
|
52
|
+
sizeBytes: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RecentImageDiscovery {
|
|
56
|
+
candidates: RecentImageCandidate[];
|
|
57
|
+
searchedDirectories: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DiscoverRecentImagesOptions {
|
|
61
|
+
platform?: NodeJS.Platform;
|
|
62
|
+
maxItems?: number;
|
|
63
|
+
homeDirectory?: string;
|
|
64
|
+
environment?: NodeJS.ProcessEnv;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PersistRecentImageOptions {
|
|
68
|
+
maxCacheFiles?: number;
|
|
69
|
+
environment?: NodeJS.ProcessEnv;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeUserPath(value: string): string {
|
|
73
|
+
const trimmed = value.trim().replace(/^"|"$/g, "");
|
|
74
|
+
return trimmed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function expandHomePath(value: string, homeDirectory: string): string {
|
|
78
|
+
if (value === "~") {
|
|
79
|
+
return homeDirectory;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
83
|
+
return join(homeDirectory, value.slice(2));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizePath(pathValue: string, homeDirectory: string): string {
|
|
90
|
+
return resolve(expandHomePath(normalizeUserPath(pathValue), homeDirectory));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getRecentImageCacheDirectory(environment: NodeJS.ProcessEnv = process.env): string {
|
|
94
|
+
const homeDirectory = homedir();
|
|
95
|
+
const configuredPath = environment[RECENT_IMAGE_CACHE_DIR_ENV_VAR];
|
|
96
|
+
|
|
97
|
+
if (configuredPath && configuredPath.trim().length > 0) {
|
|
98
|
+
return normalizePath(configuredPath, homeDirectory);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return join(tmpdir(), "pi-image-tools", "recent-cache");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseConfiguredSources(environment: NodeJS.ProcessEnv, homeDirectory: string): string[] {
|
|
105
|
+
const configured = environment[RECENT_IMAGE_ENV_VAR]?.trim();
|
|
106
|
+
if (!configured) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return configured
|
|
111
|
+
.split(";")
|
|
112
|
+
.map((value) => normalizePath(value, homeDirectory))
|
|
113
|
+
.filter((value) => value.length > 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getDefaultWindowsSources(homeDirectory: string): RecentImageSource[] {
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
path: join(homeDirectory, "Pictures", "Screenshots"),
|
|
120
|
+
filterScreenshotNames: false,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
path: join(homeDirectory, "OneDrive", "Pictures", "Screenshots"),
|
|
124
|
+
filterScreenshotNames: false,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
path: join(homeDirectory, "Desktop"),
|
|
128
|
+
filterScreenshotNames: true,
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function dedupeSources(sources: readonly RecentImageSource[]): RecentImageSource[] {
|
|
134
|
+
const deduped = new Map<string, RecentImageSource>();
|
|
135
|
+
|
|
136
|
+
for (const source of sources) {
|
|
137
|
+
const key = process.platform === "win32" ? source.path.toLowerCase() : source.path;
|
|
138
|
+
if (!deduped.has(key)) {
|
|
139
|
+
deduped.set(key, source);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [...deduped.values()];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isLikelyScreenshotName(name: string): boolean {
|
|
147
|
+
return SCREENSHOT_NAME_PATTERNS.some((pattern) => pattern.test(name));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toMimeType(fileName: string): string | null {
|
|
151
|
+
const extension = extname(fileName).toLowerCase();
|
|
152
|
+
return EXTENSION_TO_MIME.get(extension) ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function extensionForMimeType(mimeType: string): string {
|
|
156
|
+
const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
157
|
+
|
|
158
|
+
switch (normalized) {
|
|
159
|
+
case "image/png":
|
|
160
|
+
return "png";
|
|
161
|
+
case "image/jpeg":
|
|
162
|
+
return "jpg";
|
|
163
|
+
case "image/webp":
|
|
164
|
+
return "webp";
|
|
165
|
+
case "image/gif":
|
|
166
|
+
return "gif";
|
|
167
|
+
case "image/bmp":
|
|
168
|
+
return "bmp";
|
|
169
|
+
default:
|
|
170
|
+
return "png";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function listRecentImagesFromSource(source: RecentImageSource): RecentImageCandidate[] {
|
|
175
|
+
if (!existsSync(source.path)) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let names: string[];
|
|
180
|
+
try {
|
|
181
|
+
names = readdirSync(source.path);
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const candidates: RecentImageCandidate[] = [];
|
|
187
|
+
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
const mimeType = toMimeType(name);
|
|
190
|
+
if (!mimeType) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (source.filterScreenshotNames && !isLikelyScreenshotName(name)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const fullPath = join(source.path, name);
|
|
199
|
+
|
|
200
|
+
let stat;
|
|
201
|
+
try {
|
|
202
|
+
stat = statSync(fullPath);
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!stat.isFile()) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
candidates.push({
|
|
212
|
+
path: fullPath,
|
|
213
|
+
name,
|
|
214
|
+
mimeType,
|
|
215
|
+
modifiedAtMs: stat.mtimeMs,
|
|
216
|
+
sizeBytes: stat.size,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return candidates;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildSources(options: DiscoverRecentImagesOptions): RecentImageSource[] {
|
|
224
|
+
const platform = options.platform ?? process.platform;
|
|
225
|
+
const homeDirectory = options.homeDirectory ?? homedir();
|
|
226
|
+
const environment = options.environment ?? process.env;
|
|
227
|
+
|
|
228
|
+
if (platform !== "win32") {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const cacheSource: RecentImageSource = {
|
|
233
|
+
path: getRecentImageCacheDirectory(environment),
|
|
234
|
+
filterScreenshotNames: false,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const configuredPaths = parseConfiguredSources(environment, homeDirectory);
|
|
238
|
+
if (configuredPaths.length > 0) {
|
|
239
|
+
return dedupeSources([
|
|
240
|
+
cacheSource,
|
|
241
|
+
...configuredPaths.map((pathValue) => ({
|
|
242
|
+
path: pathValue,
|
|
243
|
+
filterScreenshotNames: false,
|
|
244
|
+
})),
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return dedupeSources([cacheSource, ...getDefaultWindowsSources(homeDirectory)]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function dedupeCandidates(candidates: readonly RecentImageCandidate[]): RecentImageCandidate[] {
|
|
252
|
+
const deduped = new Map<string, RecentImageCandidate>();
|
|
253
|
+
|
|
254
|
+
for (const candidate of candidates) {
|
|
255
|
+
const key = process.platform === "win32" ? candidate.path.toLowerCase() : candidate.path;
|
|
256
|
+
|
|
257
|
+
const existing = deduped.get(key);
|
|
258
|
+
if (!existing || candidate.modifiedAtMs > existing.modifiedAtMs) {
|
|
259
|
+
deduped.set(key, candidate);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return [...deduped.values()];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function discoverRecentImages(options: DiscoverRecentImagesOptions = {}): RecentImageDiscovery {
|
|
267
|
+
const sources = buildSources(options);
|
|
268
|
+
const searchedDirectories = sources.map((source) => source.path);
|
|
269
|
+
const maxItems = options.maxItems ?? DEFAULT_MAX_RECENT_IMAGES;
|
|
270
|
+
|
|
271
|
+
if (sources.length === 0) {
|
|
272
|
+
return {
|
|
273
|
+
candidates: [],
|
|
274
|
+
searchedDirectories,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const allCandidates = sources.flatMap((source) => listRecentImagesFromSource(source));
|
|
279
|
+
const sorted = dedupeCandidates(allCandidates).sort((left, right) => right.modifiedAtMs - left.modifiedAtMs);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
candidates: sorted.slice(0, Math.max(1, maxItems)),
|
|
283
|
+
searchedDirectories,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function pruneCacheDirectory(cacheDirectory: string, maxCacheFiles: number): void {
|
|
288
|
+
if (maxCacheFiles < 1 || !existsSync(cacheDirectory)) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let entries: string[];
|
|
293
|
+
try {
|
|
294
|
+
entries = readdirSync(cacheDirectory);
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const imageFiles = entries
|
|
300
|
+
.map((name) => {
|
|
301
|
+
const fullPath = join(cacheDirectory, name);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const stat = statSync(fullPath);
|
|
305
|
+
if (!stat.isFile()) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!toMimeType(name)) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { fullPath, modifiedAtMs: stat.mtimeMs };
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
.filter((entry): entry is { fullPath: string; modifiedAtMs: number } => entry !== null)
|
|
319
|
+
.sort((left, right) => right.modifiedAtMs - left.modifiedAtMs);
|
|
320
|
+
|
|
321
|
+
if (imageFiles.length <= maxCacheFiles) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const staleFile of imageFiles.slice(maxCacheFiles)) {
|
|
326
|
+
try {
|
|
327
|
+
unlinkSync(staleFile.fullPath);
|
|
328
|
+
} catch {
|
|
329
|
+
// Intentionally ignore cache cleanup failures.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function persistImageToRecentCache(
|
|
335
|
+
image: ClipboardImage,
|
|
336
|
+
options: PersistRecentImageOptions = {},
|
|
337
|
+
): string {
|
|
338
|
+
if (!image.bytes || image.bytes.length === 0) {
|
|
339
|
+
throw new Error("Cannot cache an empty image payload.");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const environment = options.environment ?? process.env;
|
|
343
|
+
const cacheDirectory = getRecentImageCacheDirectory(environment);
|
|
344
|
+
const extension = extensionForMimeType(image.mimeType);
|
|
345
|
+
|
|
346
|
+
mkdirSync(cacheDirectory, { recursive: true });
|
|
347
|
+
|
|
348
|
+
const filePath = join(cacheDirectory, `pi-recent-${Date.now()}-${randomUUID()}.${extension}`);
|
|
349
|
+
writeFileSync(filePath, Buffer.from(image.bytes));
|
|
350
|
+
|
|
351
|
+
const maxCacheFiles = options.maxCacheFiles ?? DEFAULT_MAX_CACHE_FILES;
|
|
352
|
+
pruneCacheDirectory(cacheDirectory, maxCacheFiles);
|
|
353
|
+
|
|
354
|
+
return filePath;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function formatRelativeAge(modifiedAtMs: number, nowMs: number): string {
|
|
358
|
+
const deltaMs = Math.max(0, nowMs - modifiedAtMs);
|
|
359
|
+
const deltaMinutes = Math.floor(deltaMs / 60_000);
|
|
360
|
+
|
|
361
|
+
if (deltaMinutes < 1) {
|
|
362
|
+
return "just now";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (deltaMinutes < 60) {
|
|
366
|
+
return `${deltaMinutes}m ago`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const deltaHours = Math.floor(deltaMinutes / 60);
|
|
370
|
+
if (deltaHours < 24) {
|
|
371
|
+
return `${deltaHours}h ago`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const deltaDays = Math.floor(deltaHours / 24);
|
|
375
|
+
if (deltaDays < 30) {
|
|
376
|
+
return `${deltaDays}d ago`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const deltaMonths = Math.floor(deltaDays / 30);
|
|
380
|
+
if (deltaMonths < 12) {
|
|
381
|
+
return `${deltaMonths}mo ago`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const deltaYears = Math.floor(deltaMonths / 12);
|
|
385
|
+
return `${deltaYears}y ago`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function formatSize(sizeBytes: number): string {
|
|
389
|
+
if (sizeBytes < 1024) {
|
|
390
|
+
return `${sizeBytes} B`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const units = ["KB", "MB", "GB"] as const;
|
|
394
|
+
let value = sizeBytes / 1024;
|
|
395
|
+
let unitIndex = 0;
|
|
396
|
+
|
|
397
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
398
|
+
value /= 1024;
|
|
399
|
+
unitIndex += 1;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function abbreviatePath(pathValue: string, maxChars: number): string {
|
|
406
|
+
if (pathValue.length <= maxChars) {
|
|
407
|
+
return pathValue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const fileName = basename(pathValue);
|
|
411
|
+
if (fileName.length + 4 >= maxChars) {
|
|
412
|
+
return `...${fileName.slice(-(maxChars - 3))}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const headLength = maxChars - fileName.length - 4;
|
|
416
|
+
return `${pathValue.slice(0, headLength)}...\\${fileName}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function formatRecentImageLabel(candidate: RecentImageCandidate, nowMs = Date.now()): string {
|
|
420
|
+
const age = formatRelativeAge(candidate.modifiedAtMs, nowMs);
|
|
421
|
+
const size = formatSize(candidate.sizeBytes);
|
|
422
|
+
const shortPath = abbreviatePath(candidate.path, 64);
|
|
423
|
+
|
|
424
|
+
return `${candidate.name} • ${age} • ${size} • ${shortPath}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function loadRecentImage(candidate: RecentImageCandidate): ClipboardImage {
|
|
428
|
+
const raw = readFileSync(candidate.path);
|
|
429
|
+
if (raw.length === 0) {
|
|
430
|
+
throw new Error(`File is empty: ${candidate.path}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
bytes: new Uint8Array(raw),
|
|
435
|
+
mimeType: candidate.mimeType,
|
|
436
|
+
};
|
|
437
|
+
}
|
package/src/temp-file.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { ClipboardImage } from "./types.js";
|
|
7
|
+
|
|
8
|
+
function extensionForImageMimeType(mimeType: string): string {
|
|
9
|
+
const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
10
|
+
|
|
11
|
+
switch (normalized) {
|
|
12
|
+
case "image/png":
|
|
13
|
+
return "png";
|
|
14
|
+
case "image/jpeg":
|
|
15
|
+
return "jpg";
|
|
16
|
+
case "image/webp":
|
|
17
|
+
return "webp";
|
|
18
|
+
case "image/gif":
|
|
19
|
+
return "gif";
|
|
20
|
+
default:
|
|
21
|
+
return "png";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class TempFileManager {
|
|
26
|
+
private readonly baseDir: string;
|
|
27
|
+
private readonly createdFiles = new Set<string>();
|
|
28
|
+
private exitHookRegistered = false;
|
|
29
|
+
|
|
30
|
+
constructor(baseDir: string = join(tmpdir(), "pi-images")) {
|
|
31
|
+
this.baseDir = baseDir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
registerExitCleanup(): void {
|
|
35
|
+
if (this.exitHookRegistered) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
process.once("exit", () => {
|
|
40
|
+
this.cleanupSync();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.exitHookRegistered = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
saveImage(image: ClipboardImage): string {
|
|
47
|
+
mkdirSync(this.baseDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
const ext = extensionForImageMimeType(image.mimeType);
|
|
50
|
+
const filePath = join(this.baseDir, `pi-image-${Date.now()}-${randomUUID()}.${ext}`);
|
|
51
|
+
|
|
52
|
+
writeFileSync(filePath, Buffer.from(image.bytes));
|
|
53
|
+
this.createdFiles.add(filePath);
|
|
54
|
+
|
|
55
|
+
return filePath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async cleanup(): Promise<void> {
|
|
59
|
+
this.cleanupSync();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
cleanupSync(): void {
|
|
63
|
+
for (const filePath of this.createdFiles) {
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(filePath);
|
|
66
|
+
} catch {
|
|
67
|
+
// Best-effort cleanup only.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.createdFiles.clear();
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const entries = readdirSync(this.baseDir);
|
|
75
|
+
if (entries.length === 0) {
|
|
76
|
+
rmSync(this.baseDir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore directory cleanup errors.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type PasteContext = ExtensionContext | ExtensionCommandContext;
|
|
4
|
+
|
|
5
|
+
export interface ClipboardImage {
|
|
6
|
+
bytes: Uint8Array;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ClipboardModule {
|
|
11
|
+
hasImage: () => boolean;
|
|
12
|
+
getImageBinary: () => Promise<Array<number> | Uint8Array>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type PasteImageHandler = (ctx: PasteContext) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
export interface PasteImageCommandHandlers {
|
|
18
|
+
fromClipboard: PasteImageHandler;
|
|
19
|
+
fromRecent: PasteImageHandler;
|
|
20
|
+
}
|