obsidian-mcp-server 3.5.5 → 3.6.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/AGENTS.md +4 -2
- package/CLAUDE.md +4 -2
- package/README.md +8 -8
- package/changelog/3.6.x/3.6.0.md +31 -0
- package/dist/mcp-server/tools/definitions/_shared/schemas.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/schemas.js +1 -1
- package/dist/mcp-server/tools/definitions/_shared/schemas.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +36 -0
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +12 -0
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +15 -2
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +3 -2
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +4 -3
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +15 -2
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +18 -4
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +12 -0
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +23 -10
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.d.ts +4 -3
- package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.js +379 -114
- package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
- package/dist/services/obsidian/markdown-blocks.d.ts +39 -0
- package/dist/services/obsidian/markdown-blocks.d.ts.map +1 -0
- package/dist/services/obsidian/markdown-blocks.js +611 -0
- package/dist/services/obsidian/markdown-blocks.js.map +1 -0
- package/dist/services/obsidian/obsidian-service.d.ts +9 -7
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +308 -111
- package/dist/services/obsidian/obsidian-service.js.map +1 -1
- package/dist/services/obsidian/patch-instruction.d.ts +141 -0
- package/dist/services/obsidian/patch-instruction.d.ts.map +1 -0
- package/dist/services/obsidian/patch-instruction.js +217 -0
- package/dist/services/obsidian/patch-instruction.js.map +1 -0
- package/dist/services/obsidian/section-extractor.d.ts +97 -24
- package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
- package/dist/services/obsidian/section-extractor.js +295 -58
- package/dist/services/obsidian/section-extractor.js.map +1 -1
- package/dist/services/obsidian/types.d.ts +12 -5
- package/dist/services/obsidian/types.d.ts.map +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -1
- package/server.json +3 -3
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* @module services/obsidian/frontmatter-ops
|
|
6
6
|
*/
|
|
7
7
|
import { isMap, isScalar, isSeq, parseDocument, Scalar } from 'yaml';
|
|
8
|
+
import { HTML_TAG_SOURCE, scanBlocks } from './markdown-blocks.js';
|
|
8
9
|
/**
|
|
9
10
|
* The one frontmatter boundary: an opening `---` alone on the first line, YAML,
|
|
10
11
|
* then a `---` that starts its own line. The YAML span and the newline that
|
|
@@ -309,8 +310,47 @@ function normalizeTagList(value) {
|
|
|
309
310
|
}
|
|
310
311
|
return [];
|
|
311
312
|
}
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
/**
|
|
314
|
+
* The opening backtick run of a code span. It closes at the next whole
|
|
315
|
+
* backtick run of the same length in the paragraph, across line breaks:
|
|
316
|
+
* `` a ``b ` #x`` `` is code, `` a `` #x ``` `` is not. Only the opener is a
|
|
317
|
+
* regex; `codeSpanCloser` finds the closer.
|
|
318
|
+
*/
|
|
319
|
+
const INLINE_CODE = /(?<code>`+)/;
|
|
320
|
+
/**
|
|
321
|
+
* The end of the code span each opener in `text` starts, or `undefined` when
|
|
322
|
+
* it has none. Whole backtick runs are listed once, by length; openers must be
|
|
323
|
+
* looked up in ascending order, so each length's list is walked once.
|
|
324
|
+
*/
|
|
325
|
+
function codeSpanCloser(text) {
|
|
326
|
+
const runs = new Map();
|
|
327
|
+
for (let i = 0; i < text.length;) {
|
|
328
|
+
if (text[i] !== '`') {
|
|
329
|
+
i++;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const start = i;
|
|
333
|
+
while (text[i] === '`')
|
|
334
|
+
i++;
|
|
335
|
+
const list = runs.get(i - start);
|
|
336
|
+
if (list)
|
|
337
|
+
list.push(start);
|
|
338
|
+
else
|
|
339
|
+
runs.set(i - start, [start]);
|
|
340
|
+
}
|
|
341
|
+
const walked = new Map();
|
|
342
|
+
return (open, length) => {
|
|
343
|
+
const list = runs.get(length);
|
|
344
|
+
if (!list)
|
|
345
|
+
return;
|
|
346
|
+
let k = walked.get(length) ?? 0;
|
|
347
|
+
while ((list[k] ?? Number.POSITIVE_INFINITY) <= open)
|
|
348
|
+
k++;
|
|
349
|
+
walked.set(length, k);
|
|
350
|
+
const close = list[k];
|
|
351
|
+
return close === undefined ? undefined : close + length;
|
|
352
|
+
};
|
|
353
|
+
}
|
|
314
354
|
/**
|
|
315
355
|
* `[[Target#Heading|Alias]]`. The `#` in a wikilink opens a heading anchor and
|
|
316
356
|
* the text after `|` is display text — neither is a tag. Protecting the whole
|
|
@@ -321,46 +361,30 @@ const INLINE_CODE = /`[^`\n]+`/;
|
|
|
321
361
|
* link target, so a span never nests.
|
|
322
362
|
*/
|
|
323
363
|
const WIKILINK = /\[\[[^[\]\n]*\]\]/;
|
|
324
|
-
/**
|
|
325
|
-
const
|
|
364
|
+
/** `` and `![alt][label]` — Obsidian reads no tag in an image's alt text. */
|
|
365
|
+
const IMAGE = /!\[[^[\]\n]*\](?:\([^()\n]*\)|\[[^[\]\n]*\])/;
|
|
326
366
|
/**
|
|
327
|
-
* `
|
|
328
|
-
*
|
|
329
|
-
* (`[
|
|
367
|
+
* The `](destination)` or `][label]` that makes the bracketed text before it a
|
|
368
|
+
* link. Only this tail is link syntax: Obsidian reads a tag in link text
|
|
369
|
+
* (`[Discord #tf](https://x.y)` is tagged `tf`) and none in a destination, a
|
|
370
|
+
* title, or a label. The text must open on the same line with no bracket in it;
|
|
371
|
+
* a bracketed phrase followed by a space and a second one (`[note #work] [ref]`)
|
|
372
|
+
* is no link.
|
|
330
373
|
*/
|
|
331
|
-
const
|
|
374
|
+
const LINK_TAIL = /(?<linkTail>\](?<=\[[^[\]\n]*\])(?:\([^()\n]*\)|\[[^[\]\n]*\]))/;
|
|
375
|
+
/** The `[label]:` that opens a link reference definition. */
|
|
376
|
+
const LINK_DEFINITION = /\[(?<=(?:^|\n)[ ]{0,3}\[)[^[\]\n]+\]:/;
|
|
332
377
|
/**
|
|
333
378
|
* Obsidian's metadata cache reads no tag inside an HTML comment or math. The
|
|
334
379
|
* rules below are pinned against its readback (Obsidian 1.13.7) rather than a
|
|
335
|
-
* spec
|
|
336
|
-
*
|
|
380
|
+
* spec. HTML blocks and display math blocks are block structure and live in
|
|
381
|
+
* `markdown-blocks.ts`; these are the spans inside one paragraph. Issue #138.
|
|
337
382
|
*
|
|
338
|
-
* An **
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* the note when none follows. The whole closing line is hidden, so the tag in
|
|
342
|
-
* `<!-- a --> #rd` is not one. A line opening with `<!-->` or `<!--->` closes
|
|
343
|
-
* on itself.
|
|
344
|
-
*
|
|
345
|
-
* The prefix gives every whitespace character exactly one owner — a list
|
|
346
|
-
* marker takes the one it requires, the next marker's indent takes the rest,
|
|
347
|
-
* and the tail after the last marker takes what follows it — so the lookbehind
|
|
348
|
-
* cannot backtrack exponentially over a long run of markers. The `(?=<!--)`
|
|
349
|
-
* gate runs it only where a comment opens.
|
|
350
|
-
*/
|
|
351
|
-
const HTML_BLOCK = /(?=<!--)(?<=(?:^|\n)(?:[ \t]*(?:>|(?:[-+*]|\d{1,9}[.)])[ \t]))*(?:(?<=>)[ \t]?[ ]{0,3}|(?<=(?:[-+*]|\d[.)])[ \t])[ \t]*|[ ]{0,3}))<!--(?:-?>[^\n]*|[\s\S]*?-->[^\n]*|[\s\S]*)/;
|
|
352
|
-
/**
|
|
353
|
-
* An **inline comment** anywhere else: `<!--`, then text that does not open
|
|
354
|
-
* with `>` or `->` and holds no `--`, then `-->`, within one paragraph.
|
|
355
|
-
* `<!-- x -- y -->` is not a comment, and neither is an unclosed `<!--`.
|
|
383
|
+
* An **inline comment**: `<!--`, then text that does not open with `>` or `->`
|
|
384
|
+
* and holds no `--`, then `-->`. `<!-- x -- y -->` is not a comment, and
|
|
385
|
+
* neither is an unclosed `<!--`.
|
|
356
386
|
*/
|
|
357
387
|
const HTML_COMMENT = /<!--(?!-?>)(?:(?!--|\n *\r?\n)[\s\S])*-->/;
|
|
358
|
-
/**
|
|
359
|
-
* A **display math block**: a line that opens with `$$` and holds no second
|
|
360
|
-
* `$$`. It runs, across empty lines, to the next line that is `$$` alone, or to
|
|
361
|
-
* the end of the note — `$$\na\nb$$` does not close it.
|
|
362
|
-
*/
|
|
363
|
-
const DISPLAY_MATH = /(?<=(?:^|\n)[ \t]*)\$\$(?![^\n]*\$\$)[\s\S]*?(?:\n[ \t]*\$\$[ \t]*(?=\r?\n|$)|$)/;
|
|
364
388
|
/** Unescaped `$$ … $$` within one paragraph, with no spacing rule. */
|
|
365
389
|
const INLINE_DOUBLE_MATH = /(?<=(?:^|[^\\])(?:\\\\)*)\$\$(?:(?!\n *\r?\n)[\s\S])*?\$\$/;
|
|
366
390
|
/**
|
|
@@ -427,25 +451,48 @@ function closesInlineMath(content, i) {
|
|
|
427
451
|
* A **backslash escape** of ASCII punctuation: `\]#t` and `\\#t` are tags,
|
|
428
452
|
* `\#t` is an escaped hash and not one.
|
|
429
453
|
*/
|
|
430
|
-
const ESCAPE =
|
|
431
|
-
/**
|
|
432
|
-
|
|
454
|
+
const ESCAPE = /(?<escape>\\[!-/:-@[-`{-~])/;
|
|
455
|
+
/**
|
|
456
|
+
* An **inline HTML tag**: `<br>#t`, `x <b>#t</b>`, `<a href="u">#t</a>`. Only
|
|
457
|
+
* a tag CommonMark's grammar accepts is one; `x <a b #t> y` is text.
|
|
458
|
+
*/
|
|
459
|
+
const HTML_TAG = new RegExp(`(?:${HTML_TAG_SOURCE})`);
|
|
433
460
|
/**
|
|
434
461
|
* An **emphasis, highlight, or strikethrough delimiter run** directly before
|
|
435
462
|
* `#` that has a partner elsewhere on its line: `**#t**`, `*#t*`, `**x**#t`,
|
|
436
463
|
* `foo*#t*bar`, `==#t==`, `~~#t~~`. An unpartnered run is literal text to
|
|
437
464
|
* Obsidian (`a *#t`, `a ==#t`), and so is a single `~`. The partner test is
|
|
438
|
-
* the same delimiter anywhere else on the line. `_`
|
|
439
|
-
*
|
|
465
|
+
* the same delimiter anywhere else on the line. `_` pairs by rules of its own,
|
|
466
|
+
* in `underscoreMarks`.
|
|
440
467
|
*/
|
|
441
468
|
const EMPHASIS_RUN = /(?<!\*)(?=\*+#)(?:(?<=\*[^\n]*?)|(?=\*+#[^\n]*?\*))\*+|(?<!=)(?===#)(?:(?<===[^\n]*?)|(?===#[^\n]*?==))==|(?<!~)(?=~~#)(?:(?<=~~[^\n]*?)|(?=~~#[^\n]*?~~))~~/;
|
|
442
469
|
/**
|
|
443
470
|
* A **blockquote marker** directly before `#`: `>#t`, `> >#t`. A table cell
|
|
444
|
-
* pipe is the same kind of node
|
|
445
|
-
*
|
|
446
|
-
* one-pass scan does not look at, so `|#t` stays unread either way.
|
|
471
|
+
* pipe is the same kind of node; `markupMarks` protects those, since telling a
|
|
472
|
+
* table row from a pipe in prose takes the block structure around it.
|
|
447
473
|
*/
|
|
448
474
|
const QUOTE_MARKER = />(?=#)(?<=(?:^|\n)[ \t]*(?:>[ \t]*)*>)/;
|
|
475
|
+
/**
|
|
476
|
+
* Every span a tag cannot live in, or that a `#` right after opens a tag, in
|
|
477
|
+
* the order `protectedSpans` tries them: the construct that opens first wins,
|
|
478
|
+
* so `$b <!-- c$` is math and `<!-- $b -->` is a comment.
|
|
479
|
+
*/
|
|
480
|
+
const INLINE_SPANS = new RegExp([
|
|
481
|
+
INLINE_CODE,
|
|
482
|
+
WIKILINK,
|
|
483
|
+
IMAGE,
|
|
484
|
+
LINK_TAIL,
|
|
485
|
+
LINK_DEFINITION,
|
|
486
|
+
HTML_COMMENT,
|
|
487
|
+
INLINE_DOUBLE_MATH,
|
|
488
|
+
INLINE_MATH_OPEN,
|
|
489
|
+
ESCAPE,
|
|
490
|
+
HTML_TAG,
|
|
491
|
+
EMPHASIS_RUN,
|
|
492
|
+
QUOTE_MARKER,
|
|
493
|
+
]
|
|
494
|
+
.map((r) => r.source)
|
|
495
|
+
.join('|'), 'g');
|
|
449
496
|
/**
|
|
450
497
|
* The characters that end an inline tag, as the body of a character class:
|
|
451
498
|
* whitespace, ASCII punctuation other than `_`, `-`, and `/`, and the General
|
|
@@ -466,11 +513,32 @@ const TAG_CHAR = `[^${TAG_STOP}]`;
|
|
|
466
513
|
* blocks a tag in Obsidian — `(#a`, `.#b`, `x—#c`, an unpartnered `a *#d` —
|
|
467
514
|
* and so does the `\` of an escaped `\#`. A segment starts at the note body or
|
|
468
515
|
* right after a protected span, and Obsidian reads a tag glued to either:
|
|
469
|
-
* `[[x]]#t`, `` `c`#t ``, `**#t**`,
|
|
516
|
+
* `[[x]]#t`, `` `c`#t ``, `**#t**`, `<br>#t`, `[#t]`, `_#t_`, and a table
|
|
517
|
+
* cell's `|#t|` are tags.
|
|
470
518
|
*/
|
|
471
519
|
const TAG_LEFT_BOUNDARY = String.raw `(^|\s)`;
|
|
472
520
|
/** Obsidian rejects a tag made only of ASCII digits (`#1984`); `#1990s` and `#١٢٣` are tags. */
|
|
473
521
|
const ALL_DIGITS = /^[0-9]+$/;
|
|
522
|
+
/**
|
|
523
|
+
* A run of tags glued end to end, `#a#b`. Obsidian reads each `#` that
|
|
524
|
+
* directly follows a tag as the start of another one — `#tl#tm` is two tags,
|
|
525
|
+
* `#tn/#to` is `tn/` and `to` — but not one after an all-digit run, since that
|
|
526
|
+
* run was never a tag (`#1984#x` holds none).
|
|
527
|
+
*/
|
|
528
|
+
const TAG_CHAIN_SOURCE = `${TAG_LEFT_BOUNDARY}((?:#${TAG_CHAR}+)+)`;
|
|
529
|
+
const TAG_CHAIN = new RegExp(TAG_CHAIN_SOURCE, 'gu');
|
|
530
|
+
/** A chain plus the single horizontal space after it, which a removal may take. */
|
|
531
|
+
const TAG_CHAIN_SPACED = new RegExp(`${TAG_CHAIN_SOURCE}([ \\t]?)`, 'gu');
|
|
532
|
+
/** The tags in a matched chain: each name, up to the first all-digit one. */
|
|
533
|
+
function chainTags(chain) {
|
|
534
|
+
const tags = [];
|
|
535
|
+
for (const name of chain.slice(1).split('#')) {
|
|
536
|
+
if (ALL_DIGITS.test(name))
|
|
537
|
+
break;
|
|
538
|
+
tags.push(name);
|
|
539
|
+
}
|
|
540
|
+
return tags;
|
|
541
|
+
}
|
|
474
542
|
/**
|
|
475
543
|
* Inline `#tag` syntax lives in the body. The frontmatter block is spliced off
|
|
476
544
|
* first and re-attached verbatim, so a `#` inside a YAML scalar is neither read
|
|
@@ -481,10 +549,9 @@ function mutateInlineTags(content, tags, operation, applied, skipped) {
|
|
|
481
549
|
const segments = splitProtectedSegments(body);
|
|
482
550
|
let updatedNonCode = false;
|
|
483
551
|
if (operation === 'add') {
|
|
552
|
+
const present = new Set(inlineTags(segments));
|
|
484
553
|
for (const tag of tags) {
|
|
485
|
-
|
|
486
|
-
const present = segments.some((s) => !s.protected && re.test(s.text));
|
|
487
|
-
if (present) {
|
|
554
|
+
if (present.has(tag)) {
|
|
488
555
|
skipped.add(tag);
|
|
489
556
|
}
|
|
490
557
|
else {
|
|
@@ -522,7 +589,7 @@ function mutateInlineTags(content, tags, operation, applied, skipped) {
|
|
|
522
589
|
skipped.add(tag);
|
|
523
590
|
continue;
|
|
524
591
|
}
|
|
525
|
-
const
|
|
592
|
+
const remove = removeFromChain(tag);
|
|
526
593
|
let found = false;
|
|
527
594
|
for (const s of segments) {
|
|
528
595
|
if (s.protected)
|
|
@@ -534,7 +601,7 @@ function mutateInlineTags(content, tags, operation, applied, skipped) {
|
|
|
534
601
|
* space. Repeat until the segment stops changing — every pass drops at
|
|
535
602
|
* least the tag itself, so this terminates.
|
|
536
603
|
*/
|
|
537
|
-
for (let next = s.text.replace(
|
|
604
|
+
for (let next = s.text.replace(TAG_CHAIN_SPACED, remove); next !== s.text; next = s.text.replace(TAG_CHAIN_SPACED, remove)) {
|
|
538
605
|
s.text = next;
|
|
539
606
|
found = true;
|
|
540
607
|
}
|
|
@@ -570,42 +637,96 @@ function removeAt(full, leading, trailing, offset, whole) {
|
|
|
570
637
|
return trailing;
|
|
571
638
|
}
|
|
572
639
|
/**
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
|
|
578
|
-
|
|
640
|
+
* A `TAG_CHAIN` replacer that drops every tag named `tag` from a chain and
|
|
641
|
+
* keeps the rest. Chains are split at `#` rather than matched against the
|
|
642
|
+
* name, so removing `caf` cannot strip the front off `#café`. A chain that
|
|
643
|
+
* loses all its tags takes one adjacent space with it, per `removeAt`.
|
|
644
|
+
*/
|
|
645
|
+
function removeFromChain(tag) {
|
|
646
|
+
return (full, leading, chain, trailing, offset, whole) => {
|
|
647
|
+
const names = chain.slice(1).split('#');
|
|
648
|
+
const tagged = chainTags(chain).length;
|
|
649
|
+
const kept = names.filter((name, i) => i >= tagged || name !== tag);
|
|
650
|
+
if (kept.length === names.length)
|
|
651
|
+
return full;
|
|
652
|
+
if (kept.length === 0)
|
|
653
|
+
return removeAt(full, leading, trailing, offset, whole);
|
|
654
|
+
return `${leading}#${kept.join('#')}${trailing}`;
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/** Every inline tag in the segments a tag may live in, each once, in order of first appearance. */
|
|
658
|
+
function inlineTags(segments) {
|
|
659
|
+
const tags = new Set();
|
|
660
|
+
for (const seg of segments) {
|
|
661
|
+
if (seg.protected)
|
|
662
|
+
continue;
|
|
663
|
+
for (const m of seg.text.matchAll(TAG_CHAIN)) {
|
|
664
|
+
for (const tag of chainTags(m[2] ?? ''))
|
|
665
|
+
tags.add(tag);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return [...tags];
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Split the body into stretches a tag may live in and stretches it may not.
|
|
672
|
+
* `scanBlocks` finds the block structure — code, HTML blocks, and display math
|
|
673
|
+
* are hidden whole — and each paragraph, heading, and table row is split by
|
|
674
|
+
* `splitInline`. Both the read and the write path run over the result, so
|
|
579
675
|
* `list` and `remove` agree on what counts as a tag.
|
|
580
676
|
*/
|
|
581
677
|
function splitProtectedSegments(content) {
|
|
582
678
|
const segments = [];
|
|
583
679
|
let cursor = 0;
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
680
|
+
const push = (isProtected, text) => {
|
|
681
|
+
if (text.length > 0)
|
|
682
|
+
segments.push({ protected: isProtected, text });
|
|
683
|
+
};
|
|
684
|
+
for (const block of scanBlocks(content)) {
|
|
685
|
+
push(false, content.slice(cursor, block.start));
|
|
686
|
+
const text = content.slice(block.start, block.end);
|
|
687
|
+
if (block.kind === 'hidden')
|
|
688
|
+
push(true, text);
|
|
689
|
+
else
|
|
690
|
+
for (const seg of splitInline(text, block.tableRow))
|
|
691
|
+
push(seg.protected, seg.text);
|
|
692
|
+
cursor = block.end;
|
|
693
|
+
}
|
|
694
|
+
push(false, content.slice(cursor));
|
|
695
|
+
return segments;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* The spans of `text` a tag cannot live in, or that a `#` right after opens a
|
|
699
|
+
* tag, in order.
|
|
700
|
+
*/
|
|
701
|
+
function protectedSpans(text) {
|
|
702
|
+
const spans = [];
|
|
703
|
+
const mathCloser = inlineMathCloser(text);
|
|
704
|
+
const codeCloser = codeSpanCloser(text);
|
|
705
|
+
INLINE_SPANS.lastIndex = 0;
|
|
603
706
|
for (;;) {
|
|
604
|
-
const m =
|
|
707
|
+
const m = INLINE_SPANS.exec(text);
|
|
605
708
|
if (!m)
|
|
606
709
|
break;
|
|
607
|
-
let
|
|
608
|
-
|
|
710
|
+
let start = m.index;
|
|
711
|
+
let end = start + m[0].length;
|
|
712
|
+
if (m.groups?.code !== undefined) {
|
|
713
|
+
/**
|
|
714
|
+
* A run with no closer leaves its first backtick as text, and the rest of
|
|
715
|
+
* the run is tried as an opener of its own — where CommonMark would make
|
|
716
|
+
* the whole run text, Obsidian reads `` a ``b #x` `` as the code span
|
|
717
|
+
* `` `b #x` ``. Each shorter opener is tried here rather than by
|
|
718
|
+
* re-running the regex, which would rescan the run from every backtick.
|
|
719
|
+
*/
|
|
720
|
+
const runEnd = end;
|
|
721
|
+
let close = codeCloser(start, runEnd - start);
|
|
722
|
+
while (close === undefined && ++start < runEnd)
|
|
723
|
+
close = codeCloser(start, runEnd - start);
|
|
724
|
+
if (close === undefined)
|
|
725
|
+
continue;
|
|
726
|
+
end = close;
|
|
727
|
+
INLINE_SPANS.lastIndex = end;
|
|
728
|
+
}
|
|
729
|
+
else if (m.groups?.inlineMath !== undefined) {
|
|
609
730
|
/**
|
|
610
731
|
* The `$$` constructs are tried before this one and nothing after it
|
|
611
732
|
* opens with `$`, so an opener with no closer leaves this position to
|
|
@@ -614,57 +735,201 @@ function splitProtectedSegments(content) {
|
|
|
614
735
|
const close = mathCloser(m.index);
|
|
615
736
|
if (close === undefined)
|
|
616
737
|
continue;
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
if (m.index > cursor) {
|
|
621
|
-
segments.push({ protected: false, text: content.slice(cursor, m.index) });
|
|
738
|
+
end = close + 1;
|
|
739
|
+
INLINE_SPANS.lastIndex = end;
|
|
622
740
|
}
|
|
623
|
-
|
|
624
|
-
|
|
741
|
+
const kind = m.groups?.escape !== undefined
|
|
742
|
+
? 'escape'
|
|
743
|
+
: m.groups?.linkTail !== undefined
|
|
744
|
+
? 'linkTail'
|
|
745
|
+
: 'other';
|
|
746
|
+
spans.push({ start, end, kind });
|
|
625
747
|
}
|
|
626
|
-
|
|
627
|
-
|
|
748
|
+
return spans;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Split one paragraph, heading, or table row. Beyond the spans of
|
|
752
|
+
* `protectedSpans`, three kinds of markup become protected one-character (or
|
|
753
|
+
* one-run) segments, since a `#` directly after each opens a tag: the brackets
|
|
754
|
+
* of a bracketed span, a table row's cell pipes, and an underscore run that
|
|
755
|
+
* pairs as emphasis (`underscoreMarks`).
|
|
756
|
+
*/
|
|
757
|
+
function splitInline(text, tableRow) {
|
|
758
|
+
const spans = protectedSpans(text);
|
|
759
|
+
/** `markEnd[i]` is the end of the mark starting at `i`, or 0. */
|
|
760
|
+
const markEnd = new Uint32Array(text.length);
|
|
761
|
+
const linkTexts = markupMarks(text, spans, tableRow, markEnd);
|
|
762
|
+
underscoreMarks(text, spans, linkTexts, markEnd);
|
|
763
|
+
const segments = [];
|
|
764
|
+
let cursor = 0;
|
|
765
|
+
const cut = (start, end) => {
|
|
766
|
+
if (start > cursor)
|
|
767
|
+
segments.push({ protected: false, text: text.slice(cursor, start) });
|
|
768
|
+
segments.push({ protected: true, text: text.slice(start, end) });
|
|
769
|
+
cursor = end;
|
|
770
|
+
};
|
|
771
|
+
let s = 0;
|
|
772
|
+
for (let i = 0; i < text.length;) {
|
|
773
|
+
const span = spans[s];
|
|
774
|
+
if (span?.start === i) {
|
|
775
|
+
cut(i, span.end);
|
|
776
|
+
i = span.end;
|
|
777
|
+
s++;
|
|
778
|
+
}
|
|
779
|
+
else if (markEnd[i]) {
|
|
780
|
+
const end = markEnd[i];
|
|
781
|
+
cut(i, end);
|
|
782
|
+
i = end;
|
|
783
|
+
}
|
|
784
|
+
else
|
|
785
|
+
i++;
|
|
628
786
|
}
|
|
787
|
+
if (cursor < text.length)
|
|
788
|
+
segments.push({ protected: false, text: text.slice(cursor) });
|
|
629
789
|
return segments;
|
|
630
790
|
}
|
|
631
791
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
792
|
+
* Mark the brackets of every bracketed span and, in a table row, every cell
|
|
793
|
+
* pipe; return the link texts — the ranges between a `[` and the `LINK_TAIL`
|
|
794
|
+
* that closes it. Pinned against Obsidian readback: `[#t]`, `x [a]#t`, and
|
|
795
|
+
* `[#t](u)` are tags, as is `[#t` closed on the next line of the paragraph,
|
|
796
|
+
* while `x [#t` (never closed), `x []#t` (empty), `[^#t]` (a footnote), and
|
|
797
|
+
* the outer `[` of `[#t [b] c]` (a `[` before the `]`) open none. A `]]`
|
|
798
|
+
* closes a wikilink, not a bracketed span.
|
|
799
|
+
*/
|
|
800
|
+
function markupMarks(text, spans, tableRow, markEnd) {
|
|
801
|
+
const linkTexts = [];
|
|
802
|
+
let open = -1;
|
|
803
|
+
let s = 0;
|
|
804
|
+
for (let i = 0; i < text.length;) {
|
|
805
|
+
const span = spans[s];
|
|
806
|
+
if (span?.start === i) {
|
|
807
|
+
if (span.kind === 'linkTail') {
|
|
808
|
+
if (open >= 0 && i > open + 1) {
|
|
809
|
+
markEnd[open] = open + 1;
|
|
810
|
+
linkTexts.push([open + 1, i]);
|
|
811
|
+
}
|
|
812
|
+
open = -1;
|
|
813
|
+
}
|
|
814
|
+
i = span.end;
|
|
815
|
+
s++;
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const ch = text[i];
|
|
819
|
+
if (ch === '[') {
|
|
820
|
+
open = text[i + 1] === '^' ? -1 : i;
|
|
821
|
+
}
|
|
822
|
+
else if (ch === ']') {
|
|
823
|
+
if (open >= 0 && i > open + 1 && text[i + 1] !== ']') {
|
|
824
|
+
markEnd[open] = open + 1;
|
|
825
|
+
markEnd[i] = i + 1;
|
|
826
|
+
}
|
|
827
|
+
open = -1;
|
|
828
|
+
}
|
|
829
|
+
else if (ch === '|' && tableRow) {
|
|
830
|
+
markEnd[i] = i + 1;
|
|
831
|
+
}
|
|
832
|
+
i++;
|
|
833
|
+
}
|
|
834
|
+
return linkTexts;
|
|
835
|
+
}
|
|
836
|
+
const TAG_AT = new RegExp(`#${TAG_CHAR}+`, 'uy');
|
|
837
|
+
/**
|
|
838
|
+
* Mark the underscore runs that pair as emphasis. `_` is a tag character, so
|
|
839
|
+
* this decides both where a tag may start (`x _#t_ y` is tagged `t`,
|
|
840
|
+
* `snake_case_#t` is tagged `t`) and where one ends (the closing `_` of
|
|
841
|
+
* `_#t_` is not part of it). None of this is CommonMark's flanking rule; it is
|
|
842
|
+
* fitted to more than eighty Obsidian 1.13.7 readback probes. Walking the
|
|
843
|
+
* paragraph's runs in order, with at most one opener pending:
|
|
844
|
+
*
|
|
845
|
+
* - With none pending, a run becomes the opener.
|
|
846
|
+
* - A run followed by an ASCII letter or digit cannot close (`x _#t_b` holds
|
|
847
|
+
* no tag) and is passed over.
|
|
848
|
+
* - A run longer than the opener cannot close it and is passed over.
|
|
849
|
+
* - When the text between opener and run both starts and ends with whitespace
|
|
850
|
+
* (`a_ _#t`), the pair fails and the run becomes the opener instead.
|
|
851
|
+
* - Otherwise the two pair, and both are marks.
|
|
852
|
+
*
|
|
853
|
+
* A run inside a tag's name, or inside a protected span — code, math, a
|
|
854
|
+
* comment, a link destination — can close but never open: `#qb_name_ x` is the
|
|
855
|
+
* tag `qb_name_`, and `x `a_` b_#t` holds no tag while `x _a `_` b_#t` pairs
|
|
856
|
+
* the first two runs. An escaped `\_` is text. Link text pairs on its own, as
|
|
857
|
+
* CommonMark nests it: `[x _a_#t](u)` is tagged `t`, while the run in
|
|
858
|
+
* `[a_](u) b_#t` cannot reach past the link.
|
|
635
859
|
*/
|
|
636
|
-
function
|
|
637
|
-
|
|
638
|
-
|
|
860
|
+
function underscoreMarks(text, spans, linkTexts, markEnd) {
|
|
861
|
+
let outer;
|
|
862
|
+
let inner;
|
|
863
|
+
/** Index of the link text the walk is in, or -1 outside every link. */
|
|
864
|
+
let link = -1;
|
|
865
|
+
let tagEnd = -1;
|
|
866
|
+
let s = 0;
|
|
867
|
+
let l = 0;
|
|
868
|
+
/** Offer `run` to the pending opener; returns the opener pending afterwards. */
|
|
869
|
+
const offer = (opener, run, closeOnly) => {
|
|
870
|
+
if (!opener)
|
|
871
|
+
return closeOnly ? undefined : run;
|
|
872
|
+
if (/[A-Za-z0-9]/.test(text[run.end] ?? ''))
|
|
873
|
+
return opener;
|
|
874
|
+
if (run.end - run.start > opener.end - opener.start)
|
|
875
|
+
return opener;
|
|
876
|
+
if (/\s/.test(text[opener.end] ?? '') && /\s/.test(text[run.start - 1] ?? '')) {
|
|
877
|
+
return closeOnly ? undefined : run;
|
|
878
|
+
}
|
|
879
|
+
markEnd[opener.start] = opener.end;
|
|
880
|
+
markEnd[run.start] = run.end;
|
|
881
|
+
return;
|
|
882
|
+
};
|
|
883
|
+
for (let i = 0; i < text.length;) {
|
|
884
|
+
while ((spans[s]?.end ?? Number.POSITIVE_INFINITY) <= i)
|
|
885
|
+
s++;
|
|
886
|
+
const span = spans[s];
|
|
887
|
+
const inSpan = span !== undefined && span.start <= i;
|
|
888
|
+
if (inSpan && span.kind === 'escape') {
|
|
889
|
+
i = span.end;
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
const ch = text[i];
|
|
893
|
+
if (ch === '#' && !inSpan) {
|
|
894
|
+
TAG_AT.lastIndex = i;
|
|
895
|
+
if (TAG_AT.test(text))
|
|
896
|
+
tagEnd = TAG_AT.lastIndex;
|
|
897
|
+
}
|
|
898
|
+
if (ch !== '_') {
|
|
899
|
+
i++;
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const run = { start: i, end: i };
|
|
903
|
+
while (text[run.end] === '_' && (!inSpan || run.end < span.end))
|
|
904
|
+
run.end++;
|
|
905
|
+
i = run.end;
|
|
906
|
+
const closeOnly = inSpan || run.start < tagEnd;
|
|
907
|
+
while ((linkTexts[l]?.[1] ?? Number.POSITIVE_INFINITY) <= run.start)
|
|
908
|
+
l++;
|
|
909
|
+
if ((linkTexts[l]?.[0] ?? Number.POSITIVE_INFINITY) <= run.start) {
|
|
910
|
+
if (link !== l)
|
|
911
|
+
inner = undefined;
|
|
912
|
+
link = l;
|
|
913
|
+
inner = offer(inner, run, closeOnly);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
outer = offer(outer, run, closeOnly);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
639
919
|
}
|
|
640
920
|
/**
|
|
641
921
|
* Read-only helpers for `obsidian_manage_tags list`. Inline tags are read from
|
|
642
922
|
* the body only, and through the same protected-segment split and left boundary
|
|
643
923
|
* a removal uses, so what `list` reports is exactly what `remove` can reach — a
|
|
644
|
-
* `#` inside a YAML scalar,
|
|
645
|
-
* or one glued to the character before it
|
|
646
|
-
* runs for as long as `TAG_CHAR`
|
|
924
|
+
* `#` inside a YAML scalar, code, an HTML block or comment, math, an image, or
|
|
925
|
+
* a link's destination or label, or one glued to the character before it
|
|
926
|
+
* (`\#`, `(#x`), is none of them. A tag runs for as long as `TAG_CHAR`
|
|
927
|
+
* matches, and an all-digit run is not a tag.
|
|
647
928
|
*/
|
|
648
929
|
export function listTagsFromContent(content, frontmatter) {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const re = new RegExp(`${TAG_LEFT_BOUNDARY}#(${TAG_CHAR}+)`, 'gu');
|
|
654
|
-
for (const seg of splitProtectedSegments(splice(content).body)) {
|
|
655
|
-
if (seg.protected)
|
|
656
|
-
continue;
|
|
657
|
-
for (;;) {
|
|
658
|
-
const m = re.exec(seg.text);
|
|
659
|
-
if (!m)
|
|
660
|
-
break;
|
|
661
|
-
const t = m[2];
|
|
662
|
-
if (t && !ALL_DIGITS.test(t) && !seen.has(t)) {
|
|
663
|
-
seen.add(t);
|
|
664
|
-
inline.push(t);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
return { frontmatter: fmTags, inline };
|
|
930
|
+
return {
|
|
931
|
+
frontmatter: normalizeTagList(frontmatter.tags),
|
|
932
|
+
inline: inlineTags(splitProtectedSegments(splice(content).body)),
|
|
933
|
+
};
|
|
669
934
|
}
|
|
670
935
|
//# sourceMappingURL=frontmatter-ops.js.map
|