pi-image-tools 1.0.10 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,25 +1,26 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [Unreleased]
9
-
10
- ## [1.0.10] - 2026-04-22
3
+ ## [1.1.0] - 2026-05-03
11
4
 
12
5
  ### Added
13
- - Added preview width resolution from Pi terminal settings so inline and recent-image previews can honor `terminal.imageWidthCells`.
6
+ - Added centralized image MIME, image-size, PowerShell, and error utilities used by clipboard, preview, attachment, and recent-image flows.
7
+ - Added size-limit enforcement for image attachments, recent-cache writes, recent-image loads, and Sixel preview conversion through `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES`.
14
8
 
15
9
  ### Changed
16
- - Updated recent image selection previews to resolve terminal settings from the active session working directory before rendering.
17
- - Windows Sixel fallback now activates only when native terminal image support is unavailable, with environment toggles to force or disable Sixel rendering.
18
- - Clarified README global install and config paths when `PI_CODING_AGENT_DIR` is set.
19
- - Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.68.1.
10
+ - Reworked recent-image caching to use an extension-owned cache directory with safe pruning that preserves user files.
11
+ - Reworked Sixel preview rendering to use an existing PowerShell `Sixel` module only, normalize converter output into complete terminal sequences, and fall back to native previews with actionable warnings.
12
+ - Reworked terminal image width resolution to honor Pi project/global `terminal.imageWidthCells` settings with a documented default fallback.
20
13
 
21
14
  ### Fixed
22
- - Ensured generated Sixel output is emitted as a complete DCS sequence and preserved inline image protocol rows during preview width fitting.
15
+ - Prevented extension debug logging from writing terminal output; debug events now remain file-based and disabled by default.
16
+ - Preserved Sixel, Kitty, and iTerm inline image protocol rows during preview width fitting.
17
+
18
+ ## [1.0.11] - 2026-04-25
19
+
20
+ ### Changed
21
+ - Avoid Pi's built-in image paste shortcut by default while preserving the previous primary shortcut when users disable or rebind `app.clipboard.pasteImage` (thanks to @danielcherubini for reporting this in PR #3).
22
+ - Replaced the placeholder `enabled` config with validated `debug`, explicit `shortcuts.pasteImage`, configurable built-in conflict avoidance, and built-in warning suppression settings.
23
+ - Removed obsolete packaged README image assets now that the README uses a GitHub-hosted image.
23
24
 
24
25
  ## [1.0.9] - 2026-04-01
25
26
 
@@ -74,12 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
74
75
  - Rewrote README.md with professional documentation standards
75
76
  - Added comprehensive feature documentation, configuration reference, and usage examples
76
77
 
77
- ## [1.0.1] - 2026-03-04
78
-
79
- - Included `asset/` in the npm package whitelist so README image assets ship in the tarball.
80
-
81
- ## [1.0.0] - 2026-03-02
78
+ ## 1.0.0
82
79
 
83
80
  - Standardized repository layout to `src/` + root shim entrypoint.
84
81
  - Added TypeScript/Bundler project config, package metadata, and publish whitelist.
