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