@leeoohoo/ui-apps-devkit 0.1.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.
Files changed (55) hide show
  1. package/README.md +70 -0
  2. package/bin/chatos-uiapp.js +5 -0
  3. package/package.json +22 -0
  4. package/src/cli.js +53 -0
  5. package/src/commands/dev.js +14 -0
  6. package/src/commands/init.js +141 -0
  7. package/src/commands/install.js +55 -0
  8. package/src/commands/pack.js +72 -0
  9. package/src/commands/validate.js +103 -0
  10. package/src/lib/args.js +49 -0
  11. package/src/lib/config.js +29 -0
  12. package/src/lib/fs.js +78 -0
  13. package/src/lib/path-boundary.js +16 -0
  14. package/src/lib/plugin.js +45 -0
  15. package/src/lib/template.js +168 -0
  16. package/src/sandbox/server.js +861 -0
  17. package/templates/basic/README.md +58 -0
  18. package/templates/basic/chatos.config.json +5 -0
  19. package/templates/basic/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +181 -0
  20. package/templates/basic/docs/CHATOS_UI_APPS_BACKEND_PROTOCOL.md +74 -0
  21. package/templates/basic/docs/CHATOS_UI_APPS_HOST_API.md +123 -0
  22. package/templates/basic/docs/CHATOS_UI_APPS_OVERVIEW.md +110 -0
  23. package/templates/basic/docs/CHATOS_UI_APPS_PLUGIN_MANIFEST.md +227 -0
  24. package/templates/basic/docs/CHATOS_UI_PROMPTS_PROTOCOL.md +392 -0
  25. package/templates/basic/plugin/apps/app/index.mjs +263 -0
  26. package/templates/basic/plugin/apps/app/mcp-prompt.en.md +7 -0
  27. package/templates/basic/plugin/apps/app/mcp-prompt.zh.md +7 -0
  28. package/templates/basic/plugin/apps/app/mcp-server.mjs +15 -0
  29. package/templates/basic/plugin/backend/index.mjs +37 -0
  30. package/templates/basic/template.json +7 -0
  31. package/templates/notepad/README.md +36 -0
  32. package/templates/notepad/chatos.config.json +4 -0
  33. package/templates/notepad/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +181 -0
  34. package/templates/notepad/docs/CHATOS_UI_APPS_BACKEND_PROTOCOL.md +74 -0
  35. package/templates/notepad/docs/CHATOS_UI_APPS_HOST_API.md +123 -0
  36. package/templates/notepad/docs/CHATOS_UI_APPS_OVERVIEW.md +110 -0
  37. package/templates/notepad/docs/CHATOS_UI_APPS_PLUGIN_MANIFEST.md +227 -0
  38. package/templates/notepad/docs/CHATOS_UI_PROMPTS_PROTOCOL.md +392 -0
  39. package/templates/notepad/plugin/apps/app/api.mjs +30 -0
  40. package/templates/notepad/plugin/apps/app/dom.mjs +14 -0
  41. package/templates/notepad/plugin/apps/app/ds-tree.mjs +35 -0
  42. package/templates/notepad/plugin/apps/app/index.mjs +1056 -0
  43. package/templates/notepad/plugin/apps/app/layers.mjs +338 -0
  44. package/templates/notepad/plugin/apps/app/markdown.mjs +120 -0
  45. package/templates/notepad/plugin/apps/app/mcp-prompt.en.md +22 -0
  46. package/templates/notepad/plugin/apps/app/mcp-prompt.zh.md +22 -0
  47. package/templates/notepad/plugin/apps/app/mcp-server.mjs +200 -0
  48. package/templates/notepad/plugin/apps/app/styles.mjs +355 -0
  49. package/templates/notepad/plugin/apps/app/tags.mjs +21 -0
  50. package/templates/notepad/plugin/apps/app/ui.mjs +280 -0
  51. package/templates/notepad/plugin/backend/index.mjs +99 -0
  52. package/templates/notepad/plugin/plugin.json +23 -0
  53. package/templates/notepad/plugin/shared/notepad-paths.mjs +62 -0
  54. package/templates/notepad/plugin/shared/notepad-store.mjs +765 -0
  55. package/templates/notepad/template.json +8 -0
