@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.
- package/LICENSE +21 -0
- package/README.md +342 -0
- package/dist/commands/aliases.d.ts +7 -0
- package/dist/commands/aliases.js +25 -0
- package/dist/commands/bases.d.ts +23 -0
- package/dist/commands/bases.js +139 -0
- package/dist/commands/bookmarks.d.ts +15 -0
- package/dist/commands/bookmarks.js +51 -0
- package/dist/commands/canvas.d.ts +49 -0
- package/dist/commands/canvas.js +186 -0
- package/dist/commands/config.d.ts +13 -0
- package/dist/commands/config.js +48 -0
- package/dist/commands/crud.d.ts +40 -0
- package/dist/commands/crud.js +195 -0
- package/dist/commands/daily.d.ts +20 -0
- package/dist/commands/daily.js +58 -0
- package/dist/commands/files.d.ts +23 -0
- package/dist/commands/files.js +132 -0
- package/dist/commands/graph.d.ts +4 -0
- package/dist/commands/graph.js +461 -0
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.js +52 -0
- package/dist/commands/links.d.ts +26 -0
- package/dist/commands/links.js +119 -0
- package/dist/commands/outline.d.ts +7 -0
- package/dist/commands/outline.js +48 -0
- package/dist/commands/overview.d.ts +6 -0
- package/dist/commands/overview.js +40 -0
- package/dist/commands/properties.d.ts +24 -0
- package/dist/commands/properties.js +115 -0
- package/dist/commands/search.d.ts +13 -0
- package/dist/commands/search.js +48 -0
- package/dist/commands/tags.d.ts +13 -0
- package/dist/commands/tags.js +51 -0
- package/dist/commands/tasks.d.ts +22 -0
- package/dist/commands/tasks.js +106 -0
- package/dist/commands/templates.d.ts +16 -0
- package/dist/commands/templates.js +70 -0
- package/dist/commands/vault.d.ts +4 -0
- package/dist/commands/vault.js +17 -0
- package/dist/commands/wordcount.d.ts +7 -0
- package/dist/commands/wordcount.js +43 -0
- package/dist/core/aliases.d.ts +5 -0
- package/dist/core/aliases.js +26 -0
- package/dist/core/bases.d.ts +29 -0
- package/dist/core/bases.js +67 -0
- package/dist/core/bookmarks.d.ts +14 -0
- package/dist/core/bookmarks.js +34 -0
- package/dist/core/canvas.d.ts +74 -0
- package/dist/core/canvas.js +125 -0
- package/dist/core/config.d.ts +7 -0
- package/dist/core/config.js +35 -0
- package/dist/core/crud.d.ts +32 -0
- package/dist/core/crud.js +119 -0
- package/dist/core/daily.d.ts +12 -0
- package/dist/core/daily.js +102 -0
- package/dist/core/files.d.ts +15 -0
- package/dist/core/files.js +30 -0
- package/dist/core/init.d.ts +31 -0
- package/dist/core/init.js +119 -0
- package/dist/core/links.d.ts +11 -0
- package/dist/core/links.js +66 -0
- package/dist/core/outline.d.ts +3 -0
- package/dist/core/outline.js +12 -0
- package/dist/core/overview.d.ts +15 -0
- package/dist/core/overview.js +384 -0
- package/dist/core/properties.d.ts +14 -0
- package/dist/core/properties.js +60 -0
- package/dist/core/search.d.ts +17 -0
- package/dist/core/search.js +153 -0
- package/dist/core/tags.d.ts +11 -0
- package/dist/core/tags.js +40 -0
- package/dist/core/tasks.d.ts +35 -0
- package/dist/core/tasks.js +97 -0
- package/dist/core/templates.d.ts +14 -0
- package/dist/core/templates.js +55 -0
- package/dist/core/vault.d.ts +10 -0
- package/dist/core/vault.js +37 -0
- package/dist/core/wordcount.d.ts +5 -0
- package/dist/core/wordcount.js +16 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +715 -0
- package/dist/sdk.d.ts +179 -0
- package/dist/sdk.js +232 -0
- package/dist/templates/coding.d.ts +2 -0
- package/dist/templates/coding.js +104 -0
- package/dist/templates/company.d.ts +2 -0
- package/dist/templates/company.js +121 -0
- package/dist/templates/index.d.ts +4 -0
- package/dist/templates/index.js +15 -0
- package/dist/templates/personal.d.ts +2 -0
- package/dist/templates/personal.js +91 -0
- package/dist/templates/product.d.ts +2 -0
- package/dist/templates/product.js +123 -0
- package/dist/templates/research.d.ts +2 -0
- package/dist/templates/research.js +114 -0
- package/dist/templates/types.d.ts +7 -0
- package/dist/templates/types.js +1 -0
- package/dist/utils/bases.d.ts +61 -0
- package/dist/utils/bases.js +661 -0
- package/dist/utils/config.d.ts +42 -0
- package/dist/utils/config.js +112 -0
- package/dist/utils/exit-codes.d.ts +5 -0
- package/dist/utils/exit-codes.js +5 -0
- package/dist/utils/files.d.ts +135 -0
- package/dist/utils/files.js +299 -0
- package/dist/utils/formula.d.ts +28 -0
- package/dist/utils/formula.js +462 -0
- package/dist/utils/frontmatter.d.ts +17 -0
- package/dist/utils/frontmatter.js +34 -0
- package/dist/utils/markdown.d.ts +31 -0
- package/dist/utils/markdown.js +80 -0
- package/dist/utils/output.d.ts +28 -0
- package/dist/utils/output.js +48 -0
- package/dist/utils/search-cache.d.ts +29 -0
- package/dist/utils/search-cache.js +41 -0
- package/dist/utils/test-helpers.d.ts +13 -0
- package/dist/utils/test-helpers.js +40 -0
- package/dist/utils/vault.d.ts +21 -0
- package/dist/utils/vault.js +144 -0
- 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;
|