mirror of
https://github.com/moyf/easy-copy.git
synced 2026-07-22 05:43:47 +00:00
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.
216 lines
6.1 KiB
TypeScript
216 lines
6.1 KiB
TypeScript
import { LinkFormat } from './type';
|
|
|
|
// --- URL Encoding ---
|
|
|
|
export function encodeMarkdownLinkUrl(url: string): string {
|
|
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 ---
|
|
|
|
export interface BuildHeadingLinkOptions {
|
|
heading: string;
|
|
filename: string;
|
|
frontmatterTitle?: string;
|
|
linkFormat: LinkFormat;
|
|
useHeadingAsDisplayText: boolean;
|
|
headingLinkSeparator: string;
|
|
}
|
|
|
|
export interface BuildHeadingLinkResult {
|
|
link: string;
|
|
isNoteLink: boolean;
|
|
}
|
|
|
|
export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeadingLinkResult {
|
|
const { filename, linkFormat, useHeadingAsDisplayText, headingLinkSeparator } = options;
|
|
const filenameOrTitle = options.frontmatterTitle || filename;
|
|
|
|
// 提取标题文本和级别
|
|
// 如果内容是[[内容]],移除[[]]
|
|
let selectedHeading = options.heading;
|
|
if (selectedHeading.startsWith('[[') && selectedHeading.endsWith(']]')) {
|
|
selectedHeading = selectedHeading.slice(2, -2);
|
|
}
|
|
|
|
// 根据设置决定显示文本
|
|
let displayText = selectedHeading;
|
|
if (!useHeadingAsDisplayText) {
|
|
// 如果不使用标题作为显示文本,则使用"文件名{连接符}标题名"格式
|
|
const separator = headingLinkSeparator || '#';
|
|
displayText = `${filenameOrTitle}${separator}${selectedHeading}`;
|
|
}
|
|
|
|
const sanitizedHeading = sanitizeHeadingForLink(selectedHeading);
|
|
let linkContent = `${filename}#${sanitizedHeading}`;
|
|
let isNoteLink = false;
|
|
|
|
const compareIgnoreCase = (a: string, b: string): boolean =>
|
|
a.toLowerCase() === b.toLowerCase();
|
|
|
|
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
|
|
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
|
|
if (
|
|
selectedHeading &&
|
|
(filename === selectedHeading ||
|
|
compareIgnoreCase(filename, selectedHeading) ||
|
|
compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, '')))
|
|
) {
|
|
linkContent = filename;
|
|
isNoteLink = true;
|
|
}
|
|
|
|
let link = '';
|
|
|
|
// 根据设置选择链接格式
|
|
if (linkFormat === LinkFormat.WIKILINK) {
|
|
// Wiki链接格式
|
|
if (filename === selectedHeading) {
|
|
// 特殊情况:当文件名与标题相同时,直接链接到文件
|
|
link = `[[${filename}]]`;
|
|
isNoteLink = true;
|
|
} else {
|
|
if (displayText === linkContent) {
|
|
// 特殊情况:当显示文本与 "文件名#标题" 相同时,省略显示文本
|
|
link = `[[${linkContent}]]`;
|
|
} else {
|
|
link = `[[${linkContent}|${displayText}]]`;
|
|
}
|
|
}
|
|
} else {
|
|
// Markdown链接格式
|
|
link = `[${displayText}](${encodeMarkdownLinkUrl(`${filename}#${sanitizedHeading}`)})`;
|
|
}
|
|
|
|
return { link, isNoteLink };
|
|
}
|
|
|
|
// --- Block Display Text ---
|
|
|
|
export function extractBlockDisplayText(
|
|
firstLine: string,
|
|
blockId: string,
|
|
wordLimit: number,
|
|
charLimit: number,
|
|
): string {
|
|
let text = firstLine;
|
|
// 先去掉结尾的 ^ 及其后面的内容(如果有的话)
|
|
text = text.replace(/\^.*\s*$/, '');
|
|
text = text.trim().replace(/- \[.\]\s+/, '').replace('- ', '').replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, '');
|
|
|
|
if (!text) return blockId;
|
|
|
|
// 判断是否是纯英文,如果是纯英文(以及英文常用标点符号),提取前几个单词;否则,按下面的逻辑处理
|
|
// 根据 ASCII 来判断"英文"
|
|
const isEnglish = /^[a-zA-Z\s,.!?"()[\]_^-~:;0-9]*$/.test(text);
|
|
|
|
if (isEnglish) {
|
|
const limit = wordLimit || 3;
|
|
return text.trim().split(' ').slice(0, limit).join(' ');
|
|
}
|
|
|
|
// CJK / 非英文语言
|
|
const limit = charLimit || 5;
|
|
|
|
if (text.length > limit) {
|
|
const separated = text.trim().match(/(\S+?)[~,.\-=[,。?!…:\n\s]/);
|
|
const tempText = separated ? separated[1] : text;
|
|
|
|
if (tempText.length > limit) {
|
|
return tempText.slice(0, limit) + '...';
|
|
} else if (tempText.length < 3) {
|
|
return text.slice(0, limit);
|
|
} else {
|
|
return tempText;
|
|
}
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
// --- Block Link ---
|
|
|
|
export interface BuildBlockLinkOptions {
|
|
blockId: string;
|
|
filename: string;
|
|
useBrief: boolean;
|
|
firstLine: string;
|
|
linkFormat: LinkFormat;
|
|
autoBlockDisplayText: boolean;
|
|
autoEmbedBlockLink: boolean;
|
|
blockDisplayWordLimit: number;
|
|
blockDisplayCharLimit: number;
|
|
}
|
|
|
|
export function buildBlockLink(options: BuildBlockLinkOptions): string {
|
|
const {
|
|
blockId, filename, useBrief, firstLine,
|
|
linkFormat, autoBlockDisplayText, autoEmbedBlockLink,
|
|
blockDisplayWordLimit, blockDisplayCharLimit,
|
|
} = options;
|
|
|
|
let displayText = blockId;
|
|
if (useBrief && firstLine) {
|
|
displayText = extractBlockDisplayText(firstLine, blockId, blockDisplayWordLimit, blockDisplayCharLimit);
|
|
}
|
|
|
|
let link: string;
|
|
|
|
if (autoBlockDisplayText) {
|
|
link = linkFormat === LinkFormat.WIKILINK
|
|
? `[[${filename}#^${blockId}|${displayText}]]`
|
|
: `[${displayText}](${encodeMarkdownLinkUrl(filename)}#^${blockId})`; // markdown 格式不能加 ^,不然会变成内联脚注语法 [^xxx]
|
|
} else {
|
|
link = linkFormat === LinkFormat.WIKILINK
|
|
? `[[${filename}#^${blockId}]]`
|
|
: `[](${encodeMarkdownLinkUrl(filename)}#^${blockId})`;
|
|
}
|
|
|
|
// 自动生成嵌入块
|
|
if (autoEmbedBlockLink) {
|
|
link = '!' + link;
|
|
}
|
|
|
|
return link;
|
|
}
|
|
|
|
// --- File Link ---
|
|
|
|
export interface BuildFileLinkOptions {
|
|
filename: string;
|
|
filePath: string;
|
|
displayText?: string;
|
|
linkFormat: LinkFormat;
|
|
}
|
|
|
|
export function buildFileLink(options: BuildFileLinkOptions): string {
|
|
const { filename, linkFormat } = options;
|
|
const display = options.displayText || filename;
|
|
|
|
if (linkFormat === LinkFormat.WIKILINK) {
|
|
return `[[${filename}|${display}]]`;
|
|
}
|
|
|
|
let path = options.filePath.replace(/\\/g, '/');
|
|
if (path.endsWith('.md')) path = path.slice(0, -3);
|
|
return `[${display}](${encodeMarkdownLinkUrl(path)})`;
|
|
}
|