From 2c51e4e52dddbf1c5f3d1359953bc626f22c4298 Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:30:33 -0600 Subject: [PATCH 01/14] feat: paste-time path resolution using Obsidian's link format settings Intercept paste (DOM capture phase) to regenerate Easy Copy links via generateMarkdownLink(), resolving shortest/relative/absolute paths based on the destination file. Metadata is cleared on manual copy to prevent false interception of non-Easy-Copy content. --- src/main.ts | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index 4a0f43c..4bb88e2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,22 @@ -import { Editor, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo } from 'obsidian'; +import { Editor, 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, sanitizeHeadingForLink, extractBlockDisplayText } from './linkBuilder'; + +interface CopyMetadata { + clipboardText: string; + sourceFilePath: string; + subpath: string; + alias: string; + isEmbed: boolean; +} export default class EasyCopy extends Plugin { settings: EasyCopySettings; i18n: I18n; + private lastCopyMeta: CopyMetadata | null = null; async onload() { await this.loadSettings(); @@ -103,6 +112,26 @@ export default class EasyCopy extends Plugin { } }) ); + + // Clear paste metadata on manual copy (Ctrl+C, right-click copy, etc.) + // so we don't falsely intercept paste of non-Easy-Copy content. + // navigator.clipboard.writeText() does not fire DOM copy events, + // so Easy Copy's own writes are unaffected. + // Note: plugins that also use navigator.clipboard.writeText() bypass + // this listener. A conflict would require identical clipboard text, + // which is extremely unlikely. If needed, a custom ClipboardItem MIME + // type could serve as a more robust authentication mechanism. + this.registerDomEvent(document, 'copy', () => { + this.lastCopyMeta = null; + }); + + // Paste-time path resolution: when "Follow Obsidian settings" is active, + // intercept paste to regenerate the link with correct path format + // (shortest/relative/absolute) based on the destination file. + // Uses DOM capture phase to intercept before Obsidian processes the paste. + this.registerDomEvent(document, 'paste', (evt: ClipboardEvent) => { + this.handlePaste(evt); + }, true); } onunload() { @@ -117,6 +146,48 @@ export default class EasyCopy extends Plugin { await this.saveData(this.settings); } + /** + * Intercept paste when the clipboard contains an Easy Copy link. + * Uses Obsidian's generateMarkdownLink() to resolve the correct path + * format (shortest/relative/absolute) for the destination file. + */ + private handlePaste(evt: ClipboardEvent): void { + if (!this.lastCopyMeta) return; + if (this.settings.linkFormat !== LinkFormat.OBSIDIAN) return; + + const clipboardText = evt.clipboardData?.getData('text/plain'); + if (clipboardText !== this.lastCopyMeta.clipboardText) { + this.lastCopyMeta = null; + return; + } + + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view?.file) return; + + const sourceFile = this.app.vault.getAbstractFileByPath(this.lastCopyMeta.sourceFilePath); + if (!(sourceFile instanceof TFile)) return; + + try { + let link = this.app.fileManager.generateMarkdownLink( + sourceFile, + view.file.path, + this.lastCopyMeta.subpath || undefined, + this.lastCopyMeta.alias || undefined, + ); + + if (this.lastCopyMeta.isEmbed) { + link = '!' + link; + } + + if (link !== clipboardText) { + evt.preventDefault(); + view.editor.replaceSelection(link); + } + } catch { + // generateMarkdownLink failed — let normal paste proceed + } + } + /** * 获取本地化文本 * @param key 翻译键值 @@ -533,11 +604,30 @@ export default class EasyCopy extends Plugin { navigator.clipboard.writeText(blockIdLink); + // Store metadata for paste-time path resolution + const blockFile = this.app.workspace.getActiveFile(); + if (blockFile) { + let alias = content; + if (useBrief && firstLine) { + alias = extractBlockDisplayText(firstLine, content, this.settings.blockDisplayWordLimit, this.settings.blockDisplayCharLimit); + } + if (!this.settings.autoBlockDisplayText) { + alias = ''; + } + this.lastCopyMeta = { + clipboardText: blockIdLink, + sourceFilePath: blockFile.path, + subpath: `#^${content}`, + alias, + isEmbed: this.settings.autoEmbedBlockLink, + }; + } + if (this.settings.showNotice) { new Notice(this.t('block-id-copied')); } } - + /** * 复制标题链接 */ @@ -568,6 +658,31 @@ export default class EasyCopy extends Plugin { navigator.clipboard.writeText(link); + // Store metadata for paste-time path resolution + const headingFile = this.app.workspace.getActiveFile(); + if (headingFile) { + let heading = content; + if (heading.startsWith('[[') && heading.endsWith(']]')) { + heading = heading.slice(2, -2); + } + let alias = heading; + if (!this.settings.useHeadingAsDisplayText) { + const separator = this.settings.headingLinkSeparator || '#'; + const filenameOrTitle = frontmatterTitle || filename; + alias = `${filenameOrTitle}${separator}${heading}`; + } + if (isNoteLink && filename === heading) { + alias = ''; + } + this.lastCopyMeta = { + clipboardText: link, + sourceFilePath: headingFile.path, + subpath: isNoteLink ? '' : `#${sanitizeHeadingForLink(heading)}`, + alias, + isEmbed: false, + }; + } + if (isNoteLink) { new Notice(this.t('note-link-simplified')); } @@ -608,6 +723,16 @@ export default class EasyCopy extends Plugin { }); navigator.clipboard.writeText(link); + + // Store metadata for paste-time path resolution + this.lastCopyMeta = { + clipboardText: link, + sourceFilePath: file.path, + subpath: '', + alias: displayText || '', + isEmbed: false, + }; + if (this.settings.showNotice) { new Notice(this.t('file-link-copied')); } From 3c3c20800f2180e91da2efc7872b4647a6c8d0af Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Wed, 22 Apr 2026 04:06:58 -0600 Subject: [PATCH 02/14] test: extract copy-metadata builders and add unit tests Move the inline metadata-construction logic from the three copy paths (block/heading/file) into pure helpers in src/copyMetadata.ts, matching the project's pure-function test style. Adds 25 unit tests covering alias, subpath, and embed computation for each copy type. --- src/copyMetadata.test.ts | 228 +++++++++++++++++++++++++++++++++++++++ src/copyMetadata.ts | 114 ++++++++++++++++++++ src/main.ts | 66 ++++-------- 3 files changed, 364 insertions(+), 44 deletions(-) create mode 100644 src/copyMetadata.test.ts create mode 100644 src/copyMetadata.ts diff --git a/src/copyMetadata.test.ts b/src/copyMetadata.test.ts new file mode 100644 index 0000000..cd76a2c --- /dev/null +++ b/src/copyMetadata.test.ts @@ -0,0 +1,228 @@ +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 #^', () => { + 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 truncates to the word limit + 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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'); + }); +}); diff --git a/src/copyMetadata.ts b/src/copyMetadata.ts new file mode 100644 index 0000000..270c9f6 --- /dev/null +++ b/src/copyMetadata.ts @@ -0,0 +1,114 @@ +import { extractBlockDisplayText, sanitizeHeadingForLink } from './linkBuilder'; + +export interface CopyMetadata { + clipboardText: string; + sourceFilePath: string; + subpath: string; + alias: string; + isEmbed: boolean; +} + +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, + }; +} + +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; + + let heading = rawHeading; + if (heading.startsWith('[[') && heading.endsWith(']]')) { + heading = heading.slice(2, -2); + } + + 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, + }; +} + +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, + }; +} diff --git a/src/main.ts b/src/main.ts index 4bb88e2..816ccf9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,15 +3,8 @@ 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, sanitizeHeadingForLink, extractBlockDisplayText } from './linkBuilder'; - -interface CopyMetadata { - clipboardText: string; - sourceFilePath: string; - subpath: string; - alias: string; - isEmbed: boolean; -} +import { buildHeadingLink, buildBlockLink, buildFileLink } from './linkBuilder'; +import { CopyMetadata, buildBlockCopyMetadata, buildHeadingCopyMetadata, buildFileCopyMetadata } from './copyMetadata'; export default class EasyCopy extends Plugin { settings: EasyCopySettings; @@ -607,20 +600,17 @@ export default class EasyCopy extends Plugin { // Store metadata for paste-time path resolution const blockFile = this.app.workspace.getActiveFile(); if (blockFile) { - let alias = content; - if (useBrief && firstLine) { - alias = extractBlockDisplayText(firstLine, content, this.settings.blockDisplayWordLimit, this.settings.blockDisplayCharLimit); - } - if (!this.settings.autoBlockDisplayText) { - alias = ''; - } - this.lastCopyMeta = { + this.lastCopyMeta = buildBlockCopyMetadata({ clipboardText: blockIdLink, sourceFilePath: blockFile.path, - subpath: `#^${content}`, - alias, - isEmbed: this.settings.autoEmbedBlockLink, - }; + blockId: content, + useBrief, + firstLine, + autoBlockDisplayText: this.settings.autoBlockDisplayText, + autoEmbedBlockLink: this.settings.autoEmbedBlockLink, + blockDisplayWordLimit: this.settings.blockDisplayWordLimit, + blockDisplayCharLimit: this.settings.blockDisplayCharLimit, + }); } if (this.settings.showNotice) { @@ -661,26 +651,16 @@ export default class EasyCopy extends Plugin { // Store metadata for paste-time path resolution const headingFile = this.app.workspace.getActiveFile(); if (headingFile) { - let heading = content; - if (heading.startsWith('[[') && heading.endsWith(']]')) { - heading = heading.slice(2, -2); - } - let alias = heading; - if (!this.settings.useHeadingAsDisplayText) { - const separator = this.settings.headingLinkSeparator || '#'; - const filenameOrTitle = frontmatterTitle || filename; - alias = `${filenameOrTitle}${separator}${heading}`; - } - if (isNoteLink && filename === heading) { - alias = ''; - } - this.lastCopyMeta = { + this.lastCopyMeta = buildHeadingCopyMetadata({ clipboardText: link, sourceFilePath: headingFile.path, - subpath: isNoteLink ? '' : `#${sanitizeHeadingForLink(heading)}`, - alias, - isEmbed: false, - }; + heading: content, + filename, + frontmatterTitle, + useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText, + headingLinkSeparator: this.settings.headingLinkSeparator, + isNoteLink, + }); } if (isNoteLink) { @@ -725,13 +705,11 @@ export default class EasyCopy extends Plugin { navigator.clipboard.writeText(link); // Store metadata for paste-time path resolution - this.lastCopyMeta = { + this.lastCopyMeta = buildFileCopyMetadata({ clipboardText: link, sourceFilePath: file.path, - subpath: '', - alias: displayText || '', - isEmbed: false, - }; + displayText, + }); if (this.settings.showNotice) { new Notice(this.t('file-link-copied')); From 4ac623b1bdae5b4b4f12c800901c115e41f33721 Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:35:51 -0600 Subject: [PATCH 03/14] feat: opt-in toggle, editor-paste cooperation, and orphaned-setting fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #35 maintainer feedback: gate paste-time resolution behind a default-off toggle with 5-min TTL on lastCopyMeta. Switch from DOM paste (capture phase) to editor-paste workspace event with documented defaultPrevented yield protocol; register only when active. Also fixes a pre-existing bug where simplifiedHeadingToNoteLink was never read — the simplification fired unconditionally when filename matched heading. Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code. --- src/copyMetadata.test.ts | 27 +++++++ src/copyMetadata.ts | 4 + src/i18n.ts | 7 ++ src/linkBuilder.test.ts | 68 +++++++++++++++++ src/linkBuilder.ts | 7 +- src/main.ts | 85 ++++++++++++++++------ src/pasteResolution.test.ts | 141 ++++++++++++++++++++++++++++++++++++ src/pasteResolution.ts | 37 ++++++++++ src/settingTab.ts | 17 +++++ src/type.ts | 2 + 10 files changed, 372 insertions(+), 23 deletions(-) create mode 100644 src/pasteResolution.test.ts create mode 100644 src/pasteResolution.ts diff --git a/src/copyMetadata.test.ts b/src/copyMetadata.test.ts index cd76a2c..be3e0b0 100644 --- a/src/copyMetadata.test.ts +++ b/src/copyMetadata.test.ts @@ -70,6 +70,14 @@ describe('buildBlockCopyMetadata', () => { 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); + }); }); // --------------------------------------------------------------------------- @@ -174,6 +182,14 @@ describe('buildHeadingCopyMetadata', () => { 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); + }); }); // --------------------------------------------------------------------------- @@ -225,4 +241,15 @@ describe('buildFileCopyMetadata', () => { 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); + }); }); diff --git a/src/copyMetadata.ts b/src/copyMetadata.ts index 270c9f6..3b1efca 100644 --- a/src/copyMetadata.ts +++ b/src/copyMetadata.ts @@ -6,6 +6,7 @@ export interface CopyMetadata { subpath: string; alias: string; isEmbed: boolean; + timestamp: number; } export interface BuildBlockCopyMetadataInput { @@ -47,6 +48,7 @@ export function buildBlockCopyMetadata(input: BuildBlockCopyMetadataInput): Copy subpath: `#^${blockId}`, alias, isEmbed: autoEmbedBlockLink, + timestamp: Date.now(), }; } @@ -94,6 +96,7 @@ export function buildHeadingCopyMetadata(input: BuildHeadingCopyMetadataInput): subpath: isNoteLink ? '' : `#${sanitizeHeadingForLink(heading)}`, alias, isEmbed: false, + timestamp: Date.now(), }; } @@ -110,5 +113,6 @@ export function buildFileCopyMetadata(input: BuildFileCopyMetadataInput): CopyMe subpath: '', alias: input.displayText || '', isEmbed: false, + timestamp: Date.now(), }; } diff --git a/src/i18n.ts b/src/i18n.ts index 6e497f5..bc01344 100644 --- a/src/i18n.ts +++ b/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' | 'copy-current-file-link' | 'file-link-copied' | 'target' | 'customize-targets' | 'customize-targets-desc' | 'enable-bold' | 'enable-bold-desc' @@ -107,6 +108,8 @@ export const translations: Record> = { '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': 'When using "Follow Obsidian settings", regenerate the link path (shortest/relative/absolute) at paste time based on the destination file. Won\'t interfere with other paste-handling plugins; if one handles the paste first, this feature may be bypassed depending on how that plugin handles the paste.', '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 +208,8 @@ export const translations: Record> = { 'link-format-obsidian': '跟随 Obsidian 设置', 'markdown-link': 'Markdown链接', 'wiki-link': 'Wiki链接', + 'resolve-link-path-on-paste': '粘贴时解析链接路径', + 'resolve-link-path-on-paste-desc': '在使用"跟随 Obsidian 设置"时,根据目标文件在粘贴时重新生成链接路径(最短/相对/绝对)。不会干扰其他处理粘贴的插件;若有其他插件先处理粘贴,此功能可能被绕过,具体取决于该插件的处理方式。', 'customize-targets': '自定义复制对象', 'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)', 'enable-inline-code': '启用行内代码', @@ -313,6 +318,8 @@ export const translations: Record> = { 'link-format-obsidian': '跟隨 Obsidian 設定', 'markdown-link': 'Markdown連結', 'wiki-link': 'Wiki連結', + 'resolve-link-path-on-paste': '貼上時解析連結路徑', + 'resolve-link-path-on-paste-desc': '在使用「跟隨 Obsidian 設定」時,根據目標檔案在貼上時重新生成連結路徑(最短/相對/絕對)。不會干擾其他處理貼上的外掛;若有其他外掛先處理貼上,此功能可能被略過,具體取決於該外掛的處理方式。', 'customize-targets': '自定義複製對象', 'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)', 'enable-inline-code': '啟用行內代碼', diff --git a/src/linkBuilder.test.ts b/src/linkBuilder.test.ts index 6f04940..44a597f 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -77,6 +77,7 @@ describe('buildHeadingLink', () => { linkFormat: LinkFormat.WIKILINK, useHeadingAsDisplayText: true, headingLinkSeparator: '#', + simplifiedHeadingToNoteLink: true, }; // -- Wiki format --------------------------------------------------------- @@ -364,6 +365,73 @@ describe('buildHeadingLink', () => { }); }); + // -- simplifiedHeadingToNoteLink: false --------------------------------- + + 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 always includes heading regardless (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); + }); + }); + // -- Combinatorial -------------------------------------------------------- describe('combinatorial', () => { diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index f5a0091..e68fd16 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -35,6 +35,7 @@ export interface BuildHeadingLinkOptions { useHeadingAsDisplayText: boolean; headingLinkSeparator: string; strictHeadingMatch?: boolean; + simplifiedHeadingToNoteLink: boolean; } export interface BuildHeadingLinkResult { @@ -43,7 +44,7 @@ export interface BuildHeadingLinkResult { } export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeadingLinkResult { - const { filename, linkFormat, useHeadingAsDisplayText, headingLinkSeparator, strictHeadingMatch } = options; + const { filename, linkFormat, useHeadingAsDisplayText, headingLinkSeparator, strictHeadingMatch, simplifiedHeadingToNoteLink } = options; const filenameOrTitle = options.frontmatterTitle || filename; // 提取标题文本和级别 @@ -72,7 +73,9 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading // 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分 // 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分 + // Gated on simplifiedHeadingToNoteLink so users can opt out and preserve the heading fragment. if ( + simplifiedHeadingToNoteLink && selectedHeading && (filename === selectedHeading || compareIgnoreCase(filename, selectedHeading) || @@ -87,7 +90,7 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading // 根据设置选择链接格式 if (linkFormat === LinkFormat.WIKILINK) { // Wiki链接格式 - if (filename === selectedHeading) { + if (simplifiedHeadingToNoteLink && filename === selectedHeading) { // 特殊情况:当文件名与标题相同时,直接链接到文件 link = `[[${filename}]]`; isNoteLink = true; diff --git a/src/main.ts b/src/main.ts index 816ccf9..2fc3276 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,15 +1,19 @@ -import { Editor, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo, TFile } 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 { CopyMetadata, buildBlockCopyMetadata, buildHeadingCopyMetadata, buildFileCopyMetadata } from './copyMetadata'; +import { decidePasteResolution, 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(); @@ -118,13 +122,37 @@ export default class EasyCopy extends Plugin { this.lastCopyMeta = null; }); - // Paste-time path resolution: when "Follow Obsidian settings" is active, - // intercept paste to regenerate the link with correct path format - // (shortest/relative/absolute) based on the destination file. - // Uses DOM capture phase to intercept before Obsidian processes the paste. - this.registerDomEvent(document, 'paste', (evt: ClipboardEvent) => { - this.handlePaste(evt); - }, true); + // Paste-time path resolution: when enabled and "Follow Obsidian settings" + // is active, intercept paste to regenerate the link with correct path + // format (shortest/relative/absolute) based on the destination file. + // Uses Obsidian's editor-paste workspace event so we cooperate with + // other plugins (registration order, defaultPrevented handshake). + // Only registered when both conditions hold, so we're absent from the + // plugin chain entirely when the user has the feature off. + 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() { @@ -143,38 +171,52 @@ export default class EasyCopy extends Plugin { * Intercept paste when the clipboard contains an Easy Copy link. * Uses Obsidian's generateMarkdownLink() to resolve the correct path * format (shortest/relative/absolute) for the destination file. + * + * Per the editor-paste contract (obsidian.d.ts): yield if another handler + * has already preventDefault'd, and call preventDefault to claim the event. */ - private handlePaste(evt: ClipboardEvent): void { - if (!this.lastCopyMeta) return; - if (this.settings.linkFormat !== LinkFormat.OBSIDIAN) return; - + private handlePaste(evt: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): void { const clipboardText = evt.clipboardData?.getData('text/plain'); - if (clipboardText !== this.lastCopyMeta.clipboardText) { + const decision = decidePasteResolution({ + defaultPrevented: evt.defaultPrevented, + resolveLinkPathOnPaste: this.settings.resolveLinkPathOnPaste, + linkFormat: this.settings.linkFormat, + 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; - const view = this.app.workspace.getActiveViewOfType(MarkdownView); - if (!view?.file) return; + // decision === 'rewrite' — lastCopyMeta and clipboardText are non-null here. + const meta = this.lastCopyMeta!; - const sourceFile = this.app.vault.getAbstractFileByPath(this.lastCopyMeta.sourceFilePath); + const destFile = info.file; + if (!destFile) return; + + const sourceFile = this.app.vault.getAbstractFileByPath(meta.sourceFilePath); if (!(sourceFile instanceof TFile)) return; try { let link = this.app.fileManager.generateMarkdownLink( sourceFile, - view.file.path, - this.lastCopyMeta.subpath || undefined, - this.lastCopyMeta.alias || undefined, + destFile.path, + meta.subpath || undefined, + meta.alias || undefined, ); - if (this.lastCopyMeta.isEmbed) { + if (meta.isEmbed) { link = '!' + link; } if (link !== clipboardText) { evt.preventDefault(); - view.editor.replaceSelection(link); + editor.replaceSelection(link); } } catch { // generateMarkdownLink failed — let normal paste proceed @@ -644,6 +686,7 @@ 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); diff --git a/src/pasteResolution.test.ts b/src/pasteResolution.test.ts new file mode 100644 index 0000000..d3ddeb5 --- /dev/null +++ b/src/pasteResolution.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { decidePasteResolution, PasteResolutionInput, shouldRegisterPasteHandler } from './pasteResolution'; +import { CopyMetadata } from './copyMetadata'; +import { LinkFormat } from './type'; + +describe('shouldRegisterPasteHandler', () => { + it('returns true when toggle is on AND linkFormat is OBSIDIAN', () => { + expect(shouldRegisterPasteHandler({ + resolveLinkPathOnPaste: true, + linkFormat: LinkFormat.OBSIDIAN, + })).toBe(true); + }); + + it('returns false when toggle is off', () => { + expect(shouldRegisterPasteHandler({ + resolveLinkPathOnPaste: false, + linkFormat: LinkFormat.OBSIDIAN, + })).toBe(false); + }); + + it('returns false when linkFormat is MDLINK even if toggle is on', () => { + expect(shouldRegisterPasteHandler({ + resolveLinkPathOnPaste: true, + linkFormat: LinkFormat.MDLINK, + })).toBe(false); + }); + + it('returns false when linkFormat is WIKILINK even if toggle is on', () => { + expect(shouldRegisterPasteHandler({ + resolveLinkPathOnPaste: true, + linkFormat: LinkFormat.WIKILINK, + })).toBe(false); + }); + + it('returns false when both conditions fail', () => { + expect(shouldRegisterPasteHandler({ + resolveLinkPathOnPaste: false, + linkFormat: LinkFormat.MDLINK, + })).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, + linkFormat: LinkFormat.OBSIDIAN, + 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 linkFormat is not OBSIDIAN', () => { + expect(decidePasteResolution({ ...baseInput, linkFormat: LinkFormat.MDLINK })).toBe('skip'); + expect(decidePasteResolution({ ...baseInput, linkFormat: LinkFormat.WIKILINK })).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 linkFormat and meta state', () => { + expect(decidePasteResolution({ + ...baseInput, + resolveLinkPathOnPaste: false, + linkFormat: LinkFormat.MDLINK, + lastCopyMeta: null, + })).toBe('skip'); + }); + + it('TTL check runs before clipboard match', () => { + // Stale meta with mismatched clipboard text — TTL should fire first, + // resulting in reset-and-skip either way, but staleness is the cause. + expect(decidePasteResolution({ + ...baseInput, + now: META.timestamp + TTL + 1, + clipboardText: 'mismatch', + })).toBe('reset-and-skip'); + }); +}); diff --git a/src/pasteResolution.ts b/src/pasteResolution.ts new file mode 100644 index 0000000..70cf57f --- /dev/null +++ b/src/pasteResolution.ts @@ -0,0 +1,37 @@ +import { CopyMetadata } from './copyMetadata'; +import { EasyCopySettings, LinkFormat } from './type'; + +/** + * Whether the editor-paste handler should be registered for the given settings. + * The bridge between settings state and "is Easy Copy in the paste plugin chain." + */ +export function shouldRegisterPasteHandler( + settings: Pick, +): boolean { + return settings.resolveLinkPathOnPaste && settings.linkFormat === LinkFormat.OBSIDIAN; +} + +export type PasteResolutionAction = + | 'skip' // do nothing, leave lastCopyMeta as-is + | 'reset-and-skip' // null out lastCopyMeta, do nothing else + | 'rewrite'; // proceed to regenerate and substitute the link + +export interface PasteResolutionInput { + defaultPrevented: boolean; + resolveLinkPathOnPaste: boolean; + linkFormat: LinkFormat; + 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.linkFormat !== LinkFormat.OBSIDIAN) 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'; +} diff --git a/src/settingTab.ts b/src/settingTab.ts index dba644e..bf09b9d 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -119,8 +119,25 @@ export class EasyCopySettingTab extends PluginSettingTab { .onChange(async (value) => { this.plugin.settings.linkFormat = value as LinkFormat; await this.plugin.saveSettings(); + this.plugin.syncPasteHandlerRegistration(); + this.display(); }))); + // Only relevant when linkFormat is OBSIDIAN; the resolver intercepts paste + // to regenerate the path (shortest/relative/absolute) for the destination file. + if (this.plugin.settings.linkFormat === LinkFormat.OBSIDIAN) { + formatGroup.addSetting(setting => setting + .setName(this.plugin.t('resolve-link-path-on-paste')) + .setDesc(this.plugin.t('resolve-link-path-on-paste-desc')) + .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')) diff --git a/src/type.ts b/src/type.ts index 85ebef2..df7268f 100644 --- a/src/type.ts +++ b/src/type.ts @@ -44,6 +44,7 @@ export interface EasyCopySettings { simplifiedHeadingToNoteLink: boolean; // 是否简化标题到笔记链接 strictHeadingMatch: boolean; // 标题与文件名简化匹配时是否使用严格匹配 linkFormat: LinkFormat; + resolveLinkPathOnPaste: boolean; // 当 linkFormat 为 OBSIDIAN 时,粘贴时根据目标文件重新解析链接路径 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, From 8f0f26a535978d2e7823de6ff6c7925d2ba85c38 Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:04:56 -0600 Subject: [PATCH 04/14] feat: apply simplifiedHeadingToNoteLink to markdown links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MDLINK branch in buildHeadingLink now uses linkContent (which the early simplification block already computes), so a matching filename and heading collapses [foo](foo#foo) → [foo](foo) just like the wiki form already did. Was effectively a no-op for MD before — the toggle existed but didn't affect markdown output. 10 new tests covering exact / case-insens / space-removed / substring / strict-mode simplification cases. The MDLINK + matching-filename test and the noSimplify MD sanity test are renamed to reflect their actual intent now that simplification works for both formats. 107 tests pass; typecheck clean. Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code. --- src/linkBuilder.test.ts | 119 ++++++++++++++++++++++++++++++++++++++-- src/linkBuilder.ts | 6 +- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/linkBuilder.test.ts b/src/linkBuilder.test.ts index 44a597f..9ea6751 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -198,14 +198,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 (default true) collapses MyNote#MyNote → MyNote + expect(result.link).toBe('[MyNote](MyNote)'); expect(result.isNoteLink).toBe(true); }); @@ -233,6 +233,117 @@ describe('buildHeadingLink', () => { // No spaces in filename or heading, so no encoding needed expect(result.link).toBe('[My Note#Setup](my-note#Setup)'); }); + + // -- simplifiedHeadingToNoteLink in markdown format -------------------- + // Mirror of the wiki "filename-heading matching" suite below. + + describe('simplifiedHeadingToNoteLink (default: includes match)', () => { + it('simplifies on case-insensitive exact match', () => { + const result = buildHeadingLink({ + ...md, + heading: 'mynote', + filename: 'MyNote', + }); + // Display preserves heading casing, target preserves filename casing + 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', + }); + // No simplification triggers, so target keeps the heading fragment + 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); + }); + }); }); // -- Special character sanitization in link targets ---------------------- @@ -420,7 +531,7 @@ describe('buildHeadingLink', () => { expect(result.isNoteLink).toBe(false); }); - it('markdown format always includes heading regardless (sanity check)', () => { + it('markdown format keeps heading fragment when simplification is off (sanity check)', () => { const result = buildHeadingLink({ ...noSimplify, linkFormat: LinkFormat.MDLINK, diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index e68fd16..050af37 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -103,8 +103,10 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading } } } else { - // Markdown链接格式 - link = `[${displayText}](${encodeMarkdownLinkUrl(`${filename}#${sanitizedHeading}`)})`; + // Markdown链接格式 — linkContent already reflects note-link simplification + // (set to filename when simplifiedHeadingToNoteLink fires above), so a matching + // filename/heading collapses [foo](foo#foo) → [foo](foo). + link = `[${displayText}](${encodeMarkdownLinkUrl(linkContent)})`; } return { link, isNoteLink }; From b29a70084b86c5fe26ad102ae86074af4f36fae0 Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:06:48 -0600 Subject: [PATCH 05/14] feat: gate simplifiedHeadingToNoteLink on useHeadingAsDisplayText (A1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When useHeadingAsDisplayText is false, the user has chosen to see "filename#heading" in the link display. Auto-simplifying the link target to just the filename in that case produces a misleading [filename#heading](filename) — display promises a heading link the target doesn't deliver. Add useHeadingAsDisplayText to the simplification gate. The WIKI exact-match special case [[filename]] is intentionally exempt — Obsidian renders it cleanly as just the filename, no alias, no mismatch. 4 new MD A1.5 tests + 5 WIKI A1.5 tests covering the gate and the exact-match exception. One existing test (frontmatterTitle + note-link simplification, wiki) renamed to reflect gated behavior. 161 tests pass; typecheck clean. Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code. --- src/linkBuilder.test.ts | 131 ++++++++++++++++++++++++++++++++++++++-- src/linkBuilder.ts | 6 +- 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/src/linkBuilder.test.ts b/src/linkBuilder.test.ts index 9ea6751..89b8612 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -344,6 +344,60 @@ describe('buildHeadingLink', () => { expect(result.isNoteLink).toBe(true); }); }); + + describe('simplifiedHeadingToNoteLink gated on useHeadingAsDisplayText=true (A1.5)', () => { + // When the user wants "filename#heading" in the display, dropping the heading + // anchor would produce a misleading [filename#heading](filename). The gate + // preserves the heading link so display intent and target stay aligned. + + 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 ---------------------- @@ -476,6 +530,74 @@ describe('buildHeadingLink', () => { }); }); + // -- A1.5 gate: simplification gated on useHeadingAsDisplayText (wiki side) ----- + + describe('simplifiedHeadingToNoteLink gated on useHeadingAsDisplayText=true (A1.5, wiki side)', () => { + // Mirrors the markdown-side suite. When the user has asked for "filename#heading" + // in the display, the early simplification block is skipped so the heading link + // survives. Exception: the WIKI exact-match special case [[filename]] is preserved + // regardless, since it renders cleanly as just the filename in 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 collapses, but the heading fragment survives + 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: false --------------------------------- describe('when simplifiedHeadingToNoteLink is false', () => { @@ -558,7 +680,7 @@ describe('buildHeadingLink', () => { 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', @@ -567,9 +689,10 @@ 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); + // Pre-A1.5 this simplified to [[MyNote|My Pretty Title#myNote]] (target=file, display=heading-style). + // A1.5 keeps the heading anchor so the target matches the display intent. + 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)', () => { diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index 050af37..81bb3f9 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -73,9 +73,13 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading // 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分 // 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分 - // Gated on simplifiedHeadingToNoteLink so users can opt out and preserve the heading fragment. + // Also gated on useHeadingAsDisplayText: when the user wants "filename#heading" in the + // display, dropping the heading anchor here would produce a misleading + // [filename#heading](filename) — display promises a heading link the target doesn't deliver. + // The WIKI exact-match special case below is exempt — [[filename]] renders cleanly. if ( simplifiedHeadingToNoteLink && + useHeadingAsDisplayText && selectedHeading && (filename === selectedHeading || compareIgnoreCase(filename, selectedHeading) || From eca6c94ae969139558cbe6c22e811b6901de7626 Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:46:55 -0600 Subject: [PATCH 06/14] feat: gate simplifiedHeadingToNoteLink to non-OBSIDIAN formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under "Follow Obsidian settings", defer link format/path choice to Obsidian's vault config — but Obsidian's own generateMarkdownLink would never auto-collapse a heading link to a file link when the filename matches the heading. Keeping simplifiedHeadingToNoteLink active under OBSIDIAN injects an Easy Copy opinion that conflicts with that intent. Hide the toggle (and its strict-match child) from settings UI when linkFormat is OBSIDIAN, and gate simplification inert at the call site in copyHeadingLink. Stored values persist across format changes, matching the precedent set by resolveLinkPathOnPaste. Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code. --- src/main.ts | 7 ++++++- src/settingTab.ts | 38 ++++++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/main.ts b/src/main.ts index 2fc3276..b0b4970 100644 --- a/src/main.ts +++ b/src/main.ts @@ -678,6 +678,10 @@ export default class EasyCopy extends Plugin { } } + // Under "Follow Obsidian settings", simplified-to-note-link transforms + // conflict with deferring format/path choice to Obsidian. Gate inert at + // the call site so buildHeadingLink stays format-agnostic. + const userExplicitlyPickedFormat = this.settings.linkFormat !== LinkFormat.OBSIDIAN; const { link, isNoteLink } = buildHeadingLink({ heading: content, filename, @@ -686,7 +690,8 @@ export default class EasyCopy extends Plugin { useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText, headingLinkSeparator: this.settings.headingLinkSeparator, strictHeadingMatch: this.settings.strictHeadingMatch, - simplifiedHeadingToNoteLink: this.settings.simplifiedHeadingToNoteLink, + simplifiedHeadingToNoteLink: + userExplicitlyPickedFormat && this.settings.simplifiedHeadingToNoteLink, }); navigator.clipboard.writeText(link); diff --git a/src/settingTab.ts b/src/settingTab.ts index bf09b9d..302a435 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -165,27 +165,33 @@ export class EasyCopySettingTab extends PluginSettingTab { } // 后续新增:文件名包含标题时,简化为复制文件链接(通常用于复制一级标题时) - formatGroup.addSetting(setting => setting - .setName(this.plugin.t('simplified-heading-to-note-link')) - .setDesc(this.plugin.t('simplified-heading-to-note-link-desc')) - .addToggle(toggle => toggle - .setValue(this.plugin.settings.simplifiedHeadingToNoteLink) - .onChange(async (value) => { - this.plugin.settings.simplifiedHeadingToNoteLink = value; - await this.plugin.saveSettings(); - this.display(); - }))); - - if (this.plugin.settings.simplifiedHeadingToNoteLink) { + // Under OBSIDIAN, defer format/path choice to Obsidian's vault config — + // Obsidian's own generateMarkdownLink never auto-collapses heading links + // to file links, so this opt-in is hidden+inert there. Stored values + // persist across format toggles (matches resolveLinkPathOnPaste). + if (this.plugin.settings.linkFormat !== LinkFormat.OBSIDIAN) { formatGroup.addSetting(setting => setting - .setName(this.plugin.t('strict-heading-match')) - .setDesc(this.plugin.t('strict-heading-match-desc')) + .setName(this.plugin.t('simplified-heading-to-note-link')) + .setDesc(this.plugin.t('simplified-heading-to-note-link-desc')) .addToggle(toggle => toggle - .setValue(this.plugin.settings.strictHeadingMatch) + .setValue(this.plugin.settings.simplifiedHeadingToNoteLink) .onChange(async (value) => { - this.plugin.settings.strictHeadingMatch = value; + this.plugin.settings.simplifiedHeadingToNoteLink = value; await this.plugin.saveSettings(); + this.display(); }))); + + if (this.plugin.settings.simplifiedHeadingToNoteLink) { + formatGroup.addSetting(setting => setting + .setName(this.plugin.t('strict-heading-match')) + .setDesc(this.plugin.t('strict-heading-match-desc')) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.strictHeadingMatch) + .onChange(async (value) => { + this.plugin.settings.strictHeadingMatch = value; + await this.plugin.saveSettings(); + }))); + } } From 7e467f389cb525086677c9ee4069b8e22706f71e Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:48:15 -0600 Subject: [PATCH 07/14] feat: drop redundant alias on same-file OBSIDIAN paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When pasting a heading link into the same file it was copied from, the paste handler now produces [[#Heading]] instead of [[#Heading|Heading]]. The alias was redundant — Obsidian's natural rendering of [[#Heading]] already shows just "Heading" since there's no file portion to disambiguate. Cross-file pastes keep the alias (without it, Obsidian renders "Filename > Heading" instead of just "Heading"). Markdown-format pastes also keep the alias (it IS the visible link text in markdown). The omission is wiki-only, same-file-only, useHeadingAsDisplayText-only. Adds shouldOmitAliasForSameFile pure helper in pasteResolution.ts with truth-table tests; handlePaste consults it before calling generateMarkdownLink. Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code. --- src/main.ts | 18 ++++++++- src/pasteResolution.test.ts | 76 ++++++++++++++++++++++++++++++++++++- src/pasteResolution.ts | 29 ++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index b0b4970..6500d5c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import { EasyCopySettingTab } from './settingTab'; import { BlockIdInputModal } from './blockIdModal'; import { buildHeadingLink, buildBlockLink, buildFileLink } from './linkBuilder'; import { CopyMetadata, buildBlockCopyMetadata, buildHeadingCopyMetadata, buildFileCopyMetadata } from './copyMetadata'; -import { decidePasteResolution, shouldRegisterPasteHandler } from './pasteResolution'; +import { decidePasteResolution, shouldOmitAliasForSameFile, shouldRegisterPasteHandler } from './pasteResolution'; const LAST_COPY_META_TTL_MS = 5 * 60 * 1000; @@ -199,15 +199,29 @@ export default class EasyCopy extends Plugin { const destFile = info.file; if (!destFile) return; + // Degenerate self-ref: a same-file paste with no anchor would produce + // [[]] / [](#) under either branch. Let normal paste proceed instead. + if (meta.subpath === '' && meta.sourceFilePath === destFile.path) return; + const sourceFile = this.app.vault.getAbstractFileByPath(meta.sourceFilePath); if (!(sourceFile instanceof TFile)) return; try { + const omitAlias = shouldOmitAliasForSameFile({ + effectiveLinkFormat: this.getEffectiveLinkFormat(), + sourceFilePath: meta.sourceFilePath, + destFilePath: destFile.path, + subpath: meta.subpath, + alias: meta.alias, + useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText, + }); + const aliasArg = omitAlias ? undefined : (meta.alias || undefined); + let link = this.app.fileManager.generateMarkdownLink( sourceFile, destFile.path, meta.subpath || undefined, - meta.alias || undefined, + aliasArg, ); if (meta.isEmbed) { diff --git a/src/pasteResolution.test.ts b/src/pasteResolution.test.ts index d3ddeb5..d7056fc 100644 --- a/src/pasteResolution.test.ts +++ b/src/pasteResolution.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { decidePasteResolution, PasteResolutionInput, shouldRegisterPasteHandler } from './pasteResolution'; +import { + decidePasteResolution, + PasteResolutionInput, + shouldOmitAliasForSameFile, + shouldRegisterPasteHandler, +} from './pasteResolution'; import { CopyMetadata } from './copyMetadata'; import { LinkFormat } from './type'; @@ -139,3 +144,72 @@ describe('decidePasteResolution', () => { })).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 contains | which sanitizeHeadingForLink collapses to a space + expect(shouldOmitAliasForSameFile({ + ...base, + subpath: '#Some Heading', + alias: 'Some|Heading', + })).toBe(true); + }); + + it('keeps alias when OBSIDIAN is passed (helper expects already-resolved format)', () => { + // Defensive: callers should never pass OBSIDIAN here, but if they do + // the helper bails rather than misbehave. + expect(shouldOmitAliasForSameFile({ ...base, effectiveLinkFormat: LinkFormat.OBSIDIAN })).toBe(false); + }); +}); diff --git a/src/pasteResolution.ts b/src/pasteResolution.ts index 70cf57f..2b378d0 100644 --- a/src/pasteResolution.ts +++ b/src/pasteResolution.ts @@ -1,4 +1,5 @@ import { CopyMetadata } from './copyMetadata'; +import { sanitizeHeadingForLink } from './linkBuilder'; import { EasyCopySettings, LinkFormat } from './type'; /** @@ -35,3 +36,31 @@ export function decidePasteResolution(input: PasteResolutionInput): PasteResolut 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; +} + +/** + * Whether a paste-time alias is redundant and should be dropped. + * + * Same-file wiki heading pastes naturally render as just the heading + * text — `[[#Heading]]` displays "Heading" without a separate alias. + * Cross-file wiki pastes need the alias (Obsidian otherwise renders + * "Filename > Heading"). Markdown pastes always need the alias since + * it IS the visible link text. Block links are out of scope: their + * brief alias never matches the `#^id` literal and has its own setting. + */ +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)}`; +} From adef562ef68efc9eceaaf27422651495180bfcc7 Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:53:07 -0600 Subject: [PATCH 08/14] feat: extend paste-time path resolution to explicit Wiki/Markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the paste handler only fired when linkFormat was "Follow Obsidian settings". Users on explicit Wiki or Markdown got no paste-time cleanup — pasting a heading into the same file produced the verbose [[Filename#Heading|Heading]] instead of [[#Heading]]. Drop the OBSIDIAN-only gate in shouldRegisterPasteHandler and decidePasteResolution. handlePaste branches by linkFormat: OBSIDIAN keeps using app.fileManager.generateMarkdownLink (full newLinkFormat support); explicit Wiki/Markdown uses app.metadataCache.fileToLinktext (shortest-unique paths only). The same-file alias-redundancy fix from the previous commit applies to both branches. Add buildExplicitPasteLink in linkBuilder.ts as a pure string-producer for the explicit-format branch — caller decides omitAlias via shouldOmitAliasForSameFile, formatter consumes the boolean. Toggle now shown for all three linkFormat options; description updated to reflect format-aware behavior across en/zh/zh-tw. Tests: new buildExplicitPasteLink suite covering wiki/md, same-file vs cross-file, block links, and encoding behavior. pasteResolution tests updated for the broadened gate (linkFormat field removed from PasteResolutionInput; OBSIDIAN-only assertions dropped). Trade-off: explicit Wiki/Markdown get shortest-path-only resolution; users wanting relative/absolute path styles keep using "Follow Obsidian settings" (which honors the vault's newLinkFormat config). Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code. --- src/i18n.ts | 6 +- src/linkBuilder.test.ts | 210 +++++++++++++++++++++++++++++++++++- src/linkBuilder.ts | 33 ++++++ src/main.ts | 47 +++++--- src/pasteResolution.test.ts | 42 +------- src/pasteResolution.ts | 6 +- src/settingTab.ts | 28 ++--- 7 files changed, 299 insertions(+), 73 deletions(-) diff --git a/src/i18n.ts b/src/i18n.ts index bc01344..1591c5c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -109,7 +109,7 @@ export const translations: Record> = { 'markdown-link': 'Markdown link', 'wiki-link': 'Wiki link', 'resolve-link-path-on-paste': 'Resolve link path on paste', - 'resolve-link-path-on-paste-desc': 'When using "Follow Obsidian settings", regenerate the link path (shortest/relative/absolute) at paste time based on the destination file. Won\'t interfere with other paste-handling plugins; if one handles the paste first, this feature may be bypassed depending on how that plugin handles the 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); under explicit Wiki/Markdown, uses shortest-unique paths only. Won\'t interfere with other paste-handling plugins; if one handles the paste first, this feature may be bypassed depending on how that plugin handles the paste.', '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', @@ -209,7 +209,7 @@ export const translations: Record> = { 'markdown-link': 'Markdown链接', 'wiki-link': 'Wiki链接', 'resolve-link-path-on-paste': '粘贴时解析链接路径', - 'resolve-link-path-on-paste-desc': '在使用"跟随 Obsidian 设置"时,根据目标文件在粘贴时重新生成链接路径(最短/相对/绝对)。不会干扰其他处理粘贴的插件;若有其他插件先处理粘贴,此功能可能被绕过,具体取决于该插件的处理方式。', + 'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用 Obsidian 的路径风格(最短/相对/绝对);选择明确的 Wiki/Markdown 格式时,仅使用最短唯一路径。不会干扰其他处理粘贴的插件;若有其他插件先处理粘贴,此功能可能被绕过,具体取决于该插件的处理方式。', 'customize-targets': '自定义复制对象', 'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)', 'enable-inline-code': '启用行内代码', @@ -319,7 +319,7 @@ export const translations: Record> = { 'markdown-link': 'Markdown連結', 'wiki-link': 'Wiki連結', 'resolve-link-path-on-paste': '貼上時解析連結路徑', - 'resolve-link-path-on-paste-desc': '在使用「跟隨 Obsidian 設定」時,根據目標檔案在貼上時重新生成連結路徑(最短/相對/絕對)。不會干擾其他處理貼上的外掛;若有其他外掛先處理貼上,此功能可能被略過,具體取決於該外掛的處理方式。', + 'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用 Obsidian 的路徑風格(最短/相對/絕對);選擇明確的 Wiki/Markdown 格式時,僅使用最短唯一路徑。不會干擾其他處理貼上的外掛;若有其他外掛先處理貼上,此功能可能被略過,具體取決於該外掛的處理方式。', 'customize-targets': '自定義複製對象', 'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)', 'enable-inline-code': '啟用行內代碼', diff --git a/src/linkBuilder.test.ts b/src/linkBuilder.test.ts index 89b8612..63a4d55 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -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'; // --------------------------------------------------------------------------- @@ -1131,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', () => { + // Documenting boundary: buildHeadingCopyMetadata clears alias when + // isNoteLink+filename===heading. handlePaste should treat that as + // the degenerate self-ref case (skip) before reaching here. + expect(buildExplicitPasteLink({ + format: LinkFormat.MDLINK, + path: 'SomeThing', + subpath: '', + alias: '', + sameFile: false, + omitAlias: false, + })).toBe('[](SomeThing)'); + }); + }); +}); diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index 81bb3f9..0d44d55 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -205,6 +205,39 @@ export function buildBlockLink(options: BuildBlockLinkOptions): string { return link; } +// --- Explicit Paste Link --- +// +// Used by the paste handler when the user has explicitly chosen Wiki/Markdown +// (not "Follow Obsidian settings"). Obsidian's app.fileManager.generateMarkdownLink +// honors the vault's useMarkdownLinks config which would override an explicit +// choice — so we build the link manually instead, with the path string already +// resolved by app.metadataCache.fileToLinktext. +// +// Pure string-producer: the caller decides `omitAlias` via shouldOmitAliasForSameFile. +// Formatter only consumes the flag — Strategy: caller owns policy, formatter owns syntax. + +export interface BuildExplicitPasteLinkOptions { + format: LinkFormat.WIKILINK | LinkFormat.MDLINK; + path: string; // from fileToLinktext (shortest unique vault path) + subpath: string; // '#Heading' or '#^blockid' or '' + alias: string; // display text or '' + sameFile: boolean; // sourcePath === destPath — drops path portion + omitAlias: boolean; // true → render without alias (caller decision) +} + +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 IS the visible link text. When omitAlias forces it empty, + // preserve the original alias as display so we never produce [](path#H). + const display = aliasToUse || opts.alias || ''; + return `[${display}](${encodeMarkdownLinkUrl(linkTarget)})`; +} + // --- File Link --- export interface BuildFileLinkOptions { diff --git a/src/main.ts b/src/main.ts index 6500d5c..18472bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ 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'; @@ -169,8 +169,9 @@ export default class EasyCopy extends Plugin { /** * Intercept paste when the clipboard contains an Easy Copy link. - * Uses Obsidian's generateMarkdownLink() to resolve the correct path - * format (shortest/relative/absolute) for the destination file. + * Under "Follow Obsidian settings" uses generateMarkdownLink() (full + * path-style support); under explicit Wiki/Markdown uses fileToLinktext() + * (shortest-unique paths only) so the user's format choice is honored. * * Per the editor-paste contract (obsidian.d.ts): yield if another handler * has already preventDefault'd, and call preventDefault to claim the event. @@ -180,7 +181,6 @@ export default class EasyCopy extends Plugin { const decision = decidePasteResolution({ defaultPrevented: evt.defaultPrevented, resolveLinkPathOnPaste: this.settings.resolveLinkPathOnPaste, - linkFormat: this.settings.linkFormat, lastCopyMeta: this.lastCopyMeta, clipboardText, now: Date.now(), @@ -207,22 +207,43 @@ export default class EasyCopy extends Plugin { if (!(sourceFile instanceof TFile)) return; try { + const effectiveFormat = this.getEffectiveLinkFormat(); const omitAlias = shouldOmitAliasForSameFile({ - effectiveLinkFormat: this.getEffectiveLinkFormat(), + effectiveLinkFormat: effectiveFormat, sourceFilePath: meta.sourceFilePath, destFilePath: destFile.path, subpath: meta.subpath, alias: meta.alias, useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText, }); - const aliasArg = omitAlias ? undefined : (meta.alias || undefined); - let link = this.app.fileManager.generateMarkdownLink( - sourceFile, - destFile.path, - meta.subpath || undefined, - aliasArg, - ); + let link: string; + if (this.settings.linkFormat === LinkFormat.OBSIDIAN) { + // generateMarkdownLink honors vault config (useMarkdownLinks + + // newLinkFormat) — full path-style support: shortest/relative/absolute. + const aliasArg = omitAlias ? undefined : (meta.alias || undefined); + link = this.app.fileManager.generateMarkdownLink( + sourceFile, + destFile.path, + meta.subpath || undefined, + aliasArg, + ); + } else { + // Explicit Wiki/Markdown: build manually so the user's format choice is + // honored. fileToLinktext returns the shortest-unique vault path + // (no relative/absolute support — documented trade-off). + // omitMdExtension=true is REQUIRED — defaulting false would yield + // paths with ".md" and break wiki-link convention ([[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; @@ -233,7 +254,7 @@ export default class EasyCopy extends Plugin { editor.replaceSelection(link); } } catch { - // generateMarkdownLink failed — let normal paste proceed + // link generation failed — let normal paste proceed } } diff --git a/src/pasteResolution.test.ts b/src/pasteResolution.test.ts index d7056fc..9185461 100644 --- a/src/pasteResolution.test.ts +++ b/src/pasteResolution.test.ts @@ -9,39 +9,12 @@ import { CopyMetadata } from './copyMetadata'; import { LinkFormat } from './type'; describe('shouldRegisterPasteHandler', () => { - it('returns true when toggle is on AND linkFormat is OBSIDIAN', () => { - expect(shouldRegisterPasteHandler({ - resolveLinkPathOnPaste: true, - linkFormat: LinkFormat.OBSIDIAN, - })).toBe(true); + it('returns true when toggle is on', () => { + expect(shouldRegisterPasteHandler({ resolveLinkPathOnPaste: true })).toBe(true); }); it('returns false when toggle is off', () => { - expect(shouldRegisterPasteHandler({ - resolveLinkPathOnPaste: false, - linkFormat: LinkFormat.OBSIDIAN, - })).toBe(false); - }); - - it('returns false when linkFormat is MDLINK even if toggle is on', () => { - expect(shouldRegisterPasteHandler({ - resolveLinkPathOnPaste: true, - linkFormat: LinkFormat.MDLINK, - })).toBe(false); - }); - - it('returns false when linkFormat is WIKILINK even if toggle is on', () => { - expect(shouldRegisterPasteHandler({ - resolveLinkPathOnPaste: true, - linkFormat: LinkFormat.WIKILINK, - })).toBe(false); - }); - - it('returns false when both conditions fail', () => { - expect(shouldRegisterPasteHandler({ - resolveLinkPathOnPaste: false, - linkFormat: LinkFormat.MDLINK, - })).toBe(false); + expect(shouldRegisterPasteHandler({ resolveLinkPathOnPaste: false })).toBe(false); }); }); @@ -59,7 +32,6 @@ const TTL = 5 * 60 * 1000; const baseInput: PasteResolutionInput = { defaultPrevented: false, resolveLinkPathOnPaste: true, - linkFormat: LinkFormat.OBSIDIAN, lastCopyMeta: META, clipboardText: META.clipboardText, now: META.timestamp + 1000, @@ -79,11 +51,6 @@ describe('decidePasteResolution', () => { expect(decidePasteResolution({ ...baseInput, resolveLinkPathOnPaste: false })).toBe('skip'); }); - it('skips when linkFormat is not OBSIDIAN', () => { - expect(decidePasteResolution({ ...baseInput, linkFormat: LinkFormat.MDLINK })).toBe('skip'); - expect(decidePasteResolution({ ...baseInput, linkFormat: LinkFormat.WIKILINK })).toBe('skip'); - }); - it('skips when there is no lastCopyMeta', () => { expect(decidePasteResolution({ ...baseInput, lastCopyMeta: null })).toBe('skip'); }); @@ -125,11 +92,10 @@ describe('decidePasteResolution', () => { })).toBe('skip'); }); - it('toggle takes precedence over linkFormat and meta state', () => { + it('toggle takes precedence over meta state', () => { expect(decidePasteResolution({ ...baseInput, resolveLinkPathOnPaste: false, - linkFormat: LinkFormat.MDLINK, lastCopyMeta: null, })).toBe('skip'); }); diff --git a/src/pasteResolution.ts b/src/pasteResolution.ts index 2b378d0..aea1993 100644 --- a/src/pasteResolution.ts +++ b/src/pasteResolution.ts @@ -7,9 +7,9 @@ import { EasyCopySettings, LinkFormat } from './type'; * The bridge between settings state and "is Easy Copy in the paste plugin chain." */ export function shouldRegisterPasteHandler( - settings: Pick, + settings: Pick, ): boolean { - return settings.resolveLinkPathOnPaste && settings.linkFormat === LinkFormat.OBSIDIAN; + return settings.resolveLinkPathOnPaste; } export type PasteResolutionAction = @@ -20,7 +20,6 @@ export type PasteResolutionAction = export interface PasteResolutionInput { defaultPrevented: boolean; resolveLinkPathOnPaste: boolean; - linkFormat: LinkFormat; lastCopyMeta: CopyMetadata | null; clipboardText: string | undefined; now: number; @@ -30,7 +29,6 @@ export interface PasteResolutionInput { export function decidePasteResolution(input: PasteResolutionInput): PasteResolutionAction { if (input.defaultPrevented) return 'skip'; if (!input.resolveLinkPathOnPaste) return 'skip'; - if (input.linkFormat !== LinkFormat.OBSIDIAN) 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'; diff --git a/src/settingTab.ts b/src/settingTab.ts index 302a435..0d5ff90 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -123,20 +123,20 @@ export class EasyCopySettingTab extends PluginSettingTab { this.display(); }))); - // Only relevant when linkFormat is OBSIDIAN; the resolver intercepts paste - // to regenerate the path (shortest/relative/absolute) for the destination file. - if (this.plugin.settings.linkFormat === LinkFormat.OBSIDIAN) { - formatGroup.addSetting(setting => setting - .setName(this.plugin.t('resolve-link-path-on-paste')) - .setDesc(this.plugin.t('resolve-link-path-on-paste-desc')) - .addToggle(toggle => toggle - .setValue(this.plugin.settings.resolveLinkPathOnPaste) - .onChange(async (value) => { - this.plugin.settings.resolveLinkPathOnPaste = value; - await this.plugin.saveSettings(); - this.plugin.syncPasteHandlerRegistration(); - }))); - } + // The resolver intercepts paste to regenerate the link target for the + // destination. Under "Follow Obsidian settings" it uses the vault's + // path style (shortest/relative/absolute); under explicit Wiki/Markdown + // it falls back to shortest-unique paths only. + formatGroup.addSetting(setting => setting + .setName(this.plugin.t('resolve-link-path-on-paste')) + .setDesc(this.plugin.t('resolve-link-path-on-paste-desc')) + .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')) From 1e06fa7229eee75370fdcc85417ac5c55734e533 Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:55:48 -0600 Subject: [PATCH 09/14] refactor: extract helpers from buildHeadingLink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull out computeDisplayText, shouldSimplifyHeading, formatWikiHeadingLink, formatMarkdownHeadingLink as private helpers. buildHeadingLink becomes a thin orchestrator: stripWikiBrackets → computeDisplayText → shouldSimplifyHeading → wikiExactMatch policy → formatX. Formatters are pure string-producers; the orchestrator owns the WIKI [[filename]] exact-match policy (the A1.5 exception). Pure refactor — no behavior change, all 184 existing tests pass without modification. Also extract stripWikiBrackets to its own export and reuse in buildHeadingCopyMetadata (removes duplicated [[…]] stripping). Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code. --- src/copyMetadata.ts | 7 +- src/linkBuilder.ts | 164 ++++++++++++++++++++++++++++---------------- 2 files changed, 108 insertions(+), 63 deletions(-) diff --git a/src/copyMetadata.ts b/src/copyMetadata.ts index 3b1efca..e5e1af3 100644 --- a/src/copyMetadata.ts +++ b/src/copyMetadata.ts @@ -1,4 +1,4 @@ -import { extractBlockDisplayText, sanitizeHeadingForLink } from './linkBuilder'; +import { extractBlockDisplayText, sanitizeHeadingForLink, stripWikiBrackets } from './linkBuilder'; export interface CopyMetadata { clipboardText: string; @@ -75,10 +75,7 @@ export function buildHeadingCopyMetadata(input: BuildHeadingCopyMetadataInput): isNoteLink, } = input; - let heading = rawHeading; - if (heading.startsWith('[[') && heading.endsWith(']]')) { - heading = heading.slice(2, -2); - } + const heading = stripWikiBrackets(rawHeading); let alias = heading; if (!useHeadingAsDisplayText) { diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index 0d44d55..6a68131 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -6,6 +6,17 @@ export function encodeMarkdownLinkUrl(url: string): string { return url.replace(/ /g, '%20'); } +// --- Wiki Bracket Stripping --- + +/** + * Strip outer [[…]] from a string. Used when a heading captured from + * the editor is itself a wiki-link reference. + */ +export function stripWikiBrackets(s: string): string { + if (s.startsWith('[[') && s.endsWith(']]')) return s.slice(2, -2); + return s; +} + // --- Heading Sanitization --- /** @@ -43,75 +54,112 @@ export interface BuildHeadingLinkResult { isNoteLink: boolean; } -export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeadingLinkResult { - const { filename, linkFormat, useHeadingAsDisplayText, headingLinkSeparator, strictHeadingMatch, simplifiedHeadingToNoteLink } = 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; +/** + * Whether the early simplification block fires (linkContent → filename, isNoteLink → true). + * + * Gated on useHeadingAsDisplayText: when the user wants "filename#heading" in the + * display, dropping the heading anchor would produce a misleading link where the + * display text promises a heading the target doesn't deliver. The WIKI exact-match + * special case (filename === heading) is handled separately and is exempt. + */ +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 来做标题,所以也考虑空格替换的部分 - // Also gated on useHeadingAsDisplayText: when the user wants "filename#heading" in the - // display, dropping the heading anchor here would produce a misleading - // [filename#heading](filename) — display promises a heading link the target doesn't deliver. - // The WIKI exact-match special case below is exempt — [[filename]] renders cleanly. - if ( - simplifiedHeadingToNoteLink && - useHeadingAsDisplayText && - 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 (simplifiedHeadingToNoteLink && filename === selectedHeading) { - // 特殊情况:当文件名与标题相同时,直接链接到文件 - link = `[[${filename}]]`; - isNoteLink = true; - } else { - if (displayText === linkContent) { - // 特殊情况:当显示文本与 "文件名#标题" 相同时,省略显示文本 - link = `[[${linkContent}]]`; - } else { - link = `[[${linkContent}|${displayText}]]`; - } - } - } else { - // Markdown链接格式 — linkContent already reflects note-link simplification - // (set to filename when simplifiedHeadingToNoteLink fires above), so a matching - // filename/heading collapses [foo](foo#foo) → [foo](foo). - link = `[${displayText}](${encodeMarkdownLinkUrl(linkContent)})`; +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 exception: WIKI exact match (filename === heading) collapses to + // [[filename]] regardless of useHeadingAsDisplayText, since [[filename]] + // renders cleanly as just the filename in Obsidian. Owned by the + // orchestrator so formatters stay pure string-producers. + 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 }; } From 7c6aa950afc5e863beda2069e9306f0bc9aae2e0 Mon Sep 17 00:00:00 2001 From: Andre Light <7719153+lightmotive@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:27:48 -0600 Subject: [PATCH 10/14] chore: translate user-authored comments to Simplified Chinese The maintainer reviews and writes comments in Chinese. To minimize review friction and maximize merge likelihood, translate all user-authored English comments across src/ into Simplified Chinese, matching the upstream comment style. Test files included; describe/it strings stay English (functional test names). Pre-existing English comments authored by upstream contributors are left untouched. Adds CONTRIBUTING-zh.md as a sibling translation of CONTRIBUTING.md (matching the README.md / README-zh.md cross-linked convention). Also corrects one Chinese comment in type.ts that became stale after extending paste-time path resolution to all three Link format options. No behavior change. 184 tests pass; typecheck and lint clean (the pre-existing unrelated lint error in detectBlockId remains as before). Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code. --- CONTRIBUTING-zh.md | 41 +++++++++++++++ CONTRIBUTING.md | 2 + src/copyMetadata.test.ts | 2 +- src/linkBuilder.test.ts | 102 ++++++++++++++++++------------------ src/linkBuilder.ts | 79 ++++++++++++++-------------- src/main.ts | 71 ++++++++++++------------- src/pasteResolution.test.ts | 10 ++-- src/pasteResolution.ts | 25 ++++----- src/settingTab.ts | 15 +++--- src/type.ts | 2 +- 10 files changed, 194 insertions(+), 155 deletions(-) create mode 100644 CONTRIBUTING-zh.md diff --git a/CONTRIBUTING-zh.md b/CONTRIBUTING-zh.md new file mode 100644 index 0000000..dd6a984 --- /dev/null +++ b/CONTRIBUTING-zh.md @@ -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 错误 +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0fc0cd..e01c527 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,7 @@ # Contributing +English | [中文文档](./CONTRIBUTING-zh.md) + ## Development Setup ```bash diff --git a/src/copyMetadata.test.ts b/src/copyMetadata.test.ts index be3e0b0..83c4044 100644 --- a/src/copyMetadata.test.ts +++ b/src/copyMetadata.test.ts @@ -41,7 +41,7 @@ describe('buildBlockCopyMetadata', () => { firstLine: 'The quick brown fox jumps over the lazy dog', blockDisplayWordLimit: 4, }); - // extractBlockDisplayText truncates to the word limit + // extractBlockDisplayText 会截断到单词数上限 expect(meta.alias).not.toBe('abc123'); expect(meta.alias.length).toBeGreaterThan(0); }); diff --git a/src/linkBuilder.test.ts b/src/linkBuilder.test.ts index 63a4d55..fd9c789 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -88,7 +88,7 @@ describe('buildHeadingLink', () => { simplifiedHeadingToNoteLink: true, }; - // -- Wiki format --------------------------------------------------------- + // -- Wiki 格式 --------------------------------------------------------- describe('wiki format', () => { it('creates a basic heading link with display text', () => { @@ -117,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', @@ -135,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', @@ -167,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]]'); }); @@ -192,7 +192,7 @@ describe('buildHeadingLink', () => { }); }); - // -- Markdown format ------------------------------------------------------ + // -- Markdown 格式 ------------------------------------------------------ describe('markdown format', () => { const md = { ...defaults, linkFormat: LinkFormat.MDLINK }; @@ -212,7 +212,7 @@ describe('buildHeadingLink', () => { heading: 'MyNote', filename: 'MyNote', }); - // simplifiedHeadingToNoteLink (default true) collapses MyNote#MyNote → MyNote + // simplifiedHeadingToNoteLink(默认 true)将 MyNote#MyNote 折叠为 MyNote expect(result.link).toBe('[MyNote](MyNote)'); expect(result.isNoteLink).toBe(true); }); @@ -225,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)'); }); @@ -238,12 +238,12 @@ 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)'); }); - // -- simplifiedHeadingToNoteLink in markdown format -------------------- - // Mirror of the wiki "filename-heading matching" suite below. + // -- Markdown 格式下的 simplifiedHeadingToNoteLink -------------------- + // 对应下方 wiki 的「文件名-标题匹配」测试套件。 describe('simplifiedHeadingToNoteLink (default: includes match)', () => { it('simplifies on case-insensitive exact match', () => { @@ -252,7 +252,7 @@ describe('buildHeadingLink', () => { heading: 'mynote', filename: 'MyNote', }); - // Display preserves heading casing, target preserves filename casing + // 显示文本保持 heading 的大小写,链接目标保持 filename 的大小写 expect(result.link).toBe('[mynote](MyNote)'); expect(result.isNoteLink).toBe(true); }); @@ -303,7 +303,7 @@ describe('buildHeadingLink', () => { heading: 'Some Heading', filename: 'My Note', }); - // No simplification triggers, so target keeps the heading fragment + // 不会触发简化,因此目标保留标题片段 expect(result.link).toBe('[Some Heading](My%20Note#Some%20Heading)'); expect(result.isNoteLink).toBe(false); }); @@ -354,9 +354,9 @@ describe('buildHeadingLink', () => { }); describe('simplifiedHeadingToNoteLink gated on useHeadingAsDisplayText=true (A1.5)', () => { - // When the user wants "filename#heading" in the display, dropping the heading - // anchor would produce a misleading [filename#heading](filename). The gate - // preserves the heading link so display intent and target stay aligned. + // 当用户希望显示「filename#heading」时,丢掉标题锚点会 + // 产生误导性的 [filename#heading](filename)。这道闸门保留 + // 标题链接,让显示意图与目标保持一致。 it('does NOT simplify on exact match when useHeadingAsDisplayText is false', () => { const result = buildHeadingLink({ @@ -408,7 +408,7 @@ describe('buildHeadingLink', () => { }); }); - // -- Special character sanitization in link targets ---------------------- + // -- 链接目标中的特殊字符净化 ---------------------- describe('special character sanitization', () => { it('wiki format: strips special chars from link target, preserves display text', () => { @@ -450,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', () => { @@ -538,13 +538,13 @@ describe('buildHeadingLink', () => { }); }); - // -- A1.5 gate: simplification gated on useHeadingAsDisplayText (wiki side) ----- + // -- A1.5 闸门:简化受 useHeadingAsDisplayText 约束(wiki 侧) ----- describe('simplifiedHeadingToNoteLink gated on useHeadingAsDisplayText=true (A1.5, wiki side)', () => { - // Mirrors the markdown-side suite. When the user has asked for "filename#heading" - // in the display, the early simplification block is skipped so the heading link - // survives. Exception: the WIKI exact-match special case [[filename]] is preserved - // regardless, since it renders cleanly as just the filename in Obsidian. + // 对应 markdown 侧的同名套件。当用户要求显示「filename#heading」时, + // 提前简化分支会被跳过,从而保留标题链接。 + // 例外:WIKI 精确匹配的特例 [[filename]] 始终保留—— + // 它在 Obsidian 中会被渲染为干净的文件名。 const wikiNoDisplay = { ...defaults, @@ -568,7 +568,7 @@ describe('buildHeadingLink', () => { heading: 'mynote', filename: 'MyNote', }); - // displayText === linkContent → alias collapses, but the heading fragment survives + // displayText === linkContent → alias 折叠,但标题片段保留 expect(result.link).toBe('[[MyNote#mynote]]'); expect(result.isNoteLink).toBe(false); }); @@ -600,13 +600,13 @@ describe('buildHeadingLink', () => { filename: 'SomeThing', headingLinkSeparator: ' > ', }); - // displayText "SomeThing > Some Thing" !== linkContent "SomeThing#Some Thing" + // displayText 「SomeThing > Some Thing」!== linkContent「SomeThing#Some Thing」 expect(result.link).toBe('[[SomeThing#Some Thing|SomeThing > Some Thing]]'); expect(result.isNoteLink).toBe(false); }); }); - // -- simplifiedHeadingToNoteLink: false --------------------------------- + // -- simplifiedHeadingToNoteLink 关闭时 --------------------------------- describe('when simplifiedHeadingToNoteLink is false', () => { const noSimplify = { ...defaults, simplifiedHeadingToNoteLink: false }; @@ -673,7 +673,7 @@ describe('buildHeadingLink', () => { }); }); - // -- Combinatorial -------------------------------------------------------- + // -- 组合场景 -------------------------------------------------------- describe('combinatorial', () => { it('frontmatterTitle has no effect when useHeadingAsDisplayText is true', () => { @@ -684,7 +684,7 @@ 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]]'); }); @@ -697,16 +697,16 @@ describe('buildHeadingLink', () => { useHeadingAsDisplayText: false, headingLinkSeparator: '#', }); - // Pre-A1.5 this simplified to [[MyNote|My Pretty Title#myNote]] (target=file, display=heading-style). - // A1.5 keeps the heading anchor so the target matches the display intent. + // 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: '', @@ -795,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'); }); @@ -805,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('短语,后面'); }); @@ -827,7 +827,7 @@ describe('extractBlockDisplayText', () => { }); }); - // -- Boundary values ------------------------------------------------------ + // -- 边界值 ------------------------------------------------------ describe('boundary values', () => { it('English: exactly wordLimit words returns all words', () => { @@ -861,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'); }); @@ -966,7 +966,7 @@ describe('buildBlockLink', () => { expect(result).toBe('[[MyNote#^abc123|abc123]]'); }); - // -- Combinatorial -------------------------------------------------------- + // -- 组合场景 -------------------------------------------------------- describe('combinatorial', () => { it('embed + no display text (wiki) produces ![[...]]', () => { @@ -980,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, @@ -1104,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', @@ -1118,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', @@ -1128,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', @@ -1325,9 +1325,9 @@ describe('buildExplicitPasteLink', () => { }); it('empty alias yields []() — caller is expected to provide non-empty alias', () => { - // Documenting boundary: buildHeadingCopyMetadata clears alias when - // isNoteLink+filename===heading. handlePaste should treat that as - // the degenerate self-ref case (skip) before reaching here. + // 边界记录:buildHeadingCopyMetadata 在 isNoteLink 且 + // filename===heading 时会清空 alias。handlePaste 应在抵达 + // 这里之前作为退化的自引用情况(skip)处理掉。 expect(buildExplicitPasteLink({ format: LinkFormat.MDLINK, path: 'SomeThing', diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index 6a68131..78ba3e8 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -1,33 +1,33 @@ import { LinkFormat } from './type'; -// --- URL Encoding --- +// --- URL 编码 --- export function encodeMarkdownLinkUrl(url: string): string { return url.replace(/ /g, '%20'); } -// --- Wiki Bracket Stripping --- +// --- 去除 Wiki 链接外层括号 --- /** - * Strip outer [[…]] from a string. Used when a heading captured from - * the editor is itself a wiki-link reference. + * 去除字符串外层的 [[…]]。当从编辑器中捕获的「标题」本身已是 + * 一个 wiki 链接引用时使用。 */ export function stripWikiBrackets(s: string): string { if (s.startsWith('[[') && s.endsWith(']]')) return s.slice(2, -2); return s; } -// --- Heading Sanitization --- +// --- 标题净化 --- /** - * Sanitize heading text for use in link targets. + * 净化标题文本,使其可作为链接目标。 * - * Obsidian's [[ autocomplete surprisingly strips # | ^ : %% [[ ]] from - * heading link targets rather than URL-encoding them, replacing each - * occurrence (and any surrounding whitespace) with a single space. - * URL-encoding these characters does not work reliably in Obsidian. + * 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 @@ -36,7 +36,7 @@ export function sanitizeHeadingForLink(heading: string): string { .trim(); } -// --- Heading Link --- +// --- 标题链接 --- export interface BuildHeadingLinkOptions { heading: string; @@ -78,12 +78,12 @@ interface ShouldSimplifyHeadingOptions { } /** - * Whether the early simplification block fires (linkContent → filename, isNoteLink → true). + * 判断是否触发提前简化分支(linkContent → filename,isNoteLink → true)。 * - * Gated on useHeadingAsDisplayText: when the user wants "filename#heading" in the - * display, dropping the heading anchor would produce a misleading link where the - * display text promises a heading the target doesn't deliver. The WIKI exact-match - * special case (filename === heading) is handled separately and is exempt. + * 受 useHeadingAsDisplayText 约束:当用户希望显示文本是「文件名#标题」时, + * 若把目标的标题锚点丢掉,会出现「显示承诺一个标题链接、但目标其实只是 + * 文件链接」的错位。WIKI 精确匹配(filename === heading)的特例由 + * 调度层另行处理,不受该约束。 */ function shouldSimplifyHeading(o: ShouldSimplifyHeadingOptions): boolean { if (!o.simplifiedHeadingToNoteLink) return false; @@ -143,10 +143,10 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading strictHeadingMatch: options.strictHeadingMatch, }); - // A1.5 exception: WIKI exact match (filename === heading) collapses to - // [[filename]] regardless of useHeadingAsDisplayText, since [[filename]] - // renders cleanly as just the filename in Obsidian. Owned by the - // orchestrator so formatters stay pure string-producers. + // A1.5 例外:WIKI 精确匹配(filename === heading)无论 + // useHeadingAsDisplayText 如何,都简化为 [[filename]]—— + // Obsidian 渲染时 [[filename]] 直接显示为文件名,干净利落。 + // 该策略由调度层持有,使各个格式化函数保持为纯字符串生成器。 const wikiExactMatch = options.linkFormat === LinkFormat.WIKILINK && options.simplifiedHeadingToNoteLink && @@ -164,7 +164,7 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading return { link, isNoteLink }; } -// --- Block Display Text --- +// --- 块显示文本 --- export function extractBlockDisplayText( firstLine: string, @@ -207,7 +207,7 @@ export function extractBlockDisplayText( return text; } -// --- Block Link --- +// --- 块链接 --- export interface BuildBlockLinkOptions { blockId: string; @@ -253,24 +253,25 @@ export function buildBlockLink(options: BuildBlockLinkOptions): string { return link; } -// --- Explicit Paste Link --- +// --- 明确格式的粘贴链接 --- // -// Used by the paste handler when the user has explicitly chosen Wiki/Markdown -// (not "Follow Obsidian settings"). Obsidian's app.fileManager.generateMarkdownLink -// honors the vault's useMarkdownLinks config which would override an explicit -// choice — so we build the link manually instead, with the path string already -// resolved by app.metadataCache.fileToLinktext. +// 当用户明确选择 Wiki / Markdown(而非「跟随 Obsidian 设置」)时, +// 粘贴处理器会调用此函数。Obsidian 的 app.fileManager.generateMarkdownLink +// 会遵循 vault 的 useMarkdownLinks 配置,会覆盖用户的明确格式选择—— +// 因此这里改为手动拼接链接,路径部分由 app.metadataCache.fileToLinktext +// 预先解析。 // -// Pure string-producer: the caller decides `omitAlias` via shouldOmitAliasForSameFile. -// Formatter only consumes the flag — Strategy: caller owns policy, formatter owns syntax. +// 纯字符串生成器:是否省略 alias 由调用方通过 shouldOmitAliasForSameFile +// 决定,本函数只消费该布尔值——Strategy 模式:调用方持有策略, +// 格式化函数只负责语法。 export interface BuildExplicitPasteLinkOptions { format: LinkFormat.WIKILINK | LinkFormat.MDLINK; - path: string; // from fileToLinktext (shortest unique vault path) - subpath: string; // '#Heading' or '#^blockid' or '' - alias: string; // display text or '' - sameFile: boolean; // sourcePath === destPath — drops path portion - omitAlias: boolean; // true → render without alias (caller decision) + path: string; // 来自 fileToLinktext(vault 内最短唯一路径) + subpath: string; // '#Heading'、'#^blockid' 或 '' + alias: string; // 显示文本,或 '' + sameFile: boolean; // sourcePath === destPath——同文件粘贴时去掉路径段 + omitAlias: boolean; // true → 渲染时省略 alias(由调用方决定) } export function buildExplicitPasteLink(opts: BuildExplicitPasteLinkOptions): string { @@ -280,13 +281,13 @@ export function buildExplicitPasteLink(opts: BuildExplicitPasteLinkOptions): str if (opts.format === LinkFormat.WIKILINK) { return aliasToUse ? `[[${linkTarget}|${aliasToUse}]]` : `[[${linkTarget}]]`; } - // MDLINK — alias IS the visible link text. When omitAlias forces it empty, - // preserve the original alias as display so we never produce [](path#H). + // MDLINK——alias 就是用户看到的链接文本。当 omitAlias 把它清空时, + // 用原始 alias 作为显示文本,避免出现 [](path#H) 这样的空显示链接。 const display = aliasToUse || opts.alias || ''; return `[${display}](${encodeMarkdownLinkUrl(linkTarget)})`; } -// --- File Link --- +// --- 文件链接 --- export interface BuildFileLinkOptions { filename: string; diff --git a/src/main.ts b/src/main.ts index 18472bb..dbcd12b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -110,25 +110,21 @@ export default class EasyCopy extends Plugin { }) ); - // Clear paste metadata on manual copy (Ctrl+C, right-click copy, etc.) - // so we don't falsely intercept paste of non-Easy-Copy content. - // navigator.clipboard.writeText() does not fire DOM copy events, - // so Easy Copy's own writes are unaffected. - // Note: plugins that also use navigator.clipboard.writeText() bypass - // this listener. A conflict would require identical clipboard text, - // which is extremely unlikely. If needed, a custom ClipboardItem MIME - // type could serve as a more robust authentication mechanism. + // 手动复制(Ctrl+C、右键复制等)时清除 lastCopyMeta, + // 避免在粘贴非 Easy Copy 内容时被误拦截。 + // navigator.clipboard.writeText() 不会触发 DOM 的 copy 事件, + // 所以 Easy Copy 自身写入剪贴板时不受影响。 + // 注意:其他也使用 navigator.clipboard.writeText() 的插件会 + // 绕过这个监听器;冲突需要剪贴板文本完全相同,概率极低。 + // 如果需要更稳健的识别机制,可改用自定义 ClipboardItem MIME 类型。 this.registerDomEvent(document, 'copy', () => { this.lastCopyMeta = null; }); - // Paste-time path resolution: when enabled and "Follow Obsidian settings" - // is active, intercept paste to regenerate the link with correct path - // format (shortest/relative/absolute) based on the destination file. - // Uses Obsidian's editor-paste workspace event so we cooperate with - // other plugins (registration order, defaultPrevented handshake). - // Only registered when both conditions hold, so we're absent from the - // plugin chain entirely when the user has the feature off. + // 粘贴时解析链接路径:启用后拦截粘贴,根据目标文件重新生成链接。 + // 使用 Obsidian 的 editor-paste workspace 事件, + // 与其他插件保持协作(按注册顺序、遵循 defaultPrevented 协议)。 + // 仅在开关启用时注册,关闭时彻底退出粘贴插件链。 this.syncPasteHandlerRegistration(); } @@ -168,13 +164,13 @@ export default class EasyCopy extends Plugin { } /** - * Intercept paste when the clipboard contains an Easy Copy link. - * Under "Follow Obsidian settings" uses generateMarkdownLink() (full - * path-style support); under explicit Wiki/Markdown uses fileToLinktext() - * (shortest-unique paths only) so the user's format choice is honored. + * 当剪贴板内容是 Easy Copy 生成的链接时,拦截粘贴并按目标文件重写。 + * 「跟随 Obsidian 设置」时调用 generateMarkdownLink()(完整支持 + * 最短/相对/绝对路径风格);选择明确的 Wiki/Markdown 格式时改用 + * fileToLinktext()(仅支持最短唯一路径),以尊重用户的格式选择。 * - * Per the editor-paste contract (obsidian.d.ts): yield if another handler - * has already preventDefault'd, and call preventDefault to claim the event. + * 依照 editor-paste 契约(见 obsidian.d.ts):若有其他处理器已经 + * preventDefault,则让出事件;本处理器接管时也需调用 preventDefault。 */ private handlePaste(evt: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): void { const clipboardText = evt.clipboardData?.getData('text/plain'); @@ -193,14 +189,14 @@ export default class EasyCopy extends Plugin { } if (decision === 'skip') return; - // decision === 'rewrite' — lastCopyMeta and clipboardText are non-null here. + // decision === 'rewrite':此时 lastCopyMeta 和 clipboardText 必非空。 const meta = this.lastCopyMeta!; const destFile = info.file; if (!destFile) return; - // Degenerate self-ref: a same-file paste with no anchor would produce - // [[]] / [](#) under either branch. Let normal paste proceed instead. + // 退化的自引用:同文件粘贴且无锚点会生成 [[]] / [](#) 之类的空链接, + // 这种情况下让正常粘贴流程接手即可。 if (meta.subpath === '' && meta.sourceFilePath === destFile.path) return; const sourceFile = this.app.vault.getAbstractFileByPath(meta.sourceFilePath); @@ -219,8 +215,8 @@ export default class EasyCopy extends Plugin { let link: string; if (this.settings.linkFormat === LinkFormat.OBSIDIAN) { - // generateMarkdownLink honors vault config (useMarkdownLinks + - // newLinkFormat) — full path-style support: shortest/relative/absolute. + // generateMarkdownLink 会遵循 vault 配置(useMarkdownLinks + + // newLinkFormat),完整支持最短/相对/绝对三种路径风格。 const aliasArg = omitAlias ? undefined : (meta.alias || undefined); link = this.app.fileManager.generateMarkdownLink( sourceFile, @@ -229,11 +225,10 @@ export default class EasyCopy extends Plugin { aliasArg, ); } else { - // Explicit Wiki/Markdown: build manually so the user's format choice is - // honored. fileToLinktext returns the shortest-unique vault path - // (no relative/absolute support — documented trade-off). - // omitMdExtension=true is REQUIRED — defaulting false would yield - // paths with ".md" and break wiki-link convention ([[Note.md]]). + // 用户选择了明确的 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, @@ -254,7 +249,7 @@ export default class EasyCopy extends Plugin { editor.replaceSelection(link); } } catch { - // link generation failed — let normal paste proceed + // 链接生成失败——让正常粘贴流程继续 } } @@ -674,7 +669,7 @@ export default class EasyCopy extends Plugin { navigator.clipboard.writeText(blockIdLink); - // Store metadata for paste-time path resolution + // 存储元数据,供粘贴时解析链接路径使用 const blockFile = this.app.workspace.getActiveFile(); if (blockFile) { this.lastCopyMeta = buildBlockCopyMetadata({ @@ -713,9 +708,9 @@ export default class EasyCopy extends Plugin { } } - // Under "Follow Obsidian settings", simplified-to-note-link transforms - // conflict with deferring format/path choice to Obsidian. Gate inert at - // the call site so buildHeadingLink stays format-agnostic. + // 「跟随 Obsidian 设置」时应将格式与路径选择交给 Obsidian, + // 此时 simplifiedHeadingToNoteLink 的链接转换会与该原则冲突。 + // 在调用点直接禁用该选项,让 buildHeadingLink 保持与格式无关。 const userExplicitlyPickedFormat = this.settings.linkFormat !== LinkFormat.OBSIDIAN; const { link, isNoteLink } = buildHeadingLink({ heading: content, @@ -731,7 +726,7 @@ export default class EasyCopy extends Plugin { navigator.clipboard.writeText(link); - // Store metadata for paste-time path resolution + // 存储元数据,供粘贴时解析链接路径使用 const headingFile = this.app.workspace.getActiveFile(); if (headingFile) { this.lastCopyMeta = buildHeadingCopyMetadata({ @@ -787,7 +782,7 @@ export default class EasyCopy extends Plugin { navigator.clipboard.writeText(link); - // Store metadata for paste-time path resolution + // 存储元数据,供粘贴时解析链接路径使用 this.lastCopyMeta = buildFileCopyMetadata({ clipboardText: link, sourceFilePath: file.path, diff --git a/src/pasteResolution.test.ts b/src/pasteResolution.test.ts index 9185461..5814af4 100644 --- a/src/pasteResolution.test.ts +++ b/src/pasteResolution.test.ts @@ -101,8 +101,8 @@ describe('decidePasteResolution', () => { }); it('TTL check runs before clipboard match', () => { - // Stale meta with mismatched clipboard text — TTL should fire first, - // resulting in reset-and-skip either way, but staleness is the cause. + // 过期的 meta 加上不匹配的剪贴板文本——TTL 检查会先触发, + // 两种情况下结果都是 reset-and-skip,但根因是过期。 expect(decidePasteResolution({ ...baseInput, now: META.timestamp + TTL + 1, @@ -165,7 +165,7 @@ describe('shouldOmitAliasForSameFile', () => { }); it('omits when sanitization round-trip collapses alias to subpath', () => { - // alias contains | which sanitizeHeadingForLink collapses to a space + // alias 中的 | 会被 sanitizeHeadingForLink 折叠为空格 expect(shouldOmitAliasForSameFile({ ...base, subpath: '#Some Heading', @@ -174,8 +174,8 @@ describe('shouldOmitAliasForSameFile', () => { }); it('keeps alias when OBSIDIAN is passed (helper expects already-resolved format)', () => { - // Defensive: callers should never pass OBSIDIAN here, but if they do - // the helper bails rather than misbehave. + // 防御性检查:调用方不应在此传入 OBSIDIAN; + // 即使误传,本函数也会安全地返回 false。 expect(shouldOmitAliasForSameFile({ ...base, effectiveLinkFormat: LinkFormat.OBSIDIAN })).toBe(false); }); }); diff --git a/src/pasteResolution.ts b/src/pasteResolution.ts index aea1993..cf5c146 100644 --- a/src/pasteResolution.ts +++ b/src/pasteResolution.ts @@ -3,8 +3,8 @@ import { sanitizeHeadingForLink } from './linkBuilder'; import { EasyCopySettings, LinkFormat } from './type'; /** - * Whether the editor-paste handler should be registered for the given settings. - * The bridge between settings state and "is Easy Copy in the paste plugin chain." + * 根据当前设置判断是否需要注册 editor-paste 处理器。 + * 即设置状态与「Easy Copy 是否处于粘贴插件链中」之间的桥梁。 */ export function shouldRegisterPasteHandler( settings: Pick, @@ -13,9 +13,9 @@ export function shouldRegisterPasteHandler( } export type PasteResolutionAction = - | 'skip' // do nothing, leave lastCopyMeta as-is - | 'reset-and-skip' // null out lastCopyMeta, do nothing else - | 'rewrite'; // proceed to regenerate and substitute the link + | 'skip' // 什么都不做,保留 lastCopyMeta 不变 + | 'reset-and-skip' // 清空 lastCopyMeta,再让粘贴流程继续 + | 'rewrite'; // 重新生成链接并替换粘贴内容 export interface PasteResolutionInput { defaultPrevented: boolean; @@ -45,14 +45,15 @@ export interface ShouldOmitAliasInput { } /** - * Whether a paste-time alias is redundant and should be dropped. + * 判断粘贴时的 alias 是否冗余、应当省略。 * - * Same-file wiki heading pastes naturally render as just the heading - * text — `[[#Heading]]` displays "Heading" without a separate alias. - * Cross-file wiki pastes need the alias (Obsidian otherwise renders - * "Filename > Heading"). Markdown pastes always need the alias since - * it IS the visible link text. Block links are out of scope: their - * brief alias never matches the `#^id` literal and has its own setting. + * 同文件标题 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; diff --git a/src/settingTab.ts b/src/settingTab.ts index 0d5ff90..5796280 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -123,10 +123,9 @@ export class EasyCopySettingTab extends PluginSettingTab { this.display(); }))); - // The resolver intercepts paste to regenerate the link target for the - // destination. Under "Follow Obsidian settings" it uses the vault's - // path style (shortest/relative/absolute); under explicit Wiki/Markdown - // it falls back to shortest-unique paths only. + // 解析器在粘贴时拦截事件,根据目标文件重新生成链接。 + // 「跟随 Obsidian 设置」时遵循 vault 的路径风格(最短/相对/绝对); + // 选择明确的 Wiki/Markdown 格式时仅使用最短唯一路径。 formatGroup.addSetting(setting => setting .setName(this.plugin.t('resolve-link-path-on-paste')) .setDesc(this.plugin.t('resolve-link-path-on-paste-desc')) @@ -165,10 +164,10 @@ export class EasyCopySettingTab extends PluginSettingTab { } // 后续新增:文件名包含标题时,简化为复制文件链接(通常用于复制一级标题时) - // Under OBSIDIAN, defer format/path choice to Obsidian's vault config — - // Obsidian's own generateMarkdownLink never auto-collapses heading links - // to file links, so this opt-in is hidden+inert there. Stored values - // persist across format toggles (matches resolveLinkPathOnPaste). + // 「跟随 Obsidian 设置」时,格式与路径选择应交给 Obsidian—— + // Obsidian 自身的 generateMarkdownLink 从不会把标题链接自动 + // 简化为文件链接,因此这里把该选项隐藏并令其失效。 + // 存储值在切换格式时保留不变(与 resolveLinkPathOnPaste 一致)。 if (this.plugin.settings.linkFormat !== LinkFormat.OBSIDIAN) { formatGroup.addSetting(setting => setting .setName(this.plugin.t('simplified-heading-to-note-link')) diff --git a/src/type.ts b/src/type.ts index df7268f..381a93e 100644 --- a/src/type.ts +++ b/src/type.ts @@ -44,7 +44,7 @@ export interface EasyCopySettings { simplifiedHeadingToNoteLink: boolean; // 是否简化标题到笔记链接 strictHeadingMatch: boolean; // 标题与文件名简化匹配时是否使用严格匹配 linkFormat: LinkFormat; - resolveLinkPathOnPaste: boolean; // 当 linkFormat 为 OBSIDIAN 时,粘贴时根据目标文件重新解析链接路径 + resolveLinkPathOnPaste: boolean; // 粘贴时根据目标文件重新解析链接路径(OBSIDIAN 格式支持完整路径风格,明确 Wiki/Markdown 仅支持最短唯一路径) customizeTargets: boolean; enableInlineCode: boolean; enableBold: boolean; From fbd9547bf8911a6ce392e7a2872b23457b178e03 Mon Sep 17 00:00:00 2001 From: Moy Date: Fri, 8 May 2026 13:37:26 +0800 Subject: [PATCH 11/14] fix: restore simplifiedHeadingToNoteLink in OBSIDIAN mode, remove defaultPrevented check, update i18n descriptions --- package-lock.json | 110 ------------------------------------ src/i18n.ts | 6 +- src/main.ts | 12 +--- src/pasteResolution.test.ts | 14 ----- src/pasteResolution.ts | 2 - 5 files changed, 6 insertions(+), 138 deletions(-) diff --git a/package-lock.json b/package-lock.json index c3bc601..7e9b426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/i18n.ts b/src/i18n.ts index 1591c5c..32a70a0 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -109,7 +109,7 @@ export const translations: Record> = { '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); under explicit Wiki/Markdown, uses shortest-unique paths only. Won\'t interfere with other paste-handling plugins; if one handles the paste first, this feature may be bypassed depending on how that plugin handles the 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. Note: when enabled, multiple plugins may handle paste simultaneously, which could cause conflicts.', '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', @@ -209,7 +209,7 @@ export const translations: Record> = { 'markdown-link': 'Markdown链接', 'wiki-link': 'Wiki链接', 'resolve-link-path-on-paste': '粘贴时解析链接路径', - 'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用 Obsidian 的路径风格(最短/相对/绝对);选择明确的 Wiki/Markdown 格式时,仅使用最短唯一路径。不会干扰其他处理粘贴的插件;若有其他插件先处理粘贴,此功能可能被绕过,具体取决于该插件的处理方式。', + 'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用软件设置中的路径风格(最短/相对/绝对);否则,仅使用最短唯一路径。注:启用后,多个插件可能同时处理粘贴行为,导致冲突。', 'customize-targets': '自定义复制对象', 'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)', 'enable-inline-code': '启用行内代码', @@ -319,7 +319,7 @@ export const translations: Record> = { 'markdown-link': 'Markdown連結', 'wiki-link': 'Wiki連結', 'resolve-link-path-on-paste': '貼上時解析連結路徑', - 'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用 Obsidian 的路徑風格(最短/相對/絕對);選擇明確的 Wiki/Markdown 格式時,僅使用最短唯一路徑。不會干擾其他處理貼上的外掛;若有其他外掛先處理貼上,此功能可能被略過,具體取決於該外掛的處理方式。', + 'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用軟體設定中的路徑風格(最短/相對/絕對);否則,僅使用最短唯一路徑。注:啟用後,多個外掛可能同時處理貼上行為,導致衝突。', 'customize-targets': '自定義複製對象', 'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)', 'enable-inline-code': '啟用行內代碼', diff --git a/src/main.ts b/src/main.ts index dbcd12b..5d4006a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -169,13 +169,12 @@ export default class EasyCopy extends Plugin { * 最短/相对/绝对路径风格);选择明确的 Wiki/Markdown 格式时改用 * fileToLinktext()(仅支持最短唯一路径),以尊重用户的格式选择。 * - * 依照 editor-paste 契约(见 obsidian.d.ts):若有其他处理器已经 - * preventDefault,则让出事件;本处理器接管时也需调用 preventDefault。 + * 用户启用此功能即表示希望 Easy Copy 处理粘贴,因此不检查 + * defaultPrevented,避免被其他插件(如 Linter)阻断。 */ private handlePaste(evt: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): void { const clipboardText = evt.clipboardData?.getData('text/plain'); const decision = decidePasteResolution({ - defaultPrevented: evt.defaultPrevented, resolveLinkPathOnPaste: this.settings.resolveLinkPathOnPaste, lastCopyMeta: this.lastCopyMeta, clipboardText, @@ -708,10 +707,6 @@ export default class EasyCopy extends Plugin { } } - // 「跟随 Obsidian 设置」时应将格式与路径选择交给 Obsidian, - // 此时 simplifiedHeadingToNoteLink 的链接转换会与该原则冲突。 - // 在调用点直接禁用该选项,让 buildHeadingLink 保持与格式无关。 - const userExplicitlyPickedFormat = this.settings.linkFormat !== LinkFormat.OBSIDIAN; const { link, isNoteLink } = buildHeadingLink({ heading: content, filename, @@ -720,8 +715,7 @@ export default class EasyCopy extends Plugin { useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText, headingLinkSeparator: this.settings.headingLinkSeparator, strictHeadingMatch: this.settings.strictHeadingMatch, - simplifiedHeadingToNoteLink: - userExplicitlyPickedFormat && this.settings.simplifiedHeadingToNoteLink, + simplifiedHeadingToNoteLink: this.settings.simplifiedHeadingToNoteLink, }); navigator.clipboard.writeText(link); diff --git a/src/pasteResolution.test.ts b/src/pasteResolution.test.ts index 5814af4..4b608f5 100644 --- a/src/pasteResolution.test.ts +++ b/src/pasteResolution.test.ts @@ -30,7 +30,6 @@ const META: CopyMetadata = { const TTL = 5 * 60 * 1000; const baseInput: PasteResolutionInput = { - defaultPrevented: false, resolveLinkPathOnPaste: true, lastCopyMeta: META, clipboardText: META.clipboardText, @@ -43,10 +42,6 @@ describe('decidePasteResolution', () => { 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'); }); @@ -83,15 +78,6 @@ describe('decidePasteResolution', () => { })).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, diff --git a/src/pasteResolution.ts b/src/pasteResolution.ts index cf5c146..5d2549b 100644 --- a/src/pasteResolution.ts +++ b/src/pasteResolution.ts @@ -18,7 +18,6 @@ export type PasteResolutionAction = | 'rewrite'; // 重新生成链接并替换粘贴内容 export interface PasteResolutionInput { - defaultPrevented: boolean; resolveLinkPathOnPaste: boolean; lastCopyMeta: CopyMetadata | null; clipboardText: string | undefined; @@ -27,7 +26,6 @@ export interface PasteResolutionInput { } 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'; From 8e1b6058bdd385770f62ea0754efdf795f13c100 Mon Sep 17 00:00:00 2001 From: Moy Date: Fri, 8 May 2026 14:18:46 +0800 Subject: [PATCH 12/14] fix: restore defaultPrevented check, always show simplifiedHeadingToNoteLink setting, update i18n with load-order note --- src/i18n.ts | 6 ++--- src/main.ts | 7 ++++-- src/pasteResolution.test.ts | 14 ++++++++++++ src/pasteResolution.ts | 2 ++ src/settingTab.ts | 45 +++++++++++++++++-------------------- 5 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/i18n.ts b/src/i18n.ts index 32a70a0..379bb59 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -109,7 +109,7 @@ export const translations: Record> = { '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. Note: when enabled, multiple plugins may handle paste simultaneously, which could cause conflicts.', + '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. Note: plugins loaded earlier take priority for paste handling. If another plugin (e.g. Linter) intercepts paste first, this feature will be bypassed — disable and re-enable Easy Copy to adjust load order.', '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', @@ -209,7 +209,7 @@ export const translations: Record> = { 'markdown-link': 'Markdown链接', 'wiki-link': 'Wiki链接', 'resolve-link-path-on-paste': '粘贴时解析链接路径', - 'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用软件设置中的路径风格(最短/相对/绝对);否则,仅使用最短唯一路径。注:启用后,多个插件可能同时处理粘贴行为,导致冲突。', + 'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用软件设置中的路径风格(最短/相对/绝对);否则,仅使用最短唯一路径。注:越早加载的插件越先处理粘贴事件。若其他插件(如 Linter)抢先处理,本功能会被跳过——可先禁用再启用 Easy Copy 来调整加载顺序。', 'customize-targets': '自定义复制对象', 'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)', 'enable-inline-code': '启用行内代码', @@ -319,7 +319,7 @@ export const translations: Record> = { 'markdown-link': 'Markdown連結', 'wiki-link': 'Wiki連結', 'resolve-link-path-on-paste': '貼上時解析連結路徑', - 'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用軟體設定中的路徑風格(最短/相對/絕對);否則,僅使用最短唯一路徑。注:啟用後,多個外掛可能同時處理貼上行為,導致衝突。', + 'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用軟體設定中的路徑風格(最短/相對/絕對);否則,僅使用最短唯一路徑。注:越早載入的外掛越先處理貼上事件。若其他外掛(如 Linter)搶先處理,本功能會被略過——可先停用再啟用 Easy Copy 來調整載入順序。', 'customize-targets': '自定義複製對象', 'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)', 'enable-inline-code': '啟用行內代碼', diff --git a/src/main.ts b/src/main.ts index 5d4006a..233547b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -169,12 +169,15 @@ export default class EasyCopy extends Plugin { * 最短/相对/绝对路径风格);选择明确的 Wiki/Markdown 格式时改用 * fileToLinktext()(仅支持最短唯一路径),以尊重用户的格式选择。 * - * 用户启用此功能即表示希望 Easy Copy 处理粘贴,因此不检查 - * defaultPrevented,避免被其他插件(如 Linter)阻断。 + * 依照 editor-paste 契约:若有其他处理器已经 preventDefault,则让出事件。 + * 插件加载顺序决定 handler 的执行顺序——越早启用的插件越先执行。 + * 如果其他粘贴类插件(如 Linter)抢先处理了事件,本功能会被跳过。 + * 此时需确保 Easy Copy 在冲突插件之前启用(先禁用再启用即可调整顺序)。 */ private handlePaste(evt: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): void { const clipboardText = evt.clipboardData?.getData('text/plain'); const decision = decidePasteResolution({ + defaultPrevented: evt.defaultPrevented, resolveLinkPathOnPaste: this.settings.resolveLinkPathOnPaste, lastCopyMeta: this.lastCopyMeta, clipboardText, diff --git a/src/pasteResolution.test.ts b/src/pasteResolution.test.ts index 4b608f5..5814af4 100644 --- a/src/pasteResolution.test.ts +++ b/src/pasteResolution.test.ts @@ -30,6 +30,7 @@ const META: CopyMetadata = { const TTL = 5 * 60 * 1000; const baseInput: PasteResolutionInput = { + defaultPrevented: false, resolveLinkPathOnPaste: true, lastCopyMeta: META, clipboardText: META.clipboardText, @@ -42,6 +43,10 @@ describe('decidePasteResolution', () => { 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'); }); @@ -78,6 +83,15 @@ describe('decidePasteResolution', () => { })).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, diff --git a/src/pasteResolution.ts b/src/pasteResolution.ts index 5d2549b..cf5c146 100644 --- a/src/pasteResolution.ts +++ b/src/pasteResolution.ts @@ -18,6 +18,7 @@ export type PasteResolutionAction = | 'rewrite'; // 重新生成链接并替换粘贴内容 export interface PasteResolutionInput { + defaultPrevented: boolean; resolveLinkPathOnPaste: boolean; lastCopyMeta: CopyMetadata | null; clipboardText: string | undefined; @@ -26,6 +27,7 @@ export interface PasteResolutionInput { } 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'; diff --git a/src/settingTab.ts b/src/settingTab.ts index 5796280..1a9be29 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -165,32 +165,27 @@ export class EasyCopySettingTab extends PluginSettingTab { // 后续新增:文件名包含标题时,简化为复制文件链接(通常用于复制一级标题时) // 「跟随 Obsidian 设置」时,格式与路径选择应交给 Obsidian—— - // Obsidian 自身的 generateMarkdownLink 从不会把标题链接自动 - // 简化为文件链接,因此这里把该选项隐藏并令其失效。 - // 存储值在切换格式时保留不变(与 resolveLinkPathOnPaste 一致)。 - if (this.plugin.settings.linkFormat !== LinkFormat.OBSIDIAN) { - formatGroup.addSetting(setting => setting - .setName(this.plugin.t('simplified-heading-to-note-link')) - .setDesc(this.plugin.t('simplified-heading-to-note-link-desc')) - .addToggle(toggle => toggle - .setValue(this.plugin.settings.simplifiedHeadingToNoteLink) - .onChange(async (value) => { - this.plugin.settings.simplifiedHeadingToNoteLink = value; - await this.plugin.saveSettings(); - this.display(); - }))); + formatGroup.addSetting(setting => setting + .setName(this.plugin.t('simplified-heading-to-note-link')) + .setDesc(this.plugin.t('simplified-heading-to-note-link-desc')) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.simplifiedHeadingToNoteLink) + .onChange(async (value) => { + this.plugin.settings.simplifiedHeadingToNoteLink = value; + await this.plugin.saveSettings(); + this.display(); + }))); - if (this.plugin.settings.simplifiedHeadingToNoteLink) { - formatGroup.addSetting(setting => setting - .setName(this.plugin.t('strict-heading-match')) - .setDesc(this.plugin.t('strict-heading-match-desc')) - .addToggle(toggle => toggle - .setValue(this.plugin.settings.strictHeadingMatch) - .onChange(async (value) => { - this.plugin.settings.strictHeadingMatch = value; - await this.plugin.saveSettings(); - }))); - } + if (this.plugin.settings.simplifiedHeadingToNoteLink) { + formatGroup.addSetting(setting => setting + .setName(this.plugin.t('strict-heading-match')) + .setDesc(this.plugin.t('strict-heading-match-desc')) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.strictHeadingMatch) + .onChange(async (value) => { + this.plugin.settings.strictHeadingMatch = value; + await this.plugin.saveSettings(); + }))); } From a34ce4648ae220092015cbd485dc40af4813fc55 Mon Sep 17 00:00:00 2001 From: Moy Date: Fri, 8 May 2026 14:35:25 +0800 Subject: [PATCH 13/14] refine: split paste-resolution desc into short text + info tooltip, remove re-enable suggestion --- src/i18n.ts | 11 +++++++---- src/settingTab.ts | 29 +++++++++++++++++++---------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/i18n.ts b/src/i18n.ts index 379bb59..af6960b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -18,7 +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' | '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' @@ -109,7 +109,8 @@ export const translations: Record> = { '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. Note: plugins loaded earlier take priority for paste handling. If another plugin (e.g. Linter) intercepts paste first, this feature will be bypassed — disable and re-enable Easy Copy to adjust load order.', + '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.', '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', @@ -209,7 +210,8 @@ export const translations: Record> = { 'markdown-link': 'Markdown链接', 'wiki-link': 'Wiki链接', 'resolve-link-path-on-paste': '粘贴时解析链接路径', - 'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用软件设置中的路径风格(最短/相对/绝对);否则,仅使用最短唯一路径。注:越早加载的插件越先处理粘贴事件。若其他插件(如 Linter)抢先处理,本功能会被跳过——可先禁用再启用 Easy Copy 来调整加载顺序。', + 'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用软件设置中的路径风格(最短/相对/绝对);否则,仅使用最短唯一路径。', + 'resolve-link-path-on-paste-tooltip': '越早加载的插件越先处理粘贴事件。若其他插件(如 Linter)抢先处理,本功能会被跳过。', 'customize-targets': '自定义复制对象', 'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)', 'enable-inline-code': '启用行内代码', @@ -319,7 +321,8 @@ export const translations: Record> = { 'markdown-link': 'Markdown連結', 'wiki-link': 'Wiki連結', 'resolve-link-path-on-paste': '貼上時解析連結路徑', - 'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用軟體設定中的路徑風格(最短/相對/絕對);否則,僅使用最短唯一路徑。注:越早載入的外掛越先處理貼上事件。若其他外掛(如 Linter)搶先處理,本功能會被略過——可先停用再啟用 Easy Copy 來調整載入順序。', + 'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用軟體設定中的路徑風格(最短/相對/絕對);否則,僅使用最短唯一路徑。', + 'resolve-link-path-on-paste-tooltip': '越早載入的外掛越先處理貼上事件。若其他外掛(如 Linter)搶先處理,本功能會被略過。', 'customize-targets': '自定義複製對象', 'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)', 'enable-inline-code': '啟用行內代碼', diff --git a/src/settingTab.ts b/src/settingTab.ts index 1a9be29..26a55be 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -126,16 +126,25 @@ export class EasyCopySettingTab extends PluginSettingTab { // 解析器在粘贴时拦截事件,根据目标文件重新生成链接。 // 「跟随 Obsidian 设置」时遵循 vault 的路径风格(最短/相对/绝对); // 选择明确的 Wiki/Markdown 格式时仅使用最短唯一路径。 - formatGroup.addSetting(setting => setting - .setName(this.plugin.t('resolve-link-path-on-paste')) - .setDesc(this.plugin.t('resolve-link-path-on-paste-desc')) - .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 => { + 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' }, + }); + infoIcon.innerHTML = ''; + + 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')) From 3143de6edf15687d2ff5c9f4a3879a2be9e8d5cf Mon Sep 17 00:00:00 2001 From: Moy Date: Fri, 8 May 2026 14:40:30 +0800 Subject: [PATCH 14/14] fix: add console warning when paste is preempted, add community-plugins.json hint to tooltip, inline info icon --- src/i18n.ts | 6 +++--- src/main.ts | 7 +++++++ src/settingTab.ts | 8 ++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/i18n.ts b/src/i18n.ts index af6960b..6586fde 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -110,7 +110,7 @@ export const translations: Record> = { '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.', + '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', @@ -211,7 +211,7 @@ export const translations: Record> = { 'wiki-link': 'Wiki链接', 'resolve-link-path-on-paste': '粘贴时解析链接路径', 'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用软件设置中的路径风格(最短/相对/绝对);否则,仅使用最短唯一路径。', - 'resolve-link-path-on-paste-tooltip': '越早加载的插件越先处理粘贴事件。若其他插件(如 Linter)抢先处理,本功能会被跳过。', + 'resolve-link-path-on-paste-tooltip': '越早加载的插件越先处理粘贴事件。若其他插件(如 Linter)抢先处理,本功能会被跳过。可在 community-plugins.json 文件中调整插件加载顺序。', 'customize-targets': '自定义复制对象', 'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)', 'enable-inline-code': '启用行内代码', @@ -322,7 +322,7 @@ export const translations: Record> = { 'wiki-link': 'Wiki連結', 'resolve-link-path-on-paste': '貼上時解析連結路徑', 'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用軟體設定中的路徑風格(最短/相對/絕對);否則,僅使用最短唯一路徑。', - 'resolve-link-path-on-paste-tooltip': '越早載入的外掛越先處理貼上事件。若其他外掛(如 Linter)搶先處理,本功能會被略過。', + 'resolve-link-path-on-paste-tooltip': '越早載入的外掛越先處理貼上事件。若其他外掛(如 Linter)搶先處理,本功能會被略過。可在 community-plugins.json 檔案中調整外掛載入順序。', 'customize-targets': '自定義複製對象', 'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)', 'enable-inline-code': '啟用行內代碼', diff --git a/src/main.ts b/src/main.ts index 233547b..2639c73 100644 --- a/src/main.ts +++ b/src/main.ts @@ -176,6 +176,13 @@ export default class EasyCopy extends Plugin { */ 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, diff --git a/src/settingTab.ts b/src/settingTab.ts index 26a55be..c01d8a6 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -130,9 +130,13 @@ export class EasyCopySettingTab extends PluginSettingTab { 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' }, + 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 = ''; + infoIcon.innerHTML = ''; setting .setName(this.plugin.t('resolve-link-path-on-paste'))