@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,765 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ const INDEX_VERSION = 1;
6
+
7
+ const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
8
+ const DEFAULT_LOCK_STALE_MS = 30_000;
9
+ const DEFAULT_LOCK_POLL_MS = 25;
10
+
11
+ function normalizeString(value) {
12
+ return typeof value === 'string' ? value.trim() : '';
13
+ }
14
+
15
+ function normalizeOptionalString(value) {
16
+ const out = normalizeString(value);
17
+ return out ? out : '';
18
+ }
19
+
20
+ function normalizeTag(value) {
21
+ const tag = normalizeString(value);
22
+ if (!tag) return '';
23
+ return tag;
24
+ }
25
+
26
+ function uniqTags(tags) {
27
+ const out = [];
28
+ const seen = new Set();
29
+ (Array.isArray(tags) ? tags : []).forEach((t) => {
30
+ const tag = normalizeTag(t);
31
+ if (!tag) return;
32
+ const key = tag.toLowerCase();
33
+ if (seen.has(key)) return;
34
+ seen.add(key);
35
+ out.push(tag);
36
+ });
37
+ return out;
38
+ }
39
+
40
+ function isValidPathSegment(seg) {
41
+ const s = String(seg || '').trim();
42
+ if (!s) return false;
43
+ if (s === '.' || s === '..') return false;
44
+ // Windows reserved characters: <>:"/\|?*
45
+ if (/[<>:"/\\|?*\0]/.test(s)) return false;
46
+ // Control chars
47
+ // eslint-disable-next-line no-control-regex
48
+ if (/[\u0000-\u001f]/.test(s)) return false;
49
+ return true;
50
+ }
51
+
52
+ function normalizeFolderPath(value) {
53
+ const raw = normalizeOptionalString(value).replace(/\\/g, '/');
54
+ if (!raw) return '';
55
+ const cleaned = raw.replace(/^\/+|\/+$/g, '');
56
+ if (!cleaned) return '';
57
+ const parts = cleaned.split('/').filter(Boolean);
58
+ for (const part of parts) {
59
+ if (!isValidPathSegment(part)) {
60
+ throw new Error(`Invalid folder segment: ${part}`);
61
+ }
62
+ }
63
+ return parts.join('/');
64
+ }
65
+
66
+ function splitFolder(folder) {
67
+ const f = normalizeOptionalString(folder).replace(/\\/g, '/');
68
+ if (!f) return [];
69
+ return f.split('/').filter(Boolean);
70
+ }
71
+
72
+ function joinPosix(...parts) {
73
+ const filtered = parts.filter((p) => typeof p === 'string' && p.trim());
74
+ return path.posix.join(...filtered);
75
+ }
76
+
77
+ function nowIso() {
78
+ return new Date().toISOString();
79
+ }
80
+
81
+ async function ensureDir(dirPath) {
82
+ const p = normalizeOptionalString(dirPath);
83
+ if (!p) return;
84
+ await fs.promises.mkdir(p, { recursive: true });
85
+ }
86
+
87
+ async function isDirectory(dirPath) {
88
+ try {
89
+ const stat = await fs.promises.stat(dirPath);
90
+ return stat.isDirectory();
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ async function isFile(filePath) {
97
+ try {
98
+ const stat = await fs.promises.stat(filePath);
99
+ return stat.isFile();
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ async function atomicWriteText(filePath, text) {
106
+ const target = normalizeOptionalString(filePath);
107
+ if (!target) throw new Error('filePath is required');
108
+ const dir = path.dirname(target);
109
+ await ensureDir(dir);
110
+ const base = path.basename(target);
111
+ const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now().toString(36)}.tmp`);
112
+ await fs.promises.writeFile(tmp, String(text ?? ''), 'utf8');
113
+ try {
114
+ await fs.promises.rename(tmp, target);
115
+ } catch (err) {
116
+ try {
117
+ await fs.promises.unlink(target);
118
+ await fs.promises.rename(tmp, target);
119
+ } catch (err2) {
120
+ try {
121
+ await fs.promises.unlink(tmp);
122
+ } catch {
123
+ // ignore
124
+ }
125
+ throw err2;
126
+ }
127
+ }
128
+ }
129
+
130
+ async function tryAcquireLock(lockPath) {
131
+ const p = normalizeOptionalString(lockPath);
132
+ if (!p) throw new Error('lockPath is required');
133
+ await ensureDir(path.dirname(p));
134
+ try {
135
+ const handle = await fs.promises.open(p, 'wx');
136
+ try {
137
+ await handle.writeFile(JSON.stringify({ pid: process.pid, startedAt: nowIso() }), { encoding: 'utf8' });
138
+ } catch {
139
+ // ignore
140
+ }
141
+ return handle;
142
+ } catch (err) {
143
+ if (err?.code === 'EEXIST') return null;
144
+ throw err;
145
+ }
146
+ }
147
+
148
+ async function withFileLock(lockPath, fn, options = {}) {
149
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_LOCK_TIMEOUT_MS;
150
+ const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : DEFAULT_LOCK_POLL_MS;
151
+ const staleMs = Number.isFinite(options.staleMs) ? options.staleMs : DEFAULT_LOCK_STALE_MS;
152
+ const start = Date.now();
153
+
154
+ while (true) {
155
+ const handle = await tryAcquireLock(lockPath);
156
+ if (handle) {
157
+ try {
158
+ return await fn();
159
+ } finally {
160
+ try {
161
+ await handle.close();
162
+ } catch {
163
+ // ignore
164
+ }
165
+ try {
166
+ await fs.promises.unlink(lockPath);
167
+ } catch {
168
+ // ignore
169
+ }
170
+ }
171
+ }
172
+
173
+ try {
174
+ const stat = await fs.promises.stat(lockPath);
175
+ const mtimeMs = typeof stat?.mtimeMs === 'number' ? stat.mtimeMs : 0;
176
+ if (mtimeMs && Date.now() - mtimeMs > staleMs) {
177
+ await fs.promises.unlink(lockPath);
178
+ continue;
179
+ }
180
+ } catch {
181
+ // ignore
182
+ }
183
+
184
+ if (Date.now() - start > timeoutMs) {
185
+ throw new Error(`Timed out waiting for lock (${path.basename(lockPath)}).`);
186
+ }
187
+ await new Promise((r) => setTimeout(r, pollMs));
188
+ }
189
+ }
190
+
191
+ function extractTitleFromMarkdown(markdown) {
192
+ const text = String(markdown || '').replace(/\r\n/g, '\n');
193
+ const lines = text.split('\n');
194
+ for (const line of lines) {
195
+ const trimmed = String(line || '').trim();
196
+ if (!trimmed) continue;
197
+ const heading = trimmed.match(/^#{1,6}\s+(.+)$/);
198
+ if (heading) {
199
+ return String(heading[1] || '').trim().slice(0, 120) || '';
200
+ }
201
+ return trimmed.slice(0, 120);
202
+ }
203
+ return '';
204
+ }
205
+
206
+ function normalizeTitle(value) {
207
+ const title = normalizeOptionalString(value);
208
+ return title ? title.slice(0, 120) : '';
209
+ }
210
+
211
+ function noteFileAbs(notesRoot, folder, id) {
212
+ const segments = splitFolder(folder);
213
+ return path.join(notesRoot, ...segments, `${id}.md`);
214
+ }
215
+
216
+ function noteFileRel(folder, id) {
217
+ const folderNorm = normalizeOptionalString(folder).replace(/\\/g, '/');
218
+ return joinPosix('notes', folderNorm, `${id}.md`);
219
+ }
220
+
221
+ function clone(value) {
222
+ return JSON.parse(JSON.stringify(value));
223
+ }
224
+
225
+ function normalizeIndex(index) {
226
+ const obj = index && typeof index === 'object' ? index : {};
227
+ const version = Number(obj.version) || INDEX_VERSION;
228
+ const rawNotes = Array.isArray(obj.notes) ? obj.notes : [];
229
+ const notes = [];
230
+ const seen = new Set();
231
+ rawNotes.forEach((n) => {
232
+ if (!n || typeof n !== 'object') return;
233
+ const id = normalizeOptionalString(n.id);
234
+ if (!id) return;
235
+ if (seen.has(id)) return;
236
+ seen.add(id);
237
+ notes.push({
238
+ id,
239
+ title: normalizeTitle(n.title),
240
+ folder: normalizeOptionalString(n.folder).replace(/\\/g, '/'),
241
+ tags: uniqTags(n.tags),
242
+ createdAt: normalizeOptionalString(n.createdAt),
243
+ updatedAt: normalizeOptionalString(n.updatedAt),
244
+ });
245
+ });
246
+ return { version, notes };
247
+ }
248
+
249
+ async function listMarkdownFiles(notesRoot, rel = '') {
250
+ const absDir = rel ? path.join(notesRoot, ...splitFolder(rel)) : notesRoot;
251
+ let entries = [];
252
+ try {
253
+ entries = await fs.promises.readdir(absDir, { withFileTypes: true });
254
+ } catch {
255
+ return [];
256
+ }
257
+
258
+ const results = [];
259
+ for (const entry of entries) {
260
+ const name = String(entry?.name || '');
261
+ if (!name) continue;
262
+ if (entry.isDirectory()) {
263
+ const nextRel = rel ? `${rel}/${name}` : name;
264
+ results.push(...(await listMarkdownFiles(notesRoot, nextRel)));
265
+ continue;
266
+ }
267
+ if (!entry.isFile()) continue;
268
+ if (!name.toLowerCase().endsWith('.md')) continue;
269
+ const id = name.slice(0, -3);
270
+ if (!id) continue;
271
+ const fileAbs = rel ? path.join(notesRoot, ...splitFolder(rel), name) : path.join(notesRoot, name);
272
+ results.push({ id, folder: rel, fileAbs });
273
+ }
274
+ return results;
275
+ }
276
+
277
+ async function rebuildIndexFromFilesystem(notesRoot) {
278
+ const files = await listMarkdownFiles(notesRoot);
279
+ const now = nowIso();
280
+ const notes = [];
281
+ for (const f of files) {
282
+ let content = '';
283
+ try {
284
+ content = await fs.promises.readFile(f.fileAbs, 'utf8');
285
+ } catch {
286
+ content = '';
287
+ }
288
+ let stat = null;
289
+ try {
290
+ stat = await fs.promises.stat(f.fileAbs);
291
+ } catch {
292
+ stat = null;
293
+ }
294
+ const createdAt = stat?.birthtime ? new Date(stat.birthtime).toISOString() : stat?.mtime ? new Date(stat.mtime).toISOString() : now;
295
+ const updatedAt = stat?.mtime ? new Date(stat.mtime).toISOString() : now;
296
+ notes.push({
297
+ id: f.id,
298
+ title: normalizeTitle(extractTitleFromMarkdown(content)) || 'Untitled',
299
+ folder: normalizeOptionalString(f.folder),
300
+ tags: [],
301
+ createdAt,
302
+ updatedAt,
303
+ });
304
+ }
305
+ return { version: INDEX_VERSION, notes };
306
+ }
307
+
308
+ export function createNotepadStore({ dataDir } = {}) {
309
+ const baseDirRaw = normalizeOptionalString(dataDir);
310
+ if (!baseDirRaw) {
311
+ throw new Error('dataDir is required');
312
+ }
313
+ const baseDir = path.resolve(baseDirRaw);
314
+ const notesRoot = path.join(baseDir, 'notes');
315
+ const indexPath = path.join(baseDir, 'notes-index.json');
316
+ const lockPath = path.join(baseDir, 'notes.lock');
317
+
318
+ const loadIndexLocked = async () => {
319
+ await ensureDir(notesRoot);
320
+ const exists = await isFile(indexPath);
321
+ if (!exists) {
322
+ const rebuilt = await rebuildIndexFromFilesystem(notesRoot);
323
+ await atomicWriteText(indexPath, JSON.stringify(rebuilt, null, 2));
324
+ return rebuilt;
325
+ }
326
+
327
+ let raw = '';
328
+ try {
329
+ raw = await fs.promises.readFile(indexPath, 'utf8');
330
+ } catch (err) {
331
+ if (err?.code === 'ENOENT') {
332
+ const rebuilt = await rebuildIndexFromFilesystem(notesRoot);
333
+ await atomicWriteText(indexPath, JSON.stringify(rebuilt, null, 2));
334
+ return rebuilt;
335
+ }
336
+ throw err;
337
+ }
338
+
339
+ let parsed = null;
340
+ try {
341
+ parsed = JSON.parse(String(raw || ''));
342
+ } catch {
343
+ const backup = path.join(baseDir, `notes-index.corrupted.${Date.now().toString(36)}.json`);
344
+ try {
345
+ await fs.promises.copyFile(indexPath, backup);
346
+ } catch {
347
+ // ignore
348
+ }
349
+ const rebuilt = await rebuildIndexFromFilesystem(notesRoot);
350
+ await atomicWriteText(indexPath, JSON.stringify(rebuilt, null, 2));
351
+ return rebuilt;
352
+ }
353
+
354
+ const normalized = normalizeIndex(parsed);
355
+ if (normalized.version !== INDEX_VERSION) {
356
+ // currently: keep data, bump version
357
+ normalized.version = INDEX_VERSION;
358
+ await atomicWriteText(indexPath, JSON.stringify(normalized, null, 2));
359
+ }
360
+ return normalized;
361
+ };
362
+
363
+ const saveIndexLocked = async (index) => {
364
+ const normalized = normalizeIndex(index);
365
+ normalized.version = INDEX_VERSION;
366
+ await atomicWriteText(indexPath, JSON.stringify(normalized, null, 2));
367
+ return normalized;
368
+ };
369
+
370
+ const getIndexSnapshot = async () =>
371
+ await withFileLock(lockPath, async () => clone(await loadIndexLocked()));
372
+
373
+ const init = async () => {
374
+ const index = await getIndexSnapshot();
375
+ return {
376
+ ok: true,
377
+ dataDir: baseDir,
378
+ notesRoot,
379
+ indexPath,
380
+ version: INDEX_VERSION,
381
+ notes: index?.notes?.length || 0,
382
+ };
383
+ };
384
+
385
+ const listFolders = async () => {
386
+ await ensureDir(notesRoot);
387
+ const folders = [''];
388
+ const walk = async (absDir, relDir) => {
389
+ let entries = [];
390
+ try {
391
+ entries = await fs.promises.readdir(absDir, { withFileTypes: true });
392
+ } catch {
393
+ return;
394
+ }
395
+ for (const entry of entries) {
396
+ if (!entry?.isDirectory?.()) continue;
397
+ const name = String(entry.name || '');
398
+ if (!name) continue;
399
+ const nextRel = relDir ? `${relDir}/${name}` : name;
400
+ folders.push(nextRel);
401
+ await walk(path.join(absDir, name), nextRel);
402
+ }
403
+ };
404
+ await walk(notesRoot, '');
405
+ folders.sort((a, b) => a.localeCompare(b));
406
+ return { ok: true, folders };
407
+ };
408
+
409
+ const createFolder = async ({ folder } = {}) => {
410
+ let rel = '';
411
+ try {
412
+ rel = normalizeFolderPath(folder);
413
+ } catch (err) {
414
+ return { ok: false, message: err?.message || String(err) };
415
+ }
416
+ if (!rel) return { ok: false, message: 'folder is required' };
417
+ const abs = path.join(notesRoot, ...splitFolder(rel));
418
+ try {
419
+ await ensureDir(abs);
420
+ return { ok: true, folder: rel };
421
+ } catch (err) {
422
+ return { ok: false, message: err?.message || String(err) };
423
+ }
424
+ };
425
+
426
+ const renameFolder = async ({ from, to } = {}) =>
427
+ await withFileLock(lockPath, async () => {
428
+ let fromRel = '';
429
+ let toRel = '';
430
+ try {
431
+ fromRel = normalizeFolderPath(from);
432
+ } catch (err) {
433
+ return { ok: false, message: err?.message || String(err) };
434
+ }
435
+ try {
436
+ toRel = normalizeFolderPath(to);
437
+ } catch (err) {
438
+ return { ok: false, message: err?.message || String(err) };
439
+ }
440
+ if (!fromRel) return { ok: false, message: 'from is required' };
441
+ if (!toRel) return { ok: false, message: 'to is required' };
442
+ if (fromRel === toRel) return { ok: true, from: fromRel, to: toRel, movedNotes: 0 };
443
+
444
+ const fromAbs = path.join(notesRoot, ...splitFolder(fromRel));
445
+ const toAbs = path.join(notesRoot, ...splitFolder(toRel));
446
+ if (!(await isDirectory(fromAbs))) return { ok: false, message: `Folder not found: ${fromRel}` };
447
+ if (await isDirectory(toAbs)) return { ok: false, message: `Target folder already exists: ${toRel}` };
448
+
449
+ await ensureDir(path.dirname(toAbs));
450
+ await fs.promises.rename(fromAbs, toAbs);
451
+
452
+ const index = await loadIndexLocked();
453
+ let movedNotes = 0;
454
+ index.notes = index.notes.map((n) => {
455
+ const folder = normalizeOptionalString(n.folder).replace(/\\/g, '/');
456
+ if (folder === fromRel) {
457
+ movedNotes += 1;
458
+ return { ...n, folder: toRel, updatedAt: nowIso() };
459
+ }
460
+ if (folder.startsWith(`${fromRel}/`)) {
461
+ movedNotes += 1;
462
+ return { ...n, folder: `${toRel}/${folder.slice(fromRel.length + 1)}`, updatedAt: nowIso() };
463
+ }
464
+ return n;
465
+ });
466
+ await saveIndexLocked(index);
467
+ return { ok: true, from: fromRel, to: toRel, movedNotes };
468
+ });
469
+
470
+ const deleteFolder = async ({ folder, recursive = false } = {}) =>
471
+ await withFileLock(lockPath, async () => {
472
+ let rel = '';
473
+ try {
474
+ rel = normalizeFolderPath(folder);
475
+ } catch (err) {
476
+ return { ok: false, message: err?.message || String(err) };
477
+ }
478
+ if (!rel) return { ok: false, message: 'folder is required' };
479
+ const abs = path.join(notesRoot, ...splitFolder(rel));
480
+ if (!(await isDirectory(abs))) return { ok: false, message: `Folder not found: ${rel}` };
481
+
482
+ const index = await loadIndexLocked();
483
+ const affected = index.notes.filter((n) => {
484
+ const f = normalizeOptionalString(n.folder).replace(/\\/g, '/');
485
+ return f === rel || f.startsWith(`${rel}/`);
486
+ });
487
+
488
+ if (!recursive) {
489
+ try {
490
+ await fs.promises.rmdir(abs);
491
+ } catch (err) {
492
+ return { ok: false, message: err?.message || String(err) };
493
+ }
494
+ return { ok: true, folder: rel, deletedNotes: 0 };
495
+ }
496
+
497
+ try {
498
+ await fs.promises.rm(abs, { recursive: true, force: true });
499
+ } catch (err) {
500
+ return { ok: false, message: err?.message || String(err) };
501
+ }
502
+
503
+ const toRemove = new Set(affected.map((n) => n.id));
504
+ index.notes = index.notes.filter((n) => !toRemove.has(n.id));
505
+ await saveIndexLocked(index);
506
+
507
+ return { ok: true, folder: rel, deletedNotes: affected.length };
508
+ });
509
+
510
+ const listNotes = async ({ folder = '', recursive = true, tags = [], match = 'all', query = '', limit = 200 } = {}) => {
511
+ let folderRel = '';
512
+ try {
513
+ folderRel = normalizeFolderPath(folder);
514
+ } catch (err) {
515
+ return { ok: false, message: err?.message || String(err) };
516
+ }
517
+ const desiredTags = uniqTags(tags);
518
+ const matchMode = match === 'any' ? 'any' : 'all';
519
+ const q = normalizeOptionalString(query).toLowerCase();
520
+ const max = Number.isFinite(limit) ? Math.max(1, Math.min(500, Math.floor(limit))) : 200;
521
+
522
+ const index = await getIndexSnapshot();
523
+ let notes = Array.isArray(index?.notes) ? index.notes.slice() : [];
524
+
525
+ if (folderRel) {
526
+ const prefix = `${folderRel}/`;
527
+ notes = notes.filter((n) => {
528
+ const f = normalizeOptionalString(n.folder).replace(/\\/g, '/');
529
+ if (f === folderRel) return true;
530
+ if (recursive === false) return false;
531
+ return f.startsWith(prefix);
532
+ });
533
+ }
534
+
535
+ if (desiredTags.length > 0) {
536
+ const desiredKeys = desiredTags.map((t) => t.toLowerCase());
537
+ notes = notes.filter((n) => {
538
+ const tagKeys = new Set((Array.isArray(n.tags) ? n.tags : []).map((t) => String(t || '').toLowerCase()));
539
+ if (matchMode === 'any') return desiredKeys.some((t) => tagKeys.has(t));
540
+ return desiredKeys.every((t) => tagKeys.has(t));
541
+ });
542
+ }
543
+
544
+ if (q) {
545
+ notes = notes.filter((n) => {
546
+ const title = normalizeOptionalString(n.title).toLowerCase();
547
+ const folderName = normalizeOptionalString(n.folder).toLowerCase();
548
+ return title.includes(q) || folderName.includes(q);
549
+ });
550
+ }
551
+
552
+ notes.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
553
+ return { ok: true, notes: notes.slice(0, max) };
554
+ };
555
+
556
+ const createNote = async ({ folder = '', title = '', content = '', tags = [] } = {}) =>
557
+ await withFileLock(lockPath, async () => {
558
+ let folderRel = '';
559
+ try {
560
+ folderRel = normalizeFolderPath(folder);
561
+ } catch (err) {
562
+ return { ok: false, message: err?.message || String(err) };
563
+ }
564
+ const desiredTags = uniqTags(tags);
565
+
566
+ const rawTitle = normalizeTitle(title) || normalizeTitle(extractTitleFromMarkdown(content)) || 'Untitled';
567
+ const md = normalizeOptionalString(content) || `# ${rawTitle}\n\n`;
568
+
569
+ const id = crypto.randomUUID();
570
+ const abs = noteFileAbs(notesRoot, folderRel, id);
571
+ await ensureDir(path.dirname(abs));
572
+ await atomicWriteText(abs, md);
573
+
574
+ const index = await loadIndexLocked();
575
+ const now = nowIso();
576
+ const note = {
577
+ id,
578
+ title: rawTitle,
579
+ folder: folderRel,
580
+ tags: desiredTags,
581
+ createdAt: now,
582
+ updatedAt: now,
583
+ };
584
+ index.notes.unshift(note);
585
+ await saveIndexLocked(index);
586
+
587
+ return { ok: true, note: { ...note, file: noteFileRel(folderRel, id) } };
588
+ });
589
+
590
+ const getNote = async ({ id } = {}) => {
591
+ const noteId = normalizeOptionalString(id);
592
+ if (!noteId) return { ok: false, message: 'id is required' };
593
+ const index = await getIndexSnapshot();
594
+ const note = (Array.isArray(index?.notes) ? index.notes : []).find((n) => n.id === noteId);
595
+ if (!note) return { ok: false, message: `Note not found: ${noteId}` };
596
+
597
+ const abs = noteFileAbs(notesRoot, note.folder, noteId);
598
+ let content = '';
599
+ try {
600
+ content = await fs.promises.readFile(abs, 'utf8');
601
+ } catch (err) {
602
+ return { ok: false, message: err?.message || String(err) };
603
+ }
604
+
605
+ return { ok: true, note: { ...note, file: noteFileRel(note.folder, noteId) }, content };
606
+ };
607
+
608
+ const updateNote = async ({ id, title, content, folder, tags } = {}) =>
609
+ await withFileLock(lockPath, async () => {
610
+ const noteId = normalizeOptionalString(id);
611
+ if (!noteId) return { ok: false, message: 'id is required' };
612
+ const index = await loadIndexLocked();
613
+ const idx = index.notes.findIndex((n) => n.id === noteId);
614
+ if (idx < 0) return { ok: false, message: `Note not found: ${noteId}` };
615
+
616
+ const current = index.notes[idx];
617
+ let nextFolder = normalizeOptionalString(current.folder).replace(/\\/g, '/');
618
+ if (folder !== undefined) {
619
+ try {
620
+ nextFolder = normalizeFolderPath(folder);
621
+ } catch (err) {
622
+ return { ok: false, message: err?.message || String(err) };
623
+ }
624
+ }
625
+ const nextTitle = title !== undefined ? normalizeTitle(title) : normalizeOptionalString(current.title);
626
+ const nextTags = tags !== undefined ? uniqTags(tags) : uniqTags(current.tags);
627
+ const nextContent = content !== undefined ? String(content ?? '') : null;
628
+
629
+ const oldAbs = noteFileAbs(notesRoot, current.folder, noteId);
630
+ const newAbs = noteFileAbs(notesRoot, nextFolder, noteId);
631
+
632
+ if (newAbs !== oldAbs) {
633
+ await ensureDir(path.dirname(newAbs));
634
+ try {
635
+ await fs.promises.rename(oldAbs, newAbs);
636
+ } catch (err) {
637
+ return { ok: false, message: err?.message || String(err) };
638
+ }
639
+ }
640
+
641
+ if (nextContent !== null) {
642
+ try {
643
+ await atomicWriteText(newAbs, nextContent);
644
+ } catch (err) {
645
+ return { ok: false, message: err?.message || String(err) };
646
+ }
647
+ }
648
+
649
+ const now = nowIso();
650
+ const updated = {
651
+ ...current,
652
+ title: nextTitle,
653
+ folder: nextFolder,
654
+ tags: nextTags,
655
+ updatedAt: now,
656
+ };
657
+ index.notes[idx] = updated;
658
+ await saveIndexLocked(index);
659
+
660
+ return { ok: true, note: { ...updated, file: noteFileRel(updated.folder, noteId) } };
661
+ });
662
+
663
+ const deleteNote = async ({ id } = {}) =>
664
+ await withFileLock(lockPath, async () => {
665
+ const noteId = normalizeOptionalString(id);
666
+ if (!noteId) return { ok: false, message: 'id is required' };
667
+ const index = await loadIndexLocked();
668
+ const idx = index.notes.findIndex((n) => n.id === noteId);
669
+ if (idx < 0) return { ok: false, message: `Note not found: ${noteId}` };
670
+ const note = index.notes[idx];
671
+
672
+ const abs = noteFileAbs(notesRoot, note.folder, noteId);
673
+ try {
674
+ await fs.promises.unlink(abs);
675
+ } catch {
676
+ // ignore
677
+ }
678
+ index.notes.splice(idx, 1);
679
+ await saveIndexLocked(index);
680
+ return { ok: true, id: noteId };
681
+ });
682
+
683
+ const listTags = async () => {
684
+ const index = await getIndexSnapshot();
685
+ const counts = new Map();
686
+ (Array.isArray(index?.notes) ? index.notes : []).forEach((n) => {
687
+ (Array.isArray(n.tags) ? n.tags : []).forEach((t) => {
688
+ const tag = normalizeTag(t);
689
+ if (!tag) return;
690
+ const key = tag.toLowerCase();
691
+ counts.set(key, { tag, count: (counts.get(key)?.count || 0) + 1 });
692
+ });
693
+ });
694
+ const tags = Array.from(counts.values()).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
695
+ return { ok: true, tags };
696
+ };
697
+
698
+ const searchNotes = async ({ query, folder = '', recursive = true, tags = [], match = 'all', includeContent = true, limit = 50 } = {}) => {
699
+ const q = normalizeOptionalString(query);
700
+ if (!q) return { ok: false, message: 'query is required' };
701
+ let folderRel = '';
702
+ try {
703
+ folderRel = normalizeFolderPath(folder);
704
+ } catch (err) {
705
+ return { ok: false, message: err?.message || String(err) };
706
+ }
707
+ const desiredTags = uniqTags(tags);
708
+ const matchMode = match === 'any' ? 'any' : 'all';
709
+ const max = Number.isFinite(limit) ? Math.max(1, Math.min(200, Math.floor(limit))) : 50;
710
+
711
+ const baseList = await listNotes({ folder: folderRel, recursive, tags: desiredTags, match: matchMode, query: '', limit: 500 });
712
+ if (!baseList.ok) return baseList;
713
+ const candidates = Array.isArray(baseList.notes) ? baseList.notes : [];
714
+
715
+ const qLower = q.toLowerCase();
716
+ const results = [];
717
+ for (const note of candidates) {
718
+ if (results.length >= max) break;
719
+ const title = normalizeOptionalString(note.title);
720
+ if (title.toLowerCase().includes(qLower)) {
721
+ results.push(note);
722
+ continue;
723
+ }
724
+ const folderName = normalizeOptionalString(note.folder);
725
+ if (folderName.toLowerCase().includes(qLower)) {
726
+ results.push(note);
727
+ continue;
728
+ }
729
+ if (!includeContent) continue;
730
+ try {
731
+ const abs = noteFileAbs(notesRoot, note.folder, note.id);
732
+ const content = await fs.promises.readFile(abs, 'utf8');
733
+ if (String(content || '').toLowerCase().includes(qLower)) {
734
+ results.push(note);
735
+ }
736
+ } catch {
737
+ // ignore
738
+ }
739
+ }
740
+ return { ok: true, notes: results };
741
+ };
742
+
743
+ const safe = (fn) => async (...args) => {
744
+ try {
745
+ return await fn(...args);
746
+ } catch (err) {
747
+ return { ok: false, message: err?.message || String(err) };
748
+ }
749
+ };
750
+
751
+ return {
752
+ init: safe(init),
753
+ listFolders: safe(listFolders),
754
+ createFolder: safe(createFolder),
755
+ renameFolder: safe(renameFolder),
756
+ deleteFolder: safe(deleteFolder),
757
+ listNotes: safe(listNotes),
758
+ createNote: safe(createNote),
759
+ getNote: safe(getNote),
760
+ updateNote: safe(updateNote),
761
+ deleteNote: safe(deleteNote),
762
+ listTags: safe(listTags),
763
+ searchNotes: safe(searchNotes),
764
+ };
765
+ }