Merge pull request #35 from lightmotive/feat/follow-obsidian-paste-resolution

feat: paste-time link resolution + heading-link polish
This commit is contained in:
Moy 2026-05-09 14:05:43 +08:00 committed by GitHub
commit d13b66a69c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1590 additions and 213 deletions

41
CONTRIBUTING-zh.md Normal file
View file

@ -0,0 +1,41 @@
# 贡献指南
[English](./CONTRIBUTING.md) | 中文文档
## 开发环境配置
```bash
npm install
```
## 构建
```bash
npm run build # 类型检查 + 生产构建
npm run dev # 监视模式(文件改动时自动重新构建)
npm run build:local # 构建并复制到 Obsidian 库(需要 .env 中提供 VAULT_PATH
```
如需使用 `build:local`,请在项目根目录创建 `.env` 文件:
```
VAULT_PATH=/path/to/your/obsidian/vault
```
## 测试
测试基于 [vitest](https://vitest.dev/),覆盖 `src/linkBuilder.ts` 中的纯函数。
```bash
npm test # 运行一次全部测试
npm run test:watch # 监视模式(文件改动时重新运行)
```
测试文件与源文件并列存放,使用 `.test.ts` 后缀(例如 `src/linkBuilder.test.ts`)。
## Lint
```bash
npm run lint # 检查 lint 错误
npm run lint:fix # 自动修复 lint 错误
```

View file

@ -1,5 +1,7 @@
# Contributing
English | [中文文档](./CONTRIBUTING-zh.md)
## Development Setup
```bash

110
package-lock.json generated
View file

@ -26,31 +26,6 @@
"vitest": "^4.1.4"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@ -638,23 +613,6 @@
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/json": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@eslint/json/-/json-0.14.0.tgz",
"integrity": "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^0.17.0",
"@eslint/plugin-kit": "^0.4.1",
"@humanwhocodes/momoa": "^3.3.10",
"natural-compare": "^1.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/object-schema": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
@ -717,17 +675,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/momoa": {
"version": "3.3.10",
"resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.10.tgz",
"integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
@ -749,14 +696,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@microsoft/eslint-plugin-sdl": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@microsoft/eslint-plugin-sdl/-/eslint-plugin-sdl-1.1.0.tgz",
@ -1975,14 +1914,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5569,14 +5500,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -5852,31 +5775,6 @@
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz",
"integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.59.0",
"@typescript-eslint/parser": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/utils": "8.59.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@ -6081,14 +5979,6 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

255
src/copyMetadata.test.ts Normal file
View file

@ -0,0 +1,255 @@
import { describe, it, expect } from 'vitest';
import { buildBlockCopyMetadata, buildHeadingCopyMetadata, buildFileCopyMetadata } from './copyMetadata';
// ---------------------------------------------------------------------------
// buildBlockCopyMetadata
// ---------------------------------------------------------------------------
describe('buildBlockCopyMetadata', () => {
const base = {
clipboardText: '[[note#^abc123]]',
sourceFilePath: 'notes/note.md',
blockId: 'abc123',
useBrief: false,
firstLine: '',
autoBlockDisplayText: true,
autoEmbedBlockLink: false,
blockDisplayWordLimit: 5,
blockDisplayCharLimit: 50,
};
it('passes through clipboardText and sourceFilePath', () => {
const meta = buildBlockCopyMetadata(base);
expect(meta.clipboardText).toBe('[[note#^abc123]]');
expect(meta.sourceFilePath).toBe('notes/note.md');
});
it('builds subpath as #^<blockId>', () => {
const meta = buildBlockCopyMetadata(base);
expect(meta.subpath).toBe('#^abc123');
});
it('defaults alias to the blockId when useBrief is false', () => {
const meta = buildBlockCopyMetadata(base);
expect(meta.alias).toBe('abc123');
});
it('uses extracted display text when useBrief + firstLine are set', () => {
const meta = buildBlockCopyMetadata({
...base,
useBrief: true,
firstLine: 'The quick brown fox jumps over the lazy dog',
blockDisplayWordLimit: 4,
});
// extractBlockDisplayText 会截断到单词数上限
expect(meta.alias).not.toBe('abc123');
expect(meta.alias.length).toBeGreaterThan(0);
});
it('keeps blockId alias when useBrief is true but firstLine is empty', () => {
const meta = buildBlockCopyMetadata({ ...base, useBrief: true, firstLine: '' });
expect(meta.alias).toBe('abc123');
});
it('clears alias when autoBlockDisplayText is false', () => {
const meta = buildBlockCopyMetadata({ ...base, autoBlockDisplayText: false });
expect(meta.alias).toBe('');
});
it('clears alias even when useBrief would otherwise populate it', () => {
const meta = buildBlockCopyMetadata({
...base,
useBrief: true,
firstLine: 'Some long first line of content here',
autoBlockDisplayText: false,
});
expect(meta.alias).toBe('');
});
it('sets isEmbed from autoEmbedBlockLink', () => {
expect(buildBlockCopyMetadata({ ...base, autoEmbedBlockLink: true }).isEmbed).toBe(true);
expect(buildBlockCopyMetadata({ ...base, autoEmbedBlockLink: false }).isEmbed).toBe(false);
});
it('sets timestamp to current time', () => {
const before = Date.now();
const meta = buildBlockCopyMetadata(base);
const after = Date.now();
expect(meta.timestamp).toBeGreaterThanOrEqual(before);
expect(meta.timestamp).toBeLessThanOrEqual(after);
});
});
// ---------------------------------------------------------------------------
// buildHeadingCopyMetadata
// ---------------------------------------------------------------------------
describe('buildHeadingCopyMetadata', () => {
const base = {
clipboardText: '[[note#Heading]]',
sourceFilePath: 'notes/note.md',
heading: 'Heading',
filename: 'note',
useHeadingAsDisplayText: true,
headingLinkSeparator: '#',
isNoteLink: false,
};
it('passes through clipboardText and sourceFilePath', () => {
const meta = buildHeadingCopyMetadata(base);
expect(meta.clipboardText).toBe('[[note#Heading]]');
expect(meta.sourceFilePath).toBe('notes/note.md');
});
it('uses heading as alias by default', () => {
const meta = buildHeadingCopyMetadata(base);
expect(meta.alias).toBe('Heading');
});
it('strips [[ ]] wrapper from the heading', () => {
const meta = buildHeadingCopyMetadata({ ...base, heading: '[[Wrapped Heading]]' });
expect(meta.alias).toBe('Wrapped Heading');
expect(meta.subpath).toBe('#Wrapped Heading');
});
it('builds subpath via sanitizeHeadingForLink', () => {
const meta = buildHeadingCopyMetadata({
...base,
heading: 'Test | If # This ^ Heading : Works',
});
expect(meta.subpath).toBe('#Test If This Heading Works');
});
it('uses filename + separator + heading when useHeadingAsDisplayText is false', () => {
const meta = buildHeadingCopyMetadata({
...base,
useHeadingAsDisplayText: false,
});
expect(meta.alias).toBe('note#Heading');
});
it('honors custom separator', () => {
const meta = buildHeadingCopyMetadata({
...base,
useHeadingAsDisplayText: false,
headingLinkSeparator: ' > ',
});
expect(meta.alias).toBe('note > Heading');
});
it('falls back to "#" when separator is empty', () => {
const meta = buildHeadingCopyMetadata({
...base,
useHeadingAsDisplayText: false,
headingLinkSeparator: '',
});
expect(meta.alias).toBe('note#Heading');
});
it('prefers frontmatter title over filename', () => {
const meta = buildHeadingCopyMetadata({
...base,
useHeadingAsDisplayText: false,
frontmatterTitle: 'Fancy Title',
});
expect(meta.alias).toBe('Fancy Title#Heading');
});
it('empty subpath when isNoteLink is true', () => {
const meta = buildHeadingCopyMetadata({ ...base, isNoteLink: true });
expect(meta.subpath).toBe('');
});
it('clears alias when isNoteLink and filename === heading', () => {
const meta = buildHeadingCopyMetadata({
...base,
heading: 'note',
isNoteLink: true,
});
expect(meta.alias).toBe('');
});
it('keeps alias when isNoteLink but filename !== heading', () => {
const meta = buildHeadingCopyMetadata({
...base,
heading: 'Note',
filename: 'note',
isNoteLink: true,
});
expect(meta.alias).toBe('Note');
});
it('isEmbed is always false for headings', () => {
expect(buildHeadingCopyMetadata(base).isEmbed).toBe(false);
});
it('sets timestamp to current time', () => {
const before = Date.now();
const meta = buildHeadingCopyMetadata(base);
const after = Date.now();
expect(meta.timestamp).toBeGreaterThanOrEqual(before);
expect(meta.timestamp).toBeLessThanOrEqual(after);
});
});
// ---------------------------------------------------------------------------
// buildFileCopyMetadata
// ---------------------------------------------------------------------------
describe('buildFileCopyMetadata', () => {
it('uses displayText as alias when provided', () => {
const meta = buildFileCopyMetadata({
clipboardText: '[[note|Custom]]',
sourceFilePath: 'notes/note.md',
displayText: 'Custom',
});
expect(meta.alias).toBe('Custom');
});
it('sets alias to empty string when displayText is undefined', () => {
const meta = buildFileCopyMetadata({
clipboardText: '[[note]]',
sourceFilePath: 'notes/note.md',
});
expect(meta.alias).toBe('');
});
it('sets alias to empty string when displayText is an empty string', () => {
const meta = buildFileCopyMetadata({
clipboardText: '[[note]]',
sourceFilePath: 'notes/note.md',
displayText: '',
});
expect(meta.alias).toBe('');
});
it('always has empty subpath and isEmbed=false', () => {
const meta = buildFileCopyMetadata({
clipboardText: '[[note]]',
sourceFilePath: 'notes/note.md',
displayText: 'x',
});
expect(meta.subpath).toBe('');
expect(meta.isEmbed).toBe(false);
});
it('passes through clipboardText and sourceFilePath', () => {
const meta = buildFileCopyMetadata({
clipboardText: '[[a/b/c]]',
sourceFilePath: 'a/b/c.md',
});
expect(meta.clipboardText).toBe('[[a/b/c]]');
expect(meta.sourceFilePath).toBe('a/b/c.md');
});
it('sets timestamp to current time', () => {
const before = Date.now();
const meta = buildFileCopyMetadata({
clipboardText: '[[note]]',
sourceFilePath: 'notes/note.md',
});
const after = Date.now();
expect(meta.timestamp).toBeGreaterThanOrEqual(before);
expect(meta.timestamp).toBeLessThanOrEqual(after);
});
});

115
src/copyMetadata.ts Normal file
View file

@ -0,0 +1,115 @@
import { extractBlockDisplayText, sanitizeHeadingForLink, stripWikiBrackets } from './linkBuilder';
export interface CopyMetadata {
clipboardText: string;
sourceFilePath: string;
subpath: string;
alias: string;
isEmbed: boolean;
timestamp: number;
}
export interface BuildBlockCopyMetadataInput {
clipboardText: string;
sourceFilePath: string;
blockId: string;
useBrief: boolean;
firstLine: string;
autoBlockDisplayText: boolean;
autoEmbedBlockLink: boolean;
blockDisplayWordLimit: number;
blockDisplayCharLimit: number;
}
export function buildBlockCopyMetadata(input: BuildBlockCopyMetadataInput): CopyMetadata {
const {
clipboardText,
sourceFilePath,
blockId,
useBrief,
firstLine,
autoBlockDisplayText,
autoEmbedBlockLink,
blockDisplayWordLimit,
blockDisplayCharLimit,
} = input;
let alias = blockId;
if (useBrief && firstLine) {
alias = extractBlockDisplayText(firstLine, blockId, blockDisplayWordLimit, blockDisplayCharLimit);
}
if (!autoBlockDisplayText) {
alias = '';
}
return {
clipboardText,
sourceFilePath,
subpath: `#^${blockId}`,
alias,
isEmbed: autoEmbedBlockLink,
timestamp: Date.now(),
};
}
export interface BuildHeadingCopyMetadataInput {
clipboardText: string;
sourceFilePath: string;
heading: string;
filename: string;
frontmatterTitle?: string;
useHeadingAsDisplayText: boolean;
headingLinkSeparator: string;
isNoteLink: boolean;
}
export function buildHeadingCopyMetadata(input: BuildHeadingCopyMetadataInput): CopyMetadata {
const {
clipboardText,
sourceFilePath,
heading: rawHeading,
filename,
frontmatterTitle,
useHeadingAsDisplayText,
headingLinkSeparator,
isNoteLink,
} = input;
const heading = stripWikiBrackets(rawHeading);
let alias = heading;
if (!useHeadingAsDisplayText) {
const separator = headingLinkSeparator || '#';
const filenameOrTitle = frontmatterTitle || filename;
alias = `${filenameOrTitle}${separator}${heading}`;
}
if (isNoteLink && filename === heading) {
alias = '';
}
return {
clipboardText,
sourceFilePath,
subpath: isNoteLink ? '' : `#${sanitizeHeadingForLink(heading)}`,
alias,
isEmbed: false,
timestamp: Date.now(),
};
}
export interface BuildFileCopyMetadataInput {
clipboardText: string;
sourceFilePath: string;
displayText?: string;
}
export function buildFileCopyMetadata(input: BuildFileCopyMetadataInput): CopyMetadata {
return {
clipboardText: input.clipboardText,
sourceFilePath: input.sourceFilePath,
subpath: '',
alias: input.displayText || '',
isEmbed: false,
timestamp: Date.now(),
};
}

