@restregular/dsh-notepad 0.2.0 → 0.3.1

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/index.js CHANGED
@@ -3,19 +3,27 @@
3
3
  *
4
4
  * Stores one plain-text note per workspace in `<workspace>/.dsh/notepad.json`
5
5
  * and exposes it to the same-origin browser overlay through the loopback route
6
- * `/dsh-notepad/api/note`.
6
+ * `/dsh-notepad/api/note`. Pasted images are written to the plugin-owned
7
+ * directory `<workspace>/.dsh/notepad/images/` through `/dsh-notepad/api/image`.
7
8
  *
8
9
  * Security:
9
10
  * - only loopback requests with a same-origin (or absent) Origin are served;
10
11
  * - the resolved root must be a registered workspace or the exact cwd of the
11
12
  * requesting session;
12
- * - the file path itself is fixed (`.dsh/notepad.json`), so the route cannot
13
- * be used to read or write arbitrary workspace files.
13
+ * - the file paths themselves are fixed (`.dsh/notepad.json` and the image
14
+ * directory), so the route cannot be used to read or write arbitrary
15
+ * workspace files. Image uploads must be a supported raster format whose
16
+ * bytes match the declared MIME type.
14
17
  *
15
18
  * @module dsh-notepad
16
19
  */
17
20
 
18
21
  import path from 'node:path';
