@tomingtoming/kioq 0.7.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.
@@ -0,0 +1,1115 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ const IGNORED_DIRS = new Set([".git", "node_modules", ".obsidian"]);
4
+ const MARKDOWN_EXTENSION = ".md";
5
+ function nowDate() {
6
+ return new Date().toISOString().slice(0, 10);
7
+ }
8
+ function toPosix(value) {
9
+ return value.split(path.sep).join("/");
10
+ }
11
+ function stripQuotes(value) {
12
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
13
+ return value.slice(1, -1);
14
+ }
15
+ return value;
16
+ }
17
+ function sanitizeRelativePath(value) {
18
+ if (!value) {
19
+ return "";
20
+ }
21
+ const normalized = value
22
+ .replace(/\\/g, "/")
23
+ .split("/")
24
+ .map((part) => part.trim())
25
+ .filter((part) => part.length > 0)
26
+ .join("/");
27
+ if (normalized.length === 0) {
28
+ return "";
29
+ }
30
+ const segments = normalized.split("/");
31
+ if (segments.some((segment) => segment === "." || segment === "..")) {
32
+ throw new Error(`invalid relative path: ${value}`);
33
+ }
34
+ return segments.join("/");
35
+ }
36
+ function sanitizeFileBase(title) {
37
+ const trimmed = title.trim();
38
+ const replaced = trimmed
39
+ .replace(/[\\/:*?"<>|]/g, "-")
40
+ .replace(/\s+/g, " ")
41
+ .replace(/^\.+/, "")
42
+ .replace(/\.+$/, "")
43
+ .trim();
44
+ if (replaced.length === 0) {
45
+ return "untitled";
46
+ }
47
+ return replaced;
48
+ }
49
+ function inferTitleFromIdentifier(identifier) {
50
+ const trimmed = identifier.trim();
51
+ if (trimmed.length === 0) {
52
+ return "untitled";
53
+ }
54
+ const normalized = trimmed.replace(/\\/g, "/");
55
+ const last = normalized.split("/").filter((part) => part.length > 0).pop() ?? normalized;
56
+ const withoutExtension = last.replace(/\.md$/i, "").trim();
57
+ return withoutExtension.length > 0 ? withoutExtension : "untitled";
58
+ }
59
+ function isInsideDirectory(baseDir, targetPath) {
60
+ const normalizedBase = path.resolve(baseDir);
61
+ const normalizedTarget = path.resolve(targetPath);
62
+ return normalizedTarget === normalizedBase || normalizedTarget.startsWith(`${normalizedBase}${path.sep}`);
63
+ }
64
+ function parseFrontmatterBlock(block) {
65
+ const result = {};
66
+ const lines = block.split(/\r?\n/);
67
+ let currentArrayKey;
68
+ for (const line of lines) {
69
+ const listMatch = line.match(/^\s*-\s*(.+)$/);
70
+ if (listMatch && currentArrayKey) {
71
+ const current = result[currentArrayKey];
72
+ if (Array.isArray(current)) {
73
+ current.push(stripQuotes(listMatch[1].trim()));
74
+ }
75
+ continue;
76
+ }
77
+ currentArrayKey = undefined;
78
+ const keyValueMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
79
+ if (!keyValueMatch) {
80
+ continue;
81
+ }
82
+ const key = keyValueMatch[1];
83
+ const rawValue = keyValueMatch[2].trim();
84
+ if (rawValue.length === 0) {
85
+ result[key] = [];
86
+ currentArrayKey = key;
87
+ continue;
88
+ }
89
+ if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
90
+ const inner = rawValue.slice(1, -1).trim();
91
+ result[key] = inner.length === 0
92
+ ? []
93
+ : inner.split(",").map((item) => stripQuotes(item.trim())).filter((item) => item.length > 0);
94
+ continue;
95
+ }
96
+ result[key] = stripQuotes(rawValue);
97
+ }
98
+ return result;
99
+ }
100
+ function splitFrontmatter(raw) {
101
+ if (!raw.startsWith("---\n")) {
102
+ return { frontmatter: {}, body: raw };
103
+ }
104
+ const separatorIndex = raw.indexOf("\n---\n", 4);
105
+ if (separatorIndex < 0) {
106
+ return { frontmatter: {}, body: raw };
107
+ }
108
+ const block = raw.slice(4, separatorIndex);
109
+ const body = raw.slice(separatorIndex + 5);
110
+ return {
111
+ frontmatter: parseFrontmatterBlock(block),
112
+ body,
113
+ };
114
+ }
115
+ function formatFrontmatterValue(value) {
116
+ if (Array.isArray(value)) {
117
+ if (value.length === 0) {
118
+ return "[]";
119
+ }
120
+ return `\n${value.map((item) => `- ${JSON.stringify(item)}`).join("\n")}`;
121
+ }
122
+ if (/^[A-Za-z0-9_./:-]+$/.test(value)) {
123
+ return value;
124
+ }
125
+ return JSON.stringify(value);
126
+ }
127
+ function buildFrontmatter(frontmatter) {
128
+ const preferredOrder = ["title", "type", "permalink", "tags", "created", "updated"];
129
+ const orderedKeys = [
130
+ ...preferredOrder.filter((key) => key in frontmatter),
131
+ ...Object.keys(frontmatter).filter((key) => !preferredOrder.includes(key)),
132
+ ];
133
+ const lines = orderedKeys.map((key) => `${key}: ${formatFrontmatterValue(frontmatter[key])}`);
134
+ return `---\n${lines.join("\n")}\n---`;
135
+ }
136
+ function ensureTrailingNewline(text) {
137
+ return text.endsWith("\n") ? text : `${text}\n`;
138
+ }
139
+ function normalizeContent(text) {
140
+ return text.replace(/\r\n/g, "\n");
141
+ }
142
+ function normalizeLookup(value) {
143
+ return value.toLowerCase().replace(/[\s._/-]+/g, "");
144
+ }
145
+ function safeString(value) {
146
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
147
+ }
148
+ function safeStringArray(value) {
149
+ if (!Array.isArray(value)) {
150
+ return [];
151
+ }
152
+ return value
153
+ .filter((item) => typeof item === "string")
154
+ .map((item) => item.trim())
155
+ .filter((item) => item.length > 0);
156
+ }
157
+ function countOccurrences(text, query) {
158
+ if (!query || !text) {
159
+ return 0;
160
+ }
161
+ let count = 0;
162
+ let index = 0;
163
+ while (true) {
164
+ const foundIndex = text.indexOf(query, index);
165
+ if (foundIndex < 0) {
166
+ return count;
167
+ }
168
+ count += 1;
169
+ index = foundIndex + query.length;
170
+ }
171
+ }
172
+ function identifierMatchScore(key, query) {
173
+ const keyLower = key.toLowerCase();
174
+ const queryLower = query.toLowerCase();
175
+ if (!keyLower || !queryLower) {
176
+ return 0;
177
+ }
178
+ if (keyLower === queryLower) {
179
+ return 1000;
180
+ }
181
+ const keyNormalized = normalizeLookup(keyLower);
182
+ const queryNormalized = normalizeLookup(queryLower);
183
+ if (keyNormalized.length > 0 && keyNormalized === queryNormalized) {
184
+ return 900;
185
+ }
186
+ if (keyLower.includes(queryLower)) {
187
+ return Math.max(400, 700 - Math.max(0, keyLower.length - queryLower.length));
188
+ }
189
+ if (queryLower.includes(keyLower) && keyLower.length >= 4) {
190
+ return Math.max(250, 420 - Math.max(0, queryLower.length - keyLower.length));
191
+ }
192
+ if (queryNormalized.length > 0 && keyNormalized.includes(queryNormalized)) {
193
+ return Math.max(220, 520 - Math.max(0, keyNormalized.length - queryNormalized.length));
194
+ }
195
+ if (keyNormalized.length >= 4 && queryNormalized.includes(keyNormalized)) {
196
+ return Math.max(140, 320 - Math.max(0, queryNormalized.length - keyNormalized.length));
197
+ }
198
+ return 0;
199
+ }
200
+ function splitWikiTarget(targetRaw) {
201
+ const trimmed = targetRaw.trim();
202
+ if (trimmed.length === 0) {
203
+ return { base: "", suffix: "" };
204
+ }
205
+ const hashIndex = trimmed.indexOf("#");
206
+ const blockIndex = trimmed.indexOf("^");
207
+ let splitIndex = -1;
208
+ if (hashIndex >= 0 && blockIndex >= 0) {
209
+ splitIndex = Math.min(hashIndex, blockIndex);
210
+ }
211
+ else if (hashIndex >= 0) {
212
+ splitIndex = hashIndex;
213
+ }
214
+ else if (blockIndex >= 0) {
215
+ splitIndex = blockIndex;
216
+ }
217
+ if (splitIndex < 0) {
218
+ return { base: trimmed, suffix: "" };
219
+ }
220
+ return {
221
+ base: trimmed.slice(0, splitIndex).trim(),
222
+ suffix: trimmed.slice(splitIndex),
223
+ };
224
+ }
225
+ function extractWikiLinks(text) {
226
+ const pattern = /\[\[([^\]\n]+)\]\]/g;
227
+ const tokens = [];
228
+ let match;
229
+ while ((match = pattern.exec(text)) !== null) {
230
+ const raw = match[0];
231
+ const inner = match[1].trim();
232
+ const pipeIndex = inner.indexOf("|");
233
+ const targetRaw = (pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner).trim();
234
+ const alias = pipeIndex >= 0 ? inner.slice(pipeIndex + 1) : undefined;
235
+ const { base, suffix } = splitWikiTarget(targetRaw);
236
+ tokens.push({
237
+ raw,
238
+ inner,
239
+ targetRaw,
240
+ targetBase: base,
241
+ suffix,
242
+ alias,
243
+ start: match.index,
244
+ end: match.index + raw.length,
245
+ });
246
+ }
247
+ return tokens;
248
+ }
249
+ function renderWikiLink(targetBase, suffix, alias) {
250
+ const target = `${targetBase}${suffix}`;
251
+ if (alias === undefined) {
252
+ return `[[${target}]]`;
253
+ }
254
+ return `[[${target}|${alias}]]`;
255
+ }
256
+ function rewriteWikiLinks(text, replacer) {
257
+ const tokens = extractWikiLinks(text);
258
+ if (tokens.length === 0) {
259
+ return { text, replaced: 0 };
260
+ }
261
+ let out = "";
262
+ let replaced = 0;
263
+ let lastIndex = 0;
264
+ for (const token of tokens) {
265
+ out += text.slice(lastIndex, token.start);
266
+ const next = replacer(token);
267
+ if (next !== undefined && next !== token.raw) {
268
+ out += next;
269
+ replaced += 1;
270
+ }
271
+ else {
272
+ out += token.raw;
273
+ }
274
+ lastIndex = token.end;
275
+ }
276
+ out += text.slice(lastIndex);
277
+ return { text: out, replaced };
278
+ }
279
+ function uniqueByFilePath(notes) {
280
+ const seen = new Set();
281
+ const unique = [];
282
+ for (const note of notes) {
283
+ if (seen.has(note.filePath)) {
284
+ continue;
285
+ }
286
+ seen.add(note.filePath);
287
+ unique.push(note);
288
+ }
289
+ return unique;
290
+ }
291
+ function fileExistsSafe(filePath) {
292
+ return fs
293
+ .stat(filePath)
294
+ .then((stat) => stat.isFile())
295
+ .catch(() => false);
296
+ }
297
+ export class LocalNoteStore {
298
+ config;
299
+ constructor(config) {
300
+ this.config = config;
301
+ }
302
+ projectRoot(name) {
303
+ return this.config.projectPaths[name] ?? path.resolve(this.config.projectRoot, name);
304
+ }
305
+ isProjectReadonly(name) {
306
+ return this.config.readonlyProjects.includes(name);
307
+ }
308
+ ensureProjectWritable(name) {
309
+ if (this.isProjectReadonly(name)) {
310
+ throw new Error(`project is read-only: ${name}`);
311
+ }
312
+ }
313
+ async projectExists(root) {
314
+ try {
315
+ const stat = await fs.stat(root);
316
+ return stat.isDirectory();
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ }
322
+ identifierKeys(note) {
323
+ const relativeNoExt = note.relativePath.endsWith(MARKDOWN_EXTENSION)
324
+ ? note.relativePath.slice(0, -MARKDOWN_EXTENSION.length)
325
+ : note.relativePath;
326
+ return [
327
+ note.title,
328
+ note.permalink,
329
+ note.relativePath,
330
+ relativeNoExt,
331
+ path.basename(note.filePath, MARKDOWN_EXTENSION),
332
+ ];
333
+ }
334
+ candidateKeysFromTarget(targetBase) {
335
+ const trimmed = targetBase.trim();
336
+ if (trimmed.length === 0) {
337
+ return [];
338
+ }
339
+ const normalizedPath = trimmed.replace(/\\/g, "/").replace(/^\.\//, "");
340
+ const withoutExtension = normalizedPath.endsWith(MARKDOWN_EXTENSION)
341
+ ? normalizedPath.slice(0, -MARKDOWN_EXTENSION.length)
342
+ : normalizedPath;
343
+ const baseNoExt = path.posix.basename(withoutExtension);
344
+ const baseWithExt = path.posix.basename(normalizedPath);
345
+ return Array.from(new Set([normalizedPath, withoutExtension, baseNoExt, baseWithExt].filter((key) => key.length > 0)));
346
+ }
347
+ addToLookup(map, key, note) {
348
+ const normalizedKey = key.trim();
349
+ if (normalizedKey.length === 0) {
350
+ return;
351
+ }
352
+ const current = map.get(normalizedKey) ?? [];
353
+ if (!current.some((item) => item.filePath === note.filePath)) {
354
+ current.push(note);
355
+ map.set(normalizedKey, current);
356
+ }
357
+ }
358
+ buildLookup(notes) {
359
+ const exact = new Map();
360
+ const normalized = new Map();
361
+ for (const note of notes) {
362
+ const keys = this.identifierKeys(note);
363
+ for (const key of keys) {
364
+ this.addToLookup(exact, key.toLowerCase(), note);
365
+ this.addToLookup(normalized, normalizeLookup(key), note);
366
+ }
367
+ }
368
+ return { exact, normalized };
369
+ }
370
+ resolveWikiTarget(targetBase, lookup) {
371
+ const keys = this.candidateKeysFromTarget(targetBase);
372
+ if (keys.length === 0) {
373
+ return { status: "unresolved", reason: "empty_target" };
374
+ }
375
+ const exactCandidates = [];
376
+ for (const key of keys) {
377
+ exactCandidates.push(...(lookup.exact.get(key.toLowerCase()) ?? []));
378
+ }
379
+ const uniqueExact = uniqueByFilePath(exactCandidates);
380
+ if (uniqueExact.length === 1) {
381
+ return { status: "resolved", note: uniqueExact[0] };
382
+ }
383
+ if (uniqueExact.length > 1) {
384
+ return { status: "ambiguous", reason: "multiple_exact_matches" };
385
+ }
386
+ const normalizedCandidates = [];
387
+ for (const key of keys) {
388
+ normalizedCandidates.push(...(lookup.normalized.get(normalizeLookup(key)) ?? []));
389
+ }
390
+ const uniqueNormalized = uniqueByFilePath(normalizedCandidates);
391
+ if (uniqueNormalized.length === 1) {
392
+ return { status: "resolved", note: uniqueNormalized[0] };
393
+ }
394
+ if (uniqueNormalized.length > 1) {
395
+ return { status: "ambiguous", reason: "multiple_normalized_matches" };
396
+ }
397
+ return { status: "unresolved", reason: "target_not_found" };
398
+ }
399
+ resolveWikiLinksForText(text, lookup) {
400
+ const tokens = extractWikiLinks(text);
401
+ return tokens.map((token) => {
402
+ const resolved = this.resolveWikiTarget(token.targetBase, lookup);
403
+ if (resolved.status === "resolved" && resolved.note) {
404
+ return {
405
+ raw: token.raw,
406
+ target: token.targetBase,
407
+ resolved: true,
408
+ resolvedTitle: resolved.note.title,
409
+ resolvedPermalink: resolved.note.permalink,
410
+ resolvedFilePath: resolved.note.relativePath,
411
+ };
412
+ }
413
+ return {
414
+ raw: token.raw,
415
+ target: token.targetBase,
416
+ resolved: false,
417
+ reason: resolved.reason ?? resolved.status,
418
+ };
419
+ });
420
+ }
421
+ linkHealthFromResolutions(resolutions) {
422
+ const total = resolutions.length;
423
+ const unresolved = resolutions.filter((item) => !item.resolved).length;
424
+ return {
425
+ total,
426
+ resolved: total - unresolved,
427
+ unresolved,
428
+ };
429
+ }
430
+ unresolvedTargetsFromResolutions(resolutions) {
431
+ const unresolved = new Set();
432
+ for (const item of resolutions) {
433
+ if (!item.resolved) {
434
+ unresolved.add(item.target || item.raw);
435
+ }
436
+ }
437
+ return Array.from(unresolved.values()).sort((left, right) => left.localeCompare(right, "ja"));
438
+ }
439
+ linkSuggestionsForTarget(target, notes, limit) {
440
+ const candidates = [];
441
+ for (const note of notes) {
442
+ let score = 0;
443
+ for (const key of this.identifierKeys(note)) {
444
+ score = Math.max(score, identifierMatchScore(key, target));
445
+ }
446
+ if (score <= 0) {
447
+ continue;
448
+ }
449
+ candidates.push({
450
+ note,
451
+ score,
452
+ });
453
+ }
454
+ candidates.sort((left, right) => {
455
+ if (right.score !== left.score) {
456
+ return right.score - left.score;
457
+ }
458
+ if (right.note.updated !== left.note.updated) {
459
+ return right.note.updated.localeCompare(left.note.updated);
460
+ }
461
+ return left.note.title.localeCompare(right.note.title, "ja");
462
+ });
463
+ return candidates.slice(0, limit).map((item) => ({
464
+ title: item.note.title,
465
+ permalink: item.note.permalink,
466
+ filePath: item.note.relativePath,
467
+ score: item.score,
468
+ }));
469
+ }
470
+ unresolvedHintsFromResolutions(resolutions, notes) {
471
+ return this.unresolvedTargetsFromResolutions(resolutions).map((target) => ({
472
+ target,
473
+ suggestions: this.linkSuggestionsForTarget(target, notes, 3),
474
+ }));
475
+ }
476
+ duplicateWarningsForNote(note, notes) {
477
+ const byTitle = notes.filter((item) => item.filePath !== note.filePath && item.title === note.title);
478
+ const byPermalink = notes.filter((item) => item.filePath !== note.filePath && item.permalink === note.permalink);
479
+ const warnings = [];
480
+ if (byTitle.length > 0) {
481
+ warnings.push({
482
+ kind: "title",
483
+ value: note.title,
484
+ count: byTitle.length,
485
+ examples: byTitle.slice(0, 3).map((item) => `${item.title} (${item.relativePath})`),
486
+ });
487
+ }
488
+ if (byPermalink.length > 0) {
489
+ warnings.push({
490
+ kind: "permalink",
491
+ value: note.permalink,
492
+ count: byPermalink.length,
493
+ examples: byPermalink.slice(0, 3).map((item) => `${item.title} (${item.relativePath})`),
494
+ });
495
+ }
496
+ return warnings;
497
+ }
498
+ backlinkCountForTarget(target, notes, lookup) {
499
+ let count = 0;
500
+ for (const source of notes) {
501
+ if (source.filePath === target.filePath) {
502
+ continue;
503
+ }
504
+ const links = this.resolveWikiLinksForText(source.body, lookup);
505
+ count += links.filter((link) => link.resolved && link.resolvedFilePath === target.relativePath).length;
506
+ }
507
+ return count;
508
+ }
509
+ qualitySignalsForNote(note, notes) {
510
+ const lookup = this.buildLookup(notes);
511
+ const resolutions = this.resolveWikiLinksForText(note.body, lookup);
512
+ const linkHealth = this.linkHealthFromResolutions(resolutions);
513
+ const unresolvedWikiLinks = this.unresolvedTargetsFromResolutions(resolutions);
514
+ const unresolvedLinkHints = this.unresolvedHintsFromResolutions(resolutions, notes);
515
+ const duplicateWarnings = this.duplicateWarningsForNote(note, notes);
516
+ const backlinkCount = this.backlinkCountForTarget(note, notes, lookup);
517
+ const orphanWarning = backlinkCount === 0 && linkHealth.resolved === 0;
518
+ return {
519
+ linkHealth,
520
+ unresolvedWikiLinks,
521
+ unresolvedLinkHints,
522
+ duplicateWarnings,
523
+ backlinkCount,
524
+ orphanWarning,
525
+ };
526
+ }
527
+ projectLinkHealth(notes) {
528
+ const lookup = this.buildLookup(notes);
529
+ let total = 0;
530
+ let resolved = 0;
531
+ for (const note of notes) {
532
+ const health = this.linkHealthFromResolutions(this.resolveWikiLinksForText(note.body, lookup));
533
+ total += health.total;
534
+ resolved += health.resolved;
535
+ }
536
+ return {
537
+ total,
538
+ resolved,
539
+ unresolved: total - resolved,
540
+ };
541
+ }
542
+ async loadProjectNotes(projectRoot) {
543
+ const files = await this.listMarkdownFiles(projectRoot);
544
+ return Promise.all(files.map((filePath) => this.loadNote(filePath, projectRoot)));
545
+ }
546
+ async listProjectsDetailed() {
547
+ const byName = new Map();
548
+ for (const [name, root] of Object.entries(this.config.projectPaths)) {
549
+ byName.set(name, {
550
+ name,
551
+ root,
552
+ exists: await this.projectExists(root),
553
+ readonly: this.isProjectReadonly(name),
554
+ });
555
+ }
556
+ try {
557
+ const entries = await fs.readdir(this.config.projectRoot, { withFileTypes: true });
558
+ for (const entry of entries) {
559
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
560
+ continue;
561
+ }
562
+ if (byName.has(entry.name)) {
563
+ continue;
564
+ }
565
+ const root = this.projectRoot(entry.name);
566
+ byName.set(entry.name, {
567
+ name: entry.name,
568
+ root,
569
+ exists: true,
570
+ readonly: this.isProjectReadonly(entry.name),
571
+ });
572
+ }
573
+ }
574
+ catch {
575
+ // project root can be absent; explicit project paths are still valid
576
+ }
577
+ const projects = Array.from(byName.values());
578
+ projects.sort((left, right) => left.name.localeCompare(right.name, "ja"));
579
+ return projects;
580
+ }
581
+ async listProjects() {
582
+ const projects = await this.listProjectsDetailed();
583
+ return projects.map((project) => project.name);
584
+ }
585
+ async resolveProjectName(project) {
586
+ const requested = project?.trim();
587
+ if (requested && requested.length > 0) {
588
+ return requested;
589
+ }
590
+ if (this.config.defaultProject && this.config.defaultProject.trim().length > 0) {
591
+ return this.config.defaultProject;
592
+ }
593
+ const names = await this.listProjects();
594
+ if (names.length === 1) {
595
+ return names[0];
596
+ }
597
+ throw new Error("project is required. set project, KIOQ_DEFAULT_PROJECT, or keep only one project.");
598
+ }
599
+ async resolveProjectRoot(project, createIfMissing) {
600
+ const name = await this.resolveProjectName(project);
601
+ const root = this.projectRoot(name);
602
+ if (createIfMissing) {
603
+ this.ensureProjectWritable(name);
604
+ await fs.mkdir(root, { recursive: true });
605
+ return { name, root };
606
+ }
607
+ if (!(await this.projectExists(root))) {
608
+ throw new Error(`project not found: ${name} (${root})`);
609
+ }
610
+ return { name, root };
611
+ }
612
+ async listMarkdownFiles(projectRoot) {
613
+ const files = [];
614
+ const queue = [projectRoot];
615
+ while (queue.length > 0) {
616
+ const currentDir = queue.pop();
617
+ if (!currentDir) {
618
+ continue;
619
+ }
620
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
621
+ for (const entry of entries) {
622
+ const entryPath = path.join(currentDir, entry.name);
623
+ if (entry.isDirectory()) {
624
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) {
625
+ continue;
626
+ }
627
+ queue.push(entryPath);
628
+ continue;
629
+ }
630
+ if (entry.isFile() && entry.name.endsWith(MARKDOWN_EXTENSION)) {
631
+ files.push(entryPath);
632
+ }
633
+ }
634
+ }
635
+ return files;
636
+ }
637
+ async loadNote(filePath, projectRoot) {
638
+ const raw = await fs.readFile(filePath, "utf8");
639
+ const stat = await fs.stat(filePath);
640
+ const { frontmatter, body } = splitFrontmatter(raw);
641
+ const relativePath = toPosix(path.relative(projectRoot, filePath));
642
+ const title = safeString(frontmatter.title) ?? path.basename(filePath, MARKDOWN_EXTENSION);
643
+ const created = safeString(frontmatter.created) ?? stat.birthtime.toISOString().slice(0, 10);
644
+ const updated = safeString(frontmatter.updated) ?? stat.mtime.toISOString().slice(0, 10);
645
+ const defaultPermalink = relativePath.endsWith(MARKDOWN_EXTENSION)
646
+ ? relativePath.slice(0, -MARKDOWN_EXTENSION.length)
647
+ : relativePath;
648
+ const permalink = safeString(frontmatter.permalink) ?? defaultPermalink;
649
+ return {
650
+ title,
651
+ permalink,
652
+ filePath,
653
+ relativePath,
654
+ frontmatter,
655
+ body,
656
+ raw,
657
+ created,
658
+ updated,
659
+ };
660
+ }
661
+ async resolveIdentifier(projectRoot, identifier) {
662
+ const trimmed = identifier.trim();
663
+ if (trimmed.length === 0) {
664
+ return undefined;
665
+ }
666
+ const pathCandidates = [trimmed, trimmed.endsWith(MARKDOWN_EXTENSION) ? undefined : `${trimmed}${MARKDOWN_EXTENSION}`]
667
+ .filter((candidate) => Boolean(candidate))
668
+ .map((candidate) => sanitizeRelativePath(candidate));
669
+ for (const candidate of pathCandidates) {
670
+ const absolutePath = path.resolve(projectRoot, candidate);
671
+ if (!isInsideDirectory(projectRoot, absolutePath)) {
672
+ continue;
673
+ }
674
+ try {
675
+ const stat = await fs.stat(absolutePath);
676
+ if (stat.isFile()) {
677
+ return await this.loadNote(absolutePath, projectRoot);
678
+ }
679
+ }
680
+ catch {
681
+ // continue
682
+ }
683
+ }
684
+ const notes = await this.loadProjectNotes(projectRoot);
685
+ const normalizedInput = trimmed.toLowerCase();
686
+ const fuzzyMatches = [];
687
+ for (const note of notes) {
688
+ const keys = this.identifierKeys(note);
689
+ if (keys.some((key) => key.toLowerCase() === normalizedInput)) {
690
+ return note;
691
+ }
692
+ let bestScore = 0;
693
+ for (const key of keys) {
694
+ bestScore = Math.max(bestScore, identifierMatchScore(key, trimmed));
695
+ }
696
+ if (bestScore > 0) {
697
+ fuzzyMatches.push({ note, score: bestScore });
698
+ }
699
+ }
700
+ if (fuzzyMatches.length === 0) {
701
+ return undefined;
702
+ }
703
+ fuzzyMatches.sort((left, right) => {
704
+ if (right.score !== left.score) {
705
+ return right.score - left.score;
706
+ }
707
+ return right.note.updated.localeCompare(left.note.updated);
708
+ });
709
+ if (fuzzyMatches.length === 1) {
710
+ return fuzzyMatches[0].note;
711
+ }
712
+ if (fuzzyMatches[0].score >= fuzzyMatches[1].score + 20) {
713
+ return fuzzyMatches[0].note;
714
+ }
715
+ return undefined;
716
+ }
717
+ noteScore(note, query) {
718
+ const loweredQueryTerms = query
719
+ .toLowerCase()
720
+ .split(/\s+/)
721
+ .map((term) => term.trim())
722
+ .filter((term) => term.length > 0);
723
+ if (loweredQueryTerms.length === 0) {
724
+ return 0;
725
+ }
726
+ const title = note.title.toLowerCase();
727
+ const permalink = note.permalink.toLowerCase();
728
+ const body = note.body.toLowerCase();
729
+ let score = 0;
730
+ for (const term of loweredQueryTerms) {
731
+ score += countOccurrences(title, term) * 20;
732
+ score += countOccurrences(permalink, term) * 10;
733
+ score += countOccurrences(body, term);
734
+ }
735
+ return score;
736
+ }
737
+ async searchNotes(args) {
738
+ const { name: project, root } = await this.resolveProjectRoot(args.project, false);
739
+ const notes = await this.loadProjectNotes(root);
740
+ const results = [];
741
+ for (const note of notes) {
742
+ if (args.afterDate && note.updated < args.afterDate) {
743
+ continue;
744
+ }
745
+ const score = this.noteScore(note, args.query);
746
+ if (score <= 0) {
747
+ continue;
748
+ }
749
+ results.push({
750
+ title: note.title,
751
+ permalink: note.permalink,
752
+ filePath: note.relativePath,
753
+ content: note.body,
754
+ score,
755
+ updated: note.updated,
756
+ });
757
+ }
758
+ results.sort((left, right) => {
759
+ if (right.score !== left.score) {
760
+ return right.score - left.score;
761
+ }
762
+ return right.updated.localeCompare(left.updated);
763
+ });
764
+ return {
765
+ project,
766
+ results: results.slice(0, args.limit),
767
+ };
768
+ }
769
+ async recentNotes(args) {
770
+ const { name: project, root } = await this.resolveProjectRoot(args.project, false);
771
+ const notes = await this.loadProjectNotes(root);
772
+ const directoryPrefix = sanitizeRelativePath(args.directory);
773
+ const results = [];
774
+ for (const note of notes) {
775
+ if (directoryPrefix.length > 0) {
776
+ const matchPrefix = `${directoryPrefix}/`;
777
+ if (!note.relativePath.startsWith(matchPrefix)) {
778
+ continue;
779
+ }
780
+ }
781
+ results.push({
782
+ title: note.title,
783
+ permalink: note.permalink,
784
+ filePath: note.relativePath,
785
+ updated: note.updated,
786
+ });
787
+ }
788
+ results.sort((left, right) => right.updated.localeCompare(left.updated));
789
+ return {
790
+ project,
791
+ results: results.slice(0, args.limit),
792
+ };
793
+ }
794
+ async suggestNotes(args) {
795
+ const { name: project, root } = await this.resolveProjectRoot(args.project, false);
796
+ const query = args.query.trim();
797
+ if (query.length === 0) {
798
+ return { project, results: [] };
799
+ }
800
+ const notes = await this.loadProjectNotes(root);
801
+ const results = [];
802
+ for (const note of notes) {
803
+ let keyScore = 0;
804
+ for (const key of this.identifierKeys(note)) {
805
+ keyScore = Math.max(keyScore, identifierMatchScore(key, query));
806
+ }
807
+ const bodyScore = this.noteScore(note, query);
808
+ const score = keyScore + bodyScore;
809
+ if (score <= 0) {
810
+ continue;
811
+ }
812
+ results.push({
813
+ title: note.title,
814
+ permalink: note.permalink,
815
+ filePath: note.relativePath,
816
+ content: note.body,
817
+ score,
818
+ updated: note.updated,
819
+ });
820
+ }
821
+ results.sort((left, right) => {
822
+ if (right.score !== left.score) {
823
+ return right.score - left.score;
824
+ }
825
+ return right.updated.localeCompare(left.updated);
826
+ });
827
+ return {
828
+ project,
829
+ results: results.slice(0, args.limit),
830
+ };
831
+ }
832
+ async readNote(identifier, project) {
833
+ const { name, root } = await this.resolveProjectRoot(project, false);
834
+ const note = await this.resolveIdentifier(root, identifier);
835
+ if (!note) {
836
+ throw new Error(`note not found: ${identifier}`);
837
+ }
838
+ const notes = await this.loadProjectNotes(root);
839
+ const currentNote = notes.find((item) => item.filePath === note.filePath) ?? note;
840
+ const signals = this.qualitySignalsForNote(currentNote, notes);
841
+ return {
842
+ project: name,
843
+ note: currentNote,
844
+ linkHealth: signals.linkHealth,
845
+ unresolvedWikiLinks: signals.unresolvedWikiLinks,
846
+ unresolvedLinkHints: signals.unresolvedLinkHints,
847
+ backlinkCount: signals.backlinkCount,
848
+ orphanWarning: signals.orphanWarning,
849
+ };
850
+ }
851
+ async resolveLinks(args) {
852
+ const { name: project, root } = await this.resolveProjectRoot(args.project, false);
853
+ const note = await this.resolveIdentifier(root, args.identifier);
854
+ if (!note) {
855
+ throw new Error(`note not found: ${args.identifier}`);
856
+ }
857
+ const notes = await this.loadProjectNotes(root);
858
+ const lookup = this.buildLookup(notes);
859
+ const links = this.resolveWikiLinksForText(note.body, lookup);
860
+ return {
861
+ project,
862
+ note,
863
+ links,
864
+ };
865
+ }
866
+ async listBacklinks(args) {
867
+ const { name: project, root } = await this.resolveProjectRoot(args.project, false);
868
+ const target = await this.resolveIdentifier(root, args.identifier);
869
+ if (!target) {
870
+ throw new Error(`note not found: ${args.identifier}`);
871
+ }
872
+ const notes = await this.loadProjectNotes(root);
873
+ const lookup = this.buildLookup(notes);
874
+ const backlinks = [];
875
+ for (const source of notes) {
876
+ const links = this.resolveWikiLinksForText(source.body, lookup);
877
+ const matches = links.filter((link) => link.resolved && link.resolvedFilePath === target.relativePath);
878
+ if (matches.length === 0) {
879
+ continue;
880
+ }
881
+ backlinks.push({
882
+ title: source.title,
883
+ permalink: source.permalink,
884
+ filePath: source.relativePath,
885
+ updated: source.updated,
886
+ count: matches.length,
887
+ examples: matches.slice(0, 3).map((item) => item.raw),
888
+ });
889
+ }
890
+ backlinks.sort((left, right) => {
891
+ if (right.count !== left.count) {
892
+ return right.count - left.count;
893
+ }
894
+ return right.updated.localeCompare(left.updated);
895
+ });
896
+ return {
897
+ project,
898
+ target,
899
+ backlinks: backlinks.slice(0, args.limit),
900
+ };
901
+ }
902
+ async writeNote(input) {
903
+ const { name: project, root } = await this.resolveProjectRoot(input.project, true);
904
+ this.ensureProjectWritable(project);
905
+ const directory = sanitizeRelativePath(input.directory ?? this.config.defaultDirectory);
906
+ const fileBase = sanitizeFileBase(input.title);
907
+ const fileName = `${fileBase}${MARKDOWN_EXTENSION}`;
908
+ const targetPath = path.resolve(root, directory, fileName);
909
+ if (!isInsideDirectory(root, targetPath)) {
910
+ throw new Error("invalid target path");
911
+ }
912
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
913
+ let operation = "created";
914
+ let existingFrontmatter = {};
915
+ try {
916
+ const current = await this.loadNote(targetPath, root);
917
+ existingFrontmatter = current.frontmatter;
918
+ operation = "updated";
919
+ }
920
+ catch {
921
+ // create new file
922
+ }
923
+ const date = nowDate();
924
+ const relativePath = toPosix(path.relative(root, targetPath));
925
+ const permalink = safeString(existingFrontmatter.permalink)
926
+ ?? (relativePath.endsWith(MARKDOWN_EXTENSION)
927
+ ? relativePath.slice(0, -MARKDOWN_EXTENSION.length)
928
+ : relativePath);
929
+ const nextTags = input.tags && input.tags.length > 0
930
+ ? input.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0)
931
+ : safeStringArray(existingFrontmatter.tags);
932
+ const frontmatter = {
933
+ title: input.title,
934
+ type: input.noteType ?? safeString(existingFrontmatter.type) ?? "note",
935
+ permalink,
936
+ tags: nextTags,
937
+ created: safeString(existingFrontmatter.created) ?? date,
938
+ updated: date,
939
+ };
940
+ const body = normalizeContent(input.content).trimEnd();
941
+ const rendered = `${buildFrontmatter(frontmatter)}\n\n${ensureTrailingNewline(body)}`;
942
+ await fs.writeFile(targetPath, rendered, "utf8");
943
+ const saved = await this.loadNote(targetPath, root);
944
+ const notes = await this.loadProjectNotes(root);
945
+ const signals = this.qualitySignalsForNote(saved, notes);
946
+ return {
947
+ project,
948
+ operation,
949
+ filePath: targetPath,
950
+ relativePath,
951
+ title: input.title,
952
+ permalink,
953
+ linkHealth: signals.linkHealth,
954
+ unresolvedWikiLinks: signals.unresolvedWikiLinks,
955
+ unresolvedLinkHints: signals.unresolvedLinkHints,
956
+ backlinkCount: signals.backlinkCount,
957
+ orphanWarning: signals.orphanWarning,
958
+ duplicateWarnings: signals.duplicateWarnings,
959
+ };
960
+ }
961
+ async appendNote(args) {
962
+ const { name: project, root } = await this.resolveProjectRoot(args.project, Boolean(args.createIfMissing));
963
+ this.ensureProjectWritable(project);
964
+ const note = await this.resolveIdentifier(root, args.identifier);
965
+ if (!note) {
966
+ if (!args.createIfMissing) {
967
+ throw new Error(`note not found: ${args.identifier}`);
968
+ }
969
+ const created = await this.writeNote({
970
+ project,
971
+ directory: args.directory,
972
+ title: inferTitleFromIdentifier(args.identifier),
973
+ content: normalizeContent(args.content).trimEnd(),
974
+ tags: args.tags,
975
+ noteType: args.noteType,
976
+ });
977
+ return {
978
+ project: created.project,
979
+ filePath: created.filePath,
980
+ relativePath: created.relativePath,
981
+ title: created.title,
982
+ permalink: created.permalink,
983
+ operation: "created",
984
+ linkHealth: created.linkHealth,
985
+ unresolvedWikiLinks: created.unresolvedWikiLinks,
986
+ unresolvedLinkHints: created.unresolvedLinkHints,
987
+ backlinkCount: created.backlinkCount,
988
+ orphanWarning: created.orphanWarning,
989
+ duplicateWarnings: created.duplicateWarnings,
990
+ };
991
+ }
992
+ const date = nowDate();
993
+ const nextBody = `${note.body.trimEnd()}\n\n${normalizeContent(args.content).trimEnd()}\n`;
994
+ const frontmatter = {
995
+ ...note.frontmatter,
996
+ title: note.title,
997
+ permalink: note.permalink,
998
+ created: note.created,
999
+ updated: date,
1000
+ };
1001
+ const rendered = `${buildFrontmatter(frontmatter)}\n\n${nextBody}`;
1002
+ await fs.writeFile(note.filePath, rendered, "utf8");
1003
+ const updated = await this.loadNote(note.filePath, root);
1004
+ const notes = await this.loadProjectNotes(root);
1005
+ const signals = this.qualitySignalsForNote(updated, notes);
1006
+ return {
1007
+ project,
1008
+ filePath: note.filePath,
1009
+ relativePath: note.relativePath,
1010
+ title: note.title,
1011
+ permalink: note.permalink,
1012
+ operation: "appended",
1013
+ linkHealth: signals.linkHealth,
1014
+ unresolvedWikiLinks: signals.unresolvedWikiLinks,
1015
+ unresolvedLinkHints: signals.unresolvedLinkHints,
1016
+ backlinkCount: signals.backlinkCount,
1017
+ orphanWarning: signals.orphanWarning,
1018
+ duplicateWarnings: signals.duplicateWarnings,
1019
+ };
1020
+ }
1021
+ async renameNote(args) {
1022
+ const { name: project, root } = await this.resolveProjectRoot(args.project, false);
1023
+ this.ensureProjectWritable(project);
1024
+ const beforeNotes = await this.loadProjectNotes(root);
1025
+ const projectLinkHealthBefore = this.projectLinkHealth(beforeNotes);
1026
+ const note = await this.resolveIdentifier(root, args.identifier);
1027
+ if (!note) {
1028
+ throw new Error(`note not found: ${args.identifier}`);
1029
+ }
1030
+ const oldTitle = note.title;
1031
+ const oldRelativePath = note.relativePath;
1032
+ const oldPermalink = note.permalink;
1033
+ const oldKeys = this.identifierKeys(note);
1034
+ let nextDirectory = args.directory ? sanitizeRelativePath(args.directory) : path.posix.dirname(note.relativePath);
1035
+ if (nextDirectory === ".") {
1036
+ nextDirectory = "";
1037
+ }
1038
+ const newFileBase = sanitizeFileBase(args.newTitle);
1039
+ const newRelativePath = nextDirectory.length > 0
1040
+ ? `${nextDirectory}/${newFileBase}${MARKDOWN_EXTENSION}`
1041
+ : `${newFileBase}${MARKDOWN_EXTENSION}`;
1042
+ const newAbsolutePath = path.resolve(root, newRelativePath);
1043
+ if (!isInsideDirectory(root, newAbsolutePath)) {
1044
+ throw new Error("invalid rename path");
1045
+ }
1046
+ const oldAbsolutePath = note.filePath;
1047
+ const samePath = path.resolve(oldAbsolutePath) === path.resolve(newAbsolutePath);
1048
+ if (!samePath && (await fileExistsSafe(newAbsolutePath))) {
1049
+ throw new Error(`target file already exists: ${newRelativePath}`);
1050
+ }
1051
+ if (!samePath) {
1052
+ await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
1053
+ await fs.rename(oldAbsolutePath, newAbsolutePath);
1054
+ }
1055
+ const date = nowDate();
1056
+ const newPermalink = newRelativePath.endsWith(MARKDOWN_EXTENSION)
1057
+ ? newRelativePath.slice(0, -MARKDOWN_EXTENSION.length)
1058
+ : newRelativePath;
1059
+ const frontmatter = {
1060
+ ...note.frontmatter,
1061
+ title: args.newTitle,
1062
+ permalink: newPermalink,
1063
+ created: note.created,
1064
+ updated: date,
1065
+ };
1066
+ const rewrittenTarget = `${buildFrontmatter(frontmatter)}\n\n${ensureTrailingNewline(note.body.trimEnd())}`;
1067
+ await fs.writeFile(newAbsolutePath, rewrittenTarget, "utf8");
1068
+ let updatedFiles = 0;
1069
+ let updatedLinks = 0;
1070
+ if (args.updateLinks !== false) {
1071
+ const oldKeyLowerSet = new Set(oldKeys.map((key) => key.toLowerCase()));
1072
+ const oldKeyNormalizedSet = new Set(oldKeys.map((key) => normalizeLookup(key)));
1073
+ const notes = await this.loadProjectNotes(root);
1074
+ for (const source of notes) {
1075
+ const rewritten = rewriteWikiLinks(source.raw, (token) => {
1076
+ const targetLower = token.targetBase.toLowerCase();
1077
+ const targetNormalized = normalizeLookup(token.targetBase);
1078
+ const matches = oldKeyLowerSet.has(targetLower)
1079
+ || (targetNormalized.length > 0 && oldKeyNormalizedSet.has(targetNormalized));
1080
+ if (!matches) {
1081
+ return undefined;
1082
+ }
1083
+ return renderWikiLink(newPermalink, token.suffix, token.alias);
1084
+ });
1085
+ if (rewritten.replaced > 0) {
1086
+ await fs.writeFile(source.filePath, rewritten.text, "utf8");
1087
+ updatedFiles += 1;
1088
+ updatedLinks += rewritten.replaced;
1089
+ }
1090
+ }
1091
+ }
1092
+ const afterNotes = await this.loadProjectNotes(root);
1093
+ const projectLinkHealthAfter = this.projectLinkHealth(afterNotes);
1094
+ const renamed = await this.loadNote(newAbsolutePath, root);
1095
+ const signals = this.qualitySignalsForNote(renamed, afterNotes);
1096
+ return {
1097
+ project,
1098
+ oldTitle,
1099
+ newTitle: renamed.title,
1100
+ oldRelativePath,
1101
+ newRelativePath: renamed.relativePath,
1102
+ oldPermalink,
1103
+ newPermalink: renamed.permalink,
1104
+ updatedLinks,
1105
+ updatedFiles,
1106
+ renamedNoteLinkHealth: signals.linkHealth,
1107
+ backlinkCount: signals.backlinkCount,
1108
+ orphanWarning: signals.orphanWarning,
1109
+ projectLinkHealthBefore,
1110
+ projectLinkHealthAfter,
1111
+ unresolvedWikiLinks: signals.unresolvedWikiLinks,
1112
+ unresolvedLinkHints: signals.unresolvedLinkHints,
1113
+ };
1114
+ }
1115
+ }