View file

@ -18,6 +18,7 @@ export type TranslationKey =
| 'strict-heading-match' | 'strict-heading-match-desc'
| 'use-frontmatter-as-display' | 'use-frontmatter-as-display-desc' | 'frontmatter-key' | 'frontmatter-key-desc'
| 'link-format'| 'link-format-desc' | 'link-format-obsidian' | 'markdown-link' | 'wiki-link' | 'contextual-copy'
| 'resolve-link-path-on-paste' | 'resolve-link-path-on-paste-desc' | 'resolve-link-path-on-paste-tooltip'
| 'copy-current-file-link' | 'file-link-copied'
| 'target' | 'customize-targets' | 'customize-targets-desc'
| 'enable-bold' | 'enable-bold-desc'
@ -107,6 +108,9 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'link-format-obsidian': 'Follow Obsidian settings',
'markdown-link': 'Markdown link',
'wiki-link': 'Wiki link',
'resolve-link-path-on-paste': 'Resolve link path on paste',
'resolve-link-path-on-paste-desc': 'Regenerate the link path at paste time based on the destination file. Under "Follow Obsidian settings", uses your vault\'s path style (shortest/relative/absolute); otherwise uses shortest-unique paths only.',
'resolve-link-path-on-paste-tooltip': 'Plugins loaded earlier take priority for paste handling. If another plugin (e.g. Linter) intercepts paste first, this feature will be bypassed. You can adjust plugin load order in the community-plugins.json file.',
'customize-targets': 'Customize targets',
'customize-targets-desc': 'Enable to customize which elements can be copied (disable to copy all elements)',
'enable-inline-code': 'Enable inline code',
@ -205,6 +209,9 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'link-format-obsidian': '跟随 Obsidian 设置',
'markdown-link': 'Markdown链接',
'wiki-link': 'Wiki链接',
'resolve-link-path-on-paste': '粘贴时解析链接路径',
'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用软件设置中的路径风格(最短/相对/绝对);否则,仅使用最短唯一路径。',
'resolve-link-path-on-paste-tooltip': '越早加载的插件越先处理粘贴事件。若其他插件(如 Linter抢先处理本功能会被跳过。可在 community-plugins.json 文件中调整插件加载顺序。',
'customize-targets': '自定义复制对象',
'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)',
'enable-inline-code': '启用行内代码',
@ -313,6 +320,9 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'link-format-obsidian': '跟隨 Obsidian 設定',
'markdown-link': 'Markdown連結',
'wiki-link': 'Wiki連結',
'resolve-link-path-on-paste': '貼上時解析連結路徑',
'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用軟體設定中的路徑風格(最短/相對/絕對);否則,僅使用最短唯一路徑。',
'resolve-link-path-on-paste-tooltip': '越早載入的外掛越先處理貼上事件。若其他外掛(如 Linter搶先處理本功能會被略過。可在 community-plugins.json 檔案中調整外掛載入順序。',
'customize-targets': '自定義複製對象',
'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)',
'enable-inline-code': '啟用行內代碼',

View file