@@ -0,0 +1,1056 @@
1
+ import { createNotesApi } from './api.mjs';
2
+ import { createNotepadLayerManager } from './layers.mjs';
3
+ import { normalizeString, setButtonEnabled } from './dom.mjs';
4
+ import { renderMarkdown } from './markdown.mjs';
5
+ import { parseTags, tagsToText } from './tags.mjs';
6
+ import { createNotepadManagerUi } from './ui.mjs';
7
+ import { createDsPathTreeView } from './ds-tree.mjs';
8
+
9
+ export function mount({ container, host, slots }) {
10
+ if (!container) throw new Error('container is required');
11
+ if (!host || typeof host !== 'object') throw new Error('host is required');
12
+
13
+ const ctx =
14
+ typeof host?.context?.get === 'function' ? host.context.get() : { pluginId: '', appId: '', theme: 'light' };
15
+ const bridgeEnabled = Boolean(ctx?.bridge?.enabled);
16
+
17
+ const api = createNotesApi({ host, bridgeEnabled });
18
+
19
+ const {
20
+ root,
21
+ btnNewFolder,
22
+ btnNewNote,
23
+ btnSave,
24
+ btnDelete,
25
+ btnCopy,
26
+ btnToggleEdit,
27
+ createHint,
28
+ searchInput,
29
+ btnClearSearch,
30
+ folderList,
31
+ tagRow,
32
+ titleInput,
33
+ folderSelect,
34
+ tagsInput,
35
+ infoBox,
36
+ textarea,
37
+ preview,
38
+ setStatus,
39
+ } = createNotepadManagerUi({ container, slots, ctx, bridgeEnabled });
40
+
41
+ let disposed = false;
42
+ const { closeActiveLayer, showMenu, showDialog, confirmDialog } = createNotepadLayerManager({
43
+ getDisposed: () => disposed,
44
+ setStatus,
45
+ });
46
+
47
+ let folders = [];
48
+ let tags = [];
49
+ let notes = [];
50
+ let selectedFolder = '';
51
+ let selectedTags = [];
52
+ let selectedNoteId = '';
53
+ let currentNote = null;
54
+ let currentContent = '';
55
+ let dirty = false;
56
+ let controlsEnabled = false;
57
+ let editorMode = 'preview';
58
+ let copying = false;
59
+ let copyFeedbackTimer = null;
60
+ let activeTreeKey = '';
61
+ const NOTE_KEY_PREFIX = '__note__:';
62
+ const noteIndex = new Map();
63
+ let refreshFoldersSeq = 0;
64
+ let refreshNotesSeq = 0;
65
+ let openNoteSeq = 0;
66
+ let searchDebounceTimer = null;
67
+ let searchWasActive = false;
68
+ let expandedKeysBeforeSearch = null;
69
+
70
+ const makeNoteKey = (folder, id) => {
71
+ const noteId = normalizeString(id);
72
+ const folderPath = normalizeString(folder);
73
+ if (!noteId) return folderPath || '';
74
+ const segment = `${NOTE_KEY_PREFIX}${noteId}`;
75
+ return folderPath ? `${folderPath}/${segment}` : segment;
76
+ };
77
+
78
+ const parseTreeKey = (key) => {
79
+ const raw = typeof key === 'string' ? key.trim() : '';
80
+ if (!raw) return { kind: 'folder', folder: '' };
81
+ const parts = raw.split('/').filter(Boolean);
82
+ if (parts.length === 0) return { kind: 'folder', folder: '' };
83
+ const last = parts[parts.length - 1] || '';
84
+ if (last.startsWith(NOTE_KEY_PREFIX)) {
85
+ const noteId = last.slice(NOTE_KEY_PREFIX.length);
86
+ const folder = parts.slice(0, -1).join('/');
87
+ return { kind: 'note', folder, noteId };
88
+ }
89
+ return { kind: 'folder', folder: parts.join('/') };
90
+ };
91
+
92
+ const clearCopyFeedbackTimer = () => {
93
+ if (!copyFeedbackTimer) return;
94
+ try {
95
+ clearTimeout(copyFeedbackTimer);
96
+ } catch {
97
+ // ignore
98
+ }
99
+ copyFeedbackTimer = null;
100
+ };
101
+
102
+ const flashCopyFeedback = (text) => {
103
+ if (!btnCopy) return;
104
+ const original = '复制';
105
+ btnCopy.textContent = text;
106
+ clearCopyFeedbackTimer();
107
+ copyFeedbackTimer = setTimeout(() => {
108
+ copyFeedbackTimer = null;
109
+ btnCopy.textContent = original;
110
+ }, 1000);
111
+ };
112
+
113
+ const syncEditorControls = () => {
114
+ const hasNote = Boolean(currentNote);
115
+ const editable = controlsEnabled && hasNote && editorMode === 'edit';
116
+
117
+ setButtonEnabled(btnSave, editable);
118
+ setButtonEnabled(btnDelete, controlsEnabled && hasNote);
119
+ setButtonEnabled(btnCopy, controlsEnabled && hasNote && !copying);
120
+ setButtonEnabled(btnToggleEdit, controlsEnabled && hasNote);
121
+
122
+ titleInput.disabled = !editable;
123
+ folderSelect.disabled = !editable;
124
+ tagsInput.disabled = !editable;
125
+ textarea.disabled = !editable;
126
+ };
127
+
128
+ const setEditorMode = (mode, { focus } = {}) => {
129
+ editorMode = mode === 'edit' ? 'edit' : 'preview';
130
+ if (root) root.dataset.editorMode = editorMode;
131
+ if (btnToggleEdit) btnToggleEdit.textContent = editorMode === 'edit' ? '预览' : '编辑';
132
+ syncEditorControls();
133
+ if (focus && editorMode === 'edit') {
134
+ try {
135
+ textarea.focus();
136
+ } catch {
137
+ // ignore
138
+ }
139
+ }
140
+ };
141
+
142
+ const copyPlainText = async (text) => {
143
+ const value = typeof text === 'string' ? text : String(text ?? '');
144
+ if (typeof navigator !== 'undefined' && navigator?.clipboard?.writeText) {
145
+ await navigator.clipboard.writeText(value);
146
+ return;
147
+ }
148
+ const el = document.createElement('textarea');
149
+ el.value = value;
150
+ el.setAttribute('readonly', '');
151
+ el.style.position = 'fixed';
152
+ el.style.top = '-1000px';
153
+ el.style.left = '-1000px';
154
+ el.style.opacity = '0';
155
+ document.body.appendChild(el);
156
+ el.select();
157
+ el.setSelectionRange(0, el.value.length);
158
+ const ok = document.execCommand('copy');
159
+ document.body.removeChild(el);
160
+ if (!ok) throw new Error('copy failed');
161
+ };
162
+
163
+ const setControlsEnabled = (enabled) => {
164
+ controlsEnabled = enabled;
165
+ setButtonEnabled(btnNewFolder, enabled);
166
+ setButtonEnabled(btnNewNote, enabled);
167
+ searchInput.disabled = !enabled;
168
+ setButtonEnabled(btnClearSearch, enabled);
169
+ syncEditorControls();
170
+ };
171
+
172
+ const updateCreateHint = () => {
173
+ const label = selectedFolder ? selectedFolder : '根目录';
174
+ createHint.textContent = `新笔记将创建在:${label}`;
175
+ };
176
+
177
+ const showFolderMenu = (x, y, f) => {
178
+ showMenu(x, y, [
179
+ {
180
+ label: '设为当前文件夹',
181
+ onClick: async () => {
182
+ selectedFolder = f;
183
+ activeTreeKey = f;
184
+ updateCreateHint();
185
+ renderFolderList();
186
+ },
187
+ },
188
+ {
189
+ label: '在此新建笔记…',
190
+ onClick: async () => {
191
+ if (!(await ensureSafeToSwitch())) return;
192
+ const values = await showDialog({
193
+ title: '新建笔记',
194
+ description: `目标文件夹:${f ? f : '根目录'}`,
195
+ fields: [{ name: 'title', label: '标题', kind: 'text', value: '', placeholder: '可空' }],
196
+ confirmText: '创建',
197
+ });
198
+ if (!values) return;
199
+ const noteTitle = normalizeString(values.title);
200
+ setStatus('Notes: creating note...', 'bad');
201
+ const res = await api.createNote({ folder: f, title: noteTitle });
202
+ if (!res?.ok) {
203
+ setStatus(`Notes: ${res?.message || 'create note failed'}`, 'bad');
204
+ return;
205
+ }
206
+ selectedFolder = f;
207
+ updateCreateHint();
208
+ await refreshFoldersAndTags();
209
+ await refreshNotes();
210
+ const id = res?.note?.id || '';
211
+ if (id) await openNote(id);
212
+ setStatus('Notes: note created', 'ok');
213
+ },
214
+ },
215
+ {
216
+ label: '新建子文件夹…',
217
+ onClick: async () => {
218
+ if (!(await ensureSafeToSwitch())) return;
219
+ const values = await showDialog({
220
+ title: '新建文件夹',
221
+ fields: [
222
+ {
223
+ name: 'folder',
224
+ label: '文件夹路径',
225
+ kind: 'text',
226
+ value: f ? `${f}/` : '',
227
+ placeholder: '例如:work/ideas',
228
+ required: true,
229
+ },
230
+ ],
231
+ confirmText: '创建',
232
+ });
233
+ if (!values) return;
234
+ const folder = normalizeString(values.folder);
235
+ if (!folder) return;
236
+ setStatus('Notes: creating folder...', 'bad');
237
+ const res = await api.createFolder({ folder });
238
+ if (!res?.ok) {
239
+ setStatus(`Notes: ${res?.message || 'create folder failed'}`, 'bad');
240
+ return;
241
+ }
242
+ selectedFolder = res?.folder || folder;
243
+ updateCreateHint();
244
+ await refreshFoldersAndTags();
245
+ await refreshNotes();
246
+ setStatus('Notes: folder created', 'ok');
247
+ },
248
+ },
249
+ {
250
+ label: '重命名文件夹…',
251
+ disabled: !f,
252
+ onClick: async () => {
253
+ if (!(await ensureSafeToSwitch())) return;
254
+ const values = await showDialog({
255
+ title: '重命名文件夹',
256
+ description: `当前:${f}`,
257
+ fields: [{ name: 'to', label: '新路径', kind: 'text', value: f, placeholder: '例如:work/notes', required: true }],
258
+ confirmText: '重命名',
259
+ });
260
+ if (!values) return;
261
+ const to = normalizeString(values.to);
262
+ if (!to) return;
263
+ setStatus('Notes: renaming folder...', 'bad');
264
+ const res = await api.renameFolder({ from: f, to });
265
+ if (!res?.ok) {
266
+ setStatus(`Notes: ${res?.message || 'rename failed'}`, 'bad');
267
+ return;
268
+ }
269
+ if (selectedFolder === f) {
270
+ selectedFolder = to;
271
+ } else if (selectedFolder.startsWith(`${f}/`)) {
272
+ selectedFolder = `${to}/${selectedFolder.slice(f.length + 1)}`;
273
+ }
274
+ if (currentNote?.folder === f) {
275
+ currentNote.folder = to;
276
+ } else if (currentNote?.folder && String(currentNote.folder).startsWith(`${f}/`)) {
277
+ currentNote.folder = `${to}/${String(currentNote.folder).slice(f.length + 1)}`;
278
+ }
279
+ updateCreateHint();
280
+ await refreshFoldersAndTags();
281
+ await refreshNotes();
282
+ renderEditor(true);
283
+ setStatus('Notes: folder renamed', 'ok');
284
+ },
285
+ },
286
+ {
287
+ label: '删除文件夹(递归)',
288
+ disabled: !f,
289
+ danger: true,
290
+ onClick: async () => {
291
+ if (!(await ensureSafeToSwitch())) return;
292
+ const ok = await confirmDialog(`确定删除文件夹「${f}」及其所有子目录与笔记吗?`, {
293
+ title: '删除文件夹',
294
+ danger: true,
295
+ confirmText: '删除',
296
+ });
297
+ if (!ok) return;
298
+ setStatus('Notes: deleting folder...', 'bad');
299
+ const res = await api.deleteFolder({ folder: f, recursive: true });
300
+ if (!res?.ok) {
301
+ setStatus(`Notes: ${res?.message || 'delete folder failed'}`, 'bad');
302
+ return;
303
+ }
304
+ if (selectedFolder === f || selectedFolder.startsWith(`${f}/`)) {
305
+ selectedFolder = '';
306
+ }
307
+ updateCreateHint();
308
+ await refreshFoldersAndTags();
309
+ await refreshNotes();
310
+ renderEditor(true);
311
+ setStatus('Notes: folder deleted', 'ok');
312
+ },
313
+ },
314
+ ]);
315
+ };
316
+
317
+ const showNoteMenu = (x, y, n) => {
318
+ const noteId = normalizeString(n?.id);
319
+ if (!noteId) return;
320
+ showMenu(x, y, [
321
+ {
322
+ label: noteId === selectedNoteId ? '当前已打开' : '打开',
323
+ disabled: noteId === selectedNoteId,
324
+ onClick: async () => {
325
+ if (noteId === selectedNoteId) return;
326
+ if (!(await ensureSafeToSwitch())) return;
327
+ await openNote(noteId);
328
+ },
329
+ },
330
+ {
331
+ label: '重命名…',
332
+ onClick: async () => {
333
+ const values = await showDialog({
334
+ title: '重命名笔记',
335
+ description: `ID: ${noteId}`,
336
+ fields: [{ name: 'title', label: '标题', kind: 'text', value: n?.title || '', placeholder: '例如:周报', required: true }],
337
+ confirmText: '重命名',
338
+ });
339
+ if (!values) return;
340
+ const nextTitle = normalizeString(values.title);
341
+ if (!nextTitle) return;
342
+ if (noteId === selectedNoteId && currentNote) {
343
+ currentNote.title = nextTitle;
344
+ try {
345
+ titleInput.value = nextTitle;
346
+ } catch {
347
+ // ignore
348
+ }
349
+ dirty = true;
350
+ renderEditor(false);
351
+ await doSave();
352
+ return;
353
+ }
354
+ setStatus('Notes: updating note...', 'bad');
355
+ const res = await api.updateNote({ id: noteId, title: nextTitle });
356
+ if (!res?.ok) {
357
+ setStatus(`Notes: ${res?.message || 'update failed'}`, 'bad');
358
+ return;
359
+ }
360
+ await refreshFoldersAndTags();
361
+ await refreshNotes();
362
+ setStatus('Notes: note updated', 'ok');
363
+ },
364
+ },
365
+ {
366
+ label: '移动到文件夹…',
367
+ onClick: async () => {
368
+ const options = (Array.isArray(folders) ? folders : ['']).map((f) => ({ value: f, label: f ? f : '(根目录)' }));
369
+ const values = await showDialog({
370
+ title: '移动笔记',
371
+ description: `当前:${n?.folder ? n.folder : '根目录'}`,
372
+ fields: [{ name: 'folder', label: '目标文件夹', kind: 'select', options, value: n?.folder || '' }],
373
+ confirmText: '移动',
374
+ });
375
+ if (!values) return;
376
+ const nextFolder = normalizeString(values.folder);
377
+ if (noteId === selectedNoteId && currentNote) {
378
+ currentNote.folder = nextFolder;
379
+ try {
380
+ folderSelect.value = nextFolder;
381
+ } catch {
382
+ // ignore
383
+ }
384
+ dirty = true;
385
+ renderEditor(false);
386
+ await doSave();
387
+ return;
388
+ }
389
+ setStatus('Notes: moving note...', 'bad');
390
+ const res = await api.updateNote({ id: noteId, folder: nextFolder });
391
+ if (!res?.ok) {
392
+ setStatus(`Notes: ${res?.message || 'move failed'}`, 'bad');
393
+ return;
394
+ }
395
+ await refreshFoldersAndTags();
396
+ await refreshNotes();
397
+ setStatus('Notes: note moved', 'ok');
398
+ },
399
+ },
400
+ {
401
+ label: '设置标签…',
402
+ onClick: async () => {
403
+ const values = await showDialog({
404
+ title: '设置标签',
405
+ description: '用逗号分隔,例如:work, todo',
406
+ fields: [{ name: 'tags', label: '标签', kind: 'text', value: tagsToText(n?.tags), placeholder: 'tag1, tag2' }],
407
+ confirmText: '应用',
408
+ });
409
+ if (!values) return;
410
+ const nextTags = parseTags(values.tags);
411
+ if (noteId === selectedNoteId && currentNote) {
412
+ currentNote.tags = nextTags;
413
+ try {
414
+ tagsInput.value = tagsToText(nextTags);
415
+ } catch {
416
+ // ignore
417
+ }
418
+ dirty = true;
419
+ renderEditor(false);
420
+ await doSave();
421
+ return;
422
+ }
423
+ setStatus('Notes: updating tags...', 'bad');
424
+ const res = await api.updateNote({ id: noteId, tags: nextTags });
425
+ if (!res?.ok) {
426
+ setStatus(`Notes: ${res?.message || 'update failed'}`, 'bad');
427
+ return;
428
+ }
429
+ await refreshFoldersAndTags();
430
+ await refreshNotes();
431
+ setStatus('Notes: tags updated', 'ok');
432
+ },
433
+ },
434
+ {
435
+ label: '删除',
436
+ danger: true,
437
+ onClick: async () => {
438
+ if (noteId === selectedNoteId && currentNote) {
439
+ await doDelete();
440
+ return;
441
+ }
442
+ const ok = await confirmDialog(`确定删除「${n?.title || 'Untitled'}」吗?`, {
443
+ title: '删除笔记',
444
+ danger: true,
445
+ confirmText: '删除',
446
+ });
447
+ if (!ok) return;
448
+ setStatus('Notes: deleting note...', 'bad');
449
+ const res = await api.deleteNote({ id: noteId });
450
+ if (!res?.ok) {
451
+ setStatus(`Notes: ${res?.message || 'delete failed'}`, 'bad');
452
+ return;
453
+ }
454
+ await refreshFoldersAndTags();
455
+ await refreshNotes();
456
+ setStatus('Notes: note deleted', 'ok');
457
+ },
458
+ },
459
+ ]);
460
+ };
461
+
462
+ const showTreeMenu = (x, y, key) => {
463
+ const parsed = parseTreeKey(key);
464
+ if (parsed.kind === 'note') {
465
+ const note = noteIndex.get(parsed.noteId);
466
+ if (note) showNoteMenu(x, y, note);
467
+ return;
468
+ }
469
+ showFolderMenu(x, y, parsed.folder);
470
+ };
471
+
472
+ const folderTree = createDsPathTreeView({
473
+ container: folderList,
474
+ getLabel: (key) => {
475
+ const parsed = parseTreeKey(key);
476
+ if (parsed.kind === 'note') {
477
+ const note = noteIndex.get(parsed.noteId);
478
+ return note?.title || 'Untitled';
479
+ }
480
+ return parsed.folder ? parsed.folder.split('/').slice(-1)[0] : '(根目录)';
481
+ },
482
+ getTitle: (key) => {
483
+ const parsed = parseTreeKey(key);
484
+ if (parsed.kind === 'note') {
485
+ const note = noteIndex.get(parsed.noteId);
486
+ const folderText = parsed.folder ? parsed.folder : '根目录';
487
+ const updatedAt = note?.updatedAt ? ` · ${note.updatedAt}` : '';
488
+ return `${note?.title || 'Untitled'} · ${folderText}${updatedAt}`;
489
+ }
490
+ return parsed.folder ? parsed.folder : '全部笔记的根目录';
491
+ },
492
+ getIconClass: (key) => {
493
+ const parsed = parseTreeKey(key);
494
+ if (parsed.kind === 'note') return 'ds-tree-icon-note';
495
+ return parsed.folder ? 'ds-tree-icon-folder' : 'ds-tree-icon-home';
496
+ },
497
+ getSortMeta: (key) => {
498
+ if (!key) return { group: -1, label: '' };
499
+ const parsed = parseTreeKey(key);
500
+ if (parsed.kind === 'note') {
501
+ const note = noteIndex.get(parsed.noteId);
502
+ return { group: 1, label: note?.title || 'Untitled' };
503
+ }
504
+ return { group: 0, label: parsed.folder.split('/').slice(-1)[0] };
505
+ },
506
+ onSelect: async (key) => {
507
+ if (disposed) return;
508
+ const parsed = parseTreeKey(key);
509
+ if (parsed.kind === 'note') {
510
+ const noteId = parsed.noteId;
511
+ if (!noteId) return;
512
+ if (noteId === selectedNoteId) {
513
+ if (activeTreeKey !== key) {
514
+ activeTreeKey = key;
515
+ renderFolderList();
516
+ }
517
+ return;
518
+ }
519
+ if (!(await ensureSafeToSwitch())) return;
520
+ if (selectedFolder !== parsed.folder) {
521
+ selectedFolder = parsed.folder;
522
+ updateCreateHint();
523
+ }
524
+ activeTreeKey = key;
525
+ renderFolderList();
526
+ await openNote(noteId);
527
+ return;
528
+ }
529
+ const folder = parsed.folder;
530
+ if (folder === selectedFolder && activeTreeKey === folder) return;
531
+ activeTreeKey = folder;
532
+ selectedFolder = folder;
533
+ updateCreateHint();
534
+ renderFolderList();
535
+ },
536
+ onContextMenu: (ev, key) => {
537
+ if (disposed) return;
538
+ showTreeMenu(ev?.clientX ?? 0, ev?.clientY ?? 0, key);
539
+ },
540
+ });
541
+
542
+ const ensureSafeToSwitch = async () => {
543
+ if (!dirty) return true;
544
+ return await confirmDialog('当前笔记有未保存的修改,确定丢弃并继续吗?', {
545
+ title: '未保存的更改',
546
+ danger: true,
547
+ confirmText: '丢弃并继续',
548
+ });
549
+ };
550
+
551
+ const renderFolderOptions = () => {
552
+ folderSelect.innerHTML = '';
553
+ const opts = [''].concat(folders.filter((f) => f !== ''));
554
+ for (const f of opts) {
555
+ const opt = document.createElement('option');
556
+ opt.value = f;
557
+ opt.textContent = f ? f : '(根目录)';
558
+ folderSelect.appendChild(opt);
559
+ }
560
+ };
561
+
562
+ const renderFolderList = () => {
563
+ const query = normalizeString(searchInput.value);
564
+ const isFiltering = Boolean(query) || selectedTags.length > 0;
565
+
566
+ const paths = isFiltering ? [] : Array.isArray(folders) ? [...folders] : [];
567
+ if (isFiltering && selectedFolder) paths.push(selectedFolder);
568
+ const currentId = normalizeString(currentNote?.id);
569
+ (Array.isArray(notes) ? notes : []).forEach((n) => {
570
+ const id = normalizeString(n?.id);
571
+ if (!id) return;
572
+ if (currentId && id === currentId) return;
573
+ paths.push(makeNoteKey(n?.folder, id));
574
+ });
575
+ if (currentId) {
576
+ paths.push(makeNoteKey(normalizeString(currentNote?.folder) || selectedFolder, currentId));
577
+ }
578
+
579
+ const fallbackKey = selectedNoteId
580
+ ? makeNoteKey(normalizeString(currentNote?.folder) || selectedFolder, selectedNoteId)
581
+ : selectedFolder;
582
+ const selectedKey = activeTreeKey || fallbackKey;
583
+
584
+ const parsed = parseTreeKey(selectedKey);
585
+ const folderToExpand = parsed.kind === 'note' ? parsed.folder : parsed.folder;
586
+ if (!isFiltering && searchWasActive) {
587
+ searchWasActive = false;
588
+ folderTree.setExpandedKeys(Array.isArray(expandedKeysBeforeSearch) ? expandedKeysBeforeSearch : ['']);
589
+ expandedKeysBeforeSearch = null;
590
+ }
591
+
592
+ if (isFiltering && !searchWasActive) {
593
+ searchWasActive = true;
594
+ expandedKeysBeforeSearch = folderTree.getExpandedKeys();
595
+ }
596
+
597
+ const expanded = new Set(isFiltering ? [''] : folderTree.getExpandedKeys());
598
+ expanded.add('');
599
+ if (folderToExpand) expanded.add(folderToExpand);
600
+ if (isFiltering) {
601
+ const addFolderAndParents = (folder) => {
602
+ const value = normalizeString(folder);
603
+ if (!value) return;
604
+ const parts = value.split('/').filter(Boolean);
605
+ let acc = '';
606
+ for (const part of parts) {
607
+ acc = acc ? `${acc}/${part}` : part;
608
+ expanded.add(acc);
609
+ }
610
+ };
611
+ notes.forEach((n) => addFolderAndParents(n?.folder));
612
+ addFolderAndParents(selectedFolder);
613
+ if (currentNote?.folder) addFolderAndParents(currentNote.folder);
614
+ }
615
+ folderTree.setExpandedKeys(Array.from(expanded));
616
+
617
+ folderTree.render({ paths, selectedKey });
618
+ };
619
+
620
+ const renderTags = () => {
621
+ if (!tagRow || !tagRow.isConnected) return;
622
+ tagRow.innerHTML = '';
623
+ if (!Array.isArray(tags) || tags.length === 0) {
624
+ const empty = document.createElement('div');
625
+ empty.className = 'np-meta';
626
+ empty.textContent = '暂无标签';
627
+ tagRow.appendChild(empty);
628
+ return;
629
+ }
630
+ for (const t of tags) {
631
+ const chip = document.createElement('div');
632
+ chip.className = 'np-chip';
633
+ chip.dataset.active = selectedTags.some((x) => x.toLowerCase() === String(t.tag || '').toLowerCase()) ? '1' : '0';
634
+ chip.textContent = `${t.tag} (${t.count})`;
635
+ chip.addEventListener('click', async () => {
636
+ if (disposed) return;
637
+ const key = String(t.tag || '').toLowerCase();
638
+ const idx = selectedTags.findIndex((x) => x.toLowerCase() === key);
639
+ if (idx >= 0) selectedTags.splice(idx, 1);
640
+ else selectedTags.push(t.tag);
641
+ await refreshNotes();
642
+ renderTags();
643
+ });
644
+ tagRow.appendChild(chip);
645
+ }
646
+ };
647
+
648
+ const renderEditor = (force = false) => {
649
+ if (!currentNote) {
650
+ infoBox.textContent = '未选择笔记';
651
+ titleInput.value = '';
652
+ tagsInput.value = '';
653
+ textarea.value = '';
654
+ preview.innerHTML = '<div class="np-meta">预览区</div>';
655
+ setEditorMode('preview');
656
+ return;
657
+ }
658
+ infoBox.textContent = dirty ? `未保存 · ${currentNote.updatedAt || ''}` : `${currentNote.updatedAt || ''}`;
659
+ if (force || document.activeElement !== titleInput) titleInput.value = currentNote.title || '';
660
+ if (force || document.activeElement !== folderSelect) folderSelect.value = currentNote.folder || '';
661
+ if (force || document.activeElement !== tagsInput) tagsInput.value = tagsToText(currentNote.tags);
662
+ if (force || document.activeElement !== textarea) textarea.value = currentContent;
663
+ preview.innerHTML = renderMarkdown(currentContent);
664
+ syncEditorControls();
665
+ };
666
+
667
+ const refreshFoldersAndTags = async () => {
668
+ const seq = (refreshFoldersSeq += 1);
669
+ let folderRes = null;
670
+ let tagRes = null;
671
+ const shouldLoadTags = Boolean(tagRow && tagRow.isConnected);
672
+ try {
673
+ [folderRes, tagRes] = await Promise.all([
674
+ api.listFolders(),
675
+ shouldLoadTags ? api.listTags() : Promise.resolve({ ok: true, tags: [] }),
676
+ ]);
677
+ } catch (err) {
678
+ if (disposed || seq !== refreshFoldersSeq) return;
679
+ setStatus(`Notes: ${err?.message || String(err)}`, 'bad');
680
+ return;
681
+ }
682
+ if (disposed || seq !== refreshFoldersSeq) return;
683
+
684
+ folders = Array.isArray(folderRes?.folders) ? folderRes.folders : [''];
685
+ if (!folders.includes('')) folders.unshift('');
686
+ tags = Array.isArray(tagRes?.tags) ? tagRes.tags : [];
687
+ renderFolderOptions();
688
+ renderFolderList();
689
+ if (shouldLoadTags) renderTags();
690
+ };
691
+
692
+ const refreshNotes = async () => {
693
+ const seq = (refreshNotesSeq += 1);
694
+ const query = normalizeString(searchInput.value);
695
+ const includeContent = query.length >= 2;
696
+ let res = null;
697
+ try {
698
+ if (!query || !includeContent) {
699
+ res = await api.listNotes({
700
+ folder: '',
701
+ recursive: true,
702
+ tags: selectedTags,
703
+ match: 'all',
704
+ query,
705
+ limit: 500,
706
+ });
707
+ } else {
708
+ res = await api.searchNotes({
709
+ query,
710
+ folder: '',
711
+ recursive: true,
712
+ tags: selectedTags,
713
+ match: 'all',
714
+ includeContent: true,
715
+ limit: 200,
716
+ });
717
+ }
718
+ } catch (err) {
719
+ if (disposed || seq !== refreshNotesSeq) return;
720
+ setStatus(`Notes: ${err?.message || String(err)}`, 'bad');
721
+ return;
722
+ }
723
+ if (disposed || seq !== refreshNotesSeq) return;
724
+
725
+ if (!res?.ok) {
726
+ notes = [];
727
+ setStatus(`Notes: ${res?.message || 'list notes failed'}`, 'bad');
728
+ } else {
729
+ notes = Array.isArray(res?.notes) ? res.notes : [];
730
+ }
731
+ noteIndex.clear();
732
+ notes.forEach((n) => {
733
+ const id = normalizeString(n?.id);
734
+ if (!id) return;
735
+ noteIndex.set(id, n);
736
+ });
737
+ const currentId = normalizeString(currentNote?.id);
738
+ if (currentId && currentNote) noteIndex.set(currentId, currentNote);
739
+ renderFolderList();
740
+ };
741
+
742
+ const openNote = async (id) => {
743
+ const seq = (openNoteSeq += 1);
744
+ let res = null;
745
+ try {
746
+ res = await api.getNote({ id });
747
+ } catch (err) {
748
+ if (disposed || seq !== openNoteSeq) return false;
749
+ setStatus(`Notes: ${err?.message || String(err)}`, 'bad');
750
+ return false;
751
+ }
752
+ if (disposed || seq !== openNoteSeq) return false;
753
+ if (!res?.ok) {
754
+ setStatus(`Notes: ${res?.message || 'load failed'}`, 'bad');
755
+ return false;
756
+ }
757
+ selectedNoteId = id;
758
+ currentNote = res.note || null;
759
+ currentContent = String(res.content ?? '');
760
+ dirty = false;
761
+ setEditorMode('preview');
762
+ activeTreeKey = makeNoteKey(res.note?.folder, id);
763
+ if (currentNote) noteIndex.set(id, currentNote);
764
+ renderFolderList();
765
+ renderEditor(true);
766
+ return true;
767
+ };
768
+
769
+ const doSave = async () => {
770
+ if (!currentNote) return;
771
+ const nextTitle = normalizeString(titleInput.value);
772
+ const nextFolder = normalizeString(folderSelect.value);
773
+ const nextTags = parseTags(tagsInput.value);
774
+ let res = null;
775
+ try {
776
+ res = await api.updateNote({ id: currentNote.id, title: nextTitle, folder: nextFolder, tags: nextTags, content: currentContent });
777
+ } catch (err) {
778
+ setStatus(`Notes: ${err?.message || String(err)}`, 'bad');
779
+ return;
780
+ }
781
+ if (!res?.ok) {
782
+ setStatus(`Notes: ${res?.message || 'save failed'}`, 'bad');
783
+ return;
784
+ }
785
+ currentNote = res.note || currentNote;
786
+ dirty = false;
787
+ setStatus('Notes: saved', 'ok');
788
+ await refreshFoldersAndTags();
789
+ await refreshNotes();
790
+ renderEditor(true);
791
+ };
792
+
793
+ const doDelete = async () => {
794
+ if (!currentNote) return;
795
+ const ok = await confirmDialog(`确定删除「${currentNote.title || 'Untitled'}」吗?`, {
796
+ title: '删除笔记',
797
+ danger: true,
798
+ confirmText: '删除',
799
+ });
800
+ if (!ok) return;
801
+ let res = null;
802
+ try {
803
+ res = await api.deleteNote({ id: currentNote.id });
804
+ } catch (err) {
805
+ setStatus(`Notes: ${err?.message || String(err)}`, 'bad');
806
+ return;
807
+ }
808
+ if (!res?.ok) {
809
+ setStatus(`Notes: ${res?.message || 'delete failed'}`, 'bad');
810
+ return;
811
+ }
812
+ selectedNoteId = '';
813
+ currentNote = null;
814
+ currentContent = '';
815
+ dirty = false;
816
+ setStatus('Notes: deleted', 'ok');
817
+ await refreshFoldersAndTags();
818
+ await refreshNotes();
819
+ renderEditor(true);
820
+ };
821
+
822
+ btnNewFolder.addEventListener('click', async () => {
823
+ if (disposed) return;
824
+ const values = await showDialog({
825
+ title: '新建文件夹',
826
+ fields: [
827
+ {
828
+ name: 'folder',
829
+ label: '文件夹路径',
830
+ kind: 'text',
831
+ value: selectedFolder ? `${selectedFolder}/` : '',
832
+ placeholder: '例如:work/ideas',
833
+ required: true,
834
+ },
835
+ ],
836
+ confirmText: '创建',
837
+ });
838
+ if (!values) return;
839
+ const folder = normalizeString(values.folder);
840
+ if (!folder) return;
841
+ setStatus('Notes: creating folder...', 'bad');
842
+ let res = null;
843
+ try {
844
+ res = await api.createFolder({ folder });
845
+ } catch (err) {
846
+ setStatus(`Notes: ${err?.message || String(err)}`, 'bad');
847
+ return;
848
+ }
849
+ if (!res?.ok) {
850
+ setStatus(`Notes: ${res?.message || 'create folder failed'}`, 'bad');
851
+ return;
852
+ }
853
+ const created = normalizeString(res?.folder) || folder;
854
+ if (created && !dirty) {
855
+ selectedFolder = created;
856
+ updateCreateHint();
857
+ }
858
+ await refreshFoldersAndTags();
859
+ if (created && !dirty) {
860
+ await refreshNotes();
861
+ }
862
+ setStatus('Notes: folder created', 'ok');
863
+ });
864
+
865
+ btnNewNote.addEventListener('click', async () => {
866
+ if (disposed) return;
867
+ if (!(await ensureSafeToSwitch())) return;
868
+ const values = await showDialog({
869
+ title: '新建笔记',
870
+ description: `目标文件夹:${selectedFolder ? selectedFolder : '根目录'}`,
871
+ fields: [{ name: 'title', label: '标题', kind: 'text', value: '', placeholder: '可空' }],
872
+ confirmText: '创建',
873
+ });
874
+ if (!values) return;
875
+ const title = normalizeString(values.title);
876
+ setStatus('Notes: creating note...', 'bad');
877
+ let res = null;
878
+ try {
879
+ res = await api.createNote({ folder: selectedFolder, title });
880
+ } catch (err) {
881
+ setStatus(`Notes: ${err?.message || String(err)}`, 'bad');
882
+ return;
883
+ }
884
+ if (!res?.ok) {
885
+ setStatus(`Notes: ${res?.message || 'create note failed'}`, 'bad');
886
+ return;
887
+ }
888
+ await refreshFoldersAndTags();
889
+ await refreshNotes();
890
+ const id = res?.note?.id || '';
891
+ if (id) await openNote(id);
892
+ setStatus('Notes: note created', 'ok');
893
+ });
894
+
895
+ btnSave.addEventListener('click', () => doSave());
896
+ btnDelete.addEventListener('click', () => doDelete());
897
+ btnToggleEdit.addEventListener('click', () => {
898
+ if (disposed || !currentNote) return;
899
+ setEditorMode(editorMode === 'edit' ? 'preview' : 'edit', { focus: true });
900
+ });
901
+ btnCopy.addEventListener('click', async () => {
902
+ if (disposed || !currentNote || copying) return;
903
+ copying = true;
904
+ syncEditorControls();
905
+ try {
906
+ await copyPlainText(currentContent || '');
907
+ flashCopyFeedback('已复制');
908
+ } catch {
909
+ flashCopyFeedback('复制失败');
910
+ } finally {
911
+ copying = false;
912
+ syncEditorControls();
913
+ }
914
+ });
915
+
916
+ searchInput.addEventListener('input', async () => {
917
+ if (disposed) return;
918
+ if (searchDebounceTimer) {
919
+ try {
920
+ clearTimeout(searchDebounceTimer);
921
+ } catch {
922
+ // ignore
923
+ }
924
+ }
925
+ const query = normalizeString(searchInput.value);
926
+ const delayMs = query.length >= 2 ? 320 : 180;
927
+ searchDebounceTimer = setTimeout(() => {
928
+ searchDebounceTimer = null;
929
+ refreshNotes();
930
+ }, delayMs);
931
+ });
932
+
933
+ searchInput.addEventListener('keydown', async (ev) => {
934
+ if (disposed) return;
935
+ const key = ev?.key;
936
+ if (key === 'Escape') {
937
+ try {
938
+ ev.preventDefault();
939
+ } catch {
940
+ // ignore
941
+ }
942
+ if (!searchInput.value) return;
943
+ if (searchDebounceTimer) {
944
+ try {
945
+ clearTimeout(searchDebounceTimer);
946
+ } catch {
947
+ // ignore
948
+ }
949
+ searchDebounceTimer = null;
950
+ }
951
+ searchInput.value = '';
952
+ await refreshNotes();
953
+ return;
954
+ }
955
+ if (key !== 'Enter') return;
956
+ if (!normalizeString(searchInput.value)) return;
957
+ if (!(await ensureSafeToSwitch())) return;
958
+ const first = Array.isArray(notes) && notes.length > 0 ? notes[0] : null;
959
+ const id = normalizeString(first?.id);
960
+ if (!id) return;
961
+ try {
962
+ ev.preventDefault();
963
+ } catch {
964
+ // ignore
965
+ }
966
+ await openNote(id);
967
+ });
968
+
969
+ btnClearSearch?.addEventListener('click', async () => {
970
+ if (disposed) return;
971
+ if (!searchInput.value) return;
972
+ if (searchDebounceTimer) {
973
+ try {
974
+ clearTimeout(searchDebounceTimer);
975
+ } catch {
976
+ // ignore
977
+ }
978
+ searchDebounceTimer = null;
979
+ }
980
+ searchInput.value = '';
981
+ await refreshNotes();
982
+ try {
983
+ searchInput.focus();
984
+ } catch {
985
+ // ignore
986
+ }
987
+ });
988
+
989
+ titleInput.addEventListener('input', () => {
990
+ if (!currentNote) return;
991
+ dirty = true;
992
+ currentNote.title = normalizeString(titleInput.value);
993
+ renderEditor(false);
994
+ });
995
+
996
+ folderSelect.addEventListener('change', () => {
997
+ if (!currentNote) return;
998
+ dirty = true;
999
+ currentNote.folder = normalizeString(folderSelect.value);
1000
+ renderEditor(false);
1001
+ });
1002
+
1003
+ tagsInput.addEventListener('input', () => {
1004
+ if (!currentNote) return;
1005
+ dirty = true;
1006
+ currentNote.tags = parseTags(tagsInput.value);
1007
+ renderEditor(false);
1008
+ });
1009
+
1010
+ textarea.addEventListener('input', () => {
1011
+ if (!currentNote) return;
1012
+ dirty = true;
1013
+ currentContent = String(textarea.value ?? '');
1014
+ renderEditor(false);
1015
+ });
1016
+
1017
+ const bootstrap = async () => {
1018
+ if (!bridgeEnabled) {
1019
+ setControlsEnabled(false);
1020
+ setStatus('Notes: bridge disabled (must run in ChatOS desktop UI)', 'bad');
1021
+ return;
1022
+ }
1023
+ setControlsEnabled(false);
1024
+ try {
1025
+ const res = await api.init();
1026
+ if (!res?.ok) {
1027
+ setStatus(`Notes: ${res?.message || 'init failed'}`, 'bad');
1028
+ return;
1029
+ }
1030
+ await refreshFoldersAndTags();
1031
+ await refreshNotes();
1032
+ updateCreateHint();
1033
+ setStatus('Notes: ready', 'ok');
1034
+ setControlsEnabled(true);
1035
+ renderEditor(true);
1036
+ } catch (err) {
1037
+ setStatus(`Notes: ${err?.message || String(err)}`, 'bad');
1038
+ }
1039
+ };
1040
+
1041
+ bootstrap();
1042
+
1043
+ return () => {
1044
+ disposed = true;
1045
+ clearCopyFeedbackTimer();
1046
+ if (searchDebounceTimer) {
1047
+ try {
1048
+ clearTimeout(searchDebounceTimer);
1049
+ } catch {
1050
+ // ignore
1051
+ }
1052
+ searchDebounceTimer = null;
1053
+ }
1054
+ closeActiveLayer();
1055
+ };
1056
+ }