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.
@@ -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
@@ -0,0 +1,8 @@
1
+ // Shared utilities for lib modules.
2
+
3
+ /**
4
+ * Escapes regex metacharacters in a string.
5
+ */
6
+ export function escRe(s) {
7
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+ }
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
+ }