@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,112 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
export const DEFAULT_CONFIG = {
|
|
4
|
+
overview: {
|
|
5
|
+
depth: 3,
|
|
6
|
+
keywords: 8,
|
|
7
|
+
},
|
|
8
|
+
search: {
|
|
9
|
+
limit: 30,
|
|
10
|
+
snippetLines: 0,
|
|
11
|
+
},
|
|
12
|
+
daily: {
|
|
13
|
+
folder: "daily",
|
|
14
|
+
format: "YYYY-MM-DD",
|
|
15
|
+
},
|
|
16
|
+
templates: {
|
|
17
|
+
folder: "Templates",
|
|
18
|
+
},
|
|
19
|
+
graph: {
|
|
20
|
+
renderer: "auto",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Load napkin config from config.json in the .napkin/ directory.
|
|
25
|
+
* Missing fields fall back to defaults.
|
|
26
|
+
*/
|
|
27
|
+
export function loadConfig(napkinDir) {
|
|
28
|
+
const configPath = path.join(napkinDir, "config.json");
|
|
29
|
+
if (!fs.existsSync(configPath))
|
|
30
|
+
return { ...DEFAULT_CONFIG };
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
33
|
+
return deepMerge(DEFAULT_CONFIG, raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { ...DEFAULT_CONFIG };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Save napkin config to config.json in the .napkin/ directory and sync to .obsidian/.
|
|
41
|
+
* If obsidianDir is not provided, resolves it from config.vault or defaults to .napkin/.obsidian/.
|
|
42
|
+
*/
|
|
43
|
+
export function saveConfig(napkinDir, config, obsidianDir) {
|
|
44
|
+
const configPath = path.join(napkinDir, "config.json");
|
|
45
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
46
|
+
const resolvedObsidian = obsidianDir ||
|
|
47
|
+
(config.vault?.obsidian
|
|
48
|
+
? path.resolve(napkinDir, config.vault.obsidian)
|
|
49
|
+
: path.join(napkinDir, ".obsidian"));
|
|
50
|
+
syncObsidianConfig(resolvedObsidian, config);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Update specific config fields, save, and sync.
|
|
54
|
+
*/
|
|
55
|
+
export function updateConfig(napkinDir, partial) {
|
|
56
|
+
const current = loadConfig(napkinDir);
|
|
57
|
+
const updated = deepMerge(current, partial);
|
|
58
|
+
saveConfig(napkinDir, updated);
|
|
59
|
+
return updated;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Write .obsidian/ config files derived from napkin config.
|
|
63
|
+
* napkin is the source of truth — Obsidian reads from these.
|
|
64
|
+
*/
|
|
65
|
+
function syncObsidianConfig(obsidianDir, config) {
|
|
66
|
+
if (!fs.existsSync(obsidianDir)) {
|
|
67
|
+
fs.mkdirSync(obsidianDir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
// daily-notes.json
|
|
70
|
+
fs.writeFileSync(path.join(obsidianDir, "daily-notes.json"), JSON.stringify({
|
|
71
|
+
folder: config.daily.folder,
|
|
72
|
+
format: config.daily.format,
|
|
73
|
+
template: `${config.templates.folder}/Daily Note`,
|
|
74
|
+
}, null, 2));
|
|
75
|
+
// templates.json
|
|
76
|
+
fs.writeFileSync(path.join(obsidianDir, "templates.json"), JSON.stringify({
|
|
77
|
+
folder: config.templates.folder,
|
|
78
|
+
}, null, 2));
|
|
79
|
+
// app.json
|
|
80
|
+
const appPath = path.join(obsidianDir, "app.json");
|
|
81
|
+
let app = {};
|
|
82
|
+
if (fs.existsSync(appPath)) {
|
|
83
|
+
try {
|
|
84
|
+
app = JSON.parse(fs.readFileSync(appPath, "utf-8"));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// ignore
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
app.alwaysUpdateLinks = true;
|
|
91
|
+
fs.writeFileSync(appPath, JSON.stringify(app, null, 2));
|
|
92
|
+
}
|
|
93
|
+
// biome-ignore lint/suspicious/noExplicitAny: deep merge requires flexible types
|
|
94
|
+
function deepMerge(target, source) {
|
|
95
|
+
const result = { ...target };
|
|
96
|
+
for (const key of Object.keys(source)) {
|
|
97
|
+
const srcVal = source[key];
|
|
98
|
+
const tgtVal = target[key];
|
|
99
|
+
if (srcVal &&
|
|
100
|
+
typeof srcVal === "object" &&
|
|
101
|
+
!Array.isArray(srcVal) &&
|
|
102
|
+
tgtVal &&
|
|
103
|
+
typeof tgtVal === "object" &&
|
|
104
|
+
!Array.isArray(tgtVal)) {
|
|
105
|
+
result[key] = deepMerge(tgtVal, srcVal);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
result[key] = srcVal;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
export interface FileInfo {
|
|
3
|
+
path: string;
|
|
4
|
+
name: string;
|
|
5
|
+
extension: string;
|
|
6
|
+
size: number;
|
|
7
|
+
created: number;
|
|
8
|
+
modified: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ListFilesOptions {
|
|
11
|
+
folder?: string;
|
|
12
|
+
ext?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Directory names that walkers skip unconditionally (internal Obsidian/napkin
|
|
16
|
+
* state, VCS metadata, and package managers).
|
|
17
|
+
*
|
|
18
|
+
* Shared by listFiles, listFolders, walkMd (graph), and getVaultSize so all
|
|
19
|
+
* four walkers agree on what to exclude. This matters especially when a vault
|
|
20
|
+
* contains symlinks into external trees (e.g. Brazil workspace packages) whose
|
|
21
|
+
* node_modules/ would otherwise balloon results.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SKIP_DIRS: ReadonlySet<string>;
|
|
24
|
+
/** What a Dirent resolves to, following symlinks to their target. */
|
|
25
|
+
export type EntryKind = "dir" | "file" | null;
|
|
26
|
+
/**
|
|
27
|
+
* Classify a Dirent. For symlinks, follows to the target and reports the
|
|
28
|
+
* target kind. Returns null for broken symlinks, unreadable targets, and
|
|
29
|
+
* non-regular entries (sockets, FIFOs, devices).
|
|
30
|
+
*
|
|
31
|
+
* Using this lets walkers treat symlinks to directories/files as first-class
|
|
32
|
+
* entries while still gracefully skipping unreadable targets.
|
|
33
|
+
*/
|
|
34
|
+
export declare function direntKind(fullPath: string, entry: fs.Dirent): EntryKind;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a directory's real path for cycle detection. Returns null on
|
|
37
|
+
* failure (permission errors, broken intermediate path, etc.) so callers
|
|
38
|
+
* can fail closed rather than risk an unbounded walk.
|
|
39
|
+
*/
|
|
40
|
+
export declare function safeRealpath(dir: string): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Options for {@link walkDir}.
|
|
43
|
+
*/
|
|
44
|
+
export interface WalkDirOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Called once per visited file or directory entry (after skip-dir
|
|
47
|
+
* filtering and symlink resolution). Never invoked with kind=null;
|
|
48
|
+
* non-regular and broken entries are filtered out by the walker.
|
|
49
|
+
*/
|
|
50
|
+
onEntry: (fullPath: string, entry: fs.Dirent, kind: "dir" | "file") => void;
|
|
51
|
+
/**
|
|
52
|
+
* Optional predicate to prune directories. Called for each directory
|
|
53
|
+
* entry (including symlinked dirs) AFTER the SKIP_DIRS check. Return
|
|
54
|
+
* false to skip the directory entirely: it is neither reported via
|
|
55
|
+
* onEntry nor walked. Not consulted for files.
|
|
56
|
+
*
|
|
57
|
+
* Use this to apply stricter pruning than SKIP_DIRS (e.g. walkMd and
|
|
58
|
+
* listFolders prune all dot-prefixed directories).
|
|
59
|
+
*/
|
|
60
|
+
shouldEnter?: (fullPath: string, entry: fs.Dirent) => boolean;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Walk a directory tree, invoking onEntry for each file and directory.
|
|
64
|
+
*
|
|
65
|
+
* Skip semantics:
|
|
66
|
+
* - Directory names in SKIP_DIRS are never entered or reported.
|
|
67
|
+
* - If shouldEnter is provided, directories for which it returns false
|
|
68
|
+
* are also neither entered nor reported. This lets callers prune
|
|
69
|
+
* subtrees (e.g. dotdirs) symmetrically with SKIP_DIRS — filtering
|
|
70
|
+
* inside onEntry would only hide the report while still walking the
|
|
71
|
+
* subtree.
|
|
72
|
+
*
|
|
73
|
+
* Symlink semantics:
|
|
74
|
+
* - Symlinks to regular files/directories are classified via direntKind
|
|
75
|
+
* and reported with kind "dir" or "file".
|
|
76
|
+
* - A symlink that resolves to a directory is walked recursively.
|
|
77
|
+
* - Broken symlinks, sockets, FIFOs, and devices are silently skipped.
|
|
78
|
+
*
|
|
79
|
+
* Cycle detection: uses an on-stack set of realpaths for the current
|
|
80
|
+
* recursion path. A symlink that resolves to an ancestor in the descent
|
|
81
|
+
* is skipped exactly once; two sibling symlinks to the same target are
|
|
82
|
+
* both walked. realpathSync is only invoked when crossing a symlink
|
|
83
|
+
* boundary (or at the root), so symlink-free vaults pay no extra cost
|
|
84
|
+
* versus the pre-fix baseline.
|
|
85
|
+
*
|
|
86
|
+
* Fail-closed: if realpathSync fails on a subtree entered via a symlink,
|
|
87
|
+
* that subtree is skipped to avoid potential unbounded recursion.
|
|
88
|
+
*/
|
|
89
|
+
export declare function walkDir(root: string, opts: WalkDirOptions): void;
|
|
90
|
+
/**
|
|
91
|
+
* Recursively list files in a vault.
|
|
92
|
+
*
|
|
93
|
+
* - Skips the directories in SKIP_DIRS (".obsidian", ".git", etc.).
|
|
94
|
+
* - Follows symlinks to files and directories. Note that symlinked content
|
|
95
|
+
* may physically live outside vaultPath; callers that feed results to
|
|
96
|
+
* downstream consumers (e.g. LLM prompts via the SDK) should be aware
|
|
97
|
+
* that `readFile` of a returned path may access external data.
|
|
98
|
+
* - Detects symlink cycles via realpath tracking on the current recursion
|
|
99
|
+
* path, so a symlink pointing back to an ancestor is entered at most
|
|
100
|
+
* once. Sibling symlinks to the same target are both walked (each
|
|
101
|
+
* contributes its own prefixed entries).
|
|
102
|
+
* - Broken symlinks, sockets, FIFOs, and unreadable entries are silently
|
|
103
|
+
* skipped.
|
|
104
|
+
*/
|
|
105
|
+
export declare function listFiles(vaultPath: string, opts?: ListFilesOptions): string[];
|
|
106
|
+
/**
|
|
107
|
+
* List folders in a vault. Same symlink semantics as listFiles.
|
|
108
|
+
*/
|
|
109
|
+
export declare function listFolders(vaultPath: string, parentFolder?: string): string[];
|
|
110
|
+
/**
|
|
111
|
+
* Resolve a file reference (wikilink-style name or exact path) to a relative path in the vault.
|
|
112
|
+
* Throws on ambiguous matches so the user can disambiguate.
|
|
113
|
+
*/
|
|
114
|
+
export declare function resolveFile(vaultPath: string, fileRef: string): string | null;
|
|
115
|
+
/**
|
|
116
|
+
* Like resolveFile but never throws on ambiguous matches.
|
|
117
|
+
* Returns the shallowest match (fewest path segments), matching Obsidian's behavior.
|
|
118
|
+
*/
|
|
119
|
+
export declare function resolveFileLoose(vaultPath: string, fileRef: string): string | null;
|
|
120
|
+
/**
|
|
121
|
+
* Suggest similar filenames when a file isn't found.
|
|
122
|
+
* Returns up to 3 suggestions sorted by similarity.
|
|
123
|
+
*/
|
|
124
|
+
export declare function suggestFile(vaultPath: string, fileRef: string): string[];
|
|
125
|
+
/**
|
|
126
|
+
* Read a file's contents, resolving by name or path.
|
|
127
|
+
*/
|
|
128
|
+
export declare function readFile(vaultPath: string, fileRef: string): {
|
|
129
|
+
path: string;
|
|
130
|
+
content: string;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Get file info for a resolved file path.
|
|
134
|
+
*/
|
|
135
|
+
export declare function getFileInfo(vaultPath: string, relativePath: string): FileInfo;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Directory names that walkers skip unconditionally (internal Obsidian/napkin
|
|
5
|
+
* state, VCS metadata, and package managers).
|
|
6
|
+
*
|
|
7
|
+
* Shared by listFiles, listFolders, walkMd (graph), and getVaultSize so all
|
|
8
|
+
* four walkers agree on what to exclude. This matters especially when a vault
|
|
9
|
+
* contains symlinks into external trees (e.g. Brazil workspace packages) whose
|
|
10
|
+
* node_modules/ would otherwise balloon results.
|
|
11
|
+
*/
|
|
12
|
+
export const SKIP_DIRS = new Set([
|
|
13
|
+
".obsidian",
|
|
14
|
+
".git",
|
|
15
|
+
".trash",
|
|
16
|
+
".nanny",
|
|
17
|
+
".napkin",
|
|
18
|
+
"node_modules",
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Classify a Dirent. For symlinks, follows to the target and reports the
|
|
22
|
+
* target kind. Returns null for broken symlinks, unreadable targets, and
|
|
23
|
+
* non-regular entries (sockets, FIFOs, devices).
|
|
24
|
+
*
|
|
25
|
+
* Using this lets walkers treat symlinks to directories/files as first-class
|
|
26
|
+
* entries while still gracefully skipping unreadable targets.
|
|
27
|
+
*/
|
|
28
|
+
export function direntKind(fullPath, entry) {
|
|
29
|
+
if (entry.isDirectory())
|
|
30
|
+
return "dir";
|
|
31
|
+
if (entry.isFile())
|
|
32
|
+
return "file";
|
|
33
|
+
if (entry.isSymbolicLink()) {
|
|
34
|
+
try {
|
|
35
|
+
const stat = fs.statSync(fullPath); // follows symlinks
|
|
36
|
+
if (stat.isDirectory())
|
|
37
|
+
return "dir";
|
|
38
|
+
if (stat.isFile())
|
|
39
|
+
return "file";
|
|
40
|
+
return null; // socket / FIFO / device
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Broken symlink or target inaccessible.
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a directory's real path for cycle detection. Returns null on
|
|
51
|
+
* failure (permission errors, broken intermediate path, etc.) so callers
|
|
52
|
+
* can fail closed rather than risk an unbounded walk.
|
|
53
|
+
*/
|
|
54
|
+
export function safeRealpath(dir) {
|
|
55
|
+
try {
|
|
56
|
+
return fs.realpathSync(dir);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Walk a directory tree, invoking onEntry for each file and directory.
|
|
64
|
+
*
|
|
65
|
+
* Skip semantics:
|
|
66
|
+
* - Directory names in SKIP_DIRS are never entered or reported.
|
|
67
|
+
* - If shouldEnter is provided, directories for which it returns false
|
|
68
|
+
* are also neither entered nor reported. This lets callers prune
|
|
69
|
+
* subtrees (e.g. dotdirs) symmetrically with SKIP_DIRS — filtering
|
|
70
|
+
* inside onEntry would only hide the report while still walking the
|
|
71
|
+
* subtree.
|
|
72
|
+
*
|
|
73
|
+
* Symlink semantics:
|
|
74
|
+
* - Symlinks to regular files/directories are classified via direntKind
|
|
75
|
+
* and reported with kind "dir" or "file".
|
|
76
|
+
* - A symlink that resolves to a directory is walked recursively.
|
|
77
|
+
* - Broken symlinks, sockets, FIFOs, and devices are silently skipped.
|
|
78
|
+
*
|
|
79
|
+
* Cycle detection: uses an on-stack set of realpaths for the current
|
|
80
|
+
* recursion path. A symlink that resolves to an ancestor in the descent
|
|
81
|
+
* is skipped exactly once; two sibling symlinks to the same target are
|
|
82
|
+
* both walked. realpathSync is only invoked when crossing a symlink
|
|
83
|
+
* boundary (or at the root), so symlink-free vaults pay no extra cost
|
|
84
|
+
* versus the pre-fix baseline.
|
|
85
|
+
*
|
|
86
|
+
* Fail-closed: if realpathSync fails on a subtree entered via a symlink,
|
|
87
|
+
* that subtree is skipped to avoid potential unbounded recursion.
|
|
88
|
+
*/
|
|
89
|
+
export function walkDir(root, opts) {
|
|
90
|
+
const onStack = new Set();
|
|
91
|
+
// Seed with the root's real path so a symlink back to the root is
|
|
92
|
+
// detected even though we don't mark the top-level descent as
|
|
93
|
+
// "via symlink".
|
|
94
|
+
const rootReal = safeRealpath(root);
|
|
95
|
+
if (rootReal !== null)
|
|
96
|
+
onStack.add(rootReal);
|
|
97
|
+
function walk(dir, viaSymlink) {
|
|
98
|
+
let stackKey = null;
|
|
99
|
+
if (viaSymlink) {
|
|
100
|
+
stackKey = safeRealpath(dir);
|
|
101
|
+
if (stackKey === null)
|
|
102
|
+
return; // inaccessible symlinked subtree
|
|
103
|
+
if (onStack.has(stackKey))
|
|
104
|
+
return; // cycle
|
|
105
|
+
onStack.add(stackKey);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
let entries;
|
|
109
|
+
try {
|
|
110
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (SKIP_DIRS.has(entry.name))
|
|
117
|
+
continue;
|
|
118
|
+
const fullPath = path.join(dir, entry.name);
|
|
119
|
+
const kind = direntKind(fullPath, entry);
|
|
120
|
+
if (kind === null)
|
|
121
|
+
continue;
|
|
122
|
+
if (kind === "dir") {
|
|
123
|
+
// Check shouldEnter BEFORE reporting so that a pruned
|
|
124
|
+
// directory is neither emitted nor walked, matching the
|
|
125
|
+
// pre-consolidation semantics of listFolders/walkMd.
|
|
126
|
+
if (opts.shouldEnter && !opts.shouldEnter(fullPath, entry))
|
|
127
|
+
continue;
|
|
128
|
+
opts.onEntry(fullPath, entry, kind);
|
|
129
|
+
walk(fullPath, viaSymlink || entry.isSymbolicLink());
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
opts.onEntry(fullPath, entry, kind);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
if (stackKey !== null)
|
|
138
|
+
onStack.delete(stackKey);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
walk(root, false);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Recursively list files in a vault.
|
|
145
|
+
*
|
|
146
|
+
* - Skips the directories in SKIP_DIRS (".obsidian", ".git", etc.).
|
|
147
|
+
* - Follows symlinks to files and directories. Note that symlinked content
|
|
148
|
+
* may physically live outside vaultPath; callers that feed results to
|
|
149
|
+
* downstream consumers (e.g. LLM prompts via the SDK) should be aware
|
|
150
|
+
* that `readFile` of a returned path may access external data.
|
|
151
|
+
* - Detects symlink cycles via realpath tracking on the current recursion
|
|
152
|
+
* path, so a symlink pointing back to an ancestor is entered at most
|
|
153
|
+
* once. Sibling symlinks to the same target are both walked (each
|
|
154
|
+
* contributes its own prefixed entries).
|
|
155
|
+
* - Broken symlinks, sockets, FIFOs, and unreadable entries are silently
|
|
156
|
+
* skipped.
|
|
157
|
+
*/
|
|
158
|
+
export function listFiles(vaultPath, opts) {
|
|
159
|
+
const results = [];
|
|
160
|
+
// Internal napkin files that shouldn't appear in vault content listings
|
|
161
|
+
const skipFiles = new Set(["config.json", "search-cache.json"]);
|
|
162
|
+
const baseDir = opts?.folder ? path.join(vaultPath, opts.folder) : vaultPath;
|
|
163
|
+
if (!fs.existsSync(baseDir))
|
|
164
|
+
return results;
|
|
165
|
+
walkDir(baseDir, {
|
|
166
|
+
onEntry: (fullPath, _entry, kind) => {
|
|
167
|
+
if (kind !== "file")
|
|
168
|
+
return;
|
|
169
|
+
// Skip internal config files at vault root
|
|
170
|
+
if (path.dirname(fullPath) === vaultPath &&
|
|
171
|
+
skipFiles.has(path.basename(fullPath)))
|
|
172
|
+
return;
|
|
173
|
+
const rel = path.relative(vaultPath, fullPath);
|
|
174
|
+
if (opts?.ext) {
|
|
175
|
+
if (path.extname(fullPath).slice(1) === opts.ext) {
|
|
176
|
+
results.push(rel);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
results.push(rel);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
return results.sort();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* List folders in a vault. Same symlink semantics as listFiles.
|
|
188
|
+
*/
|
|
189
|
+
export function listFolders(vaultPath, parentFolder) {
|
|
190
|
+
const results = [];
|
|
191
|
+
const baseDir = parentFolder ? path.join(vaultPath, parentFolder) : vaultPath;
|
|
192
|
+
if (!fs.existsSync(baseDir))
|
|
193
|
+
return results;
|
|
194
|
+
walkDir(baseDir, {
|
|
195
|
+
onEntry: (fullPath, _entry, kind) => {
|
|
196
|
+
if (kind !== "dir")
|
|
197
|
+
return;
|
|
198
|
+
results.push(path.relative(vaultPath, fullPath));
|
|
199
|
+
},
|
|
200
|
+
// Prune dotdirs at descent time so their subtree is not walked
|
|
201
|
+
// (matches pre-consolidation listFolders behavior). walkDir already
|
|
202
|
+
// filters SKIP_DIRS; this is the additional dotdir policy.
|
|
203
|
+
shouldEnter: (_fullPath, entry) => !entry.name.startsWith("."),
|
|
204
|
+
});
|
|
205
|
+
return results.sort();
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Find all .md files matching a wikilink-style name or exact path.
|
|
209
|
+
*/
|
|
210
|
+
function findMatches(vaultPath, fileRef) {
|
|
211
|
+
// Exact path
|
|
212
|
+
if (fileRef.includes("/") || fileRef.endsWith(".md")) {
|
|
213
|
+
const ref = fileRef.endsWith(".md") ? fileRef : `${fileRef}.md`;
|
|
214
|
+
const fullPath = path.join(vaultPath, ref);
|
|
215
|
+
return fs.existsSync(fullPath) ? [ref] : [];
|
|
216
|
+
}
|
|
217
|
+
// Wikilink-style: search by basename
|
|
218
|
+
const target = fileRef.toLowerCase();
|
|
219
|
+
const allFiles = listFiles(vaultPath, { ext: "md" });
|
|
220
|
+
return allFiles.filter((file) => path.basename(file, ".md").toLowerCase() === target);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Resolve a file reference (wikilink-style name or exact path) to a relative path in the vault.
|
|
224
|
+
* Throws on ambiguous matches so the user can disambiguate.
|
|
225
|
+
*/
|
|
226
|
+
export function resolveFile(vaultPath, fileRef) {
|
|
227
|
+
const matches = findMatches(vaultPath, fileRef);
|
|
228
|
+
if (matches.length > 1) {
|
|
229
|
+
throw new Error(`Ambiguous file reference "${fileRef}" matches ${matches.length} files: ${matches.join(", ")}. Use the full path to disambiguate.`);
|
|
230
|
+
}
|
|
231
|
+
return matches[0] ?? null;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Like resolveFile but never throws on ambiguous matches.
|
|
235
|
+
* Returns the shallowest match (fewest path segments), matching Obsidian's behavior.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveFileLoose(vaultPath, fileRef) {
|
|
238
|
+
const matches = findMatches(vaultPath, fileRef);
|
|
239
|
+
if (matches.length > 1) {
|
|
240
|
+
matches.sort((a, b) => a.split("/").length - b.split("/").length);
|
|
241
|
+
}
|
|
242
|
+
return matches[0] ?? null;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Suggest similar filenames when a file isn't found.
|
|
246
|
+
* Returns up to 3 suggestions sorted by similarity.
|
|
247
|
+
*/
|
|
248
|
+
export function suggestFile(vaultPath, fileRef) {
|
|
249
|
+
const target = fileRef.toLowerCase();
|
|
250
|
+
const allFiles = listFiles(vaultPath, { ext: "md" });
|
|
251
|
+
const scored = allFiles
|
|
252
|
+
.map((f) => {
|
|
253
|
+
const basename = path.basename(f, ".md").toLowerCase();
|
|
254
|
+
// Simple substring match scoring
|
|
255
|
+
let score = 0;
|
|
256
|
+
if (basename.includes(target) || target.includes(basename))
|
|
257
|
+
score += 3;
|
|
258
|
+
// Shared prefix
|
|
259
|
+
let prefix = 0;
|
|
260
|
+
while (prefix < basename.length &&
|
|
261
|
+
prefix < target.length &&
|
|
262
|
+
basename[prefix] === target[prefix])
|
|
263
|
+
prefix++;
|
|
264
|
+
score += prefix;
|
|
265
|
+
return { file: f, score };
|
|
266
|
+
})
|
|
267
|
+
.filter((s) => s.score > 0)
|
|
268
|
+
.sort((a, b) => b.score - a.score)
|
|
269
|
+
.slice(0, 3);
|
|
270
|
+
return scored.map((s) => s.file);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Read a file's contents, resolving by name or path.
|
|
274
|
+
*/
|
|
275
|
+
export function readFile(vaultPath, fileRef) {
|
|
276
|
+
const resolved = resolveFile(vaultPath, fileRef);
|
|
277
|
+
if (!resolved) {
|
|
278
|
+
throw new Error(`File not found: ${fileRef}`);
|
|
279
|
+
}
|
|
280
|
+
const fullPath = path.join(vaultPath, resolved);
|
|
281
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
282
|
+
return { path: resolved, content };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Get file info for a resolved file path.
|
|
286
|
+
*/
|
|
287
|
+
export function getFileInfo(vaultPath, relativePath) {
|
|
288
|
+
const fullPath = path.join(vaultPath, relativePath);
|
|
289
|
+
const stat = fs.statSync(fullPath);
|
|
290
|
+
const ext = path.extname(relativePath);
|
|
291
|
+
return {
|
|
292
|
+
path: relativePath,
|
|
293
|
+
name: path.basename(relativePath, ext),
|
|
294
|
+
extension: ext.slice(1),
|
|
295
|
+
size: stat.size,
|
|
296
|
+
created: stat.birthtimeMs,
|
|
297
|
+
modified: stat.mtimeMs,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import Jexl from "jexl";
|
|
2
|
+
/**
|
|
3
|
+
* Transform Obsidian expression syntax to jexl syntax.
|
|
4
|
+
* Converts .method(args) to |method(args) for known transforms.
|
|
5
|
+
* Also remaps if() to _if() since if is reserved.
|
|
6
|
+
*/
|
|
7
|
+
export declare function obsidianToJexl(expr: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Create a configured jexl instance with all Obsidian Bases functions.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createFormulaEngine(): InstanceType<typeof Jexl.Jexl>;
|
|
12
|
+
/**
|
|
13
|
+
* Build a context object for formula evaluation from a database row.
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildFormulaContext(columns: string[], row: unknown[], formulaResults?: Record<string, unknown>, thisFile?: {
|
|
16
|
+
name: string;
|
|
17
|
+
path: string;
|
|
18
|
+
folder: string;
|
|
19
|
+
}): Record<string, unknown>;
|
|
20
|
+
/**
|
|
21
|
+
* Evaluate all formulas for a single row.
|
|
22
|
+
* Handles formula dependencies (formula referencing another formula).
|
|
23
|
+
*/
|
|
24
|
+
export declare function evaluateFormulas(engine: InstanceType<typeof Jexl.Jexl>, formulas: Record<string, string>, columns: string[], row: unknown[], thisFile?: {
|
|
25
|
+
name: string;
|
|
26
|
+
path: string;
|
|
27
|
+
folder: string;
|
|
28
|
+
}): Promise<Record<string, unknown>>;
|