@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,661 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import yaml from "js-yaml";
4
+ import initSqlJs from "sql.js";
5
+ import { listFiles } from "./files.js";
6
+ import { createFormulaEngine, evaluateFormulas } from "./formula.js";
7
+ import { parseFrontmatter } from "./frontmatter.js";
8
+ import { extractLinks, extractTags } from "./markdown.js";
9
+ /**
10
+ * Parse a .base YAML file. Bases are YAML format.
11
+ */
12
+ export function parseBaseFile(content) {
13
+ return yaml.load(content);
14
+ }
15
+ /**
16
+ * Build an in-memory SQLite database from vault files.
17
+ * Creates a `files` table with columns for file metadata and all frontmatter properties.
18
+ */
19
+ export async function buildDatabase(vaultPath) {
20
+ const SQL = await initSqlJs();
21
+ const db = new SQL.Database();
22
+ const files = listFiles(vaultPath, { ext: "md" });
23
+ // First pass: collect all property names across the vault
24
+ const allProps = new Set();
25
+ const fileData = [];
26
+ for (const file of files) {
27
+ const fullPath = path.join(vaultPath, file);
28
+ const stat = fs.statSync(fullPath);
29
+ const content = fs.readFileSync(fullPath, "utf-8");
30
+ const { properties } = parseFrontmatter(content);
31
+ const tags = extractTags(content);
32
+ const linkInfo = extractLinks(content);
33
+ // Also get frontmatter tags
34
+ if (Array.isArray(properties.tags)) {
35
+ for (const t of properties.tags)
36
+ tags.push(String(t));
37
+ }
38
+ for (const key of Object.keys(properties)) {
39
+ if (key !== "tags")
40
+ allProps.add(key);
41
+ }
42
+ fileData.push({
43
+ path: file,
44
+ name: path.basename(file),
45
+ basename: path.basename(file, path.extname(file)),
46
+ folder: path.dirname(file),
47
+ ext: path.extname(file).slice(1),
48
+ size: stat.size,
49
+ ctime: stat.birthtimeMs,
50
+ mtime: stat.mtimeMs,
51
+ tags: JSON.stringify([...new Set(tags)]),
52
+ links: JSON.stringify(linkInfo.wikilinks),
53
+ properties,
54
+ });
55
+ }
56
+ // Create table with file columns + property columns
57
+ const propCols = [...allProps].map((p) => `"prop_${p}" TEXT`).join(", ");
58
+ const createSQL = `CREATE TABLE files (
59
+ path TEXT PRIMARY KEY,
60
+ name TEXT,
61
+ basename TEXT,
62
+ folder TEXT,
63
+ ext TEXT,
64
+ size INTEGER,
65
+ ctime REAL,
66
+ mtime REAL,
67
+ tags TEXT,
68
+ links TEXT,
69
+ backlinks TEXT,
70
+ embeds TEXT,
71
+ file_properties TEXT
72
+ ${propCols ? `, ${propCols}` : ""}
73
+ )`;
74
+ db.run(createSQL);
75
+ // Register REGEXP function for regex filter support
76
+ db.create_function("REGEXP", (pattern, value) => {
77
+ try {
78
+ return new RegExp(pattern).test(value ?? "") ? 1 : 0;
79
+ }
80
+ catch {
81
+ return 0;
82
+ }
83
+ });
84
+ // Insert data
85
+ // Compute backlinks: for each file, find which other files link to it
86
+ const backlinkMap = new Map();
87
+ for (const f of fileData) {
88
+ backlinkMap.set(f.path, []);
89
+ }
90
+ for (const f of fileData) {
91
+ const links = JSON.parse(f.links);
92
+ for (const link of links) {
93
+ // Find target file by basename match (case-insensitive, like wikilinks)
94
+ const linkLower = link.toLowerCase();
95
+ for (const target of fileData) {
96
+ if (target.basename.toLowerCase() === linkLower ||
97
+ target.name.toLowerCase() === linkLower ||
98
+ target.name.toLowerCase() === `${linkLower}.md`) {
99
+ const bl = backlinkMap.get(target.path) || [];
100
+ bl.push(f.basename);
101
+ backlinkMap.set(target.path, bl);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ // Extract embeds
107
+ const embedRegex = /!\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
108
+ const propNames = [...allProps];
109
+ // 13 base columns + N prop columns
110
+ const placeholders = [
111
+ "?",
112
+ "?",
113
+ "?",
114
+ "?",
115
+ "?",
116
+ "?",
117
+ "?",
118
+ "?",
119
+ "?",
120
+ "?",
121
+ "?",
122
+ "?",
123
+ "?",
124
+ ...propNames.map(() => "?"),
125
+ ].join(", ");
126
+ const insertSQL = `INSERT INTO files VALUES (${placeholders})`;
127
+ for (const f of fileData) {
128
+ const propValues = propNames.map((p) => {
129
+ const v = f.properties[p];
130
+ if (v === undefined || v === null)
131
+ return null;
132
+ if (typeof v === "object")
133
+ return JSON.stringify(v);
134
+ return String(v);
135
+ });
136
+ const backlinks = JSON.stringify(backlinkMap.get(f.path) || []);
137
+ // Extract embeds from content
138
+ const fullPath = path.join(vaultPath, f.path);
139
+ const content = fs.readFileSync(fullPath, "utf-8");
140
+ const embeds = [];
141
+ for (let em = embedRegex.exec(content); em !== null; em = embedRegex.exec(content)) {
142
+ embeds.push(em[1]);
143
+ }
144
+ embedRegex.lastIndex = 0;
145
+ const fileProps = JSON.stringify(f.properties);
146
+ db.run(insertSQL, [
147
+ f.path,
148
+ f.name,
149
+ f.basename,
150
+ f.folder,
151
+ f.ext,
152
+ f.size,
153
+ f.ctime,
154
+ f.mtime,
155
+ f.tags,
156
+ f.links,
157
+ backlinks,
158
+ JSON.stringify(embeds),
159
+ fileProps,
160
+ ...propValues,
161
+ ]);
162
+ }
163
+ return db;
164
+ }
165
+ /**
166
+ * Translate a Bases filter expression to SQL WHERE clause.
167
+ * Handles the recursive and/or/not structure and simple comparison strings.
168
+ */
169
+ export function filterToSQL(filter, thisFile) {
170
+ if (!filter)
171
+ return "1=1";
172
+ if (typeof filter === "string") {
173
+ return translateExpression(filter, thisFile);
174
+ }
175
+ if (typeof filter === "object" && filter !== null) {
176
+ const obj = filter;
177
+ if (obj.and) {
178
+ const clauses = obj.and.map((f) => filterToSQL(f, thisFile));
179
+ return `(${clauses.join(" AND ")})`;
180
+ }
181
+ if (obj.or) {
182
+ const clauses = obj.or.map((f) => filterToSQL(f, thisFile));
183
+ return `(${clauses.join(" OR ")})`;
184
+ }
185
+ if (obj.not) {
186
+ const clauses = obj.not.map((f) => filterToSQL(f, thisFile));
187
+ return `NOT (${clauses.join(" AND ")})`;
188
+ }
189
+ }
190
+ return "1=1";
191
+ }
192
+ /**
193
+ * Translate a single filter expression string to SQL.
194
+ * e.g. 'status != "done"' -> "prop_status != 'done'"
195
+ * e.g. 'file.hasTag("book")' -> "tags LIKE '%\"book\"%'"
196
+ */
197
+ function translateExpression(expr, thisFile) {
198
+ expr = expr.trim();
199
+ // Replace this.file.* references with literal values
200
+ if (thisFile && expr.includes("this.")) {
201
+ expr = expr
202
+ .replace(/this\.file\.name/g, `"${thisFile.name}"`)
203
+ .replace(/this\.file\.path/g, `"${thisFile.path}"`)
204
+ .replace(/this\.file\.folder/g, `"${thisFile.folder}"`)
205
+ .replace(/this\.file/g, `"${thisFile.path}"`);
206
+ }
207
+ // Handle ! prefix (NOT)
208
+ if (expr.startsWith("!") && !expr.startsWith("!=")) {
209
+ return `NOT (${translateExpression(expr.slice(1).trim(), thisFile)})`;
210
+ }
211
+ // Handle inline && and || (split respecting parentheses)
212
+ const splitByBoolOp = splitOnBooleanOps(expr, thisFile);
213
+ if (splitByBoolOp) {
214
+ return splitByBoolOp;
215
+ }
216
+ // file.hasTag("tag1", "tag2") -> OR match on tags JSON array
217
+ const hasTagMatch = expr.match(/^file\.hasTag\((.+)\)$/);
218
+ if (hasTagMatch) {
219
+ const args = parseStringArgs(hasTagMatch[1]);
220
+ const clauses = args.map((t) => `tags LIKE '%"${escapeSql(t)}"%'`);
221
+ return clauses.length === 1 ? clauses[0] : `(${clauses.join(" OR ")})`;
222
+ }
223
+ // file.hasLink("target")
224
+ const hasLinkMatch = expr.match(/^file\.hasLink\((.+)\)$/);
225
+ if (hasLinkMatch) {
226
+ const args = parseStringArgs(hasLinkMatch[1]);
227
+ const clauses = args.map((l) => `links LIKE '%"${escapeSql(l)}"%'`);
228
+ return clauses.length === 1 ? clauses[0] : `(${clauses.join(" OR ")})`;
229
+ }
230
+ // file.inFolder("folder")
231
+ const inFolderMatch = expr.match(/^file\.inFolder\("([^"]+)"\)$/);
232
+ if (inFolderMatch) {
233
+ const folder = inFolderMatch[1];
234
+ return `(folder = '${escapeSql(folder)}' OR folder LIKE '${escapeSql(folder)}/%')`;
235
+ }
236
+ // file.hasProperty("name")
237
+ const hasPropMatch = expr.match(/^file\.hasProperty\("([^"]+)"\)$/);
238
+ if (hasPropMatch) {
239
+ return `"prop_${escapeSql(hasPropMatch[1])}" IS NOT NULL`;
240
+ }
241
+ // prop.contains("value") — string LIKE
242
+ const containsMatch = expr.match(/^(.+?)\.contains\((.+)\)$/);
243
+ if (containsMatch && !containsMatch[1].startsWith("file.")) {
244
+ const prop = translateProperty(containsMatch[1].trim());
245
+ const args = parseStringArgs(containsMatch[2]);
246
+ if (args.length === 1)
247
+ return `${prop} LIKE '%${escapeSql(args[0])}%'`;
248
+ }
249
+ // prop.containsAll("a", "b")
250
+ const containsAllMatch = expr.match(/^(.+?)\.containsAll\((.+)\)$/);
251
+ if (containsAllMatch && !containsAllMatch[1].startsWith("file.")) {
252
+ const prop = translateProperty(containsAllMatch[1].trim());
253
+ const args = parseStringArgs(containsAllMatch[2]);
254
+ const clauses = args.map((a) => `${prop} LIKE '%${escapeSql(a)}%'`);
255
+ return `(${clauses.join(" AND ")})`;
256
+ }
257
+ // prop.containsAny("a", "b")
258
+ const containsAnyMatch = expr.match(/^(.+?)\.containsAny\((.+)\)$/);
259
+ if (containsAnyMatch && !containsAnyMatch[1].startsWith("file.")) {
260
+ const prop = translateProperty(containsAnyMatch[1].trim());
261
+ const args = parseStringArgs(containsAnyMatch[2]);
262
+ const clauses = args.map((a) => `${prop} LIKE '%${escapeSql(a)}%'`);
263
+ return `(${clauses.join(" OR ")})`;
264
+ }
265
+ // prop.startsWith("value")
266
+ const startsWithMatch = expr.match(/^(.+?)\.startsWith\("([^"]+)"\)$/);
267
+ if (startsWithMatch) {
268
+ const prop = translateProperty(startsWithMatch[1].trim());
269
+ return `${prop} LIKE '${escapeSql(startsWithMatch[2])}%'`;
270
+ }
271
+ // prop.endsWith("value")
272
+ const endsWithMatch = expr.match(/^(.+?)\.endsWith\("([^"]+)"\)$/);
273
+ if (endsWithMatch) {
274
+ const prop = translateProperty(endsWithMatch[1].trim());
275
+ return `${prop} LIKE '%${escapeSql(endsWithMatch[2])}'`;
276
+ }
277
+ // prop.isEmpty()
278
+ const isEmptyMatch = expr.match(/^(.+?)\.isEmpty\(\)$/);
279
+ if (isEmptyMatch) {
280
+ const prop = translateProperty(isEmptyMatch[1].trim());
281
+ return `(${prop} IS NULL OR ${prop} = '')`;
282
+ }
283
+ // /pattern/.matches(expr) — regex match
284
+ const regexMatch = expr.match(/^\/(.+?)\/\.matches\((.+)\)$/);
285
+ if (regexMatch) {
286
+ const pattern = regexMatch[1];
287
+ const target = regexMatch[2].trim();
288
+ const col = translateProperty(target);
289
+ return `${col} REGEXP '${escapeSql(pattern)}'`;
290
+ }
291
+ // Comparison expressions: property op value
292
+ // e.g. status != "done", price > 2.1, file.ext == "md"
293
+ const cmpMatch = expr.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/);
294
+ if (cmpMatch) {
295
+ const leftRaw = cmpMatch[1].trim();
296
+ const rightRaw = cmpMatch[3].trim();
297
+ // Try resolving both sides as date expressions first
298
+ const leftDate = resolveDateExpr(leftRaw);
299
+ const rightDate = resolveDateExpr(rightRaw);
300
+ const left = leftDate !== null ? String(leftDate) : translateProperty(leftRaw);
301
+ const op = cmpMatch[2] === "==" ? "=" : cmpMatch[2];
302
+ const right = rightDate !== null ? String(rightDate) : translateValue(rightRaw);
303
+ return `${left} ${op} ${right}`;
304
+ }
305
+ // Fallback: treat as a property existence check
306
+ return `"prop_${escapeSql(expr)}" IS NOT NULL`;
307
+ }
308
+ /**
309
+ * Split an expression on && and || operators, respecting parentheses and quotes.
310
+ * Returns null if no boolean operators found at the top level.
311
+ */
312
+ function splitOnBooleanOps(expr, thisFile) {
313
+ let depth = 0;
314
+ let inString = null;
315
+ // Find top-level && or ||
316
+ for (let i = 0; i < expr.length; i++) {
317
+ const ch = expr[i];
318
+ if (inString) {
319
+ if (ch === inString && expr[i - 1] !== "\\")
320
+ inString = null;
321
+ continue;
322
+ }
323
+ if (ch === '"' || ch === "'") {
324
+ inString = ch;
325
+ continue;
326
+ }
327
+ if (ch === "(") {
328
+ depth++;
329
+ continue;
330
+ }
331
+ if (ch === ")") {
332
+ depth--;
333
+ continue;
334
+ }
335
+ if (depth === 0) {
336
+ if (expr[i] === "&" && expr[i + 1] === "&") {
337
+ const left = translateExpression(expr.slice(0, i).trim(), thisFile);
338
+ const right = translateExpression(expr.slice(i + 2).trim(), thisFile);
339
+ return `(${left} AND ${right})`;
340
+ }
341
+ if (expr[i] === "|" && expr[i + 1] === "|") {
342
+ const left = translateExpression(expr.slice(0, i).trim(), thisFile);
343
+ const right = translateExpression(expr.slice(i + 2).trim(), thisFile);
344
+ return `(${left} OR ${right})`;
345
+ }
346
+ }
347
+ }
348
+ return null;
349
+ }
350
+ function translateProperty(prop) {
351
+ // file.* properties map to columns directly
352
+ if (prop === "file.name")
353
+ return "name";
354
+ if (prop === "file.basename")
355
+ return "basename";
356
+ if (prop === "file.path")
357
+ return "path";
358
+ if (prop === "file.folder")
359
+ return "folder";
360
+ if (prop === "file.ext")
361
+ return "ext";
362
+ if (prop === "file.size")
363
+ return "size";
364
+ if (prop === "file.ctime")
365
+ return "ctime";
366
+ if (prop === "file.mtime")
367
+ return "mtime";
368
+ if (prop === "file.backlinks")
369
+ return "backlinks";
370
+ if (prop === "file.embeds")
371
+ return "embeds";
372
+ if (prop === "file.properties")
373
+ return "file_properties";
374
+ if (prop === "file.tags")
375
+ return "tags";
376
+ if (prop === "file.links")
377
+ return "links";
378
+ // note.* or bare property names -> prop_ columns
379
+ const name = prop.startsWith("note.") ? prop.slice(5) : prop;
380
+ return `"prop_${escapeSql(name)}"`;
381
+ }
382
+ function translateValue(val) {
383
+ // Date functions: now(), today(), date("...")
384
+ const dateResolved = resolveDateExpr(val);
385
+ if (dateResolved !== null)
386
+ return String(dateResolved);
387
+ // Quoted string
388
+ if ((val.startsWith('"') && val.endsWith('"')) ||
389
+ (val.startsWith("'") && val.endsWith("'"))) {
390
+ return `'${escapeSql(val.slice(1, -1))}'`;
391
+ }
392
+ // Number
393
+ if (!Number.isNaN(Number(val)))
394
+ return val;
395
+ // Boolean
396
+ if (val === "true")
397
+ return "1";
398
+ if (val === "false")
399
+ return "0";
400
+ // Treat as property reference
401
+ return translateProperty(val);
402
+ }
403
+ /**
404
+ * Parse duration string like "7d", "1 week", "2h" into milliseconds.
405
+ */
406
+ function parseDuration(dur) {
407
+ const match = dur.match(/^(\d+)\s*(y|year|years|M|month|months|d|day|days|w|week|weeks|h|hour|hours|m|minute|minutes|s|second|seconds)$/);
408
+ if (!match)
409
+ return 0;
410
+ const n = Number.parseInt(match[1], 10);
411
+ const unit = match[2];
412
+ switch (unit) {
413
+ case "y":
414
+ case "year":
415
+ case "years":
416
+ return n * 365.25 * 24 * 60 * 60 * 1000;
417
+ case "M":
418
+ case "month":
419
+ case "months":
420
+ return n * 30.44 * 24 * 60 * 60 * 1000;
421
+ case "w":
422
+ case "week":
423
+ case "weeks":
424
+ return n * 7 * 24 * 60 * 60 * 1000;
425
+ case "d":
426
+ case "day":
427
+ case "days":
428
+ return n * 24 * 60 * 60 * 1000;
429
+ case "h":
430
+ case "hour":
431
+ case "hours":
432
+ return n * 60 * 60 * 1000;
433
+ case "m":
434
+ case "minute":
435
+ case "minutes":
436
+ return n * 60 * 1000;
437
+ case "s":
438
+ case "second":
439
+ case "seconds":
440
+ return n * 1000;
441
+ default:
442
+ return 0;
443
+ }
444
+ }
445
+ /**
446
+ * Resolve date expressions to epoch milliseconds.
447
+ * Handles: now(), today(), date("..."), and arithmetic like now() - "7d"
448
+ * Returns null if not a date expression.
449
+ */
450
+ function resolveDateExpr(expr) {
451
+ expr = expr.trim();
452
+ // now() +/- "duration"
453
+ const nowArith = expr.match(/^now\(\)\s*([+-])\s*"([^"]+)"$/);
454
+ if (nowArith) {
455
+ const ms = parseDuration(nowArith[2]);
456
+ return nowArith[1] === "+" ? Date.now() + ms : Date.now() - ms;
457
+ }
458
+ // today() +/- "duration"
459
+ const todayArith = expr.match(/^today\(\)\s*([+-])\s*"([^"]+)"$/);
460
+ if (todayArith) {
461
+ const today = new Date();
462
+ today.setHours(0, 0, 0, 0);
463
+ const ms = parseDuration(todayArith[2]);
464
+ return todayArith[1] === "+" ? today.getTime() + ms : today.getTime() - ms;
465
+ }
466
+ // date("...") +/- "duration"
467
+ const dateArith = expr.match(/^date\("([^"]+)"\)\s*([+-])\s*"([^"]+)"$/);
468
+ if (dateArith) {
469
+ const ts = new Date(dateArith[1]).getTime();
470
+ if (Number.isNaN(ts))
471
+ return null;
472
+ const ms = parseDuration(dateArith[3]);
473
+ return dateArith[2] === "+" ? ts + ms : ts - ms;
474
+ }
475
+ // now()
476
+ if (expr === "now()")
477
+ return Date.now();
478
+ // today()
479
+ if (expr === "today()") {
480
+ const today = new Date();
481
+ today.setHours(0, 0, 0, 0);
482
+ return today.getTime();
483
+ }
484
+ // date("...")
485
+ const dateMatch = expr.match(/^date\("([^"]+)"\)$/);
486
+ if (dateMatch) {
487
+ const ts = new Date(dateMatch[1]).getTime();
488
+ return Number.isNaN(ts) ? null : ts;
489
+ }
490
+ return null;
491
+ }
492
+ function parseStringArgs(argsStr) {
493
+ const args = [];
494
+ const regex = /"([^"]+)"|'([^']+)'/g;
495
+ for (let m = regex.exec(argsStr); m !== null; m = regex.exec(argsStr)) {
496
+ args.push(m[1] || m[2]);
497
+ }
498
+ return args;
499
+ }
500
+ function escapeSql(s) {
501
+ return s.replace(/'/g, "''");
502
+ }
503
+ /**
504
+ * Build ORDER BY clause from view config.
505
+ */
506
+ export function orderToSQL(order) {
507
+ if (!order || order.length === 0)
508
+ return "";
509
+ const cols = order.map((o) => translateProperty(o));
510
+ return `ORDER BY ${cols.join(", ")}`;
511
+ }
512
+ /**
513
+ * Query the database using a base view config.
514
+ */
515
+ export async function queryBase(db, baseConfig, viewName, thisFile) {
516
+ const view = viewName
517
+ ? baseConfig.views?.find((v) => v.name === viewName)
518
+ : baseConfig.views?.[0];
519
+ // Build WHERE from global filters + view filters
520
+ const globalWhere = filterToSQL(baseConfig.filters, thisFile);
521
+ const viewWhere = view?.filters ? filterToSQL(view.filters, thisFile) : "1=1";
522
+ const where = `(${globalWhere}) AND (${viewWhere})`;
523
+ const orderBy = orderToSQL(view?.order);
524
+ const limit = view?.limit ? `LIMIT ${view.limit}` : "";
525
+ const sql = `SELECT * FROM files WHERE ${where} ${orderBy} ${limit}`;
526
+ try {
527
+ const result = db.exec(sql);
528
+ if (result.length === 0)
529
+ return { columns: [], rows: [] };
530
+ // Clean up column names (remove prop_ prefix for display)
531
+ const columns = result[0].columns.map((c) => c.startsWith("prop_") ? c.slice(5) : c);
532
+ let rows = result[0].values;
533
+ // Evaluate formulas if any
534
+ const formulas = baseConfig.formulas;
535
+ if (formulas && Object.keys(formulas).length > 0) {
536
+ const engine = createFormulaEngine();
537
+ const formulaNames = Object.keys(formulas).map((k) => `formula.${k}`);
538
+ const newRows = [];
539
+ for (const row of rows) {
540
+ const formulaResults = await evaluateFormulas(engine, formulas, columns, row, thisFile);
541
+ const newRow = [...row, ...Object.values(formulaResults)];
542
+ newRows.push(newRow);
543
+ }
544
+ columns.push(...formulaNames);
545
+ rows = newRows;
546
+ }
547
+ // Build displayName mapping
548
+ const displayNames = {};
549
+ if (baseConfig.properties) {
550
+ for (const [key, config] of Object.entries(baseConfig.properties)) {
551
+ if (config.displayName) {
552
+ displayNames[key] = config.displayName;
553
+ }
554
+ }
555
+ }
556
+ // GroupBy
557
+ let groups;
558
+ if (view?.groupBy) {
559
+ const groupProp = view.groupBy.property;
560
+ const groupIdx = columns.indexOf(groupProp) !== -1
561
+ ? columns.indexOf(groupProp)
562
+ : columns.indexOf(groupProp.startsWith("note.") ? groupProp.slice(5) : groupProp);
563
+ if (groupIdx !== -1) {
564
+ const groupMap = new Map();
565
+ for (const row of rows) {
566
+ const key = String(row[groupIdx] ?? "(empty)");
567
+ if (!groupMap.has(key))
568
+ groupMap.set(key, []);
569
+ groupMap.get(key)?.push(row);
570
+ }
571
+ groups = [...groupMap.entries()].map(([key, rows]) => ({ key, rows }));
572
+ if (view.groupBy.direction === "DESC")
573
+ groups.reverse();
574
+ }
575
+ }
576
+ // Summaries
577
+ let summaries;
578
+ const viewSummaries = view?.summaries;
579
+ if (viewSummaries) {
580
+ summaries = {};
581
+ for (const [prop, fn] of Object.entries(viewSummaries)) {
582
+ const colIdx = columns.indexOf(prop) !== -1
583
+ ? columns.indexOf(prop)
584
+ : columns.indexOf(prop.startsWith("note.") ? prop.slice(5) : prop);
585
+ if (colIdx === -1)
586
+ continue;
587
+ const values = rows
588
+ .map((r) => r[colIdx])
589
+ .filter((v) => v !== null && v !== undefined);
590
+ summaries[prop] = computeSummary(fn, values, baseConfig);
591
+ }
592
+ }
593
+ return { columns, rows, groups, summaries, displayNames };
594
+ }
595
+ catch (e) {
596
+ throw new Error(`Base query failed: ${e.message}\nSQL: ${sql}`);
597
+ }
598
+ }
599
+ /**
600
+ * Compute a summary function over a list of values.
601
+ */
602
+ function computeSummary(fn, values, baseConfig) {
603
+ // Check custom summaries first
604
+ if (baseConfig.summaries &&
605
+ fn in baseConfig.summaries) {
606
+ // Custom summary formula — evaluate with jexl
607
+ // For now, use built-in fallback
608
+ }
609
+ const nums = values.map(Number).filter((n) => !Number.isNaN(n));
610
+ const dates = values
611
+ .map((v) => new Date(v).getTime())
612
+ .filter((n) => !Number.isNaN(n));
613
+ switch (fn) {
614
+ case "Sum":
615
+ return nums.reduce((a, b) => a + b, 0);
616
+ case "Average":
617
+ return nums.length > 0
618
+ ? nums.reduce((a, b) => a + b, 0) / nums.length
619
+ : 0;
620
+ case "Min":
621
+ return nums.length > 0 ? Math.min(...nums) : null;
622
+ case "Max":
623
+ return nums.length > 0 ? Math.max(...nums) : null;
624
+ case "Range":
625
+ return nums.length > 0 ? Math.max(...nums) - Math.min(...nums) : null;
626
+ case "Median": {
627
+ if (nums.length === 0)
628
+ return null;
629
+ const sorted = [...nums].sort((a, b) => a - b);
630
+ const mid = Math.floor(sorted.length / 2);
631
+ return sorted.length % 2
632
+ ? sorted[mid]
633
+ : (sorted[mid - 1] + sorted[mid]) / 2;
634
+ }
635
+ case "Stddev": {
636
+ if (nums.length === 0)
637
+ return null;
638
+ const mean = nums.reduce((a, b) => a + b, 0) / nums.length;
639
+ const variance = nums.reduce((sum, n) => sum + (n - mean) ** 2, 0) / nums.length;
640
+ return Math.sqrt(variance);
641
+ }
642
+ case "Earliest":
643
+ return dates.length > 0 ? Math.min(...dates) : null;
644
+ case "Latest":
645
+ return dates.length > 0 ? Math.max(...dates) : null;
646
+ case "Checked":
647
+ return values.filter((v) => v === "true" || v === true || v === 1 || v === "1").length;
648
+ case "Unchecked":
649
+ return values.filter((v) => v === "false" || v === false || v === 0 || v === "0").length;
650
+ case "Empty":
651
+ return values.filter((v) => v === null || v === undefined || v === "")
652
+ .length;
653
+ case "Filled":
654
+ return values.filter((v) => v !== null && v !== undefined && v !== "")
655
+ .length;
656
+ case "Unique":
657
+ return new Set(values.map(String)).size;
658
+ default:
659
+ return null;
660
+ }
661
+ }
@@ -0,0 +1,42 @@
1
+ export interface VaultLayout {
2
+ /** Content root relative to .napkin/ dir (e.g. ".." for sibling layout) */
3
+ root: string;
4
+ /** .obsidian/ dir relative to .napkin/ dir (e.g. "../.obsidian" for sibling layout) */
5
+ obsidian: string;
6
+ }
7
+ export interface NapkinConfig {
8
+ vault?: VaultLayout;
9
+ overview: {
10
+ depth: number;
11
+ keywords: number;
12
+ };
13
+ search: {
14
+ limit: number;
15
+ snippetLines: number;
16
+ };
17
+ daily: {
18
+ folder: string;
19
+ format: string;
20
+ };
21
+ templates: {
22
+ folder: string;
23
+ };
24
+ graph: {
25
+ renderer: "auto" | "glimpse" | "browser";
26
+ };
27
+ }
28
+ export declare const DEFAULT_CONFIG: NapkinConfig;
29
+ /**
30
+ * Load napkin config from config.json in the .napkin/ directory.
31
+ * Missing fields fall back to defaults.
32
+ */
33
+ export declare function loadConfig(napkinDir: string): NapkinConfig;
34
+ /**
35
+ * Save napkin config to config.json in the .napkin/ directory and sync to .obsidian/.
36
+ * If obsidianDir is not provided, resolves it from config.vault or defaults to .napkin/.obsidian/.
37
+ */
38
+ export declare function saveConfig(napkinDir: string, config: NapkinConfig, obsidianDir?: string): void;
39
+ /**
40
+ * Update specific config fields, save, and sync.
41
+ */
42
+ export declare function updateConfig(napkinDir: string, partial: Record<string, unknown>): NapkinConfig;