mirror of
https://github.com/moyf/easy-copy.git
synced 2026-07-22 05:43:47 +00:00
fix: sanitize heading link targets and fix substring matching
Strip # | ^ : %% [[ ]] from heading link targets to match Obsidian's autocomplete behavior. Fix compareIgnoreCase to use strict equality instead of includes(), preventing false-positive note-link simplification.
This commit is contained in:
parent
d6f6678b38
commit
60e451db9f
2 changed files with 128 additions and 34 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { buildHeadingLink, buildBlockLink, buildFileLink, extractBlockDisplayText, encodeMarkdownLinkUrl } from './linkBuilder';
|
import { buildHeadingLink, buildBlockLink, buildFileLink, extractBlockDisplayText, encodeMarkdownLinkUrl, sanitizeHeadingForLink } from './linkBuilder';
|
||||||
import { LinkFormat } from './type';
|
import { LinkFormat } from './type';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -24,6 +24,50 @@ describe('encodeMarkdownLinkUrl', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// sanitizeHeadingForLink
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('sanitizeHeadingForLink', () => {
|
||||||
|
it('strips # | ^ : with surrounding whitespace', () => {
|
||||||
|
expect(sanitizeHeadingForLink('Test | If # This ^ Heading : Works'))
|
||||||
|
.toBe('Test If This Heading Works');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips special chars without surrounding whitespace', () => {
|
||||||
|
expect(sanitizeHeadingForLink('Test|If#This^Heading:Works, Too'))
|
||||||
|
.toBe('Test If This Heading Works, Too');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips only the special chars that are present', () => {
|
||||||
|
expect(sanitizeHeadingForLink('Test | If This ^ Heading Works (No Hash or Colon)'))
|
||||||
|
.toBe('Test If This Heading Works (No Hash or Colon)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips %% (comment marker)', () => {
|
||||||
|
expect(sanitizeHeadingForLink('Before %% comment %% After'))
|
||||||
|
.toBe('Before comment After');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips [[ and ]]', () => {
|
||||||
|
expect(sanitizeHeadingForLink('See [[Other Note]] here'))
|
||||||
|
.toBe('See Other Note here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns heading unchanged when no special chars', () => {
|
||||||
|
expect(sanitizeHeadingForLink('Normal Heading Text'))
|
||||||
|
.toBe('Normal Heading Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
expect(sanitizeHeadingForLink('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles heading that is only special chars', () => {
|
||||||
|
expect(sanitizeHeadingForLink('# | ^')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// buildHeadingLink
|
// buildHeadingLink
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -190,29 +234,62 @@ describe('buildHeadingLink', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- compareIgnoreCase `includes` behavior --------------------------------
|
// -- Special character sanitization in link targets ----------------------
|
||||||
// BUG: compareIgnoreCase uses filename.includes(heading), which causes
|
|
||||||
// false-positive note-link simplification when the filename merely contains
|
|
||||||
// the heading as a substring. Should be fixed in a future PR (invert these
|
|
||||||
// tests when fixing).
|
|
||||||
|
|
||||||
describe('filename-heading substring matching (known bug)', () => {
|
describe('special character sanitization', () => {
|
||||||
it('false-positive: simplifies when filename contains heading as substring', () => {
|
it('wiki format: strips special chars from link target, preserves display text', () => {
|
||||||
// BUG: "JavaScript".includes("Java") → true, incorrectly simplifies.
|
const result = buildHeadingLink({
|
||||||
// Expected correct behavior: should NOT simplify (not an exact or
|
...defaults,
|
||||||
// derived-name match).
|
heading: 'Test | If # This ^ Heading : Works',
|
||||||
|
filename: 'MyNote',
|
||||||
|
});
|
||||||
|
expect(result.link).toBe('[[MyNote#Test If This Heading Works|Test | If # This ^ Heading : Works]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markdown format: strips special chars from URL target', () => {
|
||||||
|
const result = buildHeadingLink({
|
||||||
|
...defaults,
|
||||||
|
linkFormat: LinkFormat.MDLINK,
|
||||||
|
heading: 'Test | If # This ^ Heading : Works',
|
||||||
|
filename: 'MyNote',
|
||||||
|
});
|
||||||
|
expect(result.link).toBe('[Test | If # This ^ Heading : Works](MyNote#Test%20If%20This%20Heading%20Works)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wiki format: strips chars without surrounding whitespace', () => {
|
||||||
|
const result = buildHeadingLink({
|
||||||
|
...defaults,
|
||||||
|
heading: 'Test|If#This^Heading:Works, Too',
|
||||||
|
filename: 'MyNote',
|
||||||
|
});
|
||||||
|
expect(result.link).toBe('[[MyNote#Test If This Heading Works, Too|Test|If#This^Heading:Works, Too]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markdown format: display text preserves original heading', () => {
|
||||||
|
const result = buildHeadingLink({
|
||||||
|
...defaults,
|
||||||
|
linkFormat: LinkFormat.MDLINK,
|
||||||
|
heading: 'Test|If#This^Heading:Works, Too',
|
||||||
|
filename: 'MyNote',
|
||||||
|
});
|
||||||
|
expect(result.link).toBe('[Test|If#This^Heading:Works, Too](MyNote#Test%20If%20This%20Heading%20Works,%20Too)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- compareIgnoreCase: strict equality (no substring matching) -----------
|
||||||
|
|
||||||
|
describe('filename-heading matching (strict equality)', () => {
|
||||||
|
it('does not simplify when filename contains heading as substring', () => {
|
||||||
const result = buildHeadingLink({
|
const result = buildHeadingLink({
|
||||||
...defaults,
|
...defaults,
|
||||||
heading: 'Java',
|
heading: 'Java',
|
||||||
filename: 'JavaScript',
|
filename: 'JavaScript',
|
||||||
});
|
});
|
||||||
expect(result.link).toBe('[[JavaScript|Java]]');
|
expect(result.link).toBe('[[JavaScript#Java|Java]]');
|
||||||
expect(result.isNoteLink).toBe(true);
|
expect(result.isNoteLink).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT simplify when heading contains filename as substring', () => {
|
it('does not simplify when heading contains filename as substring', () => {
|
||||||
// The includes check is directional: filename.includes(heading)
|
|
||||||
// "Note".includes("Notebook") → false
|
|
||||||
const result = buildHeadingLink({
|
const result = buildHeadingLink({
|
||||||
...defaults,
|
...defaults,
|
||||||
heading: 'Notebook',
|
heading: 'Notebook',
|
||||||
|
|
@ -222,17 +299,14 @@ describe('buildHeadingLink', () => {
|
||||||
expect(result.isNoteLink).toBe(false);
|
expect(result.isNoteLink).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('false-positive: simplifies on space-removed substring match', () => {
|
it('does not simplify on space-removed substring match', () => {
|
||||||
// BUG: "SomeThingElse".includes("SomeThing") → true, incorrectly
|
|
||||||
// simplifies. The intended behavior (SomeThing/Some Thing) works,
|
|
||||||
// but non-exact substring matches like this are false positives.
|
|
||||||
const result = buildHeadingLink({
|
const result = buildHeadingLink({
|
||||||
...defaults,
|
...defaults,
|
||||||
heading: 'Some Thing',
|
heading: 'Some Thing',
|
||||||
filename: 'SomeThingElse',
|
filename: 'SomeThingElse',
|
||||||
});
|
});
|
||||||
expect(result.link).toBe('[[SomeThingElse|Some Thing]]');
|
expect(result.link).toBe('[[SomeThingElse#Some Thing|Some Thing]]');
|
||||||
expect(result.isNoteLink).toBe(true);
|
expect(result.isNoteLink).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -265,18 +339,17 @@ describe('buildHeadingLink', () => {
|
||||||
expect(result.isNoteLink).toBe(true);
|
expect(result.isNoteLink).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty heading triggers false note-link simplification (known bug)', () => {
|
it('empty heading produces a heading link with empty fragment (known bug)', () => {
|
||||||
// BUG: ''.includes('') is always true in JS, so compareIgnoreCase
|
// Empty heading is an edge case from Obsidian's UI. The link target
|
||||||
// falsely matches any filename. Should produce a heading link with
|
// is technically malformed (MyNote#) but this matches current behavior.
|
||||||
// an empty fragment instead. Fix alongside compareIgnoreCase in a
|
// Should be addressed in a future PR.
|
||||||
// future PR.
|
|
||||||
const result = buildHeadingLink({
|
const result = buildHeadingLink({
|
||||||
...defaults,
|
...defaults,
|
||||||
heading: '',
|
heading: '',
|
||||||
filename: 'MyNote',
|
filename: 'MyNote',
|
||||||
});
|
});
|
||||||
expect(result.link).toBe('[[MyNote|]]');
|
expect(result.link).toBe('[[MyNote#|]]');
|
||||||
expect(result.isNoteLink).toBe(true);
|
expect(result.isNoteLink).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('markdown format URL-encodes spaces in link URL', () => {
|
it('markdown format URL-encodes spaces in link URL', () => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,25 @@ export function encodeMarkdownLinkUrl(url: string): string {
|
||||||
return url.replace(/ /g, '%20');
|
return url.replace(/ /g, '%20');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Heading Sanitization ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize heading text for use in link targets.
|
||||||
|
*
|
||||||
|
* Obsidian's [[ autocomplete surprisingly strips # | ^ : %% [[ ]] from
|
||||||
|
* heading link targets rather than URL-encoding them, replacing each
|
||||||
|
* occurrence (and any surrounding whitespace) with a single space.
|
||||||
|
* URL-encoding these characters does not work reliably in Obsidian.
|
||||||
|
*
|
||||||
|
* See: https://help.obsidian.md/Linking+notes+and+files/Internal+links
|
||||||
|
*/
|
||||||
|
export function sanitizeHeadingForLink(heading: string): string {
|
||||||
|
return heading
|
||||||
|
.replace(/\s*(?:%%|\[\[|]]|[#|^:])\s*/g, ' ')
|
||||||
|
.replace(/ {2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Heading Link ---
|
// --- Heading Link ---
|
||||||
|
|
||||||
export interface BuildHeadingLinkOptions {
|
export interface BuildHeadingLinkOptions {
|
||||||
|
|
@ -41,18 +60,20 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading
|
||||||
displayText = `${filenameOrTitle}${separator}${selectedHeading}`;
|
displayText = `${filenameOrTitle}${separator}${selectedHeading}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let linkContent = `${filename}#${selectedHeading}`;
|
const sanitizedHeading = sanitizeHeadingForLink(selectedHeading);
|
||||||
|
let linkContent = `${filename}#${sanitizedHeading}`;
|
||||||
let isNoteLink = false;
|
let isNoteLink = false;
|
||||||
|
|
||||||
const compareIgnoreCase = (a: string, b: string): boolean =>
|
const compareIgnoreCase = (a: string, b: string): boolean =>
|
||||||
a.toLowerCase() === b.toLowerCase() || a.toLowerCase().includes(b.toLowerCase());
|
a.toLowerCase() === b.toLowerCase();
|
||||||
|
|
||||||
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
|
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
|
||||||
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
|
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
|
||||||
if (
|
if (
|
||||||
filename === selectedHeading ||
|
selectedHeading &&
|
||||||
|
(filename === selectedHeading ||
|
||||||
compareIgnoreCase(filename, selectedHeading) ||
|
compareIgnoreCase(filename, selectedHeading) ||
|
||||||
compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, ''))
|
compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, '')))
|
||||||
) {
|
) {
|
||||||
linkContent = filename;
|
linkContent = filename;
|
||||||
isNoteLink = true;
|
isNoteLink = true;
|
||||||
|
|
@ -77,7 +98,7 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Markdown链接格式
|
// Markdown链接格式
|
||||||
link = `[${displayText}](${encodeMarkdownLinkUrl(`${filename}#${selectedHeading}`)})`;
|
link = `[${displayText}](${encodeMarkdownLinkUrl(`${filename}#${sanitizedHeading}`)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { link, isNoteLink };
|
return { link, isNoteLink };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue