@cad0p/napkin 0.8.1

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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +342 -0
  3. package/dist/commands/aliases.d.ts +7 -0
  4. package/dist/commands/aliases.js +25 -0
  5. package/dist/commands/bases.d.ts +23 -0
  6. package/dist/commands/bases.js +139 -0
  7. package/dist/commands/bookmarks.d.ts +15 -0
  8. package/dist/commands/bookmarks.js +51 -0
  9. package/dist/commands/canvas.d.ts +49 -0
  10. package/dist/commands/canvas.js +186 -0
  11. package/dist/commands/config.d.ts +13 -0
  12. package/dist/commands/config.js +48 -0
  13. package/dist/commands/crud.d.ts +40 -0
  14. package/dist/commands/crud.js +195 -0
  15. package/dist/commands/daily.d.ts +20 -0
  16. package/dist/commands/daily.js +58 -0
  17. package/dist/commands/files.d.ts +23 -0
  18. package/dist/commands/files.js +132 -0
  19. package/dist/commands/graph.d.ts +4 -0
  20. package/dist/commands/graph.js +461 -0
  21. package/dist/commands/init.d.ts +7 -0
  22. package/dist/commands/init.js +52 -0
  23. package/dist/commands/links.d.ts +26 -0
  24. package/dist/commands/links.js +119 -0
  25. package/dist/commands/outline.d.ts +7 -0
  26. package/dist/commands/outline.js +48 -0
  27. package/dist/commands/overview.d.ts +6 -0
  28. package/dist/commands/overview.js +40 -0
  29. package/dist/commands/properties.d.ts +24 -0
  30. package/dist/commands/properties.js +115 -0
  31. package/dist/commands/search.d.ts +13 -0
  32. package/dist/commands/search.js +48 -0
  33. package/dist/commands/tags.d.ts +13 -0
  34. package/dist/commands/tags.js +51 -0
  35. package/dist/commands/tasks.d.ts +22 -0
  36. package/dist/commands/tasks.js +106 -0
  37. package/dist/commands/templates.d.ts +16 -0
  38. package/dist/commands/templates.js +70 -0
  39. package/dist/commands/vault.d.ts +4 -0
  40. package/dist/commands/vault.js +17 -0
  41. package/dist/commands/wordcount.d.ts +7 -0
  42. package/dist/commands/wordcount.js +43 -0
  43. package/dist/core/aliases.d.ts +5 -0
  44. package/dist/core/aliases.js +26 -0
  45. package/dist/core/bases.d.ts +29 -0
  46. package/dist/core/bases.js +67 -0
  47. package/dist/core/bookmarks.d.ts +14 -0
  48. package/dist/core/bookmarks.js +34 -0
  49. package/dist/core/canvas.d.ts +74 -0
  50. package/dist/core/canvas.js +125 -0
  51. package/dist/core/config.d.ts +7 -0
  52. package/dist/core/config.js +35 -0
  53. package/dist/core/crud.d.ts +32 -0
  54. package/dist/core/crud.js +119 -0
  55. package/dist/core/daily.d.ts +12 -0
  56. package/dist/core/daily.js +102 -0
  57. package/dist/core/files.d.ts +15 -0
  58. package/dist/core/files.js +30 -0
  59. package/dist/core/init.d.ts +31 -0
  60. package/dist/core/init.js +119 -0
  61. package/dist/core/links.d.ts +11 -0
  62. package/dist/core/links.js +66 -0
  63. package/dist/core/outline.d.ts +3 -0
  64. package/dist/core/outline.js +12 -0
  65. package/dist/core/overview.d.ts +15 -0
  66. package/dist/core/overview.js +384 -0
  67. package/dist/core/properties.d.ts +14 -0
  68. package/dist/core/properties.js +60 -0
  69. package/dist/core/search.d.ts +17 -0
  70. package/dist/core/search.js +153 -0
  71. package/dist/core/tags.d.ts +11 -0
  72. package/dist/core/tags.js +40 -0
  73. package/dist/core/tasks.d.ts +35 -0
  74. package/dist/core/tasks.js +97 -0
  75. package/dist/core/templates.d.ts +14 -0
  76. package/dist/core/templates.js +55 -0
  77. package/dist/core/vault.d.ts +10 -0
  78. package/dist/core/vault.js +37 -0
  79. package/dist/core/wordcount.d.ts +5 -0
  80. package/dist/core/wordcount.js +16 -0
  81. package/dist/index.d.ts +17 -0
  82. package/dist/index.js +1 -0
  83. package/dist/main.d.ts +2 -0
  84. package/dist/main.js +715 -0
  85. package/dist/sdk.d.ts +179 -0
  86. package/dist/sdk.js +232 -0
  87. package/dist/templates/coding.d.ts +2 -0
  88. package/dist/templates/coding.js +104 -0
  89. package/dist/templates/company.d.ts +2 -0
  90. package/dist/templates/company.js +121 -0
  91. package/dist/templates/index.d.ts +4 -0
  92. package/dist/templates/index.js +15 -0
  93. package/dist/templates/personal.d.ts +2 -0
  94. package/dist/templates/personal.js +91 -0
  95. package/dist/templates/product.d.ts +2 -0
  96. package/dist/templates/product.js +123 -0
  97. package/dist/templates/research.d.ts +2 -0
  98. package/dist/templates/research.js +114 -0
  99. package/dist/templates/types.d.ts +7 -0
  100. package/dist/templates/types.js +1 -0
  101. package/dist/utils/bases.d.ts +61 -0
  102. package/dist/utils/bases.js +661 -0
  103. package/dist/utils/config.d.ts +42 -0
  104. package/dist/utils/config.js +112 -0
  105. package/dist/utils/exit-codes.d.ts +5 -0
  106. package/dist/utils/exit-codes.js +5 -0
  107. package/dist/utils/files.d.ts +135 -0
  108. package/dist/utils/files.js +299 -0
  109. package/dist/utils/formula.d.ts +28 -0
  110. package/dist/utils/formula.js +462 -0
  111. package/dist/utils/frontmatter.d.ts +17 -0
  112. package/dist/utils/frontmatter.js +34 -0
  113. package/dist/utils/markdown.d.ts +31 -0
  114. package/dist/utils/markdown.js +80 -0
  115. package/dist/utils/output.d.ts +28 -0
  116. package/dist/utils/output.js +48 -0
  117. package/dist/utils/search-cache.d.ts +29 -0
  118. package/dist/utils/search-cache.js +41 -0
  119. package/dist/utils/test-helpers.d.ts +13 -0
  120. package/dist/utils/test-helpers.js +40 -0
  121. package/dist/utils/vault.d.ts +21 -0
  122. package/dist/utils/vault.js +144 -0
  123. package/package.json +76 -0
@@ -0,0 +1,384 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { loadConfig } from "../utils/config.js";
4
+ import { listFiles } from "../utils/files.js";
5
+ import { parseFrontmatter } from "../utils/frontmatter.js";
6
+ import { extractHeadings, extractTags } from "../utils/markdown.js";
7
+ const CODE_BLOCK_RE = /```[\s\S]*?```/g;
8
+ const INLINE_CODE_RE = /`[^`]+`/g;
9
+ const URL_RE = /https?:\/\/[^\s)>\]]+/g;
10
+ const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
11
+ const HEX_HASH_RE = /\b[a-f0-9]{8,}\b/g;
12
+ const TOKEN_RE = /[a-z]{3,}/g;
13
+ const FRONTMATTER_RE = /^---[\s\S]*?---\n?/;
14
+ const ATX_HEADING_LINE_RE = /^#{1,6}\s+.+$/gm;
15
+ const WIKILINK_ONLY_RE = /^\[\[[^\]]+(?:\|[^\]]+)?\]\]$/;
16
+ const ISO_DATE_PREFIX_RE = /^\d{4}-\d{2}-\d{2}/;
17
+ const STOP_WORDS = new Set([
18
+ "the",
19
+ "a",
20
+ "an",
21
+ "and",
22
+ "or",
23
+ "but",
24
+ "in",
25
+ "on",
26
+ "at",
27
+ "to",
28
+ "for",
29
+ "of",
30
+ "with",
31
+ "by",
32
+ "from",
33
+ "is",
34
+ "it",
35
+ "as",
36
+ "be",
37
+ "was",
38
+ "are",
39
+ "this",
40
+ "that",
41
+ "not",
42
+ "has",
43
+ "have",
44
+ "had",
45
+ "will",
46
+ "can",
47
+ "may",
48
+ "do",
49
+ "does",
50
+ "did",
51
+ "been",
52
+ "being",
53
+ "would",
54
+ "could",
55
+ "should",
56
+ "its",
57
+ "my",
58
+ "your",
59
+ "our",
60
+ "their",
61
+ "his",
62
+ "her",
63
+ "we",
64
+ "they",
65
+ "you",
66
+ "he",
67
+ "she",
68
+ "all",
69
+ "each",
70
+ "every",
71
+ "both",
72
+ "few",
73
+ "more",
74
+ "most",
75
+ "other",
76
+ "some",
77
+ "such",
78
+ "than",
79
+ "too",
80
+ "very",
81
+ "just",
82
+ "about",
83
+ "above",
84
+ "after",
85
+ "again",
86
+ "also",
87
+ "any",
88
+ "because",
89
+ "before",
90
+ "between",
91
+ "down",
92
+ "during",
93
+ "even",
94
+ "first",
95
+ "get",
96
+ "how",
97
+ "if",
98
+ "into",
99
+ "like",
100
+ "made",
101
+ "make",
102
+ "many",
103
+ "much",
104
+ "new",
105
+ "no",
106
+ "now",
107
+ "off",
108
+ "old",
109
+ "only",
110
+ "one",
111
+ "out",
112
+ "over",
113
+ "own",
114
+ "same",
115
+ "so",
116
+ "still",
117
+ "then",
118
+ "there",
119
+ "these",
120
+ "those",
121
+ "through",
122
+ "under",
123
+ "up",
124
+ "use",
125
+ "used",
126
+ "using",
127
+ "want",
128
+ "way",
129
+ "well",
130
+ "what",
131
+ "when",
132
+ "where",
133
+ "which",
134
+ "while",
135
+ "who",
136
+ "why",
137
+ "work",
138
+ "see",
139
+ "here",
140
+ "need",
141
+ "etc",
142
+ "two",
143
+ "next",
144
+ "per",
145
+ "via",
146
+ "vs",
147
+ "yet",
148
+ "ago",
149
+ "due",
150
+ "tbd",
151
+ ]); // prettier-ignore
152
+ function stripNoise(text) {
153
+ return text
154
+ .replace(CODE_BLOCK_RE, "")
155
+ .replace(INLINE_CODE_RE, "")
156
+ .replace(URL_RE, "")
157
+ .replace(EMAIL_RE, "")
158
+ .replace(HEX_HASH_RE, "");
159
+ }
160
+ function tokenize(text) {
161
+ const cleaned = stripNoise(text);
162
+ return (cleaned.toLowerCase().match(TOKEN_RE) || []).filter((w) => !STOP_WORDS.has(w));
163
+ }
164
+ function extractBigrams(text) {
165
+ const words = tokenize(text);
166
+ const bigrams = [];
167
+ for (let i = 0; i < words.length - 1; i++) {
168
+ bigrams.push(`${words[i]} ${words[i + 1]}`);
169
+ }
170
+ return bigrams;
171
+ }
172
+ function terms(text) {
173
+ return [...tokenize(text), ...extractBigrams(text)];
174
+ }
175
+ function addWeightedTerms(target, sourceTerms, weight) {
176
+ for (const term of sourceTerms) {
177
+ target.set(term, (target.get(term) || 0) + weight);
178
+ }
179
+ }
180
+ function buildTF(sources) {
181
+ const freq = new Map();
182
+ for (const { text, weight } of sources) {
183
+ addWeightedTerms(freq, terms(text), weight);
184
+ }
185
+ return freq;
186
+ }
187
+ function folderKeywordTokens(folderPath) {
188
+ const tokens = new Set();
189
+ for (const segment of folderPath.split("/")) {
190
+ for (const token of tokenize(segment)) {
191
+ tokens.add(token);
192
+ tokens.add(token.endsWith("s") ? token.slice(0, -1) : `${token}s`);
193
+ }
194
+ }
195
+ return tokens;
196
+ }
197
+ function shouldSkipOverviewFile(file, folder, templatesFolder) {
198
+ const basename = path.basename(file);
199
+ const topLevelFolder = folder === "/" ? "" : folder.split("/")[0];
200
+ return (topLevelFolder === templatesFolder ||
201
+ (folder === "/" && basename === "NAPKIN.md") ||
202
+ basename === "_about.md");
203
+ }
204
+ function frontmatterText(properties) {
205
+ const values = [];
206
+ const visit = (value) => {
207
+ if (typeof value === "string")
208
+ values.push(value);
209
+ else if (Array.isArray(value))
210
+ value.forEach(visit);
211
+ };
212
+ for (const [key, value] of Object.entries(properties)) {
213
+ if (key === "title" || key === "tags")
214
+ continue;
215
+ visit(value);
216
+ }
217
+ return values.filter((value) => {
218
+ const trimmed = value.trim();
219
+ return (trimmed.length > 0 &&
220
+ !WIKILINK_ONLY_RE.test(trimmed) &&
221
+ !ISO_DATE_PREFIX_RE.test(trimmed));
222
+ });
223
+ }
224
+ function markdownBodyText(content) {
225
+ return content.replace(FRONTMATTER_RE, "").replace(ATX_HEADING_LINE_RE, "");
226
+ }
227
+ function buildHeadingSignals(headings) {
228
+ const lineCount = new Map();
229
+ const weightedTerms = new Map();
230
+ const uniqueTerms = new Set();
231
+ for (const heading of headings) {
232
+ const seenInHeading = new Set(terms(heading));
233
+ for (const term of seenInHeading) {
234
+ uniqueTerms.add(term);
235
+ lineCount.set(term, (lineCount.get(term) || 0) + 1);
236
+ }
237
+ }
238
+ addWeightedTerms(weightedTerms, uniqueTerms, 3);
239
+ return { lineCount, weightedTerms };
240
+ }
241
+ function isCandidateKeyword(term, tf, folderTokens, headingLineCount, hasNonHeading) {
242
+ if (term.includes(" ")) {
243
+ const [a, b] = term.split(" ");
244
+ if (tf < 2 || a === b)
245
+ return false;
246
+ }
247
+ else if (folderTokens.has(term)) {
248
+ return false;
249
+ }
250
+ // Require corroboration: real keywords either appear outside headings or in
251
+ // multiple heading lines. Single heading-only terms are usually section labels.
252
+ return hasNonHeading.has(term) || (headingLineCount.get(term) || 0) >= 2;
253
+ }
254
+ function extractKeywordsTFIDF(folderTF, documentFrequency, totalFolders, maxKeywords, folderPath, headingLineCount, hasNonHeading) {
255
+ const folderTokens = folderKeywordTokens(folderPath);
256
+ const scored = [];
257
+ for (const [term, tf] of folderTF) {
258
+ if (!isCandidateKeyword(term, tf, folderTokens, headingLineCount, hasNonHeading)) {
259
+ continue;
260
+ }
261
+ const df = documentFrequency.get(term) || 1;
262
+ const idf = Math.log(1 + totalFolders / df);
263
+ scored.push([term, tf * idf]);
264
+ }
265
+ const sorted = scored.sort((a, b) => b[1] - a[1]);
266
+ const selected = [];
267
+ const suppressed = new Set();
268
+ for (const [term] of sorted) {
269
+ if (selected.length >= maxKeywords)
270
+ break;
271
+ if (suppressed.has(term))
272
+ continue;
273
+ selected.push(term);
274
+ if (term.includes(" ")) {
275
+ for (const part of term.split(" ")) {
276
+ suppressed.add(part);
277
+ }
278
+ }
279
+ }
280
+ return selected;
281
+ }
282
+ function groupFilesByFolder(files, templatesFolder) {
283
+ const folderFiles = new Map();
284
+ for (const file of files) {
285
+ const dir = path.dirname(file);
286
+ const folder = dir === "." ? "/" : dir;
287
+ if (shouldSkipOverviewFile(file, folder, templatesFolder))
288
+ continue;
289
+ if (!folderFiles.has(folder))
290
+ folderFiles.set(folder, []);
291
+ folderFiles.get(folder)?.push(file);
292
+ }
293
+ return folderFiles;
294
+ }
295
+ function buildFolderData(vaultPath, folderFileList, warnings) {
296
+ const allTags = new Set();
297
+ const headings = new Set();
298
+ const weightedSources = [];
299
+ for (const file of folderFileList) {
300
+ const content = fs.readFileSync(path.join(vaultPath, file), "utf-8");
301
+ let properties = {};
302
+ try {
303
+ ({ properties } = parseFrontmatter(content));
304
+ }
305
+ catch {
306
+ warnings.push(`Skipping ${file} (malformed YAML frontmatter)`);
307
+ continue;
308
+ }
309
+ for (const tag of extractTags(content))
310
+ allTags.add(tag);
311
+ if (Array.isArray(properties.tags)) {
312
+ for (const tag of properties.tags)
313
+ allTags.add(String(tag));
314
+ }
315
+ for (const heading of extractHeadings(content)) {
316
+ headings.add(heading.text.trim());
317
+ }
318
+ weightedSources.push({ text: path.basename(file, ".md"), weight: 2 });
319
+ if (properties.title) {
320
+ weightedSources.push({ text: String(properties.title), weight: 2 });
321
+ }
322
+ for (const value of frontmatterText(properties)) {
323
+ weightedSources.push({ text: value, weight: 2 });
324
+ }
325
+ weightedSources.push({ text: markdownBodyText(content), weight: 1 });
326
+ }
327
+ const tf = buildTF(weightedSources);
328
+ const hasNonHeading = new Set(tf.keys());
329
+ const headingSignals = buildHeadingSignals(headings);
330
+ for (const [term, weight] of headingSignals.weightedTerms) {
331
+ tf.set(term, (tf.get(term) || 0) + weight);
332
+ }
333
+ return {
334
+ tf,
335
+ headingLineCount: headingSignals.lineCount,
336
+ hasNonHeading,
337
+ tags: allTags,
338
+ noteCount: folderFileList.length,
339
+ };
340
+ }
341
+ function buildOverviewFolders(vaultPath, maxDepth, maxKeywords, templatesFolder) {
342
+ const files = listFiles(vaultPath, { ext: "md" });
343
+ const warnings = [];
344
+ const folderFiles = groupFilesByFolder(files, templatesFolder);
345
+ const folderData = new Map();
346
+ for (const [folder, folderFileList] of folderFiles) {
347
+ const depth = folder === "/" ? 0 : folder.split("/").length;
348
+ if (depth > maxDepth)
349
+ continue;
350
+ folderData.set(folder, buildFolderData(vaultPath, folderFileList, warnings));
351
+ }
352
+ const documentFrequency = new Map();
353
+ for (const { tf } of folderData.values()) {
354
+ for (const term of tf.keys()) {
355
+ documentFrequency.set(term, (documentFrequency.get(term) || 0) + 1);
356
+ }
357
+ }
358
+ const totalFolders = folderData.size;
359
+ const folders = [];
360
+ for (const [folder, data] of [...folderData.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
361
+ folders.push({
362
+ path: folder,
363
+ notes: data.noteCount,
364
+ keywords: extractKeywordsTFIDF(data.tf, documentFrequency, totalFolders, maxKeywords, folder, data.headingLineCount, data.hasNonHeading),
365
+ tags: [...data.tags].sort(),
366
+ });
367
+ }
368
+ return { folders, warnings };
369
+ }
370
+ export function getOverview(contentPath, configPath, opts) {
371
+ const config = loadConfig(configPath);
372
+ const maxDepth = opts?.depth ?? config.overview.depth;
373
+ const maxKeywords = opts?.keywords ?? config.overview.keywords;
374
+ const { folders, warnings } = buildOverviewFolders(contentPath, maxDepth, maxKeywords, config.templates.folder);
375
+ const contextPath = path.join(contentPath, "NAPKIN.md");
376
+ const context = fs.existsSync(contextPath)
377
+ ? fs.readFileSync(contextPath, "utf-8").trim()
378
+ : undefined;
379
+ return {
380
+ ...(context ? { context } : {}),
381
+ overview: folders,
382
+ ...(warnings.length > 0 ? { warnings } : {}),
383
+ };
384
+ }
@@ -0,0 +1,14 @@
1
+ export declare function collectProperties(vaultPath: string, fileFilter?: string): Map<string, number>;
2
+ export declare function setProperty(vaultPath: string, fileRef: string, name: string, value: string): {
3
+ path: string;
4
+ property: string;
5
+ value: unknown;
6
+ };
7
+ export declare function removeProperty(vaultPath: string, fileRef: string, name: string): {
8
+ path: string;
9
+ removed: string;
10
+ };
11
+ export declare function readProperty(vaultPath: string, fileRef: string, name: string): {
12
+ property: string;
13
+ value: unknown;
14
+ };
@@ -0,0 +1,60 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { listFiles, resolveFile } from "../utils/files.js";
4
+ import { parseFrontmatter, removeProperty as removeProp, setProperty as setProp, } from "../utils/frontmatter.js";
5
+ export function collectProperties(vaultPath, fileFilter) {
6
+ const propCounts = new Map();
7
+ const files = fileFilter
8
+ ? (() => {
9
+ const r = resolveFile(vaultPath, fileFilter);
10
+ return r ? [r] : [];
11
+ })()
12
+ : listFiles(vaultPath, { ext: "md" });
13
+ for (const file of files) {
14
+ const content = fs.readFileSync(path.join(vaultPath, file), "utf-8");
15
+ const { properties } = parseFrontmatter(content);
16
+ for (const key of Object.keys(properties)) {
17
+ propCounts.set(key, (propCounts.get(key) || 0) + 1);
18
+ }
19
+ }
20
+ return propCounts;
21
+ }
22
+ export function setProperty(vaultPath, fileRef, name, value) {
23
+ const resolved = resolveFile(vaultPath, fileRef);
24
+ if (!resolved) {
25
+ throw new Error(`File not found: ${fileRef}`);
26
+ }
27
+ const fullPath = path.join(vaultPath, resolved);
28
+ const content = fs.readFileSync(fullPath, "utf-8");
29
+ let parsedValue = value;
30
+ if (value === "true")
31
+ parsedValue = true;
32
+ else if (value === "false")
33
+ parsedValue = false;
34
+ else if (!Number.isNaN(Number(value)) && value.trim() !== "")
35
+ parsedValue = Number(value);
36
+ const updated = setProp(content, name, parsedValue);
37
+ fs.writeFileSync(fullPath, updated);
38
+ return { path: resolved, property: name, value: parsedValue };
39
+ }
40
+ export function removeProperty(vaultPath, fileRef, name) {
41
+ const resolved = resolveFile(vaultPath, fileRef);
42
+ if (!resolved) {
43
+ throw new Error(`File not found: ${fileRef}`);
44
+ }
45
+ const fullPath = path.join(vaultPath, resolved);
46
+ const content = fs.readFileSync(fullPath, "utf-8");
47
+ const updated = removeProp(content, name);
48
+ fs.writeFileSync(fullPath, updated);
49
+ return { path: resolved, removed: name };
50
+ }
51
+ export function readProperty(vaultPath, fileRef, name) {
52
+ const resolved = resolveFile(vaultPath, fileRef);
53
+ if (!resolved) {
54
+ throw new Error(`File not found: ${fileRef}`);
55
+ }
56
+ const fullPath = path.join(vaultPath, resolved);
57
+ const content = fs.readFileSync(fullPath, "utf-8");
58
+ const { properties: props } = parseFrontmatter(content);
59
+ return { property: name, value: props[name] ?? null };
60
+ }
@@ -0,0 +1,17 @@
1
+ export interface SearchResult {
2
+ file: string;
3
+ score: number;
4
+ links: number;
5
+ modified: string;
6
+ snippets: {
7
+ line: number;
8
+ text: string;
9
+ }[];
10
+ }
11
+ export interface SearchOptions {
12
+ path?: string;
13
+ limit?: number;
14
+ snippetLines?: number;
15
+ snippets?: boolean;
16
+ }
17
+ export declare function searchVault(contentPath: string, configPath: string, query: string, opts?: SearchOptions): SearchResult[];
@@ -0,0 +1,153 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import MiniSearch from "minisearch";
4
+ import { loadConfig } from "../utils/config.js";
5
+ import { listFiles, resolveFileLoose } from "../utils/files.js";
6
+ import { extractLinks } from "../utils/markdown.js";
7
+ import { computeFingerprint, loadSearchCache, saveSearchCache, } from "../utils/search-cache.js";
8
+ function buildIndex(vaultPath, folder) {
9
+ const files = listFiles(vaultPath, { folder, ext: "md" });
10
+ const docs = files.map((file, id) => {
11
+ const fullPath = path.join(vaultPath, file);
12
+ const content = fs.readFileSync(fullPath, "utf-8");
13
+ const stat = fs.statSync(fullPath);
14
+ const basename = path.basename(file, ".md");
15
+ return { id, file, basename, content, mtime: stat.mtimeMs };
16
+ });
17
+ const index = new MiniSearch({
18
+ fields: ["basename", "content"],
19
+ storeFields: ["file"],
20
+ searchOptions: {
21
+ boost: { basename: 2 },
22
+ fuzzy: 0.2,
23
+ prefix: true,
24
+ },
25
+ });
26
+ index.addAll(docs);
27
+ return { index, docs };
28
+ }
29
+ function buildBacklinkCounts(vaultPath) {
30
+ const files = listFiles(vaultPath, { ext: "md" });
31
+ const counts = new Map();
32
+ for (const file of files) {
33
+ const content = fs.readFileSync(path.join(vaultPath, file), "utf-8");
34
+ const links = extractLinks(content);
35
+ for (const target of links.wikilinks) {
36
+ const resolved = resolveFileLoose(vaultPath, target);
37
+ if (resolved) {
38
+ counts.set(resolved, (counts.get(resolved) || 0) + 1);
39
+ }
40
+ }
41
+ }
42
+ return counts;
43
+ }
44
+ function extractSnippets(content, query, contextLines) {
45
+ const terms = query
46
+ .toLowerCase()
47
+ .split(/\s+/)
48
+ .filter((t) => t.length > 0);
49
+ const lines = content.split("\n");
50
+ const matchedLines = new Set();
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const lower = lines[i].toLowerCase();
53
+ if (terms.some((t) => lower.includes(t))) {
54
+ matchedLines.add(i);
55
+ }
56
+ }
57
+ const ranges = [];
58
+ for (const lineIdx of [...matchedLines].sort((a, b) => a - b)) {
59
+ const start = Math.max(0, lineIdx - contextLines);
60
+ const end = Math.min(lines.length - 1, lineIdx + contextLines);
61
+ if (ranges.length > 0 && start <= ranges[ranges.length - 1][1] + 1) {
62
+ ranges[ranges.length - 1][1] = end;
63
+ }
64
+ else {
65
+ ranges.push([start, end]);
66
+ }
67
+ }
68
+ const snippets = [];
69
+ for (const [start, end] of ranges) {
70
+ for (let i = start; i <= end; i++) {
71
+ const line = lines[i];
72
+ if (line.trim() === "")
73
+ continue;
74
+ snippets.push({ line: i + 1, text: line });
75
+ }
76
+ }
77
+ return snippets;
78
+ }
79
+ function relativeTime(mtimeMs) {
80
+ const diff = Date.now() - mtimeMs;
81
+ const minutes = Math.floor(diff / 60000);
82
+ if (minutes < 60)
83
+ return `${minutes}m ago`;
84
+ const hours = Math.floor(minutes / 60);
85
+ if (hours < 24)
86
+ return `${hours}h ago`;
87
+ const days = Math.floor(hours / 24);
88
+ if (days < 30)
89
+ return `${days}d ago`;
90
+ const months = Math.floor(days / 30);
91
+ return `${months}mo ago`;
92
+ }
93
+ export function searchVault(contentPath, configPath, query, opts) {
94
+ const config = loadConfig(configPath);
95
+ const fingerprint = computeFingerprint(contentPath, opts?.path);
96
+ const cached = loadSearchCache(configPath, fingerprint);
97
+ let index;
98
+ let docs;
99
+ let backlinkCounts;
100
+ if (cached) {
101
+ index = MiniSearch.loadJSON(cached.index, {
102
+ fields: ["basename", "content"],
103
+ storeFields: ["file"],
104
+ searchOptions: {
105
+ boost: { basename: 2 },
106
+ fuzzy: 0.2,
107
+ prefix: true,
108
+ },
109
+ });
110
+ docs = cached.docs.map((d) => {
111
+ const fullPath = path.join(contentPath, d.file);
112
+ const content = fs.readFileSync(fullPath, "utf-8");
113
+ return { ...d, content };
114
+ });
115
+ backlinkCounts = new Map(Object.entries(cached.backlinkCounts));
116
+ }
117
+ else {
118
+ const built = buildIndex(contentPath, opts?.path);
119
+ index = built.index;
120
+ docs = built.docs;
121
+ backlinkCounts = buildBacklinkCounts(contentPath);
122
+ saveSearchCache(configPath, {
123
+ fingerprint,
124
+ index: JSON.stringify(index),
125
+ docs: docs.map(({ content: _, ...rest }) => rest),
126
+ backlinkCounts: Object.fromEntries(backlinkCounts),
127
+ });
128
+ }
129
+ const results = index.search(query);
130
+ const contextLines = opts?.snippetLines ?? config.search.snippetLines;
131
+ const limit = opts?.limit ?? config.search.limit;
132
+ const maxMtime = Math.max(...docs.map((d) => d.mtime));
133
+ const minMtime = Math.min(...docs.map((d) => d.mtime));
134
+ const mtimeRange = maxMtime - minMtime || 1;
135
+ const scored = results.map((r) => {
136
+ const doc = docs[r.id];
137
+ const bm25Score = r.score;
138
+ const links = backlinkCounts.get(doc.file) || 0;
139
+ const recency = (doc.mtime - minMtime) / mtimeRange;
140
+ const composite = bm25Score + links * 0.5 + recency * 1.0;
141
+ return {
142
+ file: doc.file,
143
+ score: Math.round(composite * 10) / 10,
144
+ links,
145
+ modified: relativeTime(doc.mtime),
146
+ snippets: opts?.snippets === false
147
+ ? []
148
+ : extractSnippets(doc.content, query, contextLines),
149
+ };
150
+ });
151
+ scored.sort((a, b) => b.score - a.score);
152
+ return scored.slice(0, limit);
153
+ }
@@ -0,0 +1,11 @@
1
+ export interface TagData {
2
+ tagCounts: Map<string, number>;
3
+ tagFiles: Map<string, string[]>;
4
+ }
5
+ export interface TagInfo {
6
+ tag: string;
7
+ count: number;
8
+ files: string[];
9
+ }
10
+ export declare function collectTags(vaultPath: string, fileFilter?: string): TagData;
11
+ export declare function getTagInfo(vaultPath: string, tagName: string): TagInfo;
@@ -0,0 +1,40 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { listFiles, resolveFile } from "../utils/files.js";
4
+ import { parseFrontmatter } from "../utils/frontmatter.js";
5
+ import { extractTags } from "../utils/markdown.js";
6
+ export function collectTags(vaultPath, fileFilter) {
7
+ const tagCounts = new Map();
8
+ const tagFiles = new Map();
9
+ const files = fileFilter
10
+ ? (() => {
11
+ const r = resolveFile(vaultPath, fileFilter);
12
+ return r ? [r] : [];
13
+ })()
14
+ : listFiles(vaultPath, { ext: "md" });
15
+ for (const file of files) {
16
+ const content = fs.readFileSync(path.join(vaultPath, file), "utf-8");
17
+ const { properties } = parseFrontmatter(content);
18
+ const inlineTags = extractTags(content);
19
+ const allTags = new Set(inlineTags);
20
+ if (Array.isArray(properties.tags)) {
21
+ for (const t of properties.tags)
22
+ allTags.add(String(t));
23
+ }
24
+ for (const tag of allTags) {
25
+ tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
26
+ if (!tagFiles.has(tag))
27
+ tagFiles.set(tag, []);
28
+ tagFiles.get(tag)?.push(file);
29
+ }
30
+ }
31
+ return { tagCounts, tagFiles };
32
+ }
33
+ export function getTagInfo(vaultPath, tagName) {
34
+ const { tagCounts, tagFiles } = collectTags(vaultPath);
35
+ return {
36
+ tag: tagName,
37
+ count: tagCounts.get(tagName) || 0,
38
+ files: tagFiles.get(tagName) || [],
39
+ };
40
+ }