@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/README.md +28 -13
- package/client.js +610 -109
- package/index.js +50 -8
- package/lib/image-store.js +176 -0
- package/lib/note-store.js +139 -9
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +4 -2
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
|
|
13
|
-
* be used to read or write arbitrary
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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 =
|
|
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 ``;
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@restregular/dsh-notepad",
|
|
3
|
-
"version": "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
|
],
|