@ -1,5 +1,13 @@
import { describe, it, expect } from 'vitest';
import { buildHeadingLink, buildBlockLink, buildFileLink, extractBlockDisplayText, encodeMarkdownLinkUrl, sanitizeHeadingForLink } from './linkBuilder';
import {
buildBlockLink,
buildExplicitPasteLink,
buildFileLink,
buildHeadingLink,
encodeMarkdownLinkUrl,
extractBlockDisplayText,
sanitizeHeadingForLink,
} from './linkBuilder';
import { LinkFormat } from './type';
// ---------------------------------------------------------------------------
@ -77,9 +85,10 @@ describe('buildHeadingLink', () => {
linkFormat: LinkFormat.WIKILINK,
useHeadingAsDisplayText: true,
headingLinkSeparator: '#',
simplifiedHeadingToNoteLink: true,
};
// -- Wiki format ---------------------------------------------------------
// -- Wiki 格式 ---------------------------------------------------------
describe('wiki format', () => {
it('creates a basic heading link with display text', () => {
@ -108,14 +117,14 @@ describe('buildHeadingLink', () => {
heading: 'mynote',
filename: 'MyNote',
});
// linkContent simplified to filename, but since filename !== heading,
// it falls to the else branch with displayText vs linkContent
// linkContent 被简化为 filename但因 filename !== heading
// 会落到 else 分支,比较 displayText 与 linkContent
expect(result.link).toBe('[[MyNote|mynote]]');
expect(result.isNoteLink).toBe(true);
});
it('simplifies when heading matches filename with spaces removed', () => {
// e.g. filename "SomeThing" heading "Some Thing"
// 例如 filename "SomeThing"、heading "Some Thing"
const result = buildHeadingLink({
...defaults,
heading: 'Some Thing',
@ -126,8 +135,8 @@ describe('buildHeadingLink', () => {
});
it('omits display text alias when displayText equals linkContent', () => {
// When useHeadingAsDisplayText is false AND separator is '#',
// displayText = "filename#heading" which equals linkContent
// useHeadingAsDisplayText 为 false 且分隔符为 '#' 时,
// displayText = "filename#heading",与 linkContent 相同
const result = buildHeadingLink({
...defaults,
heading: 'Intro',
@ -158,7 +167,7 @@ describe('buildHeadingLink', () => {
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
// displayText = "My Note#Setup", linkContent = "my-note#Setup"
// displayText = "My Note#Setup"linkContent = "my-note#Setup"
expect(result.link).toBe('[[my-note#Setup|My Note#Setup]]');
});
@ -183,7 +192,7 @@ describe('buildHeadingLink', () => {
});
});
// -- Markdown format ------------------------------------------------------
// -- Markdown 格式 ------------------------------------------------------
describe('markdown format', () => {
const md = { ...defaults, linkFormat: LinkFormat.MDLINK };
@ -197,14 +206,14 @@ describe('buildHeadingLink', () => {
expect(result.link).toBe('[Getting Started](MyNote#Getting%20Started)');
});
it('always includes #heading even when filename matches heading', () => {
it('simplifies to file link when filename equals heading exactly', () => {
const result = buildHeadingLink({
...md,
heading: 'MyNote',
filename: 'MyNote',
});
// Markdown format always uses filename#heading; no spaces so no encoding
expect(result.link).toBe('[MyNote](MyNote#MyNote)');
// simplifiedHeadingToNoteLink默认 true将 MyNote#MyNote 折叠为 MyNote
expect(result.link).toBe('[MyNote](MyNote)');
expect(result.isNoteLink).toBe(true);
});
@ -216,7 +225,7 @@ describe('buildHeadingLink', () => {
useHeadingAsDisplayText: false,
headingLinkSeparator: ' - ',
});
// Display text is not encoded, only the URL portion is
// 显示文本不做编码,只对 URL 部分编码
expect(result.link).toBe('[MyNote - Intro](MyNote#Intro)');
});
@ -229,12 +238,177 @@ describe('buildHeadingLink', () => {
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
// No spaces in filename or heading, so no encoding needed
// filename 和 heading 都没有空格,不需要编码
expect(result.link).toBe('[My Note#Setup](my-note#Setup)');
});
// -- Markdown 格式下的 simplifiedHeadingToNoteLink --------------------
// 对应下方 wiki 的「文件名-标题匹配」测试套件。
describe('simplifiedHeadingToNoteLink (default: includes match)', () => {
it('simplifies on case-insensitive exact match', () => {
const result = buildHeadingLink({
...md,
heading: 'mynote',
filename: 'MyNote',
});
// 显示文本保持 heading 的大小写,链接目标保持 filename 的大小写
expect(result.link).toBe('[mynote](MyNote)');
expect(result.isNoteLink).toBe(true);
});
it('simplifies on space-removed match', () => {
const result = buildHeadingLink({
...md,
heading: 'Some Thing',
filename: 'SomeThing',
});
expect(result.link).toBe('[Some Thing](SomeThing)');
expect(result.isNoteLink).toBe(true);
});
it('simplifies when filename contains heading as substring', () => {
const result = buildHeadingLink({
...md,
heading: 'Java',
filename: 'JavaScript',
});
expect(result.link).toBe('[Java](JavaScript)');
expect(result.isNoteLink).toBe(true);
});
it('simplifies for date-prefixed filenames containing heading', () => {
const result = buildHeadingLink({
...md,
heading: 'note',
filename: '260422_note',
});
expect(result.link).toBe('[note](260422_note)');
expect(result.isNoteLink).toBe(true);
});
it('does not simplify when heading contains filename (asymmetric)', () => {
const result = buildHeadingLink({
...md,
heading: 'Notebook',
filename: 'Note',
});
expect(result.link).toBe('[Notebook](Note#Notebook)');
expect(result.isNoteLink).toBe(false);
});
it('encodes spaces in target when not simplified', () => {
const result = buildHeadingLink({
...md,
heading: 'Some Heading',
filename: 'My Note',
});
// 不会触发简化,因此目标保留标题片段
expect(result.link).toBe('[Some Heading](My%20Note#Some%20Heading)');
expect(result.isNoteLink).toBe(false);
});
it('encodes spaces in target when simplified to filename', () => {
const result = buildHeadingLink({
...md,
heading: 'My Note',
filename: 'My Note',
});
expect(result.link).toBe('[My Note](My%20Note)');
expect(result.isNoteLink).toBe(true);
});
});
describe('simplifiedHeadingToNoteLink (strict mode)', () => {
const strictMd = { ...md, strictHeadingMatch: true };
it('does not simplify on substring match', () => {
const result = buildHeadingLink({
...strictMd,
heading: 'Java',
filename: 'JavaScript',
});
expect(result.link).toBe('[Java](JavaScript#Java)');
expect(result.isNoteLink).toBe(false);
});
it('still simplifies on case-insensitive exact match', () => {
const result = buildHeadingLink({
...strictMd,
heading: 'mynote',
filename: 'MyNote',
});
expect(result.link).toBe('[mynote](MyNote)');
expect(result.isNoteLink).toBe(true);
});
it('still simplifies on space-removed exact match', () => {
const result = buildHeadingLink({
...strictMd,
heading: 'Some Thing',
filename: 'SomeThing',
});
expect(result.link).toBe('[Some Thing](SomeThing)');
expect(result.isNoteLink).toBe(true);
});
});
describe('simplifiedHeadingToNoteLink gated on useHeadingAsDisplayText=true (A1.5)', () => {
// 当用户希望显示「filename#heading」时丢掉标题锚点会
// 产生误导性的 [filename#heading](filename)。这道闸门保留
// 标题链接,让显示意图与目标保持一致。
it('does NOT simplify on exact match when useHeadingAsDisplayText is false', () => {
const result = buildHeadingLink({
...md,
heading: 'MyNote',
filename: 'MyNote',
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
expect(result.link).toBe('[MyNote#MyNote](MyNote#MyNote)');
expect(result.isNoteLink).toBe(false);
});
it('does NOT simplify on space-removed match when useHeadingAsDisplayText is false', () => {
const result = buildHeadingLink({
...md,
heading: 'Some Thing',
filename: 'SomeThing',
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
expect(result.link).toBe('[SomeThing#Some Thing](SomeThing#Some%20Thing)');
expect(result.isNoteLink).toBe(false);
});
it('does NOT simplify on substring match when useHeadingAsDisplayText is false', () => {
const result = buildHeadingLink({
...md,
heading: 'Java',
filename: 'JavaScript',
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
expect(result.link).toBe('[JavaScript#Java](JavaScript#Java)');
expect(result.isNoteLink).toBe(false);
});
it('honors custom separator in display text when not simplified', () => {
const result = buildHeadingLink({
...md,
heading: 'Some Thing',
filename: 'SomeThing',
useHeadingAsDisplayText: false,
headingLinkSeparator: ' > ',
});
expect(result.link).toBe('[SomeThing > Some Thing](SomeThing#Some%20Thing)');
expect(result.isNoteLink).toBe(false);
});
});
});
// -- Special character sanitization in link targets ----------------------
// -- 链接目标中的特殊字符净化 ----------------------
describe('special character sanitization', () => {
it('wiki format: strips special chars from link target, preserves display text', () => {
@ -276,7 +450,7 @@ describe('buildHeadingLink', () => {
});
});
// -- compareIgnoreCase: default (includes) vs strict matching -----------
// -- compareIgnoreCase:默认(包含匹配)与严格匹配的对照 -----------
describe('filename-heading matching (default: includes)', () => {
it('simplifies when filename contains heading as substring', () => {
@ -364,7 +538,142 @@ describe('buildHeadingLink', () => {
});
});
// -- Combinatorial --------------------------------------------------------
// -- A1.5 闸门:简化受 useHeadingAsDisplayText 约束wiki 侧) -----
describe('simplifiedHeadingToNoteLink gated on useHeadingAsDisplayText=true (A1.5, wiki side)', () => {
// 对应 markdown 侧的同名套件。当用户要求显示「filename#heading」时
// 提前简化分支会被跳过,从而保留标题链接。
// 例外WIKI 精确匹配的特例 [[filename]] 始终保留——
// 它在 Obsidian 中会被渲染为干净的文件名。
const wikiNoDisplay = {
...defaults,
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
};
it('PRESERVES the [[filename]] special case on exact match (the A1.5 exception)', () => {
const result = buildHeadingLink({
...wikiNoDisplay,
heading: 'MyNote',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote]]');
expect(result.isNoteLink).toBe(true);
});
it('does NOT simplify on case-insensitive match', () => {
const result = buildHeadingLink({
...wikiNoDisplay,
heading: 'mynote',
filename: 'MyNote',
});
// displayText === linkContent → alias 折叠,但标题片段保留
expect(result.link).toBe('[[MyNote#mynote]]');
expect(result.isNoteLink).toBe(false);
});
it('does NOT simplify on space-removed match', () => {
const result = buildHeadingLink({
...wikiNoDisplay,
heading: 'Some Thing',
filename: 'SomeThing',
});
expect(result.link).toBe('[[SomeThing#Some Thing]]');
expect(result.isNoteLink).toBe(false);
});
it('does NOT simplify on substring match', () => {
const result = buildHeadingLink({
...wikiNoDisplay,
heading: 'Java',
filename: 'JavaScript',
});
expect(result.link).toBe('[[JavaScript#Java]]');
expect(result.isNoteLink).toBe(false);
});
it('honors custom separator alias when not simplified', () => {
const result = buildHeadingLink({
...wikiNoDisplay,
heading: 'Some Thing',
filename: 'SomeThing',
headingLinkSeparator: ' > ',
});
// displayText 「SomeThing > Some Thing」!== linkContent「SomeThing#Some Thing」
expect(result.link).toBe('[[SomeThing#Some Thing|SomeThing > Some Thing]]');
expect(result.isNoteLink).toBe(false);
});
});
// -- simplifiedHeadingToNoteLink 关闭时 ---------------------------------
describe('when simplifiedHeadingToNoteLink is false', () => {
const noSimplify = { ...defaults, simplifiedHeadingToNoteLink: false };
it('keeps full heading link when filename equals heading exactly (wiki)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'MyNote',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#MyNote|MyNote]]');
expect(result.isNoteLink).toBe(false);
});
it('keeps full heading link on case-insensitive match (wiki)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'mynote',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#mynote|mynote]]');
expect(result.isNoteLink).toBe(false);
});
it('keeps full heading link on space-removed match (wiki)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'Some Thing',
filename: 'SomeThing',
});
expect(result.link).toBe('[[SomeThing#Some Thing|Some Thing]]');
expect(result.isNoteLink).toBe(false);
});
it('keeps full heading link on filename-includes-heading match (wiki)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'note',
filename: '260422_note',
});
expect(result.link).toBe('[[260422_note#note|note]]');
expect(result.isNoteLink).toBe(false);
});
it('does not affect non-matching filename/heading (sanity check)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'Getting Started',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#Getting Started|Getting Started]]');
expect(result.isNoteLink).toBe(false);
});
it('markdown format keeps heading fragment when simplification is off (sanity check)', () => {
const result = buildHeadingLink({
...noSimplify,
linkFormat: LinkFormat.MDLINK,
heading: 'MyNote',
filename: 'MyNote',
});
expect(result.link).toBe('[MyNote](MyNote#MyNote)');
expect(result.isNoteLink).toBe(false);
});
});
// -- 组合场景 --------------------------------------------------------
describe('combinatorial', () => {
it('frontmatterTitle has no effect when useHeadingAsDisplayText is true', () => {
@ -375,11 +684,11 @@ describe('buildHeadingLink', () => {
frontmatterTitle: 'My Custom Title',
useHeadingAsDisplayText: true,
});
// Display text is the heading, not the frontmatter title
// 显示文本是 heading而不是 frontmatter title
expect(result.link).toBe('[[my-note#Setup|Setup]]');
});
it('frontmatterTitle + note-link simplification (wiki)', () => {
it('frontmatterTitle + heading link kept under useHeadingAsDisplayText=false (wiki, A1.5)', () => {
const result = buildHeadingLink({
...defaults,
heading: 'myNote',
@ -388,15 +697,16 @@ describe('buildHeadingLink', () => {
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
// linkContent simplified to "MyNote", display = "My Pretty Title#myNote"
expect(result.link).toBe('[[MyNote|My Pretty Title#myNote]]');
expect(result.isNoteLink).toBe(true);
// A1.5 之前会简化为 [[MyNote|My Pretty Title#myNote]](目标=文件,显示=标题样式)。
// A1.5 保留标题锚点,使目标与显示意图保持一致。
expect(result.link).toBe('[[MyNote#myNote|My Pretty Title#myNote]]');
expect(result.isNoteLink).toBe(false);
});
it('empty heading produces a heading link with empty fragment (known bug)', () => {
// Empty heading is an edge case from Obsidian's UI. The link target
// is technically malformed (MyNote#) but this matches current behavior.
// Should be addressed in a future PR.
// 空 heading 来自 Obsidian UI 的边缘情况。
// 链接目标 (MyNote#) 严格说是畸形的,但当前行为如此。
// 应在后续 PR 中处理。
const result = buildHeadingLink({
...defaults,
heading: '',
@ -485,7 +795,7 @@ describe('extractBlockDisplayText', () => {
describe('CJK text', () => {
it('returns text as-is when within char limit', () => {
const result = extractBlockDisplayText('Hello', 'fallback', 3, 5);
// "Hello" is ASCII / English, so it hits the English branch
// "Hello" 是 ASCII / 英文,会走英文分支
expect(result).toBe('Hello');
});
@ -495,13 +805,13 @@ describe('extractBlockDisplayText', () => {
});
it('uses punctuation as separator when match is 3+ chars', () => {
// '三个字' is 3 chars (>= 3 and <= charLimit), so separator match is used
// '三个字' 共 3 个字符(>= 3 且 <= charLimit命中分隔符匹配分支
const result = extractBlockDisplayText('三个字,后面还有很多内容', 'fallback', 3, 5);
expect(result).toBe('三个字');
});
it('falls back to char limit slice when separator match is too short (< 3 chars)', () => {
// '短语' is 2 chars (< 3), falls back to text.slice(0, charLimit)
// '短语' 共 2 个字符(< 3回退到 text.slice(0, charLimit)
const result = extractBlockDisplayText('短语,后面还有很多内容', 'fallback', 3, 5);
expect(result).toBe('短语,后面');
});
@ -517,7 +827,7 @@ describe('extractBlockDisplayText', () => {
});
});
// -- Boundary values ------------------------------------------------------
// -- 边界值 ------------------------------------------------------
describe('boundary values', () => {
it('English: exactly wordLimit words returns all words', () => {
@ -551,11 +861,11 @@ describe('extractBlockDisplayText', () => {
});
});
// -- Mixed content --------------------------------------------------------
// -- 混合内容 --------------------------------------------------------
describe('mixed content', () => {
it('mixed English/CJK is treated as non-English', () => {
// The ASCII regex fails if any non-ASCII char is present
// 出现任意非 ASCII 字符时,纯 ASCII 正则会失败
const result = extractBlockDisplayText('Hello 世界 and more text', 'fallback', 3, 5);
expect(result).toBe('Hello');
});
@ -656,7 +966,7 @@ describe('buildBlockLink', () => {
expect(result).toBe('[[MyNote#^abc123|abc123]]');
});
// -- Combinatorial --------------------------------------------------------
// -- 组合场景 --------------------------------------------------------
describe('combinatorial', () => {
it('embed + no display text (wiki) produces ![[...]]', () => {
@ -670,8 +980,8 @@ describe('buildBlockLink', () => {
});
it('embed + no display text (markdown) produces image-like syntax', () => {
// ![](path) is technically an image embed in standard markdown
// documenting that this combo produces that syntax
// ![](path) 在标准 markdown 中其实是图片嵌入语法—
// 此处仅记录该组合会生成这种语法
const result = buildBlockLink({
...defaults,
linkFormat: LinkFormat.MDLINK,
@ -794,11 +1104,11 @@ describe('buildFileLink', () => {
});
});
// -- Edge cases -----------------------------------------------------------
// -- 边缘情况 -----------------------------------------------------------
describe('edge cases', () => {
it('wiki format without displayText produces redundant alias', () => {
// [[name|name]] — the alias duplicates the filename
// [[name|name]]——alias 与文件名重复
const result = buildFileLink({
filename: 'MyNote',
filePath: 'folder/MyNote.md',
@ -808,7 +1118,7 @@ describe('buildFileLink', () => {
});
it('filename with parentheses in markdown format', () => {
// Parentheses can break [text](path) syntax in strict parsers
// 严格的解析器中,括号会破坏 [text](path) 语法
const result = buildFileLink({
filename: 'Note (draft)',
filePath: 'folder/Note (draft).md',
@ -818,7 +1128,7 @@ describe('buildFileLink', () => {
});
it('filename with pipe in wiki format', () => {
// | is the alias separator — pipe in filename creates ambiguity
// | 是 alias 分隔符——文件名中含 | 会造成歧义
const result = buildFileLink({
filename: 'Pros | Cons',
filePath: 'Pros | Cons.md',
@ -829,3 +1139,203 @@ describe('buildFileLink', () => {
});
});
});
// ---------------------------------------------------------------------------
// buildExplicitPasteLink
// ---------------------------------------------------------------------------
describe('buildExplicitPasteLink', () => {
describe('wiki format', () => {
it('cross-file with subpath + alias produces [[path#H|alias]]', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.WIKILINK,
path: 'SomeThing',
subpath: '#Other Heading',
alias: 'Other Heading',
sameFile: false,
omitAlias: false,
})).toBe('[[SomeThing#Other Heading|Other Heading]]');
});
it('cross-file with subpath, omitAlias=true produces [[path#H]]', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.WIKILINK,
path: 'SomeThing',
subpath: '#Other Heading',
alias: 'Other Heading',
sameFile: false,
omitAlias: true,
})).toBe('[[SomeThing#Other Heading]]');
});
it('same-file with subpath drops path portion to [[#H|alias]]', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.WIKILINK,
path: 'SomeThing',
subpath: '#Other Heading',
alias: 'Other Heading',
sameFile: true,
omitAlias: false,
})).toBe('[[#Other Heading|Other Heading]]');
});
it('same-file with subpath + omitAlias produces [[#H]]', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.WIKILINK,
path: 'SomeThing',
subpath: '#Other Heading',
alias: 'Other Heading',
sameFile: true,
omitAlias: true,
})).toBe('[[#Other Heading]]');
});
it('cross-file file link (empty subpath) with alias', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.WIKILINK,
path: 'SomeThing',
subpath: '',
alias: 'Some Thing',
sameFile: false,
omitAlias: false,
})).toBe('[[SomeThing|Some Thing]]');
});
it('cross-file file link with empty alias yields [[path]]', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.WIKILINK,
path: 'SomeThing',
subpath: '',
alias: '',
sameFile: false,
omitAlias: false,
})).toBe('[[SomeThing]]');
});
it('block-link cross-file: omitAlias=false (caller-decided) keeps alias', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.WIKILINK,
path: 'SomeThing',
subpath: '#^abc123',
alias: 'The quick brown',
sameFile: false,
omitAlias: false,
})).toBe('[[SomeThing#^abc123|The quick brown]]');
});
it('block-link same-file produces [[#^id|alias]]', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.WIKILINK,
path: 'SomeThing',
subpath: '#^abc123',
alias: 'The quick brown',
sameFile: true,
omitAlias: false,
})).toBe('[[#^abc123|The quick brown]]');
});
it('does not encode spaces in path (wiki-link convention)', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.WIKILINK,
path: 'My Note',
subpath: '#Some Heading',
alias: 'Some Heading',
sameFile: false,
omitAlias: false,
})).toBe('[[My Note#Some Heading|Some Heading]]');
});
});
describe('markdown format', () => {
it('cross-file with subpath + alias produces [alias](path#H)', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.MDLINK,
path: 'SomeThing',
subpath: '#Other Heading',
alias: 'Other Heading',
sameFile: false,
omitAlias: false,
})).toBe('[Other Heading](SomeThing#Other%20Heading)');
});
it('same-file produces [alias](#H) with no path', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.MDLINK,
path: 'SomeThing',
subpath: '#Other Heading',
alias: 'Other Heading',
sameFile: true,
omitAlias: false,
})).toBe('[Other Heading](#Other%20Heading)');
});
it('omitAlias=true preserves alias as display (MD never produces empty [])', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.MDLINK,
path: 'SomeThing',
subpath: '#Other Heading',
alias: 'Other Heading',
sameFile: true,
omitAlias: true,
})).toBe('[Other Heading](#Other%20Heading)');
});
it('cross-file file link (empty subpath) with alias', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.MDLINK,
path: 'SomeThing',
subpath: '',
alias: 'Some Thing',
sameFile: false,
omitAlias: false,
})).toBe('[Some Thing](SomeThing)');
});
it('encodes spaces in path as %20', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.MDLINK,
path: 'My Note',
subpath: '#Some Heading',
alias: 'Some Heading',
sameFile: false,
omitAlias: false,
})).toBe('[Some Heading](My%20Note#Some%20Heading)');
});
it('encodes spaces in same-file fragment', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.MDLINK,
path: 'irrelevant',
subpath: '#Some Heading',
alias: 'Some Heading',
sameFile: true,
omitAlias: false,
})).toBe('[Some Heading](#Some%20Heading)');
});
it('block-link cross-file', () => {
expect(buildExplicitPasteLink({
format: LinkFormat.MDLINK,
path: 'SomeThing',
subpath: '#^abc123',
alias: 'The quick brown',
sameFile: false,
omitAlias: false,
})).toBe('[The quick brown](SomeThing#^abc123)');
});
it('empty alias yields []() — caller is expected to provide non-empty alias', () => {
// 边界记录buildHeadingCopyMetadata 在 isNoteLink 且
// filename===heading 时会清空 alias。handlePaste 应在抵达
// 这里之前作为退化的自引用情况skip处理掉。
expect(buildExplicitPasteLink({
format: LinkFormat.MDLINK,
path: 'SomeThing',
subpath: '',
alias: '',
sameFile: false,
omitAlias: false,
})).toBe('[](SomeThing)');
});
});
});

