mirror of
https://github.com/moyf/easy-copy.git
synced 2026-07-22 05:43:47 +00:00
feat: opt-in toggle, editor-paste cooperation, and orphaned-setting fix
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.
This commit is contained in:
parent
3c3c20800f
commit
4ac623b1bd
10 changed files with 372 additions and 23 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Language, Record<TranslationKey, string>> = {
|
|||
'link-format-obsidian': 'Follow Obsidian settings',
|
||||
'markdown-link': 'Markdown link',
|
||||
'wiki-link': 'Wiki link',
|
||||
'resolve-link-path-on-paste': 'Resolve link path on paste',
|
||||
'resolve-link-path-on-paste-desc': '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<Language, Record<TranslationKey, string>> = {
|
|||
'link-format-obsidian': '跟随 Obsidian 设置',
|
||||
'markdown-link': 'Markdown链接',
|
||||
'wiki-link': 'Wiki链接',
|
||||
'resolve-link-path-on-paste': '粘贴时解析链接路径',
|
||||
'resolve-link-path-on-paste-desc': '在使用"跟随 Obsidian 设置"时,根据目标文件在粘贴时重新生成链接路径(最短/相对/绝对)。不会干扰其他处理粘贴的插件;若有其他插件先处理粘贴,此功能可能被绕过,具体取决于该插件的处理方式。',
|
||||
'customize-targets': '自定义复制对象',
|
||||
'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)',
|
||||
'enable-inline-code': '启用行内代码',
|
||||
|
|
@ -313,6 +318,8 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'link-format-obsidian': '跟隨 Obsidian 設定',
|
||||
'markdown-link': 'Markdown連結',
|
||||
'wiki-link': 'Wiki連結',
|
||||
'resolve-link-path-on-paste': '貼上時解析連結路徑',
|
||||
'resolve-link-path-on-paste-desc': '在使用「跟隨 Obsidian 設定」時,根據目標檔案在貼上時重新生成連結路徑(最短/相對/絕對)。不會干擾其他處理貼上的外掛;若有其他外掛先處理貼上,此功能可能被略過,具體取決於該外掛的處理方式。',
|
||||
'customize-targets': '自定義複製對象',
|
||||
'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)',
|
||||
'enable-inline-code': '啟用行內代碼',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
85
src/main.ts
85
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);
|
||||
|
|
|
|||
141
src/pasteResolution.test.ts
Normal file
141
src/pasteResolution.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
37
src/pasteResolution.ts
Normal file
37
src/pasteResolution.ts
Normal file
|
|
@ -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<EasyCopySettings, 'resolveLinkPathOnPaste' | 'linkFormat'>,
|
||||
): 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';
|
||||
}
|
||||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue