@restregular/dsh-notepad 0.1.0 → 0.2.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 +66 -21
- package/client.js +1342 -217
- package/index.js +4 -2
- package/lib/note-store.js +158 -25
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
const ROUTE = '/dsh-notepad/api';
|
|
29
29
|
|
|
30
30
|
/** Plugin version mirrored in package.json. */
|
|
31
|
-
const VERSION = '0.
|
|
31
|
+
const VERSION = '0.2.0';
|
|
32
32
|
|
|
33
33
|
/** Request body cap. */
|
|
34
34
|
const BODY_LIMIT = 1024 * 1024;
|
|
@@ -218,7 +218,9 @@ async function dispatchRequest(ctx, req, res) {
|
|
|
218
218
|
if (req.method === 'PUT') {
|
|
219
219
|
const body = await readJsonBody(req);
|
|
220
220
|
const root = await resolveRequestRoot(ctx, body);
|
|
221
|
-
const note = await writeNote(root, body.
|
|
221
|
+
const note = await writeNote(root, Array.isArray(body.segments) ? body.segments : body.text, {
|
|
222
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
223
|
+
});
|
|
222
224
|
sendJson(res, { ok: true, root, ...note });
|
|
223
225
|
return;
|
|
224
226
|
}
|
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,17 @@ 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 = 2;
|
|
28
37
|
|
|
29
38
|
/** A note-store failure with a stable machine-readable code. */
|
|
30
39
|
export class NoteStoreError extends Error {
|
|
@@ -47,25 +56,139 @@ export function notePath(root) {
|
|
|
47
56
|
return path.join(root, NOTE_DIR, NOTE_FILE);
|
|
48
57
|
}
|
|
49
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Normalize one segment read from disk. Reading is forgiving: a malformed
|
|
61
|
+
* entry is skipped instead of making the whole note unreadable.
|
|
62
|
+
* @param {unknown} value
|
|
63
|
+
* @param {number} index
|
|
64
|
+
* @param {Set<string>} seen
|
|
65
|
+
* @returns {{id: string, text: string, archived: boolean, archivedAt: string | null} | null}
|
|
66
|
+
*/
|
|
67
|
+
function readSegment(value, index, seen) {
|
|
68
|
+
if (typeof value === 'string') value = { text: value };
|
|
69
|
+
if (value === null || typeof value !== 'object') return null;
|
|
70
|
+
if (typeof value.text !== 'string') return null;
|
|
71
|
+
const text = value.text;
|
|
72
|
+
let id = typeof value.id === 'string' && value.id !== '' ? value.id.slice(0, 128) : `seg-${index + 1}`;
|
|
73
|
+
let suffix = 1;
|
|
74
|
+
while (seen.has(id)) {
|
|
75
|
+
id = `${id}-${suffix}`;
|
|
76
|
+
suffix += 1;
|
|
77
|
+
}
|
|
78
|
+
seen.add(id);
|
|
79
|
+
const archived = value.archived === true;
|
|
80
|
+
const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
|
|
81
|
+
? value.archivedAt
|
|
82
|
+
: null;
|
|
83
|
+
return { id, text, archived, archivedAt };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalize a segment list for a write. Unlike {@link readSegment}, malformed
|
|
88
|
+
* entries are an input error here: the route must not silently drop content.
|
|
89
|
+
*
|
|
90
|
+
* Accepts the modern `segments` array, a single legacy text string, or
|
|
91
|
+
* `{ text }`.
|
|
92
|
+
*
|
|
93
|
+
* @param {unknown} input
|
|
94
|
+
* @returns {Array<{id: string, text: string, archived: boolean, archivedAt: string | null}>}
|
|
95
|
+
*/
|
|
96
|
+
export function normalizeSegments(input) {
|
|
97
|
+
if (typeof input === 'string') {
|
|
98
|
+
if (input === '') return [];
|
|
99
|
+
return [{ id: 'seg-1', text: input, archived: false, archivedAt: null }];
|
|
100
|
+
}
|
|
101
|
+
if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
|
|
102
|
+
if (Array.isArray(input.segments)) return normalizeSegments(input.segments);
|
|
103
|
+
if (typeof input.text === 'string') return normalizeSegments(input.text);
|
|
104
|
+
throw new NoteStoreError('bad-input', 'Note must be a string or a segments array.');
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(input)) {
|
|
107
|
+
throw new NoteStoreError('bad-input', 'Note must be a string or a segments array.');
|
|
108
|
+
}
|
|
109
|
+
if (input.length > MAX_SEGMENTS) {
|
|
110
|
+
throw new NoteStoreError('too-many', `Too many segments (${input.length}; limit ${MAX_SEGMENTS}).`);
|
|
111
|
+
}
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
return input.map((value, index) => {
|
|
114
|
+
const segment = normalizeWriteSegment(value, index, seen);
|
|
115
|
+
return segment;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Strict per-segment normalization for writes.
|
|
121
|
+
* @param {unknown} value
|
|
122
|
+
* @param {number} index
|
|
123
|
+
* @param {Set<string>} seen
|
|
124
|
+
*/
|
|
125
|
+
function normalizeWriteSegment(value, index, seen) {
|
|
126
|
+
if (typeof value === 'string') value = { text: value };
|
|
127
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
128
|
+
throw new NoteStoreError('bad-input', `Segment ${index + 1} is not an object.`);
|
|
129
|
+
}
|
|
130
|
+
if (typeof value.text !== 'string') {
|
|
131
|
+
throw new NoteStoreError('bad-input', `Segment ${index + 1} has no text field.`);
|
|
132
|
+
}
|
|
133
|
+
if (value.text.length > MAX_SEGMENT_CHARS) {
|
|
134
|
+
throw new NoteStoreError(
|
|
135
|
+
'too-large',
|
|
136
|
+
`Segment ${index + 1} is too large (${value.text.length} characters; limit ${MAX_SEGMENT_CHARS}).`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
let id = typeof value.id === 'string' && value.id !== '' ? value.id.slice(0, 128) : `seg-${index + 1}`;
|
|
140
|
+
let suffix = 1;
|
|
141
|
+
while (seen.has(id)) {
|
|
142
|
+
id = `${id}-${suffix}`;
|
|
143
|
+
suffix += 1;
|
|
144
|
+
}
|
|
145
|
+
seen.add(id);
|
|
146
|
+
const archived = value.archived === true;
|
|
147
|
+
const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
|
|
148
|
+
? value.archivedAt
|
|
149
|
+
: null;
|
|
150
|
+
return { id, text: value.text, archived, archivedAt };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Merge segments into the plain body stored next to the list.
|
|
155
|
+
* @param {Array<{text: string}>} segments
|
|
156
|
+
*/
|
|
157
|
+
export function mergeSegments(segments) {
|
|
158
|
+
return segments.map((segment) => segment.text).join('\n\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
50
161
|
/**
|
|
51
162
|
* Decode a stored note file.
|
|
52
163
|
* @param {string} raw file contents
|
|
53
164
|
* @param {number | undefined} mtimeMs
|
|
54
165
|
*/
|
|
55
166
|
export function parseNote(raw, mtimeMs) {
|
|
56
|
-
let
|
|
167
|
+
let segmentsInput;
|
|
57
168
|
let updatedAt;
|
|
58
169
|
try {
|
|
59
170
|
const parsed = JSON.parse(raw);
|
|
60
|
-
if (parsed !== null && typeof parsed === 'object'
|
|
61
|
-
|
|
171
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
172
|
+
if (Array.isArray(parsed.segments)) segmentsInput = parsed.segments;
|
|
173
|
+
else if (typeof parsed.text === 'string') segmentsInput = parsed.text;
|
|
62
174
|
if (typeof parsed.updatedAt === 'string') updatedAt = parsed.updatedAt;
|
|
63
175
|
}
|
|
64
176
|
} catch {
|
|
65
177
|
/* Not our envelope: keep the raw text. */
|
|
66
178
|
}
|
|
179
|
+
if (segmentsInput === undefined) segmentsInput = raw;
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
const segments = [];
|
|
182
|
+
const values = typeof segmentsInput === 'string'
|
|
183
|
+
? (segmentsInput === '' ? [] : [segmentsInput])
|
|
184
|
+
: Array.isArray(segmentsInput) ? segmentsInput : [];
|
|
185
|
+
for (const value of values) {
|
|
186
|
+
const segment = readSegment(value, segments.length, seen);
|
|
187
|
+
if (segment !== null) segments.push(segment);
|
|
188
|
+
}
|
|
67
189
|
return {
|
|
68
|
-
|
|
190
|
+
segments,
|
|
191
|
+
text: mergeSegments(segments),
|
|
69
192
|
updatedAt: updatedAt ?? (typeof mtimeMs === 'number' ? new Date(mtimeMs).toISOString() : null),
|
|
70
193
|
mtimeMs: typeof mtimeMs === 'number' ? mtimeMs : null,
|
|
71
194
|
exists: true,
|
|
@@ -76,7 +199,7 @@ export function parseNote(raw, mtimeMs) {
|
|
|
76
199
|
/**
|
|
77
200
|
* Read one workspace note. A missing file is an empty note, not an error.
|
|
78
201
|
* @param {string} root workspace root
|
|
79
|
-
* @returns {Promise<{text: string, updatedAt: string | null, mtimeMs: number | null, exists: boolean, size: number}>}
|
|
202
|
+
* @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
203
|
*/
|
|
81
204
|
export async function readNote(root) {
|
|
82
205
|
const file = notePath(root);
|
|
@@ -85,7 +208,7 @@ export async function readNote(root) {
|
|
|
85
208
|
raw = await fs.readFile(file, 'utf8');
|
|
86
209
|
} catch (error) {
|
|
87
210
|
if (error?.code === 'ENOENT') {
|
|
88
|
-
return { text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
|
|
211
|
+
return { segments: [], text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
|
|
89
212
|
}
|
|
90
213
|
throw error;
|
|
91
214
|
}
|
|
@@ -100,25 +223,34 @@ export async function readNote(root) {
|
|
|
100
223
|
}
|
|
101
224
|
|
|
102
225
|
/**
|
|
103
|
-
* Replace one workspace note atomically.
|
|
226
|
+
* Replace one workspace note atomically. Accepts either a segment array or a
|
|
227
|
+
* legacy plain-text string.
|
|
104
228
|
* @param {string} root workspace root
|
|
105
|
-
* @param {
|
|
229
|
+
* @param {unknown} note segments array or legacy text
|
|
106
230
|
* @param {{ maxBytes?: number }} [options]
|
|
107
231
|
*/
|
|
108
|
-
export async function writeNote(root,
|
|
109
|
-
|
|
110
|
-
throw new NoteStoreError('bad-input', 'Note text must be a string.');
|
|
111
|
-
}
|
|
232
|
+
export async function writeNote(root, note, options = {}) {
|
|
233
|
+
const segments = normalizeSegments(note);
|
|
112
234
|
const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_BYTES;
|
|
113
|
-
const
|
|
235
|
+
const dir = path.join(root, NOTE_DIR);
|
|
236
|
+
const file = path.join(dir, NOTE_FILE);
|
|
237
|
+
const updatedAt = new Date().toISOString();
|
|
238
|
+
const payload = `${JSON.stringify({
|
|
239
|
+
version: NOTE_VERSION,
|
|
240
|
+
segments: segments.map((segment) => ({
|
|
241
|
+
id: segment.id,
|
|
242
|
+
text: segment.text,
|
|
243
|
+
...(segment.archived ? { archived: true } : {}),
|
|
244
|
+
...(segment.archivedAt ? { archivedAt: segment.archivedAt } : {}),
|
|
245
|
+
})),
|
|
246
|
+
text: mergeSegments(segments),
|
|
247
|
+
updatedAt,
|
|
248
|
+
}, null, 2)}\n`;
|
|
249
|
+
const size = Buffer.byteLength(payload, 'utf8');
|
|
114
250
|
if (size > maxBytes) {
|
|
115
251
|
throw new NoteStoreError('too-large', `Note is too large (${size} bytes; limit ${maxBytes} bytes).`);
|
|
116
252
|
}
|
|
117
|
-
const dir = path.join(root, NOTE_DIR);
|
|
118
|
-
const file = path.join(dir, NOTE_FILE);
|
|
119
253
|
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
254
|
const tmp = path.join(dir, `.${NOTE_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
|
|
123
255
|
await fs.writeFile(tmp, payload, 'utf8');
|
|
124
256
|
try {
|
|
@@ -129,7 +261,8 @@ export async function writeNote(root, text, options = {}) {
|
|
|
129
261
|
}
|
|
130
262
|
const stat = await fs.stat(file);
|
|
131
263
|
return {
|
|
132
|
-
|
|
264
|
+
segments,
|
|
265
|
+
text: mergeSegments(segments),
|
|
133
266
|
updatedAt,
|
|
134
267
|
mtimeMs: stat.mtimeMs,
|
|
135
268
|
exists: true,
|
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
|
|
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."
|
|
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
|
|
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 会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段卡片,支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除,可调整尺寸并适配深色模式。",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|