22
+ import {
23
+ IMAGE_TYPES,
24
+ MAX_IMAGE_BYTES,
25
+ saveClipboardImage,
26
+ } from './lib/image-store.js';
19
27
  import {
20
28
  DEFAULT_MAX_BYTES,
21
29
  NOTE_VERSION,
@@ -28,11 +36,14 @@ import {
28
36
  const ROUTE = '/dsh-notepad/api';
29
37
 
30
38
  /** Plugin version mirrored in package.json. */
31
- const VERSION = '0.2.0';
39
+ const VERSION = '0.3.1';
32
40
 
33
- /** Request body cap. */
41
+ /** Request body cap for note writes. */
34
42
  const BODY_LIMIT = 1024 * 1024;
35
43
 
44
+ /** Request body cap for base64 image uploads (decoded cap + base64 overhead). */
45
+ const IMAGE_BODY_LIMIT = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 64 * 1024;
46
+
36
47
  // ---------------------------------------------------------------------------
37
48
  // HTTP helpers
38
49
  // ---------------------------------------------------------------------------
@@ -56,16 +67,22 @@ function readJsonBody(req, limit = BODY_LIMIT) {
56
67
  return new Promise((resolve, reject) => {
57
68
  const chunks = [];
58
69
  let total = 0;
70
+ let tooLarge = false;
59
71
  req.on('data', (chunk) => {
72
+ if (tooLarge) return;
60
73
  total += chunk.length;
61
74
  if (total > limit) {
75
+ tooLarge = true;
76
+ chunks.length = 0;
62
77
  reject(new NoteStoreError('too-large', 'Request body is too large.'));
63
- req.destroy();
78
+ // Drain the rest so the 413 response can still be delivered.
79
+ req.resume();
64
80
  return;
65
81
  }
66
82
  chunks.push(chunk);
67
83
  });
68
84
  req.on('end', () => {
85
+ if (tooLarge) return;
69
86
  try {
70
87
  const text = Buffer.concat(chunks).toString('utf8');
71
88
  const value = text === '' ? {} : JSON.parse(text);
@@ -172,7 +189,10 @@ export async function handleRequest(ctx, req, res) {
172
189
  await dispatchRequest(ctx, req, res);
173
190
  } catch (error) {
174
191
  if (error instanceof NoteStoreError) {
175
- const status = error.code === 'unknown-workspace' || error.code === 'bad-root' ? 403 : 400;
192
+ let status = 400;
193
+ if (error.code === 'unknown-workspace' || error.code === 'bad-root') status = 403;
194
+ else if (error.code === 'too-large') status = 413;
195
+ else if (error.code === 'unsupported-type') status = 415;
176
196
  sendJson(res, { ok: false, code: error.code, error: error.message }, status);
177
197
  return;
178
198
  }
@@ -199,7 +219,29 @@ async function dispatchRequest(ctx, req, res) {
199
219
  }
200
220
  const action = url.pathname.slice(ROUTE.length).replace(/^\/+|\/+$/gu, '');
201
221
  if (action === 'ping') {
202
- sendJson(res, { ok: true, plugin: 'dsh-notepad', version: VERSION, noteVersion: NOTE_VERSION });
222
+ sendJson(res, {
223
+ ok: true,
224
+ plugin: 'dsh-notepad',
225
+ version: VERSION,
226
+ noteVersion: NOTE_VERSION,
227
+ imageTypes: Object.keys(IMAGE_TYPES),
228
+ maxImageBytes: MAX_IMAGE_BYTES,
229
+ });
230
+ return;
231
+ }
232
+ if (action === 'image') {
233
+ if (req.method !== 'POST') {
234
+ sendJson(res, { ok: false, code: 'method', error: 'Use POST.' }, 405);
235
+ return;
236
+ }
237
+ const body = await readJsonBody(req, IMAGE_BODY_LIMIT);
238
+ const root = await resolveRequestRoot(ctx, body);
239
+ const image = await saveClipboardImage(root, {
240
+ name: body.name,
241
+ mime: body.mime,
242
+ data: body.data,
243
+ });
244
+ sendJson(res, { ok: true, root, ...image });
203
245
  return;
204
246
  }
205
247
  if (action !== 'note') {
@@ -0,0 +1,176 @@
1
+ /**
2
+ * dsh-notepad — workspace image storage.
3
+ *
4
+ * Images pasted into the notepad have no host path in the browser, so their
5
+ * base64 payload is written under the plugin directory:
6
+ *
7
+ * <workspace>/.dsh/notepad/images/<unique>-<name>.<ext>
8
+ *
9
+ * The returned path is workspace-relative, which keeps the note file portable
10
+ * inside one workspace. Selected images keep their original path and never
11
+ * reach this module unless the browser could not expose that path.
12
+ *
13
+ * Only PNG, JPEG, WebP and GIF are accepted, and the payload must actually
14
+ * start with the matching file signature: this route is loopback-only, but it
15
+ * must not be usable as an arbitrary file writer.
16
+ *
17
+ * @module dsh-notepad/lib/image-store
18
+ */
19
+
20
+ import { randomBytes } from 'node:crypto';
21
+ import fs from 'node:fs/promises';
22
+ import path from 'node:path';
23
+
24
+ import { NoteStoreError } from './note-store.js';
25
+
26
+ /** Plugin-owned directory under the workspace root (POSIX spelling). */
27
+ export const IMAGE_DIR = '.dsh/notepad/images';
28
+
29
+ /** Maximum decoded image size accepted from the browser. */
30
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
31
+
32
+ /** Maximum display name kept for one image. */
33
+ export const MAX_IMAGE_NAME_CHARS = 160;
34
+
35
+ /** MIME type to canonical file extension. */
36
+ export const IMAGE_TYPES = Object.freeze({
37
+ 'image/png': 'png',
38
+ 'image/jpeg': 'jpg',
39
+ 'image/webp': 'webp',
40
+ 'image/gif': 'gif',
41
+ });
42
+
43
+ /**
44
+ * Validate a declared MIME type.
45
+ * @param {unknown} mime
46
+ * @returns {string | null} canonical MIME type
47
+ */
48
+ export function canonicalImageType(mime) {
49
+ if (typeof mime !== 'string') return null;
50
+ const value = mime.trim().toLowerCase();
51
+ return Object.prototype.hasOwnProperty.call(IMAGE_TYPES, value) ? value : null;
52
+ }
53
+
54
+ /**
55
+ * Read a supported image signature.
56
+ * @param {Buffer} bytes
57
+ * @returns {string | null} canonical MIME type
58
+ */
59
+ export function sniffImageType(bytes) {
60
+ if (bytes.length >= 8
61
+ && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
62
+ && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
63
+ return 'image/png';
64
+ }
65
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
66
+ return 'image/jpeg';
67
+ }
68
+ if (bytes.length >= 6) {
69
+ const gif = bytes.subarray(0, 6).toString('latin1');
70
+ if (gif === 'GIF87a' || gif === 'GIF89a') return 'image/gif';
71
+ }
72
+ if (bytes.length >= 12
73
+ && bytes.subarray(0, 4).toString('latin1') === 'RIFF'
74
+ && bytes.subarray(8, 12).toString('latin1') === 'WEBP') {
75
+ return 'image/webp';
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Strictly decode canonical base64 (no whitespace, no data-URL prefix).
82
+ * @param {unknown} value
83
+ * @returns {Buffer}
84
+ */
85
+ export function decodeBase64(value) {
86
+ if (typeof value !== 'string' || value === '') {
87
+ throw new NoteStoreError('bad-input', 'Image data is required.');
88
+ }
89
+ if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(value)) {
90
+ throw new NoteStoreError('bad-input', 'Image data is not canonical base64.');
91
+ }
92
+ const bytes = Buffer.from(value, 'base64');
93
+ // Re-encoding catches non-canonical trailing bits that Buffer accepts.
94
+ if (bytes.toString('base64') !== value) {
95
+ throw new NoteStoreError('bad-input', 'Image data is not canonical base64.');
96
+ }
97
+ return bytes;
98
+ }
99
+
100
+ /**
101
+ * Build a safe file stem from a display name.
102
+ * @param {unknown} name
103
+ * @returns {string}
104
+ */
105
+ function fileStem(name) {
106
+ const raw = typeof name === 'string' ? name : '';
107
+ let stem = raw
108
+ .replace(/[\\/:*?"<>|\u0000-\u001f\u007f]+/gu, '-')
109
+ .replace(/^[.\s]+|[.\s]+$/gu, '')
110
+ .replace(/\.(?:png|jpe?g|webp|gif)$/iu, '')
111
+ .slice(0, 80);
112
+ if (stem === '') stem = 'image';
113
+ stem = stem.replace(/-+/gu, '-').replace(/^-|-$/gu, '');
114
+ return stem === '' ? 'image' : stem;
115
+ }
116
+
117
+ /**
118
+ * Keep a human-readable name; fall back to the stored file name.
119
+ * @param {unknown} name
120
+ * @returns {string}
121
+ */
122
+ function displayName(name) {
123
+ const raw = typeof name === 'string' ? name : '';
124
+ const cleaned = raw
125
+ .replace(/[\\/\u0000-\u001f\u007f]+/gu, ' ')
126
+ .replace(/\s+/gu, ' ')
127
+ .trim()
128
+ .slice(0, MAX_IMAGE_NAME_CHARS);
129
+ return cleaned === '' ? 'image' : cleaned;
130
+ }
131
+
132
+ /**
133
+ * Save one pasted image under the plugin directory.
134
+ *
135
+ * @param {string} root workspace root
136
+ * @param {{ name?: unknown, mime?: unknown, data?: unknown }} input
137
+ * @returns {Promise<{path: string, name: string, mime: string, size: number}>}
138
+ */
139
+ export async function saveClipboardImage(root, input) {
140
+ const declared = canonicalImageType(input?.mime);
141
+ if (declared === null) {
142
+ throw new NoteStoreError('unsupported-type', 'Only PNG, JPEG, WebP and GIF images are supported.');
143
+ }
144
+ const bytes = decodeBase64(input?.data);
145
+ if (bytes.byteLength === 0) {
146
+ throw new NoteStoreError('bad-input', 'Image data is empty.');
147
+ }
148
+ if (bytes.byteLength > MAX_IMAGE_BYTES) {
149
+ throw new NoteStoreError('too-large', `Image is too large (limit ${MAX_IMAGE_BYTES} bytes).`);
150
+ }
151
+ const sniffed = sniffImageType(bytes);
152
+ if (sniffed === null || sniffed !== declared) {
153
+ throw new NoteStoreError('unsupported-type', 'The image bytes do not match the declared type.');
154
+ }
155
+ const extension = IMAGE_TYPES[declared];
156
+ const name = displayName(input?.name);
157
+ const stem = fileStem(name);
158
+ const dir = path.join(root, ...IMAGE_DIR.split('/'));
159
+ await fs.mkdir(dir, { recursive: true });
160
+ const fileName = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}-${stem}.${extension}`;
161
+ const file = path.join(dir, fileName);
162
+ try {
163
+ await fs.writeFile(file, bytes, { flag: 'wx' });
164
+ } catch (error) {
165
+ if (error?.code === 'EEXIST') {
166
+ throw new NoteStoreError('internal', 'Could not allocate a unique image file name.');
167
+ }
168
+ throw error;
169
+ }
170
+ return {
171
+ path: `${IMAGE_DIR}/${fileName}`,
172
+ name,
173
+ mime: declared,
174
+ size: bytes.byteLength,
175
+ };
176
+ }
package/lib/note-store.js CHANGED
@@ -33,7 +33,16 @@ export const MAX_SEGMENTS = 200;
33
33
  export const MAX_SEGMENT_CHARS = 160_000;
34
34
 
35
35
  /** Storage envelope version. */
36
- export const NOTE_VERSION = 2;
36
+ export const NOTE_VERSION = 3;
37
+
38
+ /** Maximum image references accepted on one segment. */
39
+ export const MAX_IMAGES_PER_SEGMENT = 20;
40
+
41
+ /** Maximum characters kept for one image path. */
42
+ export const MAX_IMAGE_PATH_CHARS = 1024;
43
+
44
+ /** Maximum characters kept for one image display name. */
45
+ export const MAX_IMAGE_NAME_CHARS = 160;
37
46
 
38
47
  /** A note-store failure with a stable machine-readable code. */
39
48
  export class NoteStoreError extends Error {
@@ -56,19 +65,133 @@ export function notePath(root) {
56
65
  return path.join(root, NOTE_DIR, NOTE_FILE);
57
66
  }
58
67
 
68
+ /**
69
+ * Final non-empty segment of a path, in either separator spelling.
70
+ * @param {string} value
71
+ */
72
+ function baseNameOf(value) {
73
+ const trimmed = String(value ?? '').replace(/[/\\]+$/u, '');
74
+ const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
75
+ return trimmed.slice(separator + 1);
76
+ }
77
+
78
+ /**
79
+ * Normalize one stored image reference. Reading and writing share this shape;
80
+ * reading drops invalid entries, writing rejects them.
81
+ * @param {unknown} value
82
+ * @returns {{path: string, name: string} | null}
83
+ */
84
+ function readImage(value) {
85
+ if (typeof value === 'string') value = { path: value };
86
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
87
+ if (typeof value.path !== 'string') return null;
88
+ const filePath = value.path.trim().replace(/\\/gu, '/');
89
+ if (filePath === '' || filePath.length > MAX_IMAGE_PATH_CHARS) return null;
90
+ const name = typeof value.name === 'string' && value.name.trim() !== ''
91
+ ? value.name.trim().replace(/\s+/gu, ' ').slice(0, MAX_IMAGE_NAME_CHARS)
92
+ : baseNameOf(filePath);
93
+ return { path: filePath, name: name === '' ? 'image' : name };
94
+ }
95
+
96
+ /**
97
+ * Normalize every surviving image reference for one segment.
98
+ * @param {unknown} value
99
+ * @returns {Array<{path: string, name: string}>}
100
+ */
101
+ function readImages(value) {
102
+ if (!Array.isArray(value)) return [];
103
+ const out = [];
104
+ for (const item of value) {
105
+ if (out.length >= MAX_IMAGES_PER_SEGMENT) break;
106
+ const image = readImage(item);
107
+ if (image !== null) out.push(image);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * Strict per-segment image normalization for writes.
114
+ * @param {unknown} value
115
+ * @param {number} index
116
+ * @returns {Array<{path: string, name: string}>}
117
+ */
118
+ function normalizeWriteImages(value, index) {
119
+ if (value === undefined || value === null) return [];
120
+ if (!Array.isArray(value)) {
121
+ throw new NoteStoreError('bad-input', `Segment ${index + 1} images is not an array.`);
122
+ }
123
+ if (value.length > MAX_IMAGES_PER_SEGMENT) {
124
+ throw new NoteStoreError(
125
+ 'too-many',
126
+ `Segment ${index + 1} has too many images (${value.length}; limit ${MAX_IMAGES_PER_SEGMENT}).`,
127
+ );
128
+ }
129
+ return value.map((item, imageIndex) => {
130
+ const image = readImage(item);
131
+ if (image === null) {
132
+ throw new NoteStoreError('bad-input', `Segment ${index + 1} image ${imageIndex + 1} is invalid.`);
133
+ }
134
+ return image;
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Render one image as Markdown for the note body and composer transfers.
140
+ * Paths are URL-escaped so the DSH Markdown renderer can resolve them.
141
+ * @param {{path?: string, name?: string}} image
142
+ * @returns {string}
143
+ */
144
+ export function imageMarkdown(image) {
145
+ const file = typeof image?.path === 'string' ? image.path.replace(/\\/gu, '/') : '';
146
+ if (file === '') return '';
147
+ const label = (typeof image?.name === 'string' ? image.name : '')
148
+ .replace(/[\r\n]+/gu, ' ')
149
+ .replace(/[[\]]/gu, ' ')
150
+ .trim() || baseNameOf(file) || 'image';
151
+ let target;
152
+ try {
153
+ target = encodeURI(file);
154
+ } catch {
155
+ target = file;
156
+ }
157
+ target = target
158
+ .replace(/#/gu, '%23')
159
+ .replace(/\?/gu, '%3F')
160
+ .replace(/\(/gu, '%28')
161
+ .replace(/\)/gu, '%29');
162
+ return `![${label}](${target})`;
163
+ }
164
+
165
+ /**
166
+ * One segment rendered as text plus one Markdown line per image.
167
+ * @param {{text?: string, images?: Array<{path: string, name: string}>}} segment
168
+ */
169
+ export function segmentBody(segment) {
170
+ const text = typeof segment?.text === 'string' ? segment.text : '';
171
+ const images = Array.isArray(segment?.images) ? segment.images : [];
172
+ const parts = [];
173
+ if (text.trim() !== '') parts.push(text);
174
+ for (const image of images) {
175
+ const markdown = imageMarkdown(image);
176
+ if (markdown !== '') parts.push(markdown);
177
+ }
178
+ return parts.join('\n');
179
+ }
180
+
59
181
  /**
60
182
  * Normalize one segment read from disk. Reading is forgiving: a malformed
61
183
  * entry is skipped instead of making the whole note unreadable.
62
184
  * @param {unknown} value
63
185
  * @param {number} index
64
186
  * @param {Set<string>} seen
65
- * @returns {{id: string, text: string, archived: boolean, archivedAt: string | null} | null}
187
+ * @returns {{id: string, text: string, images: Array<{path: string, name: string}>, archived: boolean, archivedAt: string | null} | null}
66
188
  */
67
189
  function readSegment(value, index, seen) {
68
190
  if (typeof value === 'string') value = { text: value };
69
191
  if (value === null || typeof value !== 'object') return null;
70
- if (typeof value.text !== 'string') return null;
71
- const text = value.text;
192
+ const images = readImages(value.images);
193
+ if (typeof value.text !== 'string' && images.length === 0) return null;
194
+ const text = typeof value.text === 'string' ? value.text : '';
72
195
  let id = typeof value.id === 'string' && value.id !== '' ? value.id.slice(0, 128) : `seg-${index + 1}`;
73
196
  let suffix = 1;
74
197
  while (seen.has(id)) {
@@ -80,7 +203,7 @@ function readSegment(value, index, seen) {
80
203
  const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
81
204
  ? value.archivedAt
82
205
  : null;
83
- return { id, text, archived, archivedAt };
206
+ return { id, text, images, archived, archivedAt };
84
207
  }
85
208
 
86
209
  /**
@@ -96,7 +219,7 @@ function readSegment(value, index, seen) {
96
219
  export function normalizeSegments(input) {
97
220
  if (typeof input === 'string') {
98
221
  if (input === '') return [];
99
- return [{ id: 'seg-1', text: input, archived: false, archivedAt: null }];
222
+ return [{ id: 'seg-1', text: input, images: [], archived: false, archivedAt: null }];
100
223
  }
101
224
  if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
102
225
  if (Array.isArray(input.segments)) return normalizeSegments(input.segments);
@@ -136,6 +259,7 @@ function normalizeWriteSegment(value, index, seen) {
136
259
  `Segment ${index + 1} is too large (${value.text.length} characters; limit ${MAX_SEGMENT_CHARS}).`,
137
260
  );
138
261
  }
262
+ const images = normalizeWriteImages(value.images, index);
139
263
  let id = typeof value.id === 'string' && value.id !== '' ? value.id.slice(0, 128) : `seg-${index + 1}`;
140
264
  let suffix = 1;
141
265
  while (seen.has(id)) {
@@ -147,15 +271,18 @@ function normalizeWriteSegment(value, index, seen) {
147
271
  const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
148
272
  ? value.archivedAt
149
273
  : null;
150
- return { id, text: value.text, archived, archivedAt };
274
+ return { id, text: value.text, images, archived, archivedAt };
151
275
  }
152
276
 
153
277
  /**
154
278
  * Merge segments into the plain body stored next to the list.
155
- * @param {Array<{text: string}>} segments
279
+ * @param {Array<{text: string, images?: Array<{path: string, name: string}>}>} segments
156
280
  */
157
281
  export function mergeSegments(segments) {
158
- return segments.map((segment) => segment.text).join('\n\n');
282
+ return segments
283
+ .map((segment) => segmentBody(segment))
284
+ .filter((body) => body.trim() !== '')
285
+ .join('\n\n');
159
286
  }
160
287
 
161
288
  /**
@@ -240,6 +367,9 @@ export async function writeNote(root, note, options = {}) {
240
367
  segments: segments.map((segment) => ({
241
368
  id: segment.id,
242
369
  text: segment.text,
370
+ ...(segment.images.length > 0
371
+ ? { images: segment.images.map((image) => ({ path: image.path, name: image.name })) }
372
+ : {}),
243
373
  ...(segment.archived ? { archived: true } : {}),
244
374
  ...(segment.archivedAt ? { archivedAt: segment.archivedAt } : {}),
245
375
  })),
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Notepad",
4
- "description": "A per-workspace floating notepad in the session view. Enter creates segment cards; insert, replace, or send one or many; sending archives automatically. Archived segments can be shown, hidden, restored, and deleted. Resizable and dark-mode aware."
4
+ "description": "A per-workspace floating notepad in the session view. Enter creates segment cards; paste or pick images as path-based attachment chips; insert, replace, or send one or many; sending archives automatically. Archived segments can be shown, hidden, restored, and deleted. Resizable and dark-mode aware."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "记事本",
4
- "description": "在会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段,支持单段/多段插入、替换和发送,发送后自动归档;归档可显示、隐藏、恢复与删除,浮窗可调整尺寸并适配深色模式。"
4
+ "description": "在会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段,可粘贴或选择图片并以路径附件形式引入,支持单段/多段插入、替换和发送,发送后自动归档;归档可显示、隐藏、恢复与删除,浮窗可调整尺寸并适配深色模式。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-notepad",
3
- "version": "0.2.0",
4
- "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: Enter creates segment cards, batch insert / replace / send, sending archives segments, archive management, resizable panel and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段卡片,支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除,可调整尺寸并适配深色模式。",
3
+ "version": "0.3.1",
4
+ "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: Enter creates segment cards, paste or pick images as inline attachment chips, batch insert / replace / send, sending archives segments, archive management, resizable panel and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段卡片,可粘贴或选择图片并以路径附件形式引入,支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除,可调整尺寸并适配深色模式。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "index.js",
@@ -53,6 +53,8 @@
53
53
  "notepad",
54
54
  "notes",
55
55
  "scratchpad",
56
+ "images",
57
+ "attachments",
56
58
  "workspace",
57
59
  "floating"
58
60
  ],