@restregular/dsh-notepad 0.1.0 → 0.3.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/README.md +82 -22
- package/client.js +1875 -243
- package/index.js +53 -9
- package/lib/image-store.js +176 -0
- package/lib/note-store.js +288 -25
- 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.0';
|
|
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') {
|
|
@@ -218,7 +260,9 @@ async function dispatchRequest(ctx, req, res) {
|
|
|
218
260
|
if (req.method === 'PUT') {
|
|
219
261
|
const body = await readJsonBody(req);
|
|
220
262
|
const root = await resolveRequestRoot(ctx, body);
|
|
221
|
-
const note = await writeNote(root, body.
|
|
263
|
+
const note = await writeNote(root, Array.isArray(body.segments) ? body.segments : body.text, {
|
|
264
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
265
|
+
});
|
|
222
266
|
sendJson(res, { ok: true, root, ...note });
|
|
223
267
|
return;
|
|
224
268
|
}
|
|
@@ -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
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-notepad — workspace note storage.
|
|
3
3
|
*
|
|
4
|
-
* One
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* One note per workspace, kept at `<workspace>/.dsh/notepad.json`. A note is an
|
|
5
|
+
* ordered list of text segments; each segment may be archived after it was
|
|
6
|
+
* sent, and only archived segments can be deleted by the client.
|
|
7
|
+
*
|
|
8
|
+
* Version 2 stores the segment list under `segments` and repeats the merged
|
|
9
|
+
* body under `text` so the file stays human readable and older builds can
|
|
10
|
+
* still read the content. Version 1 files (a single `text`) and hand-edited
|
|
11
|
+
* plain-text files are migrated on read as one segment.
|
|
9
12
|
*
|
|
10
13
|
* @module dsh-notepad/lib/note-store
|
|
11
14
|
*/
|
|
@@ -20,11 +23,26 @@ export const NOTE_DIR = '.dsh';
|
|
|
20
23
|
/** File name inside {@link NOTE_DIR}. */
|
|
21
24
|
export const NOTE_FILE = 'notepad.json';
|
|
22
25
|
|
|
23
|
-
/** Default maximum note size (UTF-8 bytes). */
|
|
26
|
+
/** Default maximum note file size (UTF-8 bytes). */
|
|
24
27
|
export const DEFAULT_MAX_BYTES = 512 * 1024;
|
|
25
28
|
|
|
29
|
+
/** Maximum number of segments accepted from the client. */
|
|
30
|
+
export const MAX_SEGMENTS = 200;
|
|
31
|
+
|
|
32
|
+
/** Maximum characters in one segment. */
|
|
33
|
+
export const MAX_SEGMENT_CHARS = 160_000;
|
|
34
|
+
|
|
26
35
|
/** Storage envelope version. */
|
|
27
|
-
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;
|
|
28
46
|
|
|
29
47
|
/** A note-store failure with a stable machine-readable code. */
|
|
30
48
|
export class NoteStoreError extends Error {
|
|
@@ -47,25 +65,257 @@ export function notePath(root) {
|
|
|
47
65
|
return path.join(root, NOTE_DIR, NOTE_FILE);
|
|
48
66
|
}
|
|
49
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
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Normalize one segment read from disk. Reading is forgiving: a malformed
|
|
183
|
+
* entry is skipped instead of making the whole note unreadable.
|
|
184
|
+
* @param {unknown} value
|
|
185
|
+
* @param {number} index
|
|
186
|
+
* @param {Set<string>} seen
|
|
187
|
+
* @returns {{id: string, text: string, images: Array<{path: string, name: string}>, archived: boolean, archivedAt: string | null} | null}
|
|
188
|
+
*/
|
|
189
|
+
function readSegment(value, index, seen) {
|
|
190
|
+
if (typeof value === 'string') value = { text: value };
|
|
191
|
+
if (value === null || typeof value !== 'object') return null;
|
|
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 : '';
|
|
195
|
+
let id = typeof value.id === 'string' && value.id !== '' ? value.id.slice(0, 128) : `seg-${index + 1}`;
|
|
196
|
+
let suffix = 1;
|
|
197
|
+
while (seen.has(id)) {
|
|
198
|
+
id = `${id}-${suffix}`;
|
|
199
|
+
suffix += 1;
|
|
200
|
+
}
|
|
201
|
+
seen.add(id);
|
|
202
|
+
const archived = value.archived === true;
|
|
203
|
+
const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
|
|
204
|
+
? value.archivedAt
|
|
205
|
+
: null;
|
|
206
|
+
return { id, text, images, archived, archivedAt };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Normalize a segment list for a write. Unlike {@link readSegment}, malformed
|
|
211
|
+
* entries are an input error here: the route must not silently drop content.
|
|
212
|
+
*
|
|
213
|
+
* Accepts the modern `segments` array, a single legacy text string, or
|
|
214
|
+
* `{ text }`.
|
|
215
|
+
*
|
|
216
|
+
* @param {unknown} input
|
|
217
|
+
* @returns {Array<{id: string, text: string, archived: boolean, archivedAt: string | null}>}
|
|
218
|
+
*/
|
|
219
|
+
export function normalizeSegments(input) {
|
|
220
|
+
if (typeof input === 'string') {
|
|
221
|
+
if (input === '') return [];
|
|
222
|
+
return [{ id: 'seg-1', text: input, images: [], archived: false, archivedAt: null }];
|
|
223
|
+
}
|
|
224
|
+
if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
|
|
225
|
+
if (Array.isArray(input.segments)) return normalizeSegments(input.segments);
|
|
226
|
+
if (typeof input.text === 'string') return normalizeSegments(input.text);
|
|
227
|
+
throw new NoteStoreError('bad-input', 'Note must be a string or a segments array.');
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(input)) {
|
|
230
|
+
throw new NoteStoreError('bad-input', 'Note must be a string or a segments array.');
|
|
231
|
+
}
|
|
232
|
+
if (input.length > MAX_SEGMENTS) {
|
|
233
|
+
throw new NoteStoreError('too-many', `Too many segments (${input.length}; limit ${MAX_SEGMENTS}).`);
|
|
234
|
+
}
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
return input.map((value, index) => {
|
|
237
|
+
const segment = normalizeWriteSegment(value, index, seen);
|
|
238
|
+
return segment;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Strict per-segment normalization for writes.
|
|
244
|
+
* @param {unknown} value
|
|
245
|
+
* @param {number} index
|
|
246
|
+
* @param {Set<string>} seen
|
|
247
|
+
*/
|
|
248
|
+
function normalizeWriteSegment(value, index, seen) {
|
|
249
|
+
if (typeof value === 'string') value = { text: value };
|
|
250
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
251
|
+
throw new NoteStoreError('bad-input', `Segment ${index + 1} is not an object.`);
|
|
252
|
+
}
|
|
253
|
+
if (typeof value.text !== 'string') {
|
|
254
|
+
throw new NoteStoreError('bad-input', `Segment ${index + 1} has no text field.`);
|
|
255
|
+
}
|
|
256
|
+
if (value.text.length > MAX_SEGMENT_CHARS) {
|
|
257
|
+
throw new NoteStoreError(
|
|
258
|
+
'too-large',
|
|
259
|
+
`Segment ${index + 1} is too large (${value.text.length} characters; limit ${MAX_SEGMENT_CHARS}).`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const images = normalizeWriteImages(value.images, index);
|
|
263
|
+
let id = typeof value.id === 'string' && value.id !== '' ? value.id.slice(0, 128) : `seg-${index + 1}`;
|
|
264
|
+
let suffix = 1;
|
|
265
|
+
while (seen.has(id)) {
|
|
266
|
+
id = `${id}-${suffix}`;
|
|
267
|
+
suffix += 1;
|
|
268
|
+
}
|
|
269
|
+
seen.add(id);
|
|
270
|
+
const archived = value.archived === true;
|
|
271
|
+
const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
|
|
272
|
+
? value.archivedAt
|
|
273
|
+
: null;
|
|
274
|
+
return { id, text: value.text, images, archived, archivedAt };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Merge segments into the plain body stored next to the list.
|
|
279
|
+
* @param {Array<{text: string, images?: Array<{path: string, name: string}>}>} segments
|
|
280
|
+
*/
|
|
281
|
+
export function mergeSegments(segments) {
|
|
282
|
+
return segments
|
|
283
|
+
.map((segment) => segmentBody(segment))
|
|
284
|
+
.filter((body) => body.trim() !== '')
|
|
285
|
+
.join('\n\n');
|
|
286
|
+
}
|
|
287
|
+
|
|
50
288
|
/**
|
|
51
289
|
* Decode a stored note file.
|
|
52
290
|
* @param {string} raw file contents
|
|
53
291
|
* @param {number | undefined} mtimeMs
|
|
54
292
|
*/
|
|
55
293
|
export function parseNote(raw, mtimeMs) {
|
|
56
|
-
let
|
|
294
|
+
let segmentsInput;
|
|
57
295
|
let updatedAt;
|
|
58
296
|
try {
|
|
59
297
|
const parsed = JSON.parse(raw);
|
|
60
|
-
if (parsed !== null && typeof parsed === 'object'
|
|
61
|
-
|
|
298
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
299
|
+
if (Array.isArray(parsed.segments)) segmentsInput = parsed.segments;
|
|
300
|
+
else if (typeof parsed.text === 'string') segmentsInput = parsed.text;
|
|
62
301
|
if (typeof parsed.updatedAt === 'string') updatedAt = parsed.updatedAt;
|
|
63
302
|
}
|
|
64
303
|
} catch {
|
|
65
304
|
/* Not our envelope: keep the raw text. */
|
|
66
305
|
}
|
|
306
|
+
if (segmentsInput === undefined) segmentsInput = raw;
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
const segments = [];
|
|
309
|
+
const values = typeof segmentsInput === 'string'
|
|
310
|
+
? (segmentsInput === '' ? [] : [segmentsInput])
|
|
311
|
+
: Array.isArray(segmentsInput) ? segmentsInput : [];
|
|
312
|
+
for (const value of values) {
|
|
313
|
+
const segment = readSegment(value, segments.length, seen);
|
|
314
|
+
if (segment !== null) segments.push(segment);
|
|
315
|
+
}
|
|
67
316
|
return {
|
|
68
|
-
|
|
317
|
+
segments,
|
|
318
|
+
text: mergeSegments(segments),
|
|
69
319
|
updatedAt: updatedAt ?? (typeof mtimeMs === 'number' ? new Date(mtimeMs).toISOString() : null),
|
|
70
320
|
mtimeMs: typeof mtimeMs === 'number' ? mtimeMs : null,
|
|
71
321
|
exists: true,
|
|
@@ -76,7 +326,7 @@ export function parseNote(raw, mtimeMs) {
|
|
|
76
326
|
/**
|
|
77
327
|
* Read one workspace note. A missing file is an empty note, not an error.
|
|
78
328
|
* @param {string} root workspace root
|
|
79
|
-
* @returns {Promise<{text: string, updatedAt: string | null, mtimeMs: number | null, exists: boolean, size: number}>}
|
|
329
|
+
* @returns {Promise<{segments: Array<{id: string, text: string, archived: boolean, archivedAt: string | null}>, text: string, updatedAt: string | null, mtimeMs: number | null, exists: boolean, size: number}>}
|
|
80
330
|
*/
|
|
81
331
|
export async function readNote(root) {
|
|
82
332
|
const file = notePath(root);
|
|
@@ -85,7 +335,7 @@ export async function readNote(root) {
|
|
|
85
335
|
raw = await fs.readFile(file, 'utf8');
|
|
86
336
|
} catch (error) {
|
|
87
337
|
if (error?.code === 'ENOENT') {
|
|
88
|
-
return { text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
|
|
338
|
+
return { segments: [], text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
|
|
89
339
|
}
|
|
90
340
|
throw error;
|
|
91
341
|
}
|
|
@@ -100,25 +350,37 @@ export async function readNote(root) {
|
|
|
100
350
|
}
|
|
101
351
|
|
|
102
352
|
/**
|
|
103
|
-
* Replace one workspace note atomically.
|
|
353
|
+
* Replace one workspace note atomically. Accepts either a segment array or a
|
|
354
|
+
* legacy plain-text string.
|
|
104
355
|
* @param {string} root workspace root
|
|
105
|
-
* @param {
|
|
356
|
+
* @param {unknown} note segments array or legacy text
|
|
106
357
|
* @param {{ maxBytes?: number }} [options]
|
|
107
358
|
*/
|
|
108
|
-
export async function writeNote(root,
|
|
109
|
-
|
|
110
|
-
throw new NoteStoreError('bad-input', 'Note text must be a string.');
|
|
111
|
-
}
|
|
359
|
+
export async function writeNote(root, note, options = {}) {
|
|
360
|
+
const segments = normalizeSegments(note);
|
|
112
361
|
const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_BYTES;
|
|
113
|
-
const
|
|
362
|
+
const dir = path.join(root, NOTE_DIR);
|
|
363
|
+
const file = path.join(dir, NOTE_FILE);
|
|
364
|
+
const updatedAt = new Date().toISOString();
|
|
365
|
+
const payload = `${JSON.stringify({
|
|
366
|
+
version: NOTE_VERSION,
|
|
367
|
+
segments: segments.map((segment) => ({
|
|
368
|
+
id: segment.id,
|
|
369
|
+
text: segment.text,
|
|
370
|
+
...(segment.images.length > 0
|
|
371
|
+
? { images: segment.images.map((image) => ({ path: image.path, name: image.name })) }
|
|
372
|
+
: {}),
|
|
373
|
+
...(segment.archived ? { archived: true } : {}),
|
|
374
|
+
...(segment.archivedAt ? { archivedAt: segment.archivedAt } : {}),
|
|
375
|
+
})),
|
|
376
|
+
text: mergeSegments(segments),
|
|
377
|
+
updatedAt,
|
|
378
|
+
}, null, 2)}\n`;
|
|
379
|
+
const size = Buffer.byteLength(payload, 'utf8');
|
|
114
380
|
if (size > maxBytes) {
|
|
115
381
|
throw new NoteStoreError('too-large', `Note is too large (${size} bytes; limit ${maxBytes} bytes).`);
|
|
116
382
|
}
|
|
117
|
-
const dir = path.join(root, NOTE_DIR);
|
|
118
|
-
const file = path.join(dir, NOTE_FILE);
|
|
119
383
|
await fs.mkdir(dir, { recursive: true });
|
|
120
|
-
const updatedAt = new Date().toISOString();
|
|
121
|
-
const payload = `${JSON.stringify({ version: NOTE_VERSION, text, updatedAt }, null, 2)}\n`;
|
|
122
384
|
const tmp = path.join(dir, `.${NOTE_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
|
|
123
385
|
await fs.writeFile(tmp, payload, 'utf8');
|
|
124
386
|
try {
|
|
@@ -129,7 +391,8 @@ export async function writeNote(root, text, options = {}) {
|
|
|
129
391
|
}
|
|
130
392
|
const stat = await fs.stat(file);
|
|
131
393
|
return {
|
|
132
|
-
|
|
394
|
+
segments,
|
|
395
|
+
text: mergeSegments(segments),
|
|
133
396
|
updatedAt,
|
|
134
397
|
mtimeMs: stat.mtimeMs,
|
|
135
398
|
exists: true,
|