View file

@ -1,22 +1,33 @@
import { LinkFormat } from './type';
// --- URL Encoding ---
// --- URL 编码 ---
export function encodeMarkdownLinkUrl(url: string): string {
return url.replace(/ /g, '%20');
}
// --- Heading Sanitization ---
// --- 去除 Wiki 链接外层括号 ---
/**
* Sanitize heading text for use in link targets.
* [[]]
* wiki 使
*/
export function stripWikiBrackets(s: string): string {
if (s.startsWith('[[') && s.endsWith(']]')) return s.slice(2, -2);
return s;
}
// --- 标题净化 ---
/**
* 使
*
* 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.
* Obsidian [[
* # | ^ : %% [[ ]]
* URL Obsidian
* URL
*
* See: https://help.obsidian.md/Linking+notes+and+files/Internal+links
* https://help.obsidian.md/Linking+notes+and+files/Internal+links
*/
export function sanitizeHeadingForLink(heading: string): string {
return heading
@ -25,7 +36,7 @@ export function sanitizeHeadingForLink(heading: string): string {
.trim();
}
// --- Heading Link ---
// --- 标题链接 ---
export interface BuildHeadingLinkOptions {
heading: string;
@ -35,6 +46,7 @@ export interface BuildHeadingLinkOptions {
useHeadingAsDisplayText: boolean;
headingLinkSeparator: string;
strictHeadingMatch?: boolean;
simplifiedHeadingToNoteLink: boolean;
}
export interface BuildHeadingLinkResult {
@ -42,72 +54,117 @@ export interface BuildHeadingLinkResult {
isNoteLink: boolean;
}
export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeadingLinkResult {
const { filename, linkFormat, useHeadingAsDisplayText, headingLinkSeparator, strictHeadingMatch } = options;
const filenameOrTitle = options.frontmatterTitle || filename;
interface ComputeDisplayTextOptions {
heading: string;
filename: string;
frontmatterTitle?: string;
useHeadingAsDisplayText: boolean;
headingLinkSeparator: string;
}
// 提取标题文本和级别
// 如果内容是[[内容]],移除[[]]
let selectedHeading = options.heading;
if (selectedHeading.startsWith('[[') && selectedHeading.endsWith(']]')) {
selectedHeading = selectedHeading.slice(2, -2);
}
function computeDisplayText(o: ComputeDisplayTextOptions): string {
if (o.useHeadingAsDisplayText) return o.heading;
const separator = o.headingLinkSeparator || '#';
const filenameOrTitle = o.frontmatterTitle || o.filename;
return `${filenameOrTitle}${separator}${o.heading}`;
}
// 根据设置决定显示文本
let displayText = selectedHeading;
if (!useHeadingAsDisplayText) {
// 如果不使用标题作为显示文本,则使用"文件名{连接符}标题名"格式
const separator = headingLinkSeparator || '#';
displayText = `${filenameOrTitle}${separator}${selectedHeading}`;
}
interface ShouldSimplifyHeadingOptions {
simplifiedHeadingToNoteLink: boolean;
useHeadingAsDisplayText: boolean;
selectedHeading: string;
filename: string;
strictHeadingMatch?: boolean;
}
const sanitizedHeading = sanitizeHeadingForLink(selectedHeading);
let linkContent = `${filename}#${sanitizedHeading}`;
let isNoteLink = false;
/**
* linkContent filenameisNoteLink true
*
* useHeadingAsDisplayText #
*
* WIKI filename === heading
*
*/
function shouldSimplifyHeading(o: ShouldSimplifyHeadingOptions): boolean {
if (!o.simplifiedHeadingToNoteLink) return false;
if (!o.useHeadingAsDisplayText) return false;
if (!o.selectedHeading) return false;
const compareIgnoreCase = (a: string, b: string): boolean =>
strictHeadingMatch
o.strictHeadingMatch
? a.toLowerCase() === b.toLowerCase()
: a.toLowerCase() === b.toLowerCase() || a.toLowerCase().includes(b.toLowerCase());
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
if (
selectedHeading &&
(filename === selectedHeading ||
compareIgnoreCase(filename, selectedHeading) ||
compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, '')))
) {
linkContent = filename;
isNoteLink = true;
}
return (
o.filename === o.selectedHeading ||
compareIgnoreCase(o.filename, o.selectedHeading) ||
compareIgnoreCase(o.filename, o.selectedHeading.replace(/\s+/g, ''))
);
}
let link = '';
interface FormatWikiHeadingLinkOptions {
linkContent: string;
displayText: string;
wikiExactMatch: boolean;
}
// 根据设置选择链接格式
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}`)})`;
function formatWikiHeadingLink(o: FormatWikiHeadingLinkOptions): string {
if (o.wikiExactMatch || o.displayText === o.linkContent) {
return `[[${o.linkContent}]]`;
}
return `[[${o.linkContent}|${o.displayText}]]`;
}
interface FormatMarkdownHeadingLinkOptions {
linkContent: string;
displayText: string;
}
function formatMarkdownHeadingLink(o: FormatMarkdownHeadingLinkOptions): string {
return `[${o.displayText}](${encodeMarkdownLinkUrl(o.linkContent)})`;
}
export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeadingLinkResult {
const selectedHeading = stripWikiBrackets(options.heading);
const displayText = computeDisplayText({
heading: selectedHeading,
filename: options.filename,
frontmatterTitle: options.frontmatterTitle,
useHeadingAsDisplayText: options.useHeadingAsDisplayText,
headingLinkSeparator: options.headingLinkSeparator,
});
const simplify = shouldSimplifyHeading({
simplifiedHeadingToNoteLink: options.simplifiedHeadingToNoteLink,
useHeadingAsDisplayText: options.useHeadingAsDisplayText,
selectedHeading,
filename: options.filename,
strictHeadingMatch: options.strictHeadingMatch,
});
// A1.5 例外WIKI 精确匹配filename === heading无论
// useHeadingAsDisplayText 如何,都简化为 [[filename]]——
// Obsidian 渲染时 [[filename]] 直接显示为文件名,干净利落。
// 该策略由调度层持有,使各个格式化函数保持为纯字符串生成器。
const wikiExactMatch =
options.linkFormat === LinkFormat.WIKILINK &&
options.simplifiedHeadingToNoteLink &&
options.filename === selectedHeading;
const isNoteLink = simplify || wikiExactMatch;
const linkContent = isNoteLink
? options.filename
: `${options.filename}#${sanitizeHeadingForLink(selectedHeading)}`;
const link = options.linkFormat === LinkFormat.WIKILINK
? formatWikiHeadingLink({ linkContent, displayText, wikiExactMatch })
: formatMarkdownHeadingLink({ linkContent, displayText });
return { link, isNoteLink };
}
// --- Block Display Text ---
// --- 块显示文本 ---
export function extractBlockDisplayText(
firstLine: string,
@ -150,7 +207,7 @@ export function extractBlockDisplayText(
return text;
}
// --- Block Link ---
// --- 块链接 ---
export interface BuildBlockLinkOptions {
blockId: string;
@ -196,7 +253,41 @@ export function buildBlockLink(options: BuildBlockLinkOptions): string {
return link;
}
// --- File Link ---
// --- 明确格式的粘贴链接 ---
//
// 当用户明确选择 Wiki / Markdown而非「跟随 Obsidian 设置」)时,
// 粘贴处理器会调用此函数。Obsidian 的 app.fileManager.generateMarkdownLink
// 会遵循 vault 的 useMarkdownLinks 配置,会覆盖用户的明确格式选择——
// 因此这里改为手动拼接链接,路径部分由 app.metadataCache.fileToLinktext
// 预先解析。
//
// 纯字符串生成器:是否省略 alias 由调用方通过 shouldOmitAliasForSameFile
// 决定本函数只消费该布尔值——Strategy 模式:调用方持有策略,
// 格式化函数只负责语法。
export interface BuildExplicitPasteLinkOptions {
format: LinkFormat.WIKILINK | LinkFormat.MDLINK;
path: string; // 来自 fileToLinktextvault 内最短唯一路径)
subpath: string; // '#Heading'、'#^blockid' 或 ''
alias: string; // 显示文本,或 ''
sameFile: boolean; // sourcePath === destPath——同文件粘贴时去掉路径段
omitAlias: boolean; // true → 渲染时省略 alias由调用方决定
}
export function buildExplicitPasteLink(opts: BuildExplicitPasteLinkOptions): string {
const linkTarget = opts.sameFile ? opts.subpath : `${opts.path}${opts.subpath}`;
const aliasToUse = opts.omitAlias ? '' : opts.alias;
if (opts.format === LinkFormat.WIKILINK) {
return aliasToUse ? `[[${linkTarget}|${aliasToUse}]]` : `[[${linkTarget}]]`;
}
// MDLINK——alias 就是用户看到的链接文本。当 omitAlias 把它清空时,
// 用原始 alias 作为显示文本,避免出现 [](path#H) 这样的空显示链接。
const display = aliasToUse || opts.alias || '';
return `[${display}](${encodeMarkdownLinkUrl(linkTarget)})`;
}
// --- 文件链接 ---
export interface BuildFileLinkOptions {
filename: string;

View file

@ -1,13 +1,19 @@
import { Editor, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo } from 'obsidian';
import { Editor, EventRef, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo, TFile } from 'obsidian';
import { Language, TranslationKey, I18n } from './i18n';
import { ContextData, ContextType, DEFAULT_SETTINGS, EasyCopySettings, LinkFormat, BlockIdInsertPosition } from './type';
import { EasyCopySettingTab } from './settingTab';
import { BlockIdInputModal } from './blockIdModal';
import { buildHeadingLink, buildBlockLink, buildFileLink } from './linkBuilder';
import { buildHeadingLink, buildBlockLink, buildFileLink, buildExplicitPasteLink } from './linkBuilder';
import { CopyMetadata, buildBlockCopyMetadata, buildHeadingCopyMetadata, buildFileCopyMetadata } from './copyMetadata';
import { decidePasteResolution, shouldOmitAliasForSameFile, shouldRegisterPasteHandler } from './pasteResolution';
const LAST_COPY_META_TTL_MS = 5 * 60 * 1000;
export default class EasyCopy extends Plugin {
settings: EasyCopySettings;
i18n: I18n;
private lastCopyMeta: CopyMetadata | null = null;
private pasteEventRef: EventRef | null = null;
async onload() {
await this.loadSettings();
@ -103,6 +109,46 @@ export default class EasyCopy extends Plugin {
}
})
);
// 手动复制Ctrl+C、右键复制等时清除 lastCopyMeta
// 避免在粘贴非 Easy Copy 内容时被误拦截。
// navigator.clipboard.writeText() 不会触发 DOM 的 copy 事件,
// 所以 Easy Copy 自身写入剪贴板时不受影响。
// 注意:其他也使用 navigator.clipboard.writeText() 的插件会
// 绕过这个监听器;冲突需要剪贴板文本完全相同,概率极低。
// 如果需要更稳健的识别机制,可改用自定义 ClipboardItem MIME 类型。
this.registerDomEvent(document, 'copy', () => {
this.lastCopyMeta = null;
});
// 粘贴时解析链接路径:启用后拦截粘贴,根据目标文件重新生成链接。
// 使用 Obsidian 的 editor-paste workspace 事件,
// 与其他插件保持协作(按注册顺序、遵循 defaultPrevented 协议)。
// 仅在开关启用时注册,关闭时彻底退出粘贴插件链。
this.syncPasteHandlerRegistration();
}
syncPasteHandlerRegistration(): void {
if (shouldRegisterPasteHandler(this.settings)) {
this.registerPasteHandler();
} else {
this.unregisterPasteHandler();
}
}
private registerPasteHandler(): void {
if (this.pasteEventRef) return;
this.pasteEventRef = this.app.workspace.on('editor-paste', (evt, editor, info) => {
this.handlePaste(evt, editor, info);
});
this.registerEvent(this.pasteEventRef);
}
private unregisterPasteHandler(): void {
if (!this.pasteEventRef) return;
this.app.workspace.offref(this.pasteEventRef);
this.pasteEventRef = null;
this.lastCopyMeta = null;
}
onunload() {
@ -117,6 +163,105 @@ export default class EasyCopy extends Plugin {
await this.saveData(this.settings);
}
/**
* Easy Copy
* Obsidian generateMarkdownLink()
* // Wiki/Markdown
* fileToLinktext()
*
* editor-paste preventDefault
* handler
* Linter
* Easy Copy
*/
private handlePaste(evt: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): void {
const clipboardText = evt.clipboardData?.getData('text/plain');
// 如果有活跃的 meta 且剪贴板内容匹配,但被其他插件抢先处理了,输出提示
if (evt.defaultPrevented && this.lastCopyMeta && clipboardText === this.lastCopyMeta.clipboardText) {
console.log('[Easy Copy] Paste event was already handled by another plugin. Link path resolution skipped. You can adjust plugin load order in .obsidian/community-plugins.json.');
return;
}
const decision = decidePasteResolution({
defaultPrevented: evt.defaultPrevented,
resolveLinkPathOnPaste: this.settings.resolveLinkPathOnPaste,
lastCopyMeta: this.lastCopyMeta,
clipboardText,
now: Date.now(),
ttlMs: LAST_COPY_META_TTL_MS,
});
if (decision === 'reset-and-skip') {
this.lastCopyMeta = null;
return;
}
if (decision === 'skip') return;
// decision === 'rewrite':此时 lastCopyMeta 和 clipboardText 必非空。
const meta = this.lastCopyMeta!;
const destFile = info.file;
if (!destFile) return;
// 退化的自引用:同文件粘贴且无锚点会生成 [[]] / [](#) 之类的空链接,
// 这种情况下让正常粘贴流程接手即可。
if (meta.subpath === '' && meta.sourceFilePath === destFile.path) return;
const sourceFile = this.app.vault.getAbstractFileByPath(meta.sourceFilePath);
if (!(sourceFile instanceof TFile)) return;
try {
const effectiveFormat = this.getEffectiveLinkFormat();
const omitAlias = shouldOmitAliasForSameFile({
effectiveLinkFormat: effectiveFormat,
sourceFilePath: meta.sourceFilePath,
destFilePath: destFile.path,
subpath: meta.subpath,
alias: meta.alias,
useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText,
});
let link: string;
if (this.settings.linkFormat === LinkFormat.OBSIDIAN) {
// generateMarkdownLink 会遵循 vault 配置useMarkdownLinks +
// newLinkFormat完整支持最短/相对/绝对三种路径风格。
const aliasArg = omitAlias ? undefined : (meta.alias || undefined);
link = this.app.fileManager.generateMarkdownLink(
sourceFile,
destFile.path,
meta.subpath || undefined,
aliasArg,
);
} else {
// 用户选择了明确的 Wiki/Markdown手动拼接以尊重该格式选择。
// fileToLinktext 只返回最短唯一路径(不支持相对/绝对路径,
// 这是有意识的权衡。omitMdExtension=true 必须传入——
// 默认 false 会带 ".md",破坏 wiki 链接约定([[Note.md]])。
const path = this.app.metadataCache.fileToLinktext(sourceFile, destFile.path, true);
link = buildExplicitPasteLink({
format: effectiveFormat as LinkFormat.WIKILINK | LinkFormat.MDLINK,
path,
subpath: meta.subpath,
alias: meta.alias,
sameFile: meta.sourceFilePath === destFile.path,
omitAlias,
});
}
if (meta.isEmbed) {
link = '!' + link;
}
if (link !== clipboardText) {
evt.preventDefault();
editor.replaceSelection(link);
}
} catch {
// 链接生成失败——让正常粘贴流程继续
}
}
/**
*
* @param key
@ -533,11 +678,27 @@ export default class EasyCopy extends Plugin {
navigator.clipboard.writeText(blockIdLink);
// 存储元数据,供粘贴时解析链接路径使用
const blockFile = this.app.workspace.getActiveFile();
if (blockFile) {
this.lastCopyMeta = buildBlockCopyMetadata({
clipboardText: blockIdLink,
sourceFilePath: blockFile.path,
blockId: content,
useBrief,
firstLine,
autoBlockDisplayText: this.settings.autoBlockDisplayText,
autoEmbedBlockLink: this.settings.autoEmbedBlockLink,
blockDisplayWordLimit: this.settings.blockDisplayWordLimit,
blockDisplayCharLimit: this.settings.blockDisplayCharLimit,
});
}
if (this.settings.showNotice) {
new Notice(this.t('block-id-copied'));
}
}
/**
*
*/
@ -564,10 +725,26 @@ export default class EasyCopy extends Plugin {
useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText,
headingLinkSeparator: this.settings.headingLinkSeparator,
strictHeadingMatch: this.settings.strictHeadingMatch,
simplifiedHeadingToNoteLink: this.settings.simplifiedHeadingToNoteLink,
});
navigator.clipboard.writeText(link);
// 存储元数据,供粘贴时解析链接路径使用
const headingFile = this.app.workspace.getActiveFile();
if (headingFile) {
this.lastCopyMeta = buildHeadingCopyMetadata({
clipboardText: link,
sourceFilePath: headingFile.path,
heading: content,
filename,
frontmatterTitle,
useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText,
headingLinkSeparator: this.settings.headingLinkSeparator,
isNoteLink,
});
}
if (isNoteLink) {
new Notice(this.t('note-link-simplified'));
}
@ -608,6 +785,14 @@ export default class EasyCopy extends Plugin {
});
navigator.clipboard.writeText(link);
// 存储元数据,供粘贴时解析链接路径使用
this.lastCopyMeta = buildFileCopyMetadata({
clipboardText: link,
sourceFilePath: file.path,
displayText,
});
if (this.settings.showNotice) {
new Notice(this.t('file-link-copied'));
}

181
src/pasteResolution.test.ts Normal file
View file

@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest';
import {
decidePasteResolution,
PasteResolutionInput,
shouldOmitAliasForSameFile,
shouldRegisterPasteHandler,
} from './pasteResolution';
import { CopyMetadata } from './copyMetadata';
import { LinkFormat } from './type';
describe('shouldRegisterPasteHandler', () => {
it('returns true when toggle is on', () => {
expect(shouldRegisterPasteHandler({ resolveLinkPathOnPaste: true })).toBe(true);
});
it('returns false when toggle is off', () => {
expect(shouldRegisterPasteHandler({ resolveLinkPathOnPaste: false })).toBe(false);
});
});
const META: CopyMetadata = {
clipboardText: '[[note#Heading]]',
sourceFilePath: 'notes/note.md',
subpath: '#Heading',
alias: 'Heading',
isEmbed: false,
timestamp: 1_000_000,
};
const TTL = 5 * 60 * 1000;
const baseInput: PasteResolutionInput = {
defaultPrevented: false,
resolveLinkPathOnPaste: true,
lastCopyMeta: META,
clipboardText: META.clipboardText,
now: META.timestamp + 1000,
ttlMs: TTL,
};
describe('decidePasteResolution', () => {
it('returns rewrite when all guards pass', () => {
expect(decidePasteResolution(baseInput)).toBe('rewrite');
});
it('skips when another handler already preventDefault\'d', () => {
expect(decidePasteResolution({ ...baseInput, defaultPrevented: true })).toBe('skip');
});
it('skips when the toggle is off', () => {
expect(decidePasteResolution({ ...baseInput, resolveLinkPathOnPaste: false })).toBe('skip');
});
it('skips when there is no lastCopyMeta', () => {
expect(decidePasteResolution({ ...baseInput, lastCopyMeta: null })).toBe('skip');
});
it('resets and skips when meta is older than TTL', () => {
expect(decidePasteResolution({
...baseInput,
now: META.timestamp + TTL + 1,
})).toBe('reset-and-skip');
});
it('does not consider meta stale at exactly the TTL boundary', () => {
expect(decidePasteResolution({
...baseInput,
now: META.timestamp + TTL,
})).toBe('rewrite');
});
it('resets and skips when clipboard text differs from copied text', () => {
expect(decidePasteResolution({
...baseInput,
clipboardText: 'something else entirely',
})).toBe('reset-and-skip');
});
it('resets and skips when clipboard text is undefined', () => {
expect(decidePasteResolution({
...baseInput,
clipboardText: undefined,
})).toBe('reset-and-skip');
});
it('defaultPrevented takes precedence over toggle and other state', () => {
expect(decidePasteResolution({
...baseInput,
defaultPrevented: true,
resolveLinkPathOnPaste: false,
lastCopyMeta: null,
})).toBe('skip');
});
it('toggle takes precedence over meta state', () => {
expect(decidePasteResolution({
...baseInput,
resolveLinkPathOnPaste: false,
lastCopyMeta: null,
})).toBe('skip');
});
it('TTL check runs before clipboard match', () => {
// 过期的 meta 加上不匹配的剪贴板文本——TTL 检查会先触发,
// 两种情况下结果都是 reset-and-skip但根因是过期。
expect(decidePasteResolution({
...baseInput,
now: META.timestamp + TTL + 1,
clipboardText: 'mismatch',
})).toBe('reset-and-skip');
});
});
describe('shouldOmitAliasForSameFile', () => {
const sameFile = 'notes/SomeThing.md';
const otherFile = 'notes/MyNote.md';
const base = {
effectiveLinkFormat: LinkFormat.WIKILINK,
sourceFilePath: sameFile,
destFilePath: sameFile,
subpath: '#Other Heading',
alias: 'Other Heading',
useHeadingAsDisplayText: true,
};
it('omits when WIKI + same-file + heading subpath matches alias (sanitized)', () => {
expect(shouldOmitAliasForSameFile(base)).toBe(true);
});
it('keeps alias when WIKI + same-file but alias does not match heading', () => {
expect(shouldOmitAliasForSameFile({ ...base, alias: 'Different' })).toBe(false);
});
it('keeps alias when useHeadingAsDisplayText is false', () => {
expect(shouldOmitAliasForSameFile({
...base,
useHeadingAsDisplayText: false,
alias: 'SomeThing#Other Heading',
})).toBe(false);
});
it('keeps alias on cross-file paste (WIKI)', () => {
expect(shouldOmitAliasForSameFile({ ...base, destFilePath: otherFile })).toBe(false);
});
it('keeps alias for MD format even on same-file', () => {
expect(shouldOmitAliasForSameFile({ ...base, effectiveLinkFormat: LinkFormat.MDLINK })).toBe(false);
});
it('keeps alias when subpath is empty', () => {
expect(shouldOmitAliasForSameFile({ ...base, subpath: '' })).toBe(false);
});
it('keeps alias when alias is empty', () => {
expect(shouldOmitAliasForSameFile({ ...base, alias: '' })).toBe(false);
});
it('keeps alias on block-link subpath (#^id)', () => {
expect(shouldOmitAliasForSameFile({
...base,
subpath: '#^abc123',
alias: 'The quick brown',
})).toBe(false);
});
it('omits when sanitization round-trip collapses alias to subpath', () => {
// alias 中的 | 会被 sanitizeHeadingForLink 折叠为空格
expect(shouldOmitAliasForSameFile({
...base,
subpath: '#Some Heading',
alias: 'Some|Heading',
})).toBe(true);
});
it('keeps alias when OBSIDIAN is passed (helper expects already-resolved format)', () => {
// 防御性检查:调用方不应在此传入 OBSIDIAN
// 即使误传,本函数也会安全地返回 false。
expect(shouldOmitAliasForSameFile({ ...base, effectiveLinkFormat: LinkFormat.OBSIDIAN })).toBe(false);
});
});

65
src/pasteResolution.ts Normal file
View file

@ -0,0 +1,65 @@
import { CopyMetadata } from './copyMetadata';
import { sanitizeHeadingForLink } from './linkBuilder';
import { EasyCopySettings, LinkFormat } from './type';
/**
* editor-paste
* Easy Copy
*/
export function shouldRegisterPasteHandler(
settings: Pick<EasyCopySettings, 'resolveLinkPathOnPaste'>,
): boolean {
return settings.resolveLinkPathOnPaste;
}
export type PasteResolutionAction =
| 'skip' // 什么都不做,保留 lastCopyMeta 不变
| 'reset-and-skip' // 清空 lastCopyMeta再让粘贴流程继续
| 'rewrite'; // 重新生成链接并替换粘贴内容
export interface PasteResolutionInput {
defaultPrevented: boolean;
resolveLinkPathOnPaste: boolean;
lastCopyMeta: CopyMetadata | null;
clipboardText: string | undefined;
now: number;
ttlMs: number;
}
export function decidePasteResolution(input: PasteResolutionInput): PasteResolutionAction {
if (input.defaultPrevented) return 'skip';
if (!input.resolveLinkPathOnPaste) return 'skip';
if (!input.lastCopyMeta) return 'skip';
if (input.now - input.lastCopyMeta.timestamp > input.ttlMs) return 'reset-and-skip';
if (input.clipboardText !== input.lastCopyMeta.clipboardText) return 'reset-and-skip';
return 'rewrite';
}
export interface ShouldOmitAliasInput {
effectiveLinkFormat: LinkFormat;
sourceFilePath: string;
destFilePath: string;
subpath: string;
alias: string;
useHeadingAsDisplayText: boolean;
}
/**
* alias
*
* wiki
* `[[#Heading]]` Heading alias
* wiki alias Obsidian
* Filename > HeadingMarkdown alias
* alias
* alias `#^id`
*
*/
export function shouldOmitAliasForSameFile(input: ShouldOmitAliasInput): boolean {
if (input.effectiveLinkFormat !== LinkFormat.WIKILINK) return false;
if (input.sourceFilePath !== input.destFilePath) return false;
if (!input.subpath || !input.alias) return false;
if (!input.useHeadingAsDisplayText) return false;
if (input.subpath.startsWith('#^')) return false;
return input.subpath === `#${sanitizeHeadingForLink(input.alias)}`;
}

View file

@ -119,8 +119,37 @@ export class EasyCopySettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.linkFormat = value as LinkFormat;
await this.plugin.saveSettings();
this.plugin.syncPasteHandlerRegistration();
this.display();
})));
// 解析器在粘贴时拦截事件,根据目标文件重新生成链接。
// 「跟随 Obsidian 设置」时遵循 vault 的路径风格(最短/相对/绝对);
// 选择明确的 Wiki/Markdown 格式时仅使用最短唯一路径。
formatGroup.addSetting(setting => {
const descFragment = document.createDocumentFragment();
descFragment.append(this.plugin.t('resolve-link-path-on-paste-desc') + ' ');
const infoIcon = descFragment.createEl('span', {
attr: {
'aria-label': this.plugin.t('resolve-link-path-on-paste-tooltip'),
'class': 'clickable-icon setting-editor-extra-setting-button',
'style': 'display:inline; vertical-align:middle; cursor:help;',
},
});
infoIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>';
setting
.setName(this.plugin.t('resolve-link-path-on-paste'))
.setDesc(descFragment)
.addToggle(toggle => toggle
.setValue(this.plugin.settings.resolveLinkPathOnPaste)
.onChange(async (value) => {
this.plugin.settings.resolveLinkPathOnPaste = value;
await this.plugin.saveSettings();
this.plugin.syncPasteHandlerRegistration();
}));
});
formatGroup.addSetting(setting => setting
.setName(this.plugin.t('use-heading-as-display'))
.setDesc(this.plugin.t('use-heading-as-display-desc'))
@ -148,6 +177,7 @@ export class EasyCopySettingTab extends PluginSettingTab {
}
// 后续新增:文件名包含标题时,简化为复制文件链接(通常用于复制一级标题时)
// 「跟随 Obsidian 设置」时,格式与路径选择应交给 Obsidian——
formatGroup.addSetting(setting => setting
.setName(this.plugin.t('simplified-heading-to-note-link'))
.setDesc(this.plugin.t('simplified-heading-to-note-link-desc'))

View file

@ -44,6 +44,7 @@ export interface EasyCopySettings {
simplifiedHeadingToNoteLink: boolean; // 是否简化标题到笔记链接
strictHeadingMatch: boolean; // 标题与文件名简化匹配时是否使用严格匹配
linkFormat: LinkFormat;
resolveLinkPathOnPaste: boolean; // 粘贴时根据目标文件重新解析链接路径OBSIDIAN 格式支持完整路径风格,明确 Wiki/Markdown 仅支持最短唯一路径)
customizeTargets: boolean;
enableInlineCode: boolean;
enableBold: boolean;
@ -76,6 +77,7 @@ export const DEFAULT_SETTINGS: EasyCopySettings = {
simplifiedHeadingToNoteLink: true,
strictHeadingMatch: false,
linkFormat: LinkFormat.OBSIDIAN,
resolveLinkPathOnPaste: false,
customizeTargets: false,
enableInlineCode: true,
enableBold: true,