feat: extend paste-time path resolution to explicit Wiki/Markdown

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.
This commit is contained in:
Andre Light 2026-04-28 20:53:07 -06:00
parent 7e467f389c
commit adef562ef6
7 changed files with 299 additions and 73 deletions

View file

@ -109,7 +109,7 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'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<Language, Record<TranslationKey, string>> = {
'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<Language, Record<TranslationKey, string>> = {
'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': '啟用行內代碼',

View file

@ -1,5 +1,13 @@
import { describe, it, expect } from 'vitest';
import { buildHeadingLink, buildBlockLink, buildFileLink, extractBlockDisplayText, encodeMarkdownLinkUrl, sanitizeHeadingForLink } from './linkBuilder';
import {
buildBlockLink,
buildExplicitPasteLink,
buildFileLink,
buildHeadingLink,
encodeMarkdownLinkUrl,
extractBlockDisplayText,
sanitizeHeadingForLink,
} from './linkBuilder';
import { LinkFormat } from './type';
// ---------------------------------------------------------------------------
@ -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)');
});
});
});

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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');
});

View file

@ -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<EasyCopySettings, 'resolveLinkPathOnPaste' | 'linkFormat'>,
settings: Pick<EasyCopySettings, 'resolveLinkPathOnPaste'>,
): 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';

View file

@ -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'))