mirror of
https://github.com/moyf/easy-copy.git
synced 2026-07-22 05:43:47 +00:00
Merge pull request #35 from lightmotive/feat/follow-obsidian-paste-resolution
feat: paste-time link resolution + heading-link polish
This commit is contained in:
commit
d13b66a69c
13 changed files with 1590 additions and 213 deletions
41
CONTRIBUTING-zh.md
Normal file
41
CONTRIBUTING-zh.md
Normal 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 错误
|
||||
```
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
# Contributing
|
||||
|
||||
English | [中文文档](./CONTRIBUTING-zh.md)
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
|
|
|
|||
110
package-lock.json
generated
110
package-lock.json
generated
|
|
@ -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
255
src/copyMetadata.test.ts
Normal 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
115
src/copyMetadata.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
10
src/i18n.ts
10
src/i18n.ts
|
|
@ -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': '啟用行內代碼',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
//  is technically an image embed in standard markdown —
|
||||
// documenting that this combo produces that syntax
|
||||
//  在标准 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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 → filename,isNoteLink → 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; // 来自 fileToLinktext(vault 内最短唯一路径)
|
||||
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;
|
||||
|
|
|
|||
191
src/main.ts
191
src/main.ts
|
|
@ -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
181
src/pasteResolution.test.ts
Normal 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
65
src/pasteResolution.ts
Normal 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 > Heading」)。Markdown 粘贴永远需要 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)}`;
|
||||
}
|
||||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue