pi-image-tools 1.0.4 → 1.0.6
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 +42 -37
- package/README.md +244 -244
- package/package.json +61 -53
- package/src/clipboard.ts +385 -385
- package/src/commands.ts +79 -79
- package/src/index.ts +252 -252
- package/src/inline-user-preview.ts +354 -354
- package/src/keybindings.ts +21 -21
- package/src/recent-images.ts +508 -508
- package/src/temp-file.ts +82 -82
package/src/recent-images.ts
CHANGED
|
@@ -1,508 +1,508 @@
|
|
|
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 getDefaultLinuxSources(homeDirectory: string): RecentImageSource[] {
|
|
134
|
-
return [
|
|
135
|
-
{
|
|
136
|
-
path: join(homeDirectory, "Pictures", "Screenshots"),
|
|
137
|
-
filterScreenshotNames: false,
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
path: join(homeDirectory, "Pictures"),
|
|
141
|
-
filterScreenshotNames: true,
|
|
142
|
-
},
|
|
143
|
-
{
|
|
144
|
-
path: join(homeDirectory, "Downloads"),
|
|
145
|
-
filterScreenshotNames: true,
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
path: join(homeDirectory, "Desktop"),
|
|
149
|
-
filterScreenshotNames: true,
|
|
150
|
-
},
|
|
151
|
-
];
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function getDefaultMacSources(homeDirectory: string): RecentImageSource[] {
|
|
155
|
-
return [
|
|
156
|
-
{
|
|
157
|
-
path: join(homeDirectory, "Desktop"),
|
|
158
|
-
filterScreenshotNames: true,
|
|
159
|
-
},
|
|
160
|
-
{
|
|
161
|
-
path: join(homeDirectory, "Downloads"),
|
|
162
|
-
filterScreenshotNames: true,
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
path: join(homeDirectory, "Pictures", "Screenshots"),
|
|
166
|
-
filterScreenshotNames: false,
|
|
167
|
-
},
|
|
168
|
-
{
|
|
169
|
-
path: join(homeDirectory, "Pictures"),
|
|
170
|
-
filterScreenshotNames: true,
|
|
171
|
-
},
|
|
172
|
-
];
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function dedupeSources(
|
|
176
|
-
sources: readonly RecentImageSource[],
|
|
177
|
-
platform: NodeJS.Platform,
|
|
178
|
-
): RecentImageSource[] {
|
|
179
|
-
const deduped = new Map<string, RecentImageSource>();
|
|
180
|
-
|
|
181
|
-
for (const source of sources) {
|
|
182
|
-
const key = platform === "win32" ? source.path.toLowerCase() : source.path;
|
|
183
|
-
if (!deduped.has(key)) {
|
|
184
|
-
deduped.set(key, source);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return [...deduped.values()];
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function isLikelyScreenshotName(name: string): boolean {
|
|
192
|
-
return SCREENSHOT_NAME_PATTERNS.some((pattern) => pattern.test(name));
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function toMimeType(fileName: string): string | null {
|
|
196
|
-
const extension = extname(fileName).toLowerCase();
|
|
197
|
-
return EXTENSION_TO_MIME.get(extension) ?? null;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function extensionForMimeType(mimeType: string): string {
|
|
201
|
-
const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
202
|
-
|
|
203
|
-
switch (normalized) {
|
|
204
|
-
case "image/png":
|
|
205
|
-
return "png";
|
|
206
|
-
case "image/jpeg":
|
|
207
|
-
return "jpg";
|
|
208
|
-
case "image/webp":
|
|
209
|
-
return "webp";
|
|
210
|
-
case "image/gif":
|
|
211
|
-
return "gif";
|
|
212
|
-
case "image/bmp":
|
|
213
|
-
return "bmp";
|
|
214
|
-
default:
|
|
215
|
-
return "png";
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function listRecentImagesFromSource(source: RecentImageSource): RecentImageCandidate[] {
|
|
220
|
-
if (!existsSync(source.path)) {
|
|
221
|
-
return [];
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
let names: string[];
|
|
225
|
-
try {
|
|
226
|
-
names = readdirSync(source.path);
|
|
227
|
-
} catch {
|
|
228
|
-
return [];
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const candidates: RecentImageCandidate[] = [];
|
|
232
|
-
|
|
233
|
-
for (const name of names) {
|
|
234
|
-
const mimeType = toMimeType(name);
|
|
235
|
-
if (!mimeType) {
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (source.filterScreenshotNames && !isLikelyScreenshotName(name)) {
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const fullPath = join(source.path, name);
|
|
244
|
-
|
|
245
|
-
let stat;
|
|
246
|
-
try {
|
|
247
|
-
stat = statSync(fullPath);
|
|
248
|
-
} catch {
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (!stat.isFile()) {
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
candidates.push({
|
|
257
|
-
path: fullPath,
|
|
258
|
-
name,
|
|
259
|
-
mimeType,
|
|
260
|
-
modifiedAtMs: stat.mtimeMs,
|
|
261
|
-
sizeBytes: stat.size,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return candidates;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function getPlatformDefaultSources(
|
|
269
|
-
platform: NodeJS.Platform,
|
|
270
|
-
homeDirectory: string,
|
|
271
|
-
): RecentImageSource[] {
|
|
272
|
-
switch (platform) {
|
|
273
|
-
case "win32":
|
|
274
|
-
return getDefaultWindowsSources(homeDirectory);
|
|
275
|
-
case "linux":
|
|
276
|
-
return getDefaultLinuxSources(homeDirectory);
|
|
277
|
-
case "darwin":
|
|
278
|
-
return getDefaultMacSources(homeDirectory);
|
|
279
|
-
default:
|
|
280
|
-
return [];
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function buildSources(options: DiscoverRecentImagesOptions): RecentImageSource[] {
|
|
285
|
-
const platform = options.platform ?? process.platform;
|
|
286
|
-
const homeDirectory = options.homeDirectory ?? homedir();
|
|
287
|
-
const environment = options.environment ?? process.env;
|
|
288
|
-
|
|
289
|
-
const cacheSource: RecentImageSource = {
|
|
290
|
-
path: getRecentImageCacheDirectory(environment),
|
|
291
|
-
filterScreenshotNames: false,
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const configuredPaths = parseConfiguredSources(environment, homeDirectory);
|
|
295
|
-
if (configuredPaths.length > 0) {
|
|
296
|
-
return dedupeSources(
|
|
297
|
-
[
|
|
298
|
-
cacheSource,
|
|
299
|
-
...configuredPaths.map((pathValue) => ({
|
|
300
|
-
path: pathValue,
|
|
301
|
-
filterScreenshotNames: false,
|
|
302
|
-
})),
|
|
303
|
-
],
|
|
304
|
-
platform,
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
return dedupeSources([cacheSource, ...getPlatformDefaultSources(platform, homeDirectory)], platform);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function dedupeCandidates(
|
|
312
|
-
candidates: readonly RecentImageCandidate[],
|
|
313
|
-
platform: NodeJS.Platform,
|
|
314
|
-
): RecentImageCandidate[] {
|
|
315
|
-
const deduped = new Map<string, RecentImageCandidate>();
|
|
316
|
-
|
|
317
|
-
for (const candidate of candidates) {
|
|
318
|
-
const key = platform === "win32" ? candidate.path.toLowerCase() : candidate.path;
|
|
319
|
-
|
|
320
|
-
const existing = deduped.get(key);
|
|
321
|
-
if (!existing || candidate.modifiedAtMs > existing.modifiedAtMs) {
|
|
322
|
-
deduped.set(key, candidate);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return [...deduped.values()];
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export function discoverRecentImages(options: DiscoverRecentImagesOptions = {}): RecentImageDiscovery {
|
|
330
|
-
const platform = options.platform ?? process.platform;
|
|
331
|
-
const sources = buildSources(options);
|
|
332
|
-
const searchedDirectories = sources.map((source) => source.path);
|
|
333
|
-
const maxItems = options.maxItems ?? DEFAULT_MAX_RECENT_IMAGES;
|
|
334
|
-
|
|
335
|
-
if (sources.length === 0) {
|
|
336
|
-
return {
|
|
337
|
-
candidates: [],
|
|
338
|
-
searchedDirectories,
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const allCandidates = sources.flatMap((source) => listRecentImagesFromSource(source));
|
|
343
|
-
const sorted = dedupeCandidates(allCandidates, platform).sort(
|
|
344
|
-
(left, right) => right.modifiedAtMs - left.modifiedAtMs,
|
|
345
|
-
);
|
|
346
|
-
|
|
347
|
-
return {
|
|
348
|
-
candidates: sorted.slice(0, Math.max(1, maxItems)),
|
|
349
|
-
searchedDirectories,
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function pruneCacheDirectory(cacheDirectory: string, maxCacheFiles: number): void {
|
|
354
|
-
if (maxCacheFiles < 1 || !existsSync(cacheDirectory)) {
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
let entries: string[];
|
|
359
|
-
try {
|
|
360
|
-
entries = readdirSync(cacheDirectory);
|
|
361
|
-
} catch {
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const imageFiles = entries
|
|
366
|
-
.map((name) => {
|
|
367
|
-
const fullPath = join(cacheDirectory, name);
|
|
368
|
-
|
|
369
|
-
try {
|
|
370
|
-
const stat = statSync(fullPath);
|
|
371
|
-
if (!stat.isFile()) {
|
|
372
|
-
return null;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (!toMimeType(name)) {
|
|
376
|
-
return null;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return { fullPath, modifiedAtMs: stat.mtimeMs };
|
|
380
|
-
} catch {
|
|
381
|
-
return null;
|
|
382
|
-
}
|
|
383
|
-
})
|
|
384
|
-
.filter((entry): entry is { fullPath: string; modifiedAtMs: number } => entry !== null)
|
|
385
|
-
.sort((left, right) => right.modifiedAtMs - left.modifiedAtMs);
|
|
386
|
-
|
|
387
|
-
if (imageFiles.length <= maxCacheFiles) {
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
for (const staleFile of imageFiles.slice(maxCacheFiles)) {
|
|
392
|
-
try {
|
|
393
|
-
unlinkSync(staleFile.fullPath);
|
|
394
|
-
} catch {
|
|
395
|
-
// Intentionally ignore cache cleanup failures.
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
export function persistImageToRecentCache(
|
|
401
|
-
image: ClipboardImage,
|
|
402
|
-
options: PersistRecentImageOptions = {},
|
|
403
|
-
): string {
|
|
404
|
-
if (!image.bytes || image.bytes.length === 0) {
|
|
405
|
-
throw new Error("Cannot cache an empty image payload.");
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const environment = options.environment ?? process.env;
|
|
409
|
-
const cacheDirectory = getRecentImageCacheDirectory(environment);
|
|
410
|
-
const extension = extensionForMimeType(image.mimeType);
|
|
411
|
-
|
|
412
|
-
mkdirSync(cacheDirectory, { recursive: true });
|
|
413
|
-
|
|
414
|
-
const filePath = join(cacheDirectory, `pi-recent-${Date.now()}-${randomUUID()}.${extension}`);
|
|
415
|
-
writeFileSync(filePath, Buffer.from(image.bytes));
|
|
416
|
-
|
|
417
|
-
const maxCacheFiles = options.maxCacheFiles ?? DEFAULT_MAX_CACHE_FILES;
|
|
418
|
-
pruneCacheDirectory(cacheDirectory, maxCacheFiles);
|
|
419
|
-
|
|
420
|
-
return filePath;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function formatRelativeAge(modifiedAtMs: number, nowMs: number): string {
|
|
424
|
-
const deltaMs = Math.max(0, nowMs - modifiedAtMs);
|
|
425
|
-
const deltaMinutes = Math.floor(deltaMs / 60_000);
|
|
426
|
-
|
|
427
|
-
if (deltaMinutes < 1) {
|
|
428
|
-
return "just now";
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (deltaMinutes < 60) {
|
|
432
|
-
return `${deltaMinutes}m ago`;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const deltaHours = Math.floor(deltaMinutes / 60);
|
|
436
|
-
if (deltaHours < 24) {
|
|
437
|
-
return `${deltaHours}h ago`;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const deltaDays = Math.floor(deltaHours / 24);
|
|
441
|
-
if (deltaDays < 30) {
|
|
442
|
-
return `${deltaDays}d ago`;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const deltaMonths = Math.floor(deltaDays / 30);
|
|
446
|
-
if (deltaMonths < 12) {
|
|
447
|
-
return `${deltaMonths}mo ago`;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const deltaYears = Math.floor(deltaMonths / 12);
|
|
451
|
-
return `${deltaYears}y ago`;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function formatSize(sizeBytes: number): string {
|
|
455
|
-
if (sizeBytes < 1024) {
|
|
456
|
-
return `${sizeBytes} B`;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const units = ["KB", "MB", "GB"] as const;
|
|
460
|
-
let value = sizeBytes / 1024;
|
|
461
|
-
let unitIndex = 0;
|
|
462
|
-
|
|
463
|
-
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
464
|
-
value /= 1024;
|
|
465
|
-
unitIndex += 1;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function detectPathSeparator(pathValue: string): string {
|
|
472
|
-
return pathValue.includes("\\") ? "\\" : "/";
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function abbreviatePath(pathValue: string, maxChars: number): string {
|
|
476
|
-
if (pathValue.length <= maxChars) {
|
|
477
|
-
return pathValue;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const fileName = basename(pathValue);
|
|
481
|
-
if (fileName.length + 4 >= maxChars) {
|
|
482
|
-
return `...${fileName.slice(-(maxChars - 3))}`;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const separator = detectPathSeparator(pathValue);
|
|
486
|
-
const headLength = maxChars - fileName.length - 4;
|
|
487
|
-
return `${pathValue.slice(0, headLength)}...${separator}${fileName}`;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
export function formatRecentImageLabel(candidate: RecentImageCandidate, nowMs = Date.now()): string {
|
|
491
|
-
const age = formatRelativeAge(candidate.modifiedAtMs, nowMs);
|
|
492
|
-
const size = formatSize(candidate.sizeBytes);
|
|
493
|
-
const shortPath = abbreviatePath(candidate.path, 64);
|
|
494
|
-
|
|
495
|
-
return `${candidate.name} • ${age} • ${size} • ${shortPath}`;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
export function loadRecentImage(candidate: RecentImageCandidate): ClipboardImage {
|
|
499
|
-
const raw = readFileSync(candidate.path);
|
|
500
|
-
if (raw.length === 0) {
|
|
501
|
-
throw new Error(`File is empty: ${candidate.path}`);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
return {
|
|
505
|
-
bytes: new Uint8Array(raw),
|
|
506
|
-
mimeType: candidate.mimeType,
|
|
507
|
-
};
|
|
508
|
-
}
|
|
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 getDefaultLinuxSources(homeDirectory: string): RecentImageSource[] {
|
|
134
|
+
return [
|
|
135
|
+
{
|
|
136
|
+
path: join(homeDirectory, "Pictures", "Screenshots"),
|
|
137
|
+
filterScreenshotNames: false,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
path: join(homeDirectory, "Pictures"),
|
|
141
|
+
filterScreenshotNames: true,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
path: join(homeDirectory, "Downloads"),
|
|
145
|
+
filterScreenshotNames: true,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
path: join(homeDirectory, "Desktop"),
|
|
149
|
+
filterScreenshotNames: true,
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getDefaultMacSources(homeDirectory: string): RecentImageSource[] {
|
|
155
|
+
return [
|
|
156
|
+
{
|
|
157
|
+
path: join(homeDirectory, "Desktop"),
|
|
158
|
+
filterScreenshotNames: true,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
path: join(homeDirectory, "Downloads"),
|
|
162
|
+
filterScreenshotNames: true,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
path: join(homeDirectory, "Pictures", "Screenshots"),
|
|
166
|
+
filterScreenshotNames: false,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
path: join(homeDirectory, "Pictures"),
|
|
170
|
+
filterScreenshotNames: true,
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function dedupeSources(
|
|
176
|
+
sources: readonly RecentImageSource[],
|
|
177
|
+
platform: NodeJS.Platform,
|
|
178
|
+
): RecentImageSource[] {
|
|
179
|
+
const deduped = new Map<string, RecentImageSource>();
|
|
180
|
+
|
|
181
|
+
for (const source of sources) {
|
|
182
|
+
const key = platform === "win32" ? source.path.toLowerCase() : source.path;
|
|
183
|
+
if (!deduped.has(key)) {
|
|
184
|
+
deduped.set(key, source);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return [...deduped.values()];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isLikelyScreenshotName(name: string): boolean {
|
|
192
|
+
return SCREENSHOT_NAME_PATTERNS.some((pattern) => pattern.test(name));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toMimeType(fileName: string): string | null {
|
|
196
|
+
const extension = extname(fileName).toLowerCase();
|
|
197
|
+
return EXTENSION_TO_MIME.get(extension) ?? null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extensionForMimeType(mimeType: string): string {
|
|
201
|
+
const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
202
|
+
|
|
203
|
+
switch (normalized) {
|
|
204
|
+
case "image/png":
|
|
205
|
+
return "png";
|
|
206
|
+
case "image/jpeg":
|
|
207
|
+
return "jpg";
|
|
208
|
+
case "image/webp":
|
|
209
|
+
return "webp";
|
|
210
|
+
case "image/gif":
|
|
211
|
+
return "gif";
|
|
212
|
+
case "image/bmp":
|
|
213
|
+
return "bmp";
|
|
214
|
+
default:
|
|
215
|
+
return "png";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function listRecentImagesFromSource(source: RecentImageSource): RecentImageCandidate[] {
|
|
220
|
+
if (!existsSync(source.path)) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let names: string[];
|
|
225
|
+
try {
|
|
226
|
+
names = readdirSync(source.path);
|
|
227
|
+
} catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const candidates: RecentImageCandidate[] = [];
|
|
232
|
+
|
|
233
|
+
for (const name of names) {
|
|
234
|
+
const mimeType = toMimeType(name);
|
|
235
|
+
if (!mimeType) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (source.filterScreenshotNames && !isLikelyScreenshotName(name)) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const fullPath = join(source.path, name);
|
|
244
|
+
|
|
245
|
+
let stat;
|
|
246
|
+
try {
|
|
247
|
+
stat = statSync(fullPath);
|
|
248
|
+
} catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!stat.isFile()) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
candidates.push({
|
|
257
|
+
path: fullPath,
|
|
258
|
+
name,
|
|
259
|
+
mimeType,
|
|
260
|
+
modifiedAtMs: stat.mtimeMs,
|
|
261
|
+
sizeBytes: stat.size,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return candidates;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getPlatformDefaultSources(
|
|
269
|
+
platform: NodeJS.Platform,
|
|
270
|
+
homeDirectory: string,
|
|
271
|
+
): RecentImageSource[] {
|
|
272
|
+
switch (platform) {
|
|
273
|
+
case "win32":
|
|
274
|
+
return getDefaultWindowsSources(homeDirectory);
|
|
275
|
+
case "linux":
|
|
276
|
+
return getDefaultLinuxSources(homeDirectory);
|
|
277
|
+
case "darwin":
|
|
278
|
+
return getDefaultMacSources(homeDirectory);
|
|
279
|
+
default:
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildSources(options: DiscoverRecentImagesOptions): RecentImageSource[] {
|
|
285
|
+
const platform = options.platform ?? process.platform;
|
|
286
|
+
const homeDirectory = options.homeDirectory ?? homedir();
|
|
287
|
+
const environment = options.environment ?? process.env;
|
|
288
|
+
|
|
289
|
+
const cacheSource: RecentImageSource = {
|
|
290
|
+
path: getRecentImageCacheDirectory(environment),
|
|
291
|
+
filterScreenshotNames: false,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const configuredPaths = parseConfiguredSources(environment, homeDirectory);
|
|
295
|
+
if (configuredPaths.length > 0) {
|
|
296
|
+
return dedupeSources(
|
|
297
|
+
[
|
|
298
|
+
cacheSource,
|
|
299
|
+
...configuredPaths.map((pathValue) => ({
|
|
300
|
+
path: pathValue,
|
|
301
|
+
filterScreenshotNames: false,
|
|
302
|
+
})),
|
|
303
|
+
],
|
|
304
|
+
platform,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return dedupeSources([cacheSource, ...getPlatformDefaultSources(platform, homeDirectory)], platform);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function dedupeCandidates(
|
|
312
|
+
candidates: readonly RecentImageCandidate[],
|
|
313
|
+
platform: NodeJS.Platform,
|
|
314
|
+
): RecentImageCandidate[] {
|
|
315
|
+
const deduped = new Map<string, RecentImageCandidate>();
|
|
316
|
+
|
|
317
|
+
for (const candidate of candidates) {
|
|
318
|
+
const key = platform === "win32" ? candidate.path.toLowerCase() : candidate.path;
|
|
319
|
+
|
|
320
|
+
const existing = deduped.get(key);
|
|
321
|
+
if (!existing || candidate.modifiedAtMs > existing.modifiedAtMs) {
|
|
322
|
+
deduped.set(key, candidate);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return [...deduped.values()];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function discoverRecentImages(options: DiscoverRecentImagesOptions = {}): RecentImageDiscovery {
|
|
330
|
+
const platform = options.platform ?? process.platform;
|
|
331
|
+
const sources = buildSources(options);
|
|
332
|
+
const searchedDirectories = sources.map((source) => source.path);
|
|
333
|
+
const maxItems = options.maxItems ?? DEFAULT_MAX_RECENT_IMAGES;
|
|
334
|
+
|
|
335
|
+
if (sources.length === 0) {
|
|
336
|
+
return {
|
|
337
|
+
candidates: [],
|
|
338
|
+
searchedDirectories,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const allCandidates = sources.flatMap((source) => listRecentImagesFromSource(source));
|
|
343
|
+
const sorted = dedupeCandidates(allCandidates, platform).sort(
|
|
344
|
+
(left, right) => right.modifiedAtMs - left.modifiedAtMs,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
candidates: sorted.slice(0, Math.max(1, maxItems)),
|
|
349
|
+
searchedDirectories,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function pruneCacheDirectory(cacheDirectory: string, maxCacheFiles: number): void {
|
|
354
|
+
if (maxCacheFiles < 1 || !existsSync(cacheDirectory)) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let entries: string[];
|
|
359
|
+
try {
|
|
360
|
+
entries = readdirSync(cacheDirectory);
|
|
361
|
+
} catch {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const imageFiles = entries
|
|
366
|
+
.map((name) => {
|
|
367
|
+
const fullPath = join(cacheDirectory, name);
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const stat = statSync(fullPath);
|
|
371
|
+
if (!stat.isFile()) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!toMimeType(name)) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return { fullPath, modifiedAtMs: stat.mtimeMs };
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
.filter((entry): entry is { fullPath: string; modifiedAtMs: number } => entry !== null)
|
|
385
|
+
.sort((left, right) => right.modifiedAtMs - left.modifiedAtMs);
|
|
386
|
+
|
|
387
|
+
if (imageFiles.length <= maxCacheFiles) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const staleFile of imageFiles.slice(maxCacheFiles)) {
|
|
392
|
+
try {
|
|
393
|
+
unlinkSync(staleFile.fullPath);
|
|
394
|
+
} catch {
|
|
395
|
+
// Intentionally ignore cache cleanup failures.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function persistImageToRecentCache(
|
|
401
|
+
image: ClipboardImage,
|
|
402
|
+
options: PersistRecentImageOptions = {},
|
|
403
|
+
): string {
|
|
404
|
+
if (!image.bytes || image.bytes.length === 0) {
|
|
405
|
+
throw new Error("Cannot cache an empty image payload.");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const environment = options.environment ?? process.env;
|
|
409
|
+
const cacheDirectory = getRecentImageCacheDirectory(environment);
|
|
410
|
+
const extension = extensionForMimeType(image.mimeType);
|
|
411
|
+
|
|
412
|
+
mkdirSync(cacheDirectory, { recursive: true });
|
|
413
|
+
|
|
414
|
+
const filePath = join(cacheDirectory, `pi-recent-${Date.now()}-${randomUUID()}.${extension}`);
|
|
415
|
+
writeFileSync(filePath, Buffer.from(image.bytes));
|
|
416
|
+
|
|
417
|
+
const maxCacheFiles = options.maxCacheFiles ?? DEFAULT_MAX_CACHE_FILES;
|
|
418
|
+
pruneCacheDirectory(cacheDirectory, maxCacheFiles);
|
|
419
|
+
|
|
420
|
+
return filePath;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function formatRelativeAge(modifiedAtMs: number, nowMs: number): string {
|
|
424
|
+
const deltaMs = Math.max(0, nowMs - modifiedAtMs);
|
|
425
|
+
const deltaMinutes = Math.floor(deltaMs / 60_000);
|
|
426
|
+
|
|
427
|
+
if (deltaMinutes < 1) {
|
|
428
|
+
return "just now";
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (deltaMinutes < 60) {
|
|
432
|
+
return `${deltaMinutes}m ago`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const deltaHours = Math.floor(deltaMinutes / 60);
|
|
436
|
+
if (deltaHours < 24) {
|
|
437
|
+
return `${deltaHours}h ago`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const deltaDays = Math.floor(deltaHours / 24);
|
|
441
|
+
if (deltaDays < 30) {
|
|
442
|
+
return `${deltaDays}d ago`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const deltaMonths = Math.floor(deltaDays / 30);
|
|
446
|
+
if (deltaMonths < 12) {
|
|
447
|
+
return `${deltaMonths}mo ago`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const deltaYears = Math.floor(deltaMonths / 12);
|
|
451
|
+
return `${deltaYears}y ago`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function formatSize(sizeBytes: number): string {
|
|
455
|
+
if (sizeBytes < 1024) {
|
|
456
|
+
return `${sizeBytes} B`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const units = ["KB", "MB", "GB"] as const;
|
|
460
|
+
let value = sizeBytes / 1024;
|
|
461
|
+
let unitIndex = 0;
|
|
462
|
+
|
|
463
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
464
|
+
value /= 1024;
|
|
465
|
+
unitIndex += 1;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function detectPathSeparator(pathValue: string): string {
|
|
472
|
+
return pathValue.includes("\\") ? "\\" : "/";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function abbreviatePath(pathValue: string, maxChars: number): string {
|
|
476
|
+
if (pathValue.length <= maxChars) {
|
|
477
|
+
return pathValue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const fileName = basename(pathValue);
|
|
481
|
+
if (fileName.length + 4 >= maxChars) {
|
|
482
|
+
return `...${fileName.slice(-(maxChars - 3))}`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const separator = detectPathSeparator(pathValue);
|
|
486
|
+
const headLength = maxChars - fileName.length - 4;
|
|
487
|
+
return `${pathValue.slice(0, headLength)}...${separator}${fileName}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function formatRecentImageLabel(candidate: RecentImageCandidate, nowMs = Date.now()): string {
|
|
491
|
+
const age = formatRelativeAge(candidate.modifiedAtMs, nowMs);
|
|
492
|
+
const size = formatSize(candidate.sizeBytes);
|
|
493
|
+
const shortPath = abbreviatePath(candidate.path, 64);
|
|
494
|
+
|
|
495
|
+
return `${candidate.name} • ${age} • ${size} • ${shortPath}`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function loadRecentImage(candidate: RecentImageCandidate): ClipboardImage {
|
|
499
|
+
const raw = readFileSync(candidate.path);
|
|
500
|
+
if (raw.length === 0) {
|
|
501
|
+
throw new Error(`File is empty: ${candidate.path}`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
bytes: new Uint8Array(raw),
|
|
506
|
+
mimeType: candidate.mimeType,
|
|
507
|
+
};
|
|
508
|
+
}
|