85
- - Added standard docs, license, and config template/runtime placeholder files.
82
+ - Added standard docs, license, and initial runtime config files.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # 🖼️ pi-image-tools
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/pi-image-tools?style=flat-square)](https://www.npmjs.com/package/pi-image-tools) [![License](https://img.shields.io/github/license/MasuRii/pi-image-tools?style=flat-square)](LICENSE)
4
+
3
5
  Image attachment and preview extension for the **Pi coding agent**.
4
6
 
5
7
  `pi-image-tools` lets you attach clipboard images or recent screenshots to your next user message, then preview them inline in the TUI before the message is sent.
@@ -93,8 +95,16 @@ Selecting an entry queues that image for your next message.
93
95
 
94
96
  | Platform | Shortcuts |
95
97
  |----------|-----------|
96
- | Windows | `Alt+V`, `Ctrl+Alt+V` |
97
- | Linux / macOS | `Ctrl+V`, `Alt+V`, `Ctrl+Alt+V` |
98
+ | Windows | `Ctrl+Alt+V`; `Alt+V` when Pi's built-in `app.clipboard.pasteImage` shortcut is disabled or rebound |
99
+ | Linux / macOS | `Alt+V`, `Ctrl+Alt+V`; `Ctrl+V` when Pi's built-in `app.clipboard.pasteImage` shortcut is disabled or rebound |
100
+
101
+ Pi's built-in image paste shortcut is not overridden by default. To keep the previous primary shortcut behavior without startup conflict warnings, disable the built-in binding manually in `~/.pi/agent/keybindings.json`:
102
+
103
+ ```json
104
+ {
105
+ "app.clipboard.pasteImage": []
106
+ }
107
+ ```
98
108
 
99
109
  ## Configuration
100
110
 
@@ -104,6 +114,7 @@ Selecting an entry queues that image for your next message.
104
114
  |----------|-------------|---------|
105
115
  | `PI_IMAGE_TOOLS_RECENT_DIRS` | Semicolon-separated directories to search for recent images | Platform defaults listed below |
106
116
  | `PI_IMAGE_TOOLS_RECENT_CACHE_DIR` | Custom cache directory for clipboard-pasted images | OS temp dir + `pi-image-tools/recent-cache` |
117
+ | `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES` | Maximum accepted image payload size before attachment, recent-cache writes, and preview conversion | `20971520` (20 MB) |
107
118
 
108
119
  Example:
109
120
 
@@ -124,12 +135,85 @@ Starter template:
124
135
 
125
136
  ```json
126
137
  {
127
- "enabled": true
138
+ "debug": false,
139
+ "shortcuts": {
140
+ "pasteImage": ["ctrl+alt+v"],
141
+ "avoidBuiltinConflicts": true,
142
+ "suppressBuiltinConflictWarnings": true
143
+ }
128
144
  }
129
145
  ```
130
146
 
131
147
  See `config/config.example.json` for the same template.
132
148
 
149
+ #### Debug logging
150
+
151
+ Debug logging is disabled by default. Set `debug` to `true` to append debug events to `debug/debug.log` inside the extension directory:
152
+
153
+ ```json
154
+ {
155
+ "debug": true,
156
+ "shortcuts": {
157
+ "pasteImage": ["ctrl+alt+v"],
158
+ "avoidBuiltinConflicts": true,
159
+ "suppressBuiltinConflictWarnings": true
160
+ }
161
+ }
162
+ ```
163
+
164
+ When `debug` is `false` or omitted, no debug log file is opened or written.
165
+
166
+ #### Custom shortcuts
167
+
168
+ Set `shortcuts.pasteImage` to choose the exact shortcuts registered by `pi-image-tools`. The value can be a single shortcut string or an array of shortcut strings. Add multiple entries when you want several key combinations to run the same paste-image action:
169
+
170
+ ```json
171
+ {
172
+ "debug": false,
173
+ "shortcuts": {
174
+ "pasteImage": ["ctrl+alt+v", "alt+v", "ctrl+shift+v"],
175
+ "avoidBuiltinConflicts": true,
176
+ "suppressBuiltinConflictWarnings": true
177
+ }
178
+ }
179
+ ```
180
+
181
+ A single shortcut is also valid:
182
+
183
+ ```json
184
+ {
185
+ "debug": false,
186
+ "shortcuts": {
187
+ "pasteImage": "ctrl+alt+v",
188
+ "avoidBuiltinConflicts": true,
189
+ "suppressBuiltinConflictWarnings": true
190
+ }
191
+ }
192
+ ```
193
+
194
+ Config changes are applied the next time the extension loads, so restart Pi or reload extensions after editing `config.json`.
195
+
196
+ Not every terminal can transmit every key combination to Pi. Triple-modifier shortcuts such as `ctrl+shift+alt+v` may be intercepted by the OS, terminal, shell, SSH, or tmux before Pi can see them. If a configured shortcut does not work, try a simpler shortcut such as `ctrl+alt+v`, `ctrl+shift+v`, `alt+p`, `f8`, or another function key.
197
+
198
+ Keep `shortcuts.avoidBuiltinConflicts` set to `true` to skip configured paste-image shortcuts that overlap any effective Pi built-in shortcut. Keep `shortcuts.suppressBuiltinConflictWarnings` set to `true` when your goal is specifically to remove Pi's startup conflict warning noise. Both options use the same safe mechanism: `pi-image-tools` does not register the overlapping shortcut, so Pi has nothing to warn about. For example, `ctrl+p` is skipped because Pi uses it for built-in model/session actions.
199
+
200
+ If both options are `false`, Pi handles conflicts itself. Non-reserved built-in conflicts may be taken over by `pi-image-tools`, but Pi can still print a startup warning. Reserved built-in shortcuts cannot be stolen through the extension shortcut API; Pi skips those registrations before `pi-image-tools` can handle them. To use a reserved Pi shortcut, rebind or disable the relevant built-in action in `~/.pi/agent/keybindings.json`, then restart Pi or reload extensions.
201
+
202
+ Use an empty array to disable the extension's paste-image shortcuts while keeping `/paste-image` available:
203
+
204
+ ```json
205
+ {
206
+ "debug": false,
207
+ "shortcuts": {
208
+ "pasteImage": [],
209
+ "avoidBuiltinConflicts": true,
210
+ "suppressBuiltinConflictWarnings": true
211
+ }
212
+ }
213
+ ```
214
+
215
+ If `shortcuts.pasteImage` is omitted, `pi-image-tools` uses non-conflicting defaults and automatically restores the previous primary shortcut when Pi's built-in `app.clipboard.pasteImage` binding is disabled or rebound.
216
+
133
217
  ## Default recent-image search locations
134
218
 
135
219
  ### Windows
@@ -171,8 +255,10 @@ When you queue one or more images, the extension renders an inline preview insid
171
255
 
172
256
  Preview behavior:
173
257
  - up to **3 images** are previewed per message
174
- - Sixel rendering is attempted on Windows when available
258
+ - Sixel rendering is attempted on Windows when the PowerShell `Sixel` module is already installed
259
+ - no PowerShell modules are installed automatically at runtime
175
260
  - native TUI image rendering is used as the fallback
261
+ - image payloads over `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES` are rejected before attachment, recent-cache writes, or Sixel conversion
176
262
  - inline width fitting now preserves Sixel, Kitty, and iTerm image protocol rows instead of truncating them like plain text
177
263
 
178
264
  ### Clipboard readers
@@ -200,16 +286,21 @@ pi-image-tools/
200
286
  │ ├── index.ts # Extension bootstrap and message flow
201
287
  │ ├── commands.ts # /paste-image command registration
202
288
  │ ├── clipboard.ts # Cross-platform clipboard image reading
289
+ │ ├── config.ts # Runtime config loading and validation
290
+ │ ├── debug-logger.ts # File-based debug logging
291
+ │ ├── errors.ts # Shared error normalization
292
+ │ ├── image-mime.ts # Shared image MIME and extension mapping
293
+ │ ├── image-size.ts # Shared image byte-size limits
203
294
  │ ├── recent-images.ts # Recent image discovery and cache management
204
295
  │ ├── image-preview.ts # Preview building and Sixel/native rendering
205
296
  │ ├── inline-user-preview.ts # Inline preview patching for user messages
206
297
  │ ├── keybindings.ts # Keyboard shortcut registration
207
- │ ├── temp-file.ts # Temporary file management and cleanup
298
+ │ ├── powershell.ts # Shared PowerShell command runner
299
+ │ ├── sixel-protocol.ts # Sixel protocol normalization and render lines
300
+ │ ├── terminal-image-width.ts # Terminal image width settings resolution
208
301
  │ └── types.ts # Shared TypeScript types
209
- ├── config/
210
- └── config.example.json # Starter runtime config
211
- └── asset/
212
- └── pi-image-tools.png # README preview image
302
+ └── config/
303
+ └── config.example.json # Starter runtime config
213
304
  ```
214
305
 
215
306
  ## Troubleshooting
@@ -1,3 +1,8 @@
1
- {
2
- "enabled": true
3
- }
1
+ {
2
+ "debug": false,
3
+ "shortcuts": {
4
+ "pasteImage": ["ctrl+alt+v"],
5
+ "avoidBuiltinConflicts": true,
6
+ "suppressBuiltinConflictWarnings": true
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-image-tools",
3
- "version": "1.0.10",
3
+ "version": "1.1.0",
4
4
  "description": "Image attachment and rendering extension for Pi TUI",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -11,7 +11,6 @@
11
11
  "index.ts",
12
12
  "src",
13
13
  "config/config.example.json",
14
- "asset",
15
14
  "README.md",
16
15
  "CHANGELOG.md",
17
16
  "LICENSE"
@@ -58,10 +57,10 @@
58
57
  ]
59
58
  },
60
59
  "peerDependencies": {
61
- "@mariozechner/pi-coding-agent": "^0.68.1",
62
- "@mariozechner/pi-tui": "^0.68.1"
60
+ "@mariozechner/pi-coding-agent": "^0.72.0",
61
+ "@mariozechner/pi-tui": "^0.72.0"
63
62
  },
64
63
  "optionalDependencies": {
65
- "@mariozechner/clipboard": "^0.3.2"
64
+ "@mariozechner/clipboard": "^0.3.5"
66
65
  }
67
66
  }
package/src/clipboard.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
3
 
4
+ import { isErrnoException } from "./errors.js";
5
+ import { normalizeMimeType, selectPreferredImageMimeType, SUPPORTED_IMAGE_MIME_TYPES } from "./image-mime.js";
6
+ import { runPowerShellCommand } from "./powershell.js";
4
7
  import type { ClipboardImage, ClipboardModule } from "./types.js";
5
8
 
6
9
  const require = createRequire(import.meta.url);
@@ -8,14 +11,6 @@ const require = createRequire(import.meta.url);
8
11
  const LIST_TYPES_TIMEOUT_MS = 1000;
9
12
  const READ_TIMEOUT_MS = 5000;
10
13
  const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
11
- const SUPPORTED_IMAGE_MIME_TYPES = [
12
- "image/png",
13
- "image/jpeg",
14
- "image/webp",
15
- "image/gif",
16
- "image/bmp",
17
- ] as const;
18
-
19
14
  let cachedClipboardModule: ClipboardModule | null | undefined;
20
15
 
21
16
  interface CommandResult {
@@ -29,10 +24,6 @@ interface ClipboardReadResult {
29
24
  image: ClipboardImage | null;
30
25
  }
31
26
 
32
- function isErrnoException(error: Error): error is NodeJS.ErrnoException {
33
- return "code" in error;
34
- }
35
-
36
27
  function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
37
28
  return platform !== "linux" || Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY);
38
29
  }
@@ -41,27 +32,6 @@ function isWaylandSession(environment: NodeJS.ProcessEnv): boolean {
41
32
  return Boolean(environment.WAYLAND_DISPLAY) || environment.XDG_SESSION_TYPE === "wayland";
42
33
  }
43
34
 
44
- function normalizeMimeType(mimeType: string): string {
45
- return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
46
- }
47
-
48
- function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
49
- const normalized = mimeTypes
50
- .map((mimeType) => mimeType.trim())
51
- .filter((mimeType) => mimeType.length > 0)
52
- .map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
53
-
54
- for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
55
- const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
56
- if (match) {
57
- return match.raw;
58
- }
59
- }
60
-
61
- const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
62
- return firstImage?.raw ?? null;
63
- }
64
-
65
35
  function loadClipboardModule(
66
36
  platform: NodeJS.Platform = process.platform,
67
37
  environment: NodeJS.ProcessEnv = process.env,
@@ -141,10 +111,6 @@ function runCommand(
141
111
  };
142
112
  }
143
113
 
144
- function encodePowerShell(script: string): string {
145
- return Buffer.from(script, "utf16le").toString("base64");
146
- }
147
-
148
114
  function readClipboardImageViaPowerShell(): ClipboardReadResult {
149
115
  const script = `
150
116
  $ErrorActionPreference = 'Stop'
@@ -170,33 +136,18 @@ try {
170
136
  }
171
137
  `;
172
138
 
173
- const result = spawnSync(
174
- "powershell.exe",
175
- [
176
- "-NoProfile",
177
- "-NonInteractive",
178
- "-ExecutionPolicy",
179
- "Bypass",
180
- "-STA",
181
- "-EncodedCommand",
182
- encodePowerShell(script),
183
- ],
184
- {
185
- encoding: "utf8",
186
- timeout: READ_TIMEOUT_MS,
187
- maxBuffer: MAX_BUFFER_BYTES,
188
- windowsHide: true,
189
- },
190
- );
139
+ const result = runPowerShellCommand(script, {
140
+ encoded: true,
141
+ sta: true,
142
+ timeout: READ_TIMEOUT_MS,
143
+ maxBuffer: MAX_BUFFER_BYTES,
144
+ });
191
145
 
192
- if (result.error) {
193
- return {
194
- available: !isErrnoException(result.error) || result.error.code !== "ENOENT",
195
- image: null,
196
- };
146
+ if (result.missingCommand) {
147
+ return { available: false, image: null };
197
148
  }
198
149
 
199
- if (result.status !== 0) {
150
+ if (!result.ok) {
200
151
  return { available: true, image: null };
201
152
  }
202
153
 
package/src/config.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import type { KeyId } from "@mariozechner/pi-tui";
6
+
7
+ const CONFIG_FILE_NAME = "config.json";
8
+ const EXTENSION_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
+
10
+ export interface ImageToolsShortcutConfig {
11
+ avoidBuiltinConflicts: boolean;
12
+ suppressBuiltinConflictWarnings: boolean;
13
+ pasteImage?: KeyId[];
14
+ }
15
+
16
+ export interface ImageToolsConfig {
17
+ debug: boolean;
18
+ shortcuts: ImageToolsShortcutConfig;
19
+ }
20
+
21
+ export function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+
25
+ export function getExtensionRoot(): string {
26
+ return EXTENSION_ROOT;
27
+ }
28
+
29
+ export function getConfigPath(): string {
30
+ return join(getExtensionRoot(), CONFIG_FILE_NAME);
31
+ }
32
+
33
+ function formatConfigPath(path: string, property: string): string {
34
+ return `${path}${property.length > 0 ? ` property \"${property}\"` : ""}`;
35
+ }
36
+
37
+ function parseBoolean(value: unknown, property: string, path: string): boolean {
38
+ if (value === undefined) {
39
+ return false;
40
+ }
41
+
42
+ if (typeof value !== "boolean") {
43
+ throw new Error(`Invalid pi-image-tools config at ${formatConfigPath(path, property)}: expected a boolean.`);
44
+ }
45
+
46
+ return value;
47
+ }
48
+
49
+ function normalizeShortcut(value: unknown, property: string, path: string): KeyId {
50
+ if (typeof value !== "string") {
51
+ throw new Error(`Invalid pi-image-tools config at ${formatConfigPath(path, property)}: expected a string shortcut.`);
52
+ }
53
+
54
+ const shortcut = value.trim();
55
+ if (shortcut.length === 0) {
56
+ throw new Error(`Invalid pi-image-tools config at ${formatConfigPath(path, property)}: shortcut cannot be empty.`);
57
+ }
58
+
59
+ return shortcut as KeyId;
60
+ }
61
+
62
+ function parseShortcutList(value: unknown, property: string, path: string): KeyId[] | undefined {
63
+ if (value === undefined) {
64
+ return undefined;
65
+ }
66
+
67
+ const rawShortcuts = Array.isArray(value) ? value : [value];
68
+ const shortcuts: KeyId[] = [];
69
+ const seen = new Set<string>();
70
+
71
+ for (const [index, rawShortcut] of rawShortcuts.entries()) {
72
+ const shortcut = normalizeShortcut(rawShortcut, `${property}[${index}]`, path);
73
+ const normalized = shortcut.toLowerCase();
74
+ if (seen.has(normalized)) {
75
+ continue;
76
+ }
77
+
78
+ seen.add(normalized);
79
+ shortcuts.push(shortcut);
80
+ }
81
+
82
+ return shortcuts;
83
+ }
84
+
85
+ function parseShortcutConfig(value: unknown, path: string): ImageToolsShortcutConfig {
86
+ if (value === undefined) {
87
+ return {
88
+ avoidBuiltinConflicts: false,
89
+ suppressBuiltinConflictWarnings: false,
90
+ };
91
+ }
92
+
93
+ if (!isRecord(value)) {
94
+ throw new Error(`Invalid pi-image-tools config at ${formatConfigPath(path, "shortcuts")}: expected an object.`);
95
+ }
96
+
97
+ const avoidBuiltinConflicts = parseBoolean(
98
+ value.avoidBuiltinConflicts,
99
+ "shortcuts.avoidBuiltinConflicts",
100
+ path,
101
+ );
102
+ const suppressBuiltinConflictWarnings = parseBoolean(
103
+ value.suppressBuiltinConflictWarnings,
104
+ "shortcuts.suppressBuiltinConflictWarnings",
105
+ path,
106
+ );
107
+ const pasteImage = parseShortcutList(value.pasteImage, "shortcuts.pasteImage", path);
108
+
109
+ return pasteImage === undefined
110
+ ? { avoidBuiltinConflicts, suppressBuiltinConflictWarnings }
111
+ : { avoidBuiltinConflicts, suppressBuiltinConflictWarnings, pasteImage };
112
+ }
113
+
114
+ function parseConfig(rawConfig: unknown, path: string): ImageToolsConfig {
115
+ if (!isRecord(rawConfig)) {
116
+ throw new Error(`Invalid pi-image-tools config at ${path}: expected a JSON object.`);
117
+ }
118
+
119
+ return {
120
+ debug: parseBoolean(rawConfig.debug, "debug", path),
121
+ shortcuts: parseShortcutConfig(rawConfig.shortcuts, path),
122
+ };
123
+ }
124
+
125
+ export function loadImageToolsConfig(path = getConfigPath()): ImageToolsConfig {
126
+ if (!existsSync(path)) {
127
+ return {
128
+ debug: false,
129
+ shortcuts: {
130
+ avoidBuiltinConflicts: false,
131
+ suppressBuiltinConflictWarnings: false,
132
+ },
133
+ };
134
+ }
135
+
136
+ try {
137
+ const rawConfig = JSON.parse(readFileSync(path, "utf-8")) as unknown;
138
+ return parseConfig(rawConfig, path);
139
+ } catch (error) {
140
+ if (error instanceof SyntaxError) {
141
+ throw new Error(`Invalid pi-image-tools config at ${path}: ${error.message}`);
142
+ }
143
+
144
+ throw error;
145
+ }
146
+ }
@@ -0,0 +1,41 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { getExtensionRoot, type ImageToolsConfig } from "./config.js";
5
+ import { getErrorMessage } from "./errors.js";
6
+
7
+ const DEBUG_DIRECTORY_NAME = "debug";
8
+ const DEBUG_LOG_FILE_NAME = "debug.log";
9
+
10
+ type DebugFields = Record<string, unknown>;
11
+
12
+ export class DebugLogger {
13
+ private readonly logPath: string | undefined;
14
+
15
+ private constructor(private readonly enabled: boolean) {
16
+ this.logPath = enabled ? join(getExtensionRoot(), DEBUG_DIRECTORY_NAME, DEBUG_LOG_FILE_NAME) : undefined;
17
+ }
18
+
19
+ static create(config: ImageToolsConfig): DebugLogger {
20
+ return new DebugLogger(config.debug);
21
+ }
22
+
23
+ log(event: string, fields: DebugFields = {}): void {
24
+ if (!this.enabled || !this.logPath) {
25
+ return;
26
+ }
27
+
28
+ const debugDirectory = join(getExtensionRoot(), DEBUG_DIRECTORY_NAME);
29
+
30
+ try {
31
+ mkdirSync(debugDirectory, { recursive: true });
32
+ appendFileSync(
33
+ this.logPath,
34
+ `${JSON.stringify({ timestamp: new Date().toISOString(), event, ...fields })}\n`,
35
+ "utf-8",
36
+ );
37
+ } catch (error) {
38
+ throw new Error(`pi-image-tools debug logging failed at ${this.logPath}: ${getErrorMessage(error)}`);
39
+ }
40
+ }
41
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,11 @@
1
+ export function getErrorMessage(error: unknown): string {
2
+ if (error instanceof Error && error.message.trim().length > 0) {
3
+ return error.message;
4
+ }
5
+
6
+ return "Unknown error";
7
+ }
8
+
9
+ export function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
10
+ return error instanceof Error && "code" in error;
11
+ }
@@ -0,0 +1,60 @@
1
+ const PREFERRED_IMAGE_MIME_TYPES = [
2
+ "image/png",
3
+ "image/jpeg",
4
+ "image/webp",
5
+ "image/gif",
6
+ "image/bmp",
7
+ ] as const;
8
+
9
+ const MIME_TYPE_TO_EXTENSION = new Map<string, string>([
10
+ ["image/png", "png"],
11
+ ["image/jpeg", "jpg"],
12
+ ["image/webp", "webp"],
13
+ ["image/gif", "gif"],
14
+ ["image/bmp", "bmp"],
15
+ ["image/tiff", "tiff"],
16
+ ]);
17
+
18
+ const EXTENSION_TO_MIME_TYPE = new Map<string, string>([
19
+ [".png", "image/png"],
20
+ [".jpg", "image/jpeg"],
21
+ [".jpeg", "image/jpeg"],
22
+ [".webp", "image/webp"],
23
+ [".gif", "image/gif"],
24
+ [".bmp", "image/bmp"],
25
+ ]);
26
+
27
+ export const SUPPORTED_IMAGE_MIME_TYPES: readonly string[] = PREFERRED_IMAGE_MIME_TYPES;
28
+
29
+ export function normalizeMimeType(mimeType: string): string {
30
+ return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
31
+ }
32
+
33
+ export function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
34
+ const normalized = mimeTypes
35
+ .map((mimeType) => mimeType.trim())
36
+ .filter((mimeType) => mimeType.length > 0)
37
+ .map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
38
+
39
+ for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
40
+ const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
41
+ if (match) {
42
+ return match.raw;
43
+ }
44
+ }
45
+
46
+ const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
47
+ return firstImage?.raw ?? null;
48
+ }
49
+
50
+ export function mimeTypeToExtension(mimeType: string, fallbackExtension = "png"): string {
51
+ return MIME_TYPE_TO_EXTENSION.get(normalizeMimeType(mimeType)) ?? fallbackExtension;
52
+ }
53
+
54
+ export function extensionToMimeType(fileNameOrExtension: string): string | null {
55
+ const extension = fileNameOrExtension.startsWith(".")
56
+ ? fileNameOrExtension.toLowerCase()
57
+ : `.${fileNameOrExtension.toLowerCase()}`;
58
+
59
+ return EXTENSION_TO_MIME_TYPE.get(extension) ?? null;
60
+ }