@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/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.1.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
- 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') {
@@ -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.text, { maxBytes: DEFAULT_MAX_BYTES });
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 plain-text note per workspace, kept at `<workspace>/.dsh/notepad.json`.
5
- * The JSON envelope lets the file carry its own `updatedAt` while still
6
- * degrading gracefully: if the file is not JSON (a user or another tool
7
- * edited it by hand), the raw bytes are returned as the note text instead of
8
- * losing the content.
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 = 1;
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 `![${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
+
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 text = raw;
294
+ let segmentsInput;
57
295
  let updatedAt;
58
296
  try {
59
297
  const parsed = JSON.parse(raw);
60
- if (parsed !== null && typeof parsed === 'object' && typeof parsed.text === 'string') {
61
- text = parsed.text;
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
- text,
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 {string} text note body
356
+ * @param {unknown} note segments array or legacy text
106
357
  * @param {{ maxBytes?: number }} [options]
107
358
  */
108
- export async function writeNote(root, text, options = {}) {
109
- if (typeof text !== 'string') {
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 size = Buffer.byteLength(text, 'utf8');
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
- text,
394
+ segments,
395
+ text: mergeSegments(segments),
133
396
  updatedAt,
134
397
  mtimeMs: stat.mtimeMs,
135
398
  exists: true,