obsidian-mcp-brain 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/access.mjs +108 -0
- package/lib/fetch.mjs +184 -0
- package/lib/frontmatter.mjs +212 -0
- package/lib/preconditions.mjs +51 -0
- package/lib/sections.mjs +196 -0
- package/lib/utils.mjs +8 -0
- package/lib/vault.mjs +359 -0
- package/lib/wikilinks.mjs +77 -0
- package/obsidian-mcp-brain.mjs +1507 -0
- package/package.json +36 -0
package/lib/sections.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Pure functions for heading-section replacement and checkbox toggling within note content.
|
|
2
|
+
// All functions take and return strings; no filesystem I/O.
|
|
3
|
+
|
|
4
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
|
5
|
+
const CHECKBOX_RE = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/;
|
|
6
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Computes which lines fall inside a fenced code block (``` or ~~~, optionally indented
|
|
10
|
+
* up to 3 spaces), so heading/checkbox parsing can skip them — otherwise a code sample
|
|
11
|
+
* containing e.g. `# comment` or `- [ ] text` would be misread as real note structure.
|
|
12
|
+
* @returns {boolean[]} true for each line index that is part of a fence (opening/closing
|
|
13
|
+
* fence lines included, since neither is itself a heading or checkbox line).
|
|
14
|
+
*/
|
|
15
|
+
function computeFenceMask(lines) {
|
|
16
|
+
const mask = new Array(lines.length).fill(false);
|
|
17
|
+
let fenceChar = null;
|
|
18
|
+
let fenceLen = 0;
|
|
19
|
+
for (let i = 0; i < lines.length; i++) {
|
|
20
|
+
const m = FENCE_RE.exec(lines[i]);
|
|
21
|
+
if (fenceChar) {
|
|
22
|
+
mask[i] = true;
|
|
23
|
+
if (m && m[1][0] === fenceChar && m[1].length >= fenceLen) {
|
|
24
|
+
fenceChar = null;
|
|
25
|
+
fenceLen = 0;
|
|
26
|
+
}
|
|
27
|
+
} else if (m) {
|
|
28
|
+
fenceChar = m[1][0];
|
|
29
|
+
fenceLen = m[1].length;
|
|
30
|
+
mask[i] = true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return mask;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parses all ATX headings (# through ######) in content, ignoring lines inside fenced
|
|
38
|
+
* code blocks. Setext-style (===/---) headings are not recognised.
|
|
39
|
+
* @returns {Array<{text: string, level: number, line: number, bodyEndLine: number}>}
|
|
40
|
+
* line is 1-based. bodyEndLine is the 1-based last line belonging to this heading's
|
|
41
|
+
* section — everything up to (but not including) the next heading of the same or
|
|
42
|
+
* shallower level, or the last line of the file if there is none.
|
|
43
|
+
*/
|
|
44
|
+
export function findHeadings(content) {
|
|
45
|
+
const lines = content.split('\n');
|
|
46
|
+
const inFence = computeFenceMask(lines);
|
|
47
|
+
const headings = [];
|
|
48
|
+
lines.forEach((line, i) => {
|
|
49
|
+
if (inFence[i]) return;
|
|
50
|
+
const m = HEADING_RE.exec(line);
|
|
51
|
+
if (m) headings.push({ text: m[2].trim(), level: m[1].length, line: i + 1 });
|
|
52
|
+
});
|
|
53
|
+
return headings.map((h, idx) => {
|
|
54
|
+
let bodyEndLine = lines.length;
|
|
55
|
+
for (let j = idx + 1; j < headings.length; j++) {
|
|
56
|
+
if (headings[j].level <= h.level) {
|
|
57
|
+
bodyEndLine = headings[j].line - 1;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { ...h, bodyEndLine };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Constructs an error message for an ambiguous match, listing all matches and suggesting
|
|
67
|
+
* the user retry with an occurrence parameter to disambiguate.
|
|
68
|
+
* @param {string} kind — "heading" or "task" (used in error message)
|
|
69
|
+
* @param {string} matchText — the search term that matched multiple times
|
|
70
|
+
* @param {Array} matches — array of matching objects
|
|
71
|
+
* @param {Function} describeMatch — function that takes a match and returns a descriptive string
|
|
72
|
+
* @returns {Error} an error with formatted message describing all matches
|
|
73
|
+
*/
|
|
74
|
+
function ambiguityError(kind, matchText, matches, describeMatch) {
|
|
75
|
+
const list = matches.map((m, i) => `${i + 1}. ${describeMatch(m)}`).join('\n');
|
|
76
|
+
return new Error(
|
|
77
|
+
`Ambiguous ${kind} "${matchText}" — ${matches.length} matches:\n${list}\nRetry with an occurrence parameter (1-${matches.length}).`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Selects a match from an array using a 1-based occurrence index, with validation and
|
|
83
|
+
* fallback to the first match if occurrence is undefined. Throws if occurrence is not a
|
|
84
|
+
* positive integer or if the requested occurrence index is out of bounds.
|
|
85
|
+
* @param {Array} matches — array of matches to select from
|
|
86
|
+
* @param {number} occurrence — 1-based index into matches; undefined defaults to the first match
|
|
87
|
+
* @returns {*} the selected match object
|
|
88
|
+
* @throws {Error} if occurrence is not a positive integer, or if index is out of bounds
|
|
89
|
+
*/
|
|
90
|
+
function pickMatch(matches, occurrence) {
|
|
91
|
+
if (occurrence !== undefined && !(Number.isInteger(occurrence) && occurrence >= 1)) {
|
|
92
|
+
throw new Error(`Invalid occurrence ${occurrence}: must be a positive integer`);
|
|
93
|
+
}
|
|
94
|
+
const idx = occurrence !== undefined ? occurrence - 1 : 0;
|
|
95
|
+
const match = matches[idx];
|
|
96
|
+
if (!match) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Invalid occurrence ${occurrence}: ${matches.length} match(es) available`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return match;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Replaces the content under the heading matching `heading` (exact text) with `newBody`,
|
|
106
|
+
* leaving the heading line itself untouched. The replaced span runs from immediately after
|
|
107
|
+
* the heading line up to (but not including) the next heading of the same or shallower
|
|
108
|
+
* level, or end of file.
|
|
109
|
+
*
|
|
110
|
+
* Throws if `heading` matches no heading, or matches more than one and no `occurrence`
|
|
111
|
+
* (1-based) is given to disambiguate.
|
|
112
|
+
*/
|
|
113
|
+
export function replaceSection(content, heading, newBody, occurrence) {
|
|
114
|
+
const headings = findHeadings(content);
|
|
115
|
+
const matches = headings.filter(h => h.text === heading);
|
|
116
|
+
if (matches.length === 0) {
|
|
117
|
+
throw new Error(`Heading not found: ${heading}`);
|
|
118
|
+
}
|
|
119
|
+
if (matches.length > 1 && occurrence === undefined) {
|
|
120
|
+
throw ambiguityError('heading', heading, matches, m => `line ${m.line} (level ${m.level})`);
|
|
121
|
+
}
|
|
122
|
+
const match = pickMatch(matches, occurrence);
|
|
123
|
+
|
|
124
|
+
const lines = content.split('\n');
|
|
125
|
+
const before = lines.slice(0, match.line);
|
|
126
|
+
const after = lines.slice(match.bodyEndLine);
|
|
127
|
+
return [...before, ...newBody.split('\n'), ...after].join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Removes the heading matching `heading` (exact text) along with its entire body — the
|
|
132
|
+
* heading line itself plus everything up to (but not including) the next heading of the
|
|
133
|
+
* same or shallower level, or end of file. Unlike replaceSection, nothing is left behind
|
|
134
|
+
* in the heading's place.
|
|
135
|
+
*
|
|
136
|
+
* Throws if `heading` matches no heading, or matches more than one and no `occurrence`
|
|
137
|
+
* (1-based) is given to disambiguate.
|
|
138
|
+
*/
|
|
139
|
+
export function deleteSection(content, heading, occurrence) {
|
|
140
|
+
const headings = findHeadings(content);
|
|
141
|
+
const matches = headings.filter(h => h.text === heading);
|
|
142
|
+
if (matches.length === 0) {
|
|
143
|
+
throw new Error(`Heading not found: ${heading}`);
|
|
144
|
+
}
|
|
145
|
+
if (matches.length > 1 && occurrence === undefined) {
|
|
146
|
+
throw ambiguityError('heading', heading, matches, m => `line ${m.line} (level ${m.level})`);
|
|
147
|
+
}
|
|
148
|
+
const match = pickMatch(matches, occurrence);
|
|
149
|
+
|
|
150
|
+
const lines = content.split('\n');
|
|
151
|
+
const before = lines.slice(0, match.line - 1);
|
|
152
|
+
const after = lines.slice(match.bodyEndLine);
|
|
153
|
+
return [...before, ...after].join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parses all Markdown checkbox list items (`- [ ]`/`- [x]`, `*` bullets also accepted),
|
|
158
|
+
* ignoring lines inside fenced code blocks.
|
|
159
|
+
* @returns {Array<{text: string, checked: boolean, line: number}>} line is 1-based.
|
|
160
|
+
*/
|
|
161
|
+
export function findCheckboxes(content) {
|
|
162
|
+
const lines = content.split('\n');
|
|
163
|
+
const inFence = computeFenceMask(lines);
|
|
164
|
+
const boxes = [];
|
|
165
|
+
lines.forEach((line, i) => {
|
|
166
|
+
if (inFence[i]) return;
|
|
167
|
+
const m = CHECKBOX_RE.exec(line);
|
|
168
|
+
if (m) boxes.push({ text: m[2].trim(), checked: m[1].toLowerCase() === 'x', line: i + 1 });
|
|
169
|
+
});
|
|
170
|
+
return boxes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sets the checked state of the checkbox line whose text (after the marker) exactly
|
|
175
|
+
* matches `taskText`. If `checked` is omitted, the box's current state is flipped;
|
|
176
|
+
* otherwise it's set to that explicit boolean.
|
|
177
|
+
*
|
|
178
|
+
* Throws if `taskText` matches no checkbox, or matches more than one and no `occurrence`
|
|
179
|
+
* (1-based) is given to disambiguate.
|
|
180
|
+
*/
|
|
181
|
+
export function toggleCheckbox(content, taskText, checked, occurrence) {
|
|
182
|
+
const boxes = findCheckboxes(content);
|
|
183
|
+
const matches = boxes.filter(b => b.text === taskText);
|
|
184
|
+
if (matches.length === 0) {
|
|
185
|
+
throw new Error(`Checkbox not found: ${taskText}`);
|
|
186
|
+
}
|
|
187
|
+
if (matches.length > 1 && occurrence === undefined) {
|
|
188
|
+
throw ambiguityError('task', taskText, matches, m => `line ${m.line} (currently ${m.checked ? 'checked' : 'unchecked'})`);
|
|
189
|
+
}
|
|
190
|
+
const match = pickMatch(matches, occurrence);
|
|
191
|
+
|
|
192
|
+
const newChecked = checked === undefined ? !match.checked : checked;
|
|
193
|
+
const lines = content.split('\n');
|
|
194
|
+
lines[match.line - 1] = lines[match.line - 1].replace(/\[[ xX]\]/, `[${newChecked ? 'x' : ' '}]`);
|
|
195
|
+
return lines.join('\n');
|
|
196
|
+
}
|
package/lib/utils.mjs
ADDED
package/lib/vault.mjs
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Filesystem operations for vault notes.
|
|
2
|
+
// Callers are responsible for path validation and access control.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { rewriteLinks, extractReferences } from './wikilinks.mjs';
|
|
7
|
+
import { isDenied } from './access.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Recursively walks vault directories, calling callback for each markdown file.
|
|
11
|
+
* Skips directories and files starting with '.' (e.g., .trash, .obsidian, .git).
|
|
12
|
+
* @param {string} dir - Directory to walk
|
|
13
|
+
* @param {Function} cb - Async callback receiving absolute path of each .md file
|
|
14
|
+
*/
|
|
15
|
+
export async function walkVault(dir, cb) {
|
|
16
|
+
let entries;
|
|
17
|
+
try {
|
|
18
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (entry.name.startsWith('.')) continue; // skip .trash, .obsidian, .git, etc.
|
|
24
|
+
const full = path.join(dir, entry.name);
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
await walkVault(full, cb);
|
|
27
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
28
|
+
await cb(full);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Recursively walks vault directories, calling callback for every file regardless of
|
|
35
|
+
* extension. Skips directories and files starting with '.' (e.g., .trash, .obsidian, .git).
|
|
36
|
+
* Unlike walkVault, this visits binary files too — needed since a wikilink can resolve to one.
|
|
37
|
+
* @param {string} dir - Directory to walk
|
|
38
|
+
* @param {Function} cb - Async callback receiving absolute path of each file
|
|
39
|
+
*/
|
|
40
|
+
export async function walkAllFiles(dir, cb) {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.name.startsWith('.')) continue; // skip .trash, .obsidian, .git, etc.
|
|
49
|
+
const full = path.join(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
await walkAllFiles(full, cb);
|
|
52
|
+
} else if (entry.isFile()) {
|
|
53
|
+
await cb(full);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Computes the match key for a vault-relative path: the .md extension is stripped for
|
|
60
|
+
* notes (so links match their extensionless target), kept for everything else (so embeds,
|
|
61
|
+
* which reference the full filename, match exactly).
|
|
62
|
+
*/
|
|
63
|
+
function matchKeyFor(relPath) {
|
|
64
|
+
return relPath.endsWith('.md') ? relPath.replace(/\.md$/, '') : relPath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Finds every note that links to or embeds the given target file (a note or binary file),
|
|
69
|
+
* matching by the target's match key or bare basename — the same rule rewriteLinks uses.
|
|
70
|
+
* Basename matching can over-attribute when the basename is ambiguous elsewhere in the
|
|
71
|
+
* vault (e.g. a `[[sauce]]` link is reported against every file named "sauce", the same
|
|
72
|
+
* limitation rewriteLinks/moveNote already have) — use resolve-wikilink first to check
|
|
73
|
+
* whether a target is unambiguous.
|
|
74
|
+
* @param {string} vaultPath - Absolute path to vault root
|
|
75
|
+
* @param {string} targetRelPath - Vault-relative path of the target file
|
|
76
|
+
* @param {string[]} denyPaths - Vault-relative paths excluded from the results
|
|
77
|
+
* @returns {Promise<string[]>} Sorted vault-relative paths of notes referencing the target
|
|
78
|
+
*/
|
|
79
|
+
export async function findBacklinks(vaultPath, targetRelPath, denyPaths) {
|
|
80
|
+
const matchKey = matchKeyFor(targetRelPath);
|
|
81
|
+
const basename = path.posix.basename(matchKey);
|
|
82
|
+
const results = [];
|
|
83
|
+
|
|
84
|
+
await walkVault(vaultPath, async filePath => {
|
|
85
|
+
const rel = path.relative(vaultPath, filePath);
|
|
86
|
+
if (isDenied(denyPaths, rel)) return;
|
|
87
|
+
if (rel === targetRelPath) return; // a note doesn't "backlink" to itself
|
|
88
|
+
let content;
|
|
89
|
+
try {
|
|
90
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const refs = extractReferences(content);
|
|
95
|
+
if (refs.some(r => r.target === matchKey || r.target === basename)) {
|
|
96
|
+
results.push(rel);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
results.sort();
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolves a raw wikilink target string (as passed to rewriteLinks — no [[ ]], heading,
|
|
106
|
+
* or alias) to the vault file(s) whose match key or basename equals it. Zero results
|
|
107
|
+
* means the target doesn't resolve; more than one means it's ambiguous.
|
|
108
|
+
* @param {string} vaultPath - Absolute path to vault root
|
|
109
|
+
* @param {string} target - Raw wikilink target string
|
|
110
|
+
* @param {string[]} denyPaths - Vault-relative paths excluded from the results
|
|
111
|
+
* @returns {Promise<string[]>} Sorted vault-relative paths matching the target
|
|
112
|
+
*/
|
|
113
|
+
export async function resolveWikilink(vaultPath, target, denyPaths) {
|
|
114
|
+
const results = [];
|
|
115
|
+
|
|
116
|
+
await walkAllFiles(vaultPath, async filePath => {
|
|
117
|
+
const rel = path.relative(vaultPath, filePath);
|
|
118
|
+
if (isDenied(denyPaths, rel)) return;
|
|
119
|
+
const matchKey = matchKeyFor(rel);
|
|
120
|
+
const basename = path.posix.basename(matchKey);
|
|
121
|
+
if (matchKey === target || basename === target) {
|
|
122
|
+
results.push(rel);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
results.sort();
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reads note content from disk.
|
|
132
|
+
*/
|
|
133
|
+
export async function readNote(absPath) {
|
|
134
|
+
return fs.readFile(absPath, 'utf8');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Writes note content to disk, creating parent directories as needed.
|
|
139
|
+
*/
|
|
140
|
+
export async function writeNote(absPath, content) {
|
|
141
|
+
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
142
|
+
await fs.writeFile(absPath, content, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Deletes a note permanently or moves to .trash. On filename collision, appends timestamp suffix.
|
|
147
|
+
* @param {string} absPath - Absolute path to note
|
|
148
|
+
* @param {boolean} permanent - If true, permanently delete; if false, move to .trash
|
|
149
|
+
* @param {string} vaultPath - Absolute path to vault root
|
|
150
|
+
*/
|
|
151
|
+
export async function deleteNote(absPath, permanent, vaultPath) {
|
|
152
|
+
if (permanent) {
|
|
153
|
+
await fs.unlink(absPath);
|
|
154
|
+
} else {
|
|
155
|
+
const trashDir = path.join(vaultPath, '.trash');
|
|
156
|
+
await fs.mkdir(trashDir, { recursive: true });
|
|
157
|
+
const baseName = path.basename(absPath, '.md');
|
|
158
|
+
let dest = path.join(trashDir, path.basename(absPath));
|
|
159
|
+
try {
|
|
160
|
+
await fs.access(dest);
|
|
161
|
+
// Collision — suffix with timestamp to avoid silent overwrite
|
|
162
|
+
dest = path.join(trashDir, `${baseName}_${Date.now()}.md`);
|
|
163
|
+
} catch {
|
|
164
|
+
// ENOENT: destination free, use it
|
|
165
|
+
}
|
|
166
|
+
await fs.rename(absPath, dest);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Moves note from srcAbs to dstAbs, then rewrites all vault-wide wikilinks to the old path.
|
|
172
|
+
* Throws if destination already exists. Updates are best-effort; fails silently for inaccessible notes.
|
|
173
|
+
* @param {string} vaultPath - Absolute path to vault root
|
|
174
|
+
* @param {string} srcAbs - Absolute path to source note
|
|
175
|
+
* @param {string} dstAbs - Absolute path to destination note
|
|
176
|
+
* @param {string[]} denyPaths - Vault-relative paths to exclude from rewriting
|
|
177
|
+
*/
|
|
178
|
+
export async function moveNote(vaultPath, srcAbs, dstAbs, denyPaths) {
|
|
179
|
+
// Guard against silently clobbering an existing note
|
|
180
|
+
try {
|
|
181
|
+
await fs.access(dstAbs);
|
|
182
|
+
throw new Error(`Destination already exists: ${path.relative(vaultPath, dstAbs)}`);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err.code !== 'ENOENT') throw err;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await fs.mkdir(path.dirname(dstAbs), { recursive: true });
|
|
188
|
+
|
|
189
|
+
const oldRel = path.relative(vaultPath, srcAbs).replace(/\.md$/, '');
|
|
190
|
+
const newRel = path.relative(vaultPath, dstAbs).replace(/\.md$/, '');
|
|
191
|
+
|
|
192
|
+
await fs.rename(srcAbs, dstAbs);
|
|
193
|
+
|
|
194
|
+
// Rewrite links in every other vault note
|
|
195
|
+
await walkVault(vaultPath, async filePath => {
|
|
196
|
+
if (filePath === dstAbs) return;
|
|
197
|
+
const rel = path.relative(vaultPath, filePath);
|
|
198
|
+
if (isDenied(denyPaths, rel)) return;
|
|
199
|
+
let content;
|
|
200
|
+
try {
|
|
201
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
202
|
+
} catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const updated = rewriteLinks(content, oldRel, newRel);
|
|
206
|
+
if (updated !== content) {
|
|
207
|
+
try {
|
|
208
|
+
await fs.writeFile(filePath, updated, 'utf8');
|
|
209
|
+
} catch {
|
|
210
|
+
return; // best-effort: log failure without aborting remaining rewrites
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reads binary file content from disk.
|
|
218
|
+
*/
|
|
219
|
+
export async function readBinaryFile(absPath) {
|
|
220
|
+
return fs.readFile(absPath);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Writes binary content to disk, creating parent directories as needed.
|
|
225
|
+
* @param {string} absPath - Absolute path to write
|
|
226
|
+
* @param {Buffer} buffer - Binary content
|
|
227
|
+
*/
|
|
228
|
+
export async function writeBinaryFile(absPath, buffer) {
|
|
229
|
+
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
230
|
+
await fs.writeFile(absPath, buffer);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Deletes a binary file permanently or moves to .trash. On filename collision, appends a
|
|
235
|
+
* timestamp suffix before the extension (unlike deleteNote, the extension varies per file).
|
|
236
|
+
* @param {string} absPath - Absolute path to binary file
|
|
237
|
+
* @param {boolean} permanent - If true, permanently delete; if false, move to .trash
|
|
238
|
+
* @param {string} vaultPath - Absolute path to vault root
|
|
239
|
+
*/
|
|
240
|
+
export async function deleteBinaryFile(absPath, permanent, vaultPath) {
|
|
241
|
+
if (permanent) {
|
|
242
|
+
await fs.unlink(absPath);
|
|
243
|
+
} else {
|
|
244
|
+
const trashDir = path.join(vaultPath, '.trash');
|
|
245
|
+
await fs.mkdir(trashDir, { recursive: true });
|
|
246
|
+
const ext = path.extname(absPath);
|
|
247
|
+
const baseName = path.basename(absPath, ext);
|
|
248
|
+
let dest = path.join(trashDir, path.basename(absPath));
|
|
249
|
+
try {
|
|
250
|
+
await fs.access(dest);
|
|
251
|
+
// Collision — suffix with timestamp to avoid silent overwrite
|
|
252
|
+
dest = path.join(trashDir, `${baseName}_${Date.now()}${ext}`);
|
|
253
|
+
} catch {
|
|
254
|
+
// ENOENT: destination free, use it
|
|
255
|
+
}
|
|
256
|
+
await fs.rename(absPath, dest);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Moves a binary file from srcAbs to dstAbs, then rewrites all vault-wide wikilink embeds
|
|
262
|
+
* pointing at the old path. Throws if destination already exists.
|
|
263
|
+
*
|
|
264
|
+
* Unlike moveNote, the filename (including extension) is kept intact when rewriting, since
|
|
265
|
+
* embeds reference binary files by their full filename rather than an extensionless basename.
|
|
266
|
+
* @param {string} vaultPath - Absolute path to vault root
|
|
267
|
+
* @param {string} srcAbs - Absolute path to source file
|
|
268
|
+
* @param {string} dstAbs - Absolute path to destination file
|
|
269
|
+
* @param {string[]} denyPaths - Vault-relative paths to exclude from rewriting
|
|
270
|
+
*/
|
|
271
|
+
export async function moveBinaryFile(vaultPath, srcAbs, dstAbs, denyPaths) {
|
|
272
|
+
// Guard against silently clobbering an existing file
|
|
273
|
+
try {
|
|
274
|
+
await fs.access(dstAbs);
|
|
275
|
+
throw new Error(`Destination already exists: ${path.relative(vaultPath, dstAbs)}`);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (err.code !== 'ENOENT') throw err;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await fs.mkdir(path.dirname(dstAbs), { recursive: true });
|
|
281
|
+
|
|
282
|
+
const oldRel = path.relative(vaultPath, srcAbs);
|
|
283
|
+
const newRel = path.relative(vaultPath, dstAbs);
|
|
284
|
+
|
|
285
|
+
await fs.rename(srcAbs, dstAbs);
|
|
286
|
+
|
|
287
|
+
// Rewrite embeds in every vault note (walkVault only visits .md files, so the moved
|
|
288
|
+
// binary file itself is never read as text)
|
|
289
|
+
await walkVault(vaultPath, async filePath => {
|
|
290
|
+
const rel = path.relative(vaultPath, filePath);
|
|
291
|
+
if (isDenied(denyPaths, rel)) return;
|
|
292
|
+
let content;
|
|
293
|
+
try {
|
|
294
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const updated = rewriteLinks(content, oldRel, newRel);
|
|
299
|
+
if (updated !== content) {
|
|
300
|
+
try {
|
|
301
|
+
await fs.writeFile(filePath, updated, 'utf8');
|
|
302
|
+
} catch {
|
|
303
|
+
return; // best-effort: log failure without aborting remaining rewrites
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Searches note content for query (case-insensitive).
|
|
311
|
+
* @returns {Promise<Array<{path: string, matches: Array<{line: number, text: string}>}>>} Results sorted by path.
|
|
312
|
+
*/
|
|
313
|
+
export async function searchContent(vaultPath, query, scopeDir, denyPaths) {
|
|
314
|
+
const lower = query.toLowerCase();
|
|
315
|
+
const results = [];
|
|
316
|
+
|
|
317
|
+
await walkVault(scopeDir, async filePath => {
|
|
318
|
+
const rel = path.relative(vaultPath, filePath);
|
|
319
|
+
if (isDenied(denyPaths, rel)) return;
|
|
320
|
+
let content;
|
|
321
|
+
try {
|
|
322
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
323
|
+
} catch {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const matches = content
|
|
327
|
+
.split('\n')
|
|
328
|
+
.reduce((acc, text, i) => {
|
|
329
|
+
if (text.toLowerCase().includes(lower)) acc.push({ line: i + 1, text });
|
|
330
|
+
return acc;
|
|
331
|
+
}, []);
|
|
332
|
+
if (matches.length > 0) {
|
|
333
|
+
results.push({ path: rel, matches });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
results.sort((a, b) => a.path.localeCompare(b.path));
|
|
338
|
+
return results;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Searches note filenames and vault-relative paths for query (case-insensitive).
|
|
343
|
+
* @returns {Promise<string[]>} Vault-relative paths, sorted.
|
|
344
|
+
*/
|
|
345
|
+
export async function searchFilename(vaultPath, query, scopeDir, denyPaths) {
|
|
346
|
+
const lower = query.toLowerCase();
|
|
347
|
+
const results = [];
|
|
348
|
+
|
|
349
|
+
await walkVault(scopeDir, async filePath => {
|
|
350
|
+
const rel = path.relative(vaultPath, filePath);
|
|
351
|
+
if (isDenied(denyPaths, rel)) return;
|
|
352
|
+
if (rel.toLowerCase().includes(lower)) {
|
|
353
|
+
results.push(rel);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
results.sort();
|
|
358
|
+
return results;
|
|
359
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Pure functions for Obsidian wikilink parsing and rewriting.
|
|
2
|
+
// All functions take and return strings; no filesystem I/O.
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { escRe } from './utils.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parses all wikilinks in content and returns their components.
|
|
9
|
+
* Handles format: [[target#heading|alias]] (heading and alias optional).
|
|
10
|
+
* @returns {Array<{target: string, heading: string, alias: string}>} Parsed wikilinks.
|
|
11
|
+
*/
|
|
12
|
+
export function extractLinks(content) {
|
|
13
|
+
const re = /\[\[([^\]#|]+?)(?:#([^\]|]*))?(?:\|([^\]]*))?\]\]/g;
|
|
14
|
+
const links = [];
|
|
15
|
+
let m;
|
|
16
|
+
while ((m = re.exec(content)) !== null) {
|
|
17
|
+
links.push({
|
|
18
|
+
target: m[1].trim(),
|
|
19
|
+
heading: m[2] ?? '',
|
|
20
|
+
alias: m[3] ?? '',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return links;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parses all wikilinks and embeds in content and returns their components.
|
|
28
|
+
* Handles both [[target#heading|alias]] and ![[target#heading|alias]] (embed), heading
|
|
29
|
+
* and alias optional. Unlike extractLinks, this also reports whether each match was an
|
|
30
|
+
* embed — used by backlink/resolution lookups that must treat both forms as a reference.
|
|
31
|
+
* @returns {Array<{target: string, heading: string, alias: string, embed: boolean}>}
|
|
32
|
+
*/
|
|
33
|
+
export function extractReferences(content) {
|
|
34
|
+
const re = /(!)?\[\[([^\]#|]+?)(?:#([^\]|]*))?(?:\|([^\]]*))?\]\]/g;
|
|
35
|
+
const refs = [];
|
|
36
|
+
let m;
|
|
37
|
+
while ((m = re.exec(content)) !== null) {
|
|
38
|
+
refs.push({
|
|
39
|
+
target: m[2].trim(),
|
|
40
|
+
heading: m[3] ?? '',
|
|
41
|
+
alias: m[4] ?? '',
|
|
42
|
+
embed: Boolean(m[1]),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return refs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rewrites wikilinks pointing to oldPath to point to newPath instead (vault-relative, no .md).
|
|
50
|
+
* Preserves heading anchors and aliases. Also rewrites embeds (![[target]]), used to reference
|
|
51
|
+
* binary files such as images, preserving the leading '!'.
|
|
52
|
+
*
|
|
53
|
+
* Matches both exact vault-relative path and bare basename (e.g., "recipe" from "food/recipe").
|
|
54
|
+
* Does not match partial names or headings-only links ([[#heading]]).
|
|
55
|
+
*/
|
|
56
|
+
export function rewriteLinks(content, oldPath, newPath) {
|
|
57
|
+
const oldBasename = path.posix.basename(oldPath);
|
|
58
|
+
const escapedPath = escRe(oldPath);
|
|
59
|
+
const escapedBase = escRe(oldBasename);
|
|
60
|
+
|
|
61
|
+
// Alternate on full path first (more specific), then basename
|
|
62
|
+
const targetPattern =
|
|
63
|
+
escapedPath === escapedBase
|
|
64
|
+
? escapedPath
|
|
65
|
+
: `(?:${escapedPath}|${escapedBase})`;
|
|
66
|
+
|
|
67
|
+
// Pattern: !?[[target#heading|alias]] where the leading '!' (embed), #heading and
|
|
68
|
+
// |alias are all optional. The target must be followed by #, |, or ]] — not by other path chars
|
|
69
|
+
const re = new RegExp(
|
|
70
|
+
`(!)?\\[\\[(${targetPattern})(#[^\\]|]*)?(\\|[^\\]]*)?\\]\\]`,
|
|
71
|
+
'g',
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return content.replace(re, (_match, embed, _target, heading, alias) => {
|
|
75
|
+
return `${embed ?? ''}[[${newPath}${heading ?? ''}${alias ?? ''}]]`;
|
|
76
|
+
});
|
|
77
|
+
}
|