diff --git a/src/engine/CaptureChoiceEngine.notice.test.ts b/src/engine/CaptureChoiceEngine.notice.test.ts index 422ee565..39f26759 100644 --- a/src/engine/CaptureChoiceEngine.notice.test.ts +++ b/src/engine/CaptureChoiceEngine.notice.test.ts @@ -80,6 +80,7 @@ vi.mock("../formatters/captureChoiceFormatter", () => { openExistingFileTab: vi.fn(() => null), openFile: vi.fn(), overwriteTemplaterOnce: vi.fn(), + resolveClipboardForNoteContent: vi.fn(async () => ""), templaterParseTemplate: vi.fn(async (_app, content) => content), getTemplater: vi.fn(() => ({})), })); diff --git a/src/engine/CaptureChoiceEngine.selection.test.ts b/src/engine/CaptureChoiceEngine.selection.test.ts index 06f9b837..ecc9741e 100644 --- a/src/engine/CaptureChoiceEngine.selection.test.ts +++ b/src/engine/CaptureChoiceEngine.selection.test.ts @@ -55,6 +55,7 @@ vi.mock("../utilityObsidian", () => ({ openExistingFileTab: vi.fn(() => null), openFile: vi.fn(), overwriteTemplaterOnce: vi.fn(), + resolveClipboardForNoteContent: vi.fn(async () => ""), templaterParseTemplate: vi.fn(async (_app, content) => content), waitForTemplaterTriggerOnCreateToComplete: vi.fn(), })); diff --git a/src/engine/CaptureChoiceEngine.template-property-types.test.ts b/src/engine/CaptureChoiceEngine.template-property-types.test.ts index 02b9800d..0def432d 100644 --- a/src/engine/CaptureChoiceEngine.template-property-types.test.ts +++ b/src/engine/CaptureChoiceEngine.template-property-types.test.ts @@ -132,6 +132,7 @@ vi.mock("../utilityObsidian", () => ({ openExistingFileTab: vi.fn().mockReturnValue(null), openFile: vi.fn(), overwriteTemplaterOnce: vi.fn().mockResolvedValue(undefined), + resolveClipboardForNoteContent: vi.fn(async () => ""), templaterParseTemplate: vi.fn(async (_app, content) => content), waitForFileToStopChanging: vi.fn().mockResolvedValue(undefined), getTemplater: vi.fn(() => ({})), diff --git a/src/engine/MacroChoiceEngine.conditional.test.ts b/src/engine/MacroChoiceEngine.conditional.test.ts index a57cda55..74b69307 100644 --- a/src/engine/MacroChoiceEngine.conditional.test.ts +++ b/src/engine/MacroChoiceEngine.conditional.test.ts @@ -27,6 +27,8 @@ vi.mock("../settingsStore", () => ({ vi.mock("../formatters/completeFormatter", () => ({ CompleteFormatter: class CompleteFormatterMock { constructor() {} + setDestinationFile() {} + setDestinationSourcePath() {} }, })); vi.mock("../ai/AIAssistant", () => ({ diff --git a/src/engine/MacroChoiceEngine.editorCommands.test.ts b/src/engine/MacroChoiceEngine.editorCommands.test.ts index 696dc526..6728ffd0 100644 --- a/src/engine/MacroChoiceEngine.editorCommands.test.ts +++ b/src/engine/MacroChoiceEngine.editorCommands.test.ts @@ -2,7 +2,10 @@ import type { App } from "obsidian"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../formatters/completeFormatter", () => ({ - CompleteFormatter: class CompleteFormatterMock {}, + CompleteFormatter: class CompleteFormatterMock { + setDestinationFile() {} + setDestinationSourcePath() {} + }, })); vi.mock("obsidian-dataview", () => ({ diff --git a/src/engine/MacroChoiceEngine.entry.test.ts b/src/engine/MacroChoiceEngine.entry.test.ts index dc63f85d..1d92ae0b 100644 --- a/src/engine/MacroChoiceEngine.entry.test.ts +++ b/src/engine/MacroChoiceEngine.entry.test.ts @@ -108,7 +108,10 @@ vi.mock("../settingsStore", () => ({ })); vi.mock("../formatters/completeFormatter", () => ({ - CompleteFormatter: class CompleteFormatterMock {}, + CompleteFormatter: class CompleteFormatterMock { + setDestinationFile() {} + setDestinationSourcePath() {} + }, })); vi.mock("../ai/AIAssistant", () => ({ diff --git a/src/engine/MacroChoiceEngine.notice.test.ts b/src/engine/MacroChoiceEngine.notice.test.ts index d9608499..a514806c 100644 --- a/src/engine/MacroChoiceEngine.notice.test.ts +++ b/src/engine/MacroChoiceEngine.notice.test.ts @@ -44,7 +44,10 @@ vi.mock("../quickAddSettingsTab", () => { }); vi.mock("../formatters/completeFormatter", () => ({ - CompleteFormatter: class CompleteFormatterMock {}, + CompleteFormatter: class CompleteFormatterMock { + setDestinationFile() {} + setDestinationSourcePath() {} + }, })); vi.mock("obsidian-dataview", () => ({ diff --git a/src/engine/SingleMacroEngine.member-access.test.ts b/src/engine/SingleMacroEngine.member-access.test.ts index 87cdb46f..a5e0afda 100644 --- a/src/engine/SingleMacroEngine.member-access.test.ts +++ b/src/engine/SingleMacroEngine.member-access.test.ts @@ -79,7 +79,10 @@ vi.mock("../settingsStore", () => ({ })); vi.mock("../formatters/completeFormatter", () => ({ - CompleteFormatter: class CompleteFormatterMock {}, + CompleteFormatter: class CompleteFormatterMock { + setDestinationFile() {} + setDestinationSourcePath() {} + }, })); vi.mock("../ai/AIAssistant", () => ({ diff --git a/src/engine/TemplateChoiceEngine.collision.test.ts b/src/engine/TemplateChoiceEngine.collision.test.ts index 23c042cc..718a0220 100644 --- a/src/engine/TemplateChoiceEngine.collision.test.ts +++ b/src/engine/TemplateChoiceEngine.collision.test.ts @@ -60,6 +60,9 @@ vi.mock("../formatters/completeFormatter", () => { constructor() {} setLinkToCurrentFileBehavior() {} setTitle() {} + setDestinationFile() {} + setDestinationSourcePath() {} + clearDestinationContext() {} async formatFileName(format: string, prompt: string) { return formatFileNameMock(format, prompt); } @@ -85,6 +88,7 @@ vi.mock("../utilityObsidian", () => ({ insertFileLinkToActiveView: vi.fn(), openExistingFileTab: vi.fn(() => null), openFile: vi.fn(), + resolveClipboardForNoteContent: vi.fn(async () => ""), })); vi.mock("../gui/GenericSuggester/genericSuggester", () => ({ diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 19111314..27ea4811 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -60,6 +60,9 @@ vi.mock("../formatters/completeFormatter", () => { constructor() {} setLinkToCurrentFileBehavior() {} setTitle() {} + setDestinationFile() {} + setDestinationSourcePath() {} + clearDestinationContext() {} async formatFileName(format: string, prompt: string) { return formatFileNameMock(format, prompt); } @@ -88,6 +91,7 @@ vi.mock("../formatters/completeFormatter", () => { insertFileLinkToActiveView: vi.fn(), openExistingFileTab: vi.fn(() => null), openFile: vi.fn(), + resolveClipboardForNoteContent: vi.fn(async () => ""), })); vi.mock("../gui/GenericSuggester/genericSuggester", () => ({ diff --git a/src/engine/TemplateEngine.ts b/src/engine/TemplateEngine.ts index 7453487a..f4eb9ad8 100644 --- a/src/engine/TemplateEngine.ts +++ b/src/engine/TemplateEngine.ts @@ -93,6 +93,24 @@ export abstract class TemplateEngine extends QuickAddEngine { | Promise | Promise<{ file: TFile; content: string }>; + private setTemplateDestinationContext(filePath: string): void { + if (MARKDOWN_FILE_EXTENSION_REGEX.test(filePath)) { + this.formatter.setDestinationSourcePath(filePath); + return; + } + + this.formatter.clearDestinationContext(); + } + + private setTemplateDestinationContextForFile(file: TFile): void { + if (MARKDOWN_FILE_EXTENSION_REGEX.test(file.path)) { + this.formatter.setDestinationFile(file); + return; + } + + this.formatter.clearDestinationContext(); + } + protected async getOrCreateFolder( folders: string[], options: FolderChoiceOptions = {}, @@ -478,6 +496,7 @@ export abstract class TemplateEngine extends QuickAddEngine { // Extract filename without extension from the full path. const fileBasename = basenameWithoutMdOrCanvas(filePath); this.formatter.setTitle(fileBasename); + this.setTemplateDestinationContext(filePath); const formattedTemplateContent: string = await this.formatter.withTemplatePropertyCollection(() => @@ -537,6 +556,7 @@ export abstract class TemplateEngine extends QuickAddEngine { // Use the existing file's basename as the title const fileBasename = file.basename; this.formatter.setTitle(fileBasename); + this.setTemplateDestinationContextForFile(file); const formattedTemplateContent: string = await this.formatter.withTemplatePropertyCollection(() => @@ -584,6 +604,7 @@ export abstract class TemplateEngine extends QuickAddEngine { // Use the existing file's basename as the title const fileBasename = file.basename; this.formatter.setTitle(fileBasename); + this.setTemplateDestinationContextForFile(file); let formattedTemplateContent: string = await this.formatter.formatFileContent(templateContent); diff --git a/src/engine/templateEngine-title.test.ts b/src/engine/templateEngine-title.test.ts index a3dfe1a1..a9ff4b25 100644 --- a/src/engine/templateEngine-title.test.ts +++ b/src/engine/templateEngine-title.test.ts @@ -9,9 +9,26 @@ vi.mock('../formatters/completeFormatter', () => { return { CompleteFormatter: vi.fn().mockImplementation(() => { let title = ''; + let destinationFile: unknown = null; + let destinationSourcePath: string | null = null; return { setTitle: vi.fn((t: string) => { title = t; }), + setDestinationFile: vi.fn((file: unknown) => { + destinationFile = file; + }), + setDestinationSourcePath: vi.fn((path: string) => { + destinationSourcePath = path; + }), + clearDestinationContext: vi.fn(() => { + destinationFile = null; + destinationSourcePath = null; + }), getTitle: () => title, + getDestinationFile: () => destinationFile, + getDestinationSourcePath: () => destinationSourcePath, + getAndClearTemplatePropertyVars: vi.fn( + () => new Map(), + ), withTemplatePropertyCollection: vi.fn( async (work: () => Promise) => await work(), ), @@ -29,6 +46,8 @@ vi.mock('../formatters/completeFormatter', () => { vi.mock('../utilityObsidian', () => ({ getTemplater: vi.fn(() => null), overwriteTemplaterOnce: vi.fn().mockResolvedValue(undefined), + templaterParseTemplate: vi.fn(async (_app, content: string) => content), + resolveClipboardForNoteContent: vi.fn(async () => ""), })); // Test implementation of TemplateEngine @@ -46,10 +65,26 @@ class TestTemplateEngine extends TemplateEngine { return await this.createFileWithTemplate(filePath, templatePath); } + public async testOverwriteFileWithTemplate(file: any, templatePath: string) { + return await this.overwriteFileWithTemplate(file, templatePath); + } + + public async testAppendToFileWithTemplate(file: any, templatePath: string, section: "top" | "bottom") { + return await this.appendToFileWithTemplate(file, templatePath, section); + } + public getFormatterTitle(): string { // Access the title that was set on the formatter return (this.formatter as any).getTitle(); } + + public getFormatterDestinationSourcePath(): string | null { + return (this.formatter as any).getDestinationSourcePath(); + } + + public getFormatterDestinationFile(): unknown { + return (this.formatter as any).getDestinationFile(); + } } describe('TemplateEngine - Title Handling', () => { @@ -144,6 +179,70 @@ describe('TemplateEngine - Title Handling', () => { // Verify formatFileContent was called expect(mockFormatter.formatFileContent).toHaveBeenCalled(); }); + + it('should set destination source path before formatting new template content', async () => { + await engine.testCreateFileWithTemplate('folder/TestDocument.md', 'template.md'); + + expect(engine.getFormatterDestinationSourcePath()).toBe('folder/TestDocument.md'); + }); + + it('should clear destination context for new non-markdown template output', async () => { + await engine.testCreateFileWithTemplate('folder/Kanban.base', 'template.base'); + + expect(engine.getFormatterDestinationSourcePath()).toBeNull(); + expect(engine.getFormatterDestinationFile()).toBeNull(); + }); + }); + + describe('existing file template updates', () => { + const existingFile = { + path: 'folder/Existing.md', + basename: 'Existing', + extension: 'md', + } as any; + + beforeEach(() => { + mockApp.vault.modify = vi.fn().mockResolvedValue(undefined); + mockApp.vault.cachedRead = vi.fn().mockResolvedValue('Existing content'); + }); + + it('should set destination file before overwriting template content', async () => { + await engine.testOverwriteFileWithTemplate(existingFile, 'template.md'); + + expect(engine.getFormatterDestinationFile()).toBe(existingFile); + }); + + it('should set destination file before appending template content', async () => { + await engine.testAppendToFileWithTemplate(existingFile, 'template.md', 'bottom'); + + expect(engine.getFormatterDestinationFile()).toBe(existingFile); + }); + + it('should clear destination context before overwriting non-markdown template output', async () => { + const existingBaseFile = { + path: 'folder/Kanban.base', + basename: 'Kanban', + extension: 'base', + } as any; + + await engine.testOverwriteFileWithTemplate(existingBaseFile, 'template.base'); + + expect(engine.getFormatterDestinationFile()).toBeNull(); + expect(engine.getFormatterDestinationSourcePath()).toBeNull(); + }); + + it('should clear destination context before appending non-markdown template output', async () => { + const existingCanvasFile = { + path: 'folder/Board.canvas', + basename: 'Board', + extension: 'canvas', + } as any; + + await engine.testAppendToFileWithTemplate(existingCanvasFile, 'template.canvas', 'bottom'); + + expect(engine.getFormatterDestinationFile()).toBeNull(); + expect(engine.getFormatterDestinationSourcePath()).toBeNull(); + }); }); describe('formatFileName - title exclusion', () => { diff --git a/src/formatters/captureChoiceFormatter-frontmatter.test.ts b/src/formatters/captureChoiceFormatter-frontmatter.test.ts index ab3c08d0..328a6db3 100644 --- a/src/formatters/captureChoiceFormatter-frontmatter.test.ts +++ b/src/formatters/captureChoiceFormatter-frontmatter.test.ts @@ -4,6 +4,7 @@ import type ICaptureChoice from '../types/choices/ICaptureChoice'; vi.mock('../utilityObsidian', () => ({ templaterParseTemplate: vi.fn().mockResolvedValue(null), + resolveClipboardForNoteContent: vi.fn(async () => ''), })); vi.mock('../gui/InputPrompt', () => ({ diff --git a/src/formatters/captureChoiceFormatter-write-position.test.ts b/src/formatters/captureChoiceFormatter-write-position.test.ts index 18fad173..72c5062f 100644 --- a/src/formatters/captureChoiceFormatter-write-position.test.ts +++ b/src/formatters/captureChoiceFormatter-write-position.test.ts @@ -4,6 +4,7 @@ import type ICaptureChoice from "../types/choices/ICaptureChoice"; vi.mock("../utilityObsidian", () => ({ templaterParseTemplate: vi.fn().mockResolvedValue(null), + resolveClipboardForNoteContent: vi.fn(async () => ""), })); vi.mock("../gui/InputPrompt", () => ({ diff --git a/src/formatters/captureChoiceFormatter.ts b/src/formatters/captureChoiceFormatter.ts index 404a93b7..ae35f723 100644 --- a/src/formatters/captureChoiceFormatter.ts +++ b/src/formatters/captureChoiceFormatter.ts @@ -30,11 +30,13 @@ export class CaptureChoiceFormatter extends CompleteFormatter { private templaterProcessed = false; public setDestinationFile(file: TFile): void { + super.setDestinationFile(file); this.file = file; this.sourcePath = file.path; } public setDestinationSourcePath(path: string): void { + super.setDestinationSourcePath(path); this.sourcePath = path; this.file = null; } diff --git a/src/formatters/completeFormatter.clipboard.test.ts b/src/formatters/completeFormatter.clipboard.test.ts new file mode 100644 index 00000000..7ec6da5d --- /dev/null +++ b/src/formatters/completeFormatter.clipboard.test.ts @@ -0,0 +1,348 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App, TFile } from "obsidian"; + +vi.mock("../gui/choiceList/ChoiceView.svelte", () => ({})); +vi.mock("../gui/GlobalVariables/GlobalVariablesView.svelte", () => ({})); +vi.mock("../main", () => ({ + __esModule: true, + default: class QuickAddMock {}, +})); +vi.mock("obsidian-dataview", () => ({ + getAPI: vi.fn(), +})); + +import { CompleteFormatter } from "./completeFormatter"; + +const createMockApp = () => { + const attachmentFile = { + path: "Assets/pasted-image.png", + name: "pasted-image.png", + basename: "pasted-image", + extension: "png", + } as unknown as TFile; + + return { + attachmentFile, + app: { + workspace: { + getActiveFile: vi.fn().mockReturnValue(null), + getActiveViewOfType: vi.fn().mockReturnValue(null), + }, + fileManager: { + generateMarkdownLink: vi.fn().mockReturnValue("[[Assets/pasted-image.png]]"), + getAvailablePathForAttachment: vi + .fn() + .mockResolvedValue("Assets/pasted-image.png"), + }, + vault: { + createBinary: vi.fn().mockResolvedValue(attachmentFile), + }, + } as unknown as App, + }; +}; + +const createPlugin = () => + ({ + settings: { + inputPrompt: "single-line", + enableTemplatePropertyTypes: false, + globalVariables: {}, + }, + }) as any; + +const setClipboard = (clipboard: Record) => { + Object.defineProperty(globalThis, "navigator", { + value: { clipboard }, + configurable: true, + writable: true, + }); +}; + +const mockFetchForFile = ( + bytes: Uint8Array, + opts?: { reject?: boolean }, +) => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + if (!url.startsWith("file://")) { + return new Response(null, { status: 404 }); + } + if (opts?.reject) { + return new Response(null, { status: 404, statusText: "Not Found" }); + } + return new Response(bytes, { status: 200 }); + }); +}; + +describe("CompleteFormatter clipboard handling", () => { + beforeEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + setClipboard({ + readText: vi.fn().mockResolvedValue("clipboard text"), + }); + }); + + it("uses plain text clipboard content for note formatting when no image is present", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + formatter.setDestinationSourcePath("Notes/Daily.md"); + + const result = await formatter.formatFileContent("{{clipboard}}"); + + expect(result).toBe("clipboard text"); + expect(app.vault.createBinary).not.toHaveBeenCalled(); + }); + + it("does not resolve clipboard content when the token is absent", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + formatter.setDestinationSourcePath("Notes/Daily.md"); + + const readText = vi.fn().mockResolvedValue("clipboard text"); + const read = vi.fn().mockResolvedValue([ + { + types: ["image/png"], + getType: vi.fn().mockResolvedValue( + new Blob(["image-bytes"], { type: "image/png" }), + ), + }, + ]); + setClipboard({ + read, + readText, + }); + + const result = await formatter.formatFileContent("No clipboard token here"); + + expect(result).toBe("No clipboard token here"); + expect(read).not.toHaveBeenCalled(); + expect(readText).not.toHaveBeenCalled(); + expect(app.vault.createBinary).not.toHaveBeenCalled(); + }); + + it("saves a clipboard image attachment and returns an Obsidian embed", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + formatter.setDestinationSourcePath("Notes/Daily.md"); + + const imageBlob = new Blob(["image-bytes"], { type: "image/png" }); + const read = vi.fn().mockResolvedValue([ + { + types: ["image/png"], + getType: vi.fn().mockResolvedValue(imageBlob), + }, + ]); + setClipboard({ + read, + readText: vi.fn().mockResolvedValue("clipboard text"), + }); + + const result = await formatter.formatFileContent("{{clipboard}}"); + + expect(read).toHaveBeenCalledOnce(); + expect(result).toBe("![[Assets/pasted-image.png]]"); + expect(app.fileManager.getAvailablePathForAttachment).toHaveBeenCalledWith( + expect.stringMatching(/^Pasted image \d{8}-\d{6}\.png$/), + "Notes/Daily.md", + ); + expect(app.vault.createBinary).toHaveBeenCalledWith( + "Assets/pasted-image.png", + expect.any(ArrayBuffer), + ); + expect(app.fileManager.generateMarkdownLink).toHaveBeenCalledWith( + expect.objectContaining({ path: "Assets/pasted-image.png" }), + "Notes/Daily.md", + ); + }); + + it("falls back to clipboard text when image inspection fails", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + formatter.setDestinationSourcePath("Notes/Daily.md"); + + setClipboard({ + read: vi.fn().mockRejectedValue(new Error("denied")), + readText: vi.fn().mockResolvedValue("clipboard text"), + }); + + const result = await formatter.formatFileContent("{{clipboard}}"); + + expect(result).toBe("clipboard text"); + expect(app.vault.createBinary).not.toHaveBeenCalled(); + }); + + it("falls back to plain text when no destination note context exists", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + + setClipboard({ + read: vi.fn().mockResolvedValue([ + { + types: ["image/png"], + getType: vi.fn().mockResolvedValue( + new Blob(["image-bytes"], { type: "image/png" }), + ), + }, + ]), + readText: vi.fn().mockResolvedValue("clipboard text"), + }); + + const result = await formatter.formatFileContent("{{clipboard}}"); + + expect(result).toBe("clipboard text"); + expect(app.vault.createBinary).not.toHaveBeenCalled(); + }); + + it("re-reads clipboard text on each call when no destination note context exists", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + + const readText = vi + .fn() + .mockResolvedValueOnce("first clipboard text") + .mockResolvedValueOnce("second clipboard text"); + setClipboard({ + readText, + }); + + const first = await formatter.formatFileContent("{{clipboard}}"); + const second = await formatter.formatFileContent("{{clipboard}}"); + + expect(first).toBe("first clipboard text"); + expect(second).toBe("second clipboard text"); + expect(readText).toHaveBeenCalledTimes(2); + expect(app.vault.createBinary).not.toHaveBeenCalled(); + }); + + it("imports a local clipboard image path as an attachment embed", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + formatter.setDestinationSourcePath("Notes/Daily.md"); + + const imageBytes = new Uint8Array([137, 80, 78, 71]); + mockFetchForFile(imageBytes); + setClipboard({ + read: vi.fn().mockResolvedValue([]), + readText: vi + .fn() + .mockResolvedValue("/Users/christian/Library/Caches/Clop/images/85074.png"), + }); + + const result = await formatter.formatFileContent("{{clipboard}}"); + + expect(result).toBe("![[Assets/pasted-image.png]]"); + expect(globalThis.fetch).toHaveBeenCalledWith( + "file:///Users/christian/Library/Caches/Clop/images/85074.png", + ); + expect(app.fileManager.getAvailablePathForAttachment).toHaveBeenCalledWith( + expect.stringMatching(/^Pasted image \d{8}-\d{6}\.png$/), + "Notes/Daily.md", + ); + expect(app.vault.createBinary).toHaveBeenCalledWith( + "Assets/pasted-image.png", + expect.any(ArrayBuffer), + ); + }); + + it("imports a local .jpeg clipboard image path as an attachment embed", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + formatter.setDestinationSourcePath("Notes/Daily.md"); + + const imageBytes = new Uint8Array([255, 216, 255, 224]); + mockFetchForFile(imageBytes); + setClipboard({ + read: vi.fn().mockResolvedValue([]), + readText: vi + .fn() + .mockResolvedValue("/Users/christian/Library/Caches/Clop/images/85074.jpeg"), + }); + + const result = await formatter.formatFileContent("{{clipboard}}"); + + expect(result).toBe("![[Assets/pasted-image.png]]"); + expect(app.fileManager.getAvailablePathForAttachment).toHaveBeenCalledWith( + expect.stringMatching(/^Pasted image \d{8}-\d{6}\.jpeg$/), + "Notes/Daily.md", + ); + expect(globalThis.fetch).toHaveBeenCalledWith( + "file:///Users/christian/Library/Caches/Clop/images/85074.jpeg", + ); + }); + + it("reuses the same resolved clipboard embed across repeated content passes", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + formatter.setDestinationSourcePath("Notes/Daily.md"); + + const imageBytes = new Uint8Array([137, 80, 78, 71]); + mockFetchForFile(imageBytes); + setClipboard({ + read: vi.fn().mockResolvedValue([]), + readText: vi + .fn() + .mockResolvedValue("/Users/christian/Library/Caches/Clop/images/85074.png"), + }); + + const first = await formatter.formatFileContent("{{clipboard}}"); + const second = await formatter.formatFileContent("{{clipboard}}"); + + expect(first).toBe("![[Assets/pasted-image.png]]"); + expect(second).toBe("![[Assets/pasted-image.png]]"); + // fetch is called only once; second pass uses cached result + expect(globalThis.fetch).toHaveBeenCalledOnce(); + expect(app.vault.createBinary).toHaveBeenCalledOnce(); + }); + + it("leaves plain text untouched when clipboard path is not an existing image", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + formatter.setDestinationSourcePath("Notes/Daily.md"); + + mockFetchForFile(new Uint8Array(), { reject: true }); + setClipboard({ + read: vi.fn().mockResolvedValue([]), + readText: vi + .fn() + .mockResolvedValue("/Users/christian/Library/Caches/Clop/images/85074.png"), + }); + + const result = await formatter.formatFileContent("{{clipboard}}"); + + expect(result).toBe( + "/Users/christian/Library/Caches/Clop/images/85074.png", + ); + expect(globalThis.fetch).toHaveBeenCalledWith( + "file:///Users/christian/Library/Caches/Clop/images/85074.png", + ); + expect(app.vault.createBinary).not.toHaveBeenCalled(); + }); + + it("keeps filename formatting text-only even when clipboard contains an image", async () => { + const { app } = createMockApp(); + const formatter = new CompleteFormatter(app, createPlugin()); + formatter.setDestinationSourcePath("Notes/Daily.md"); + + const read = vi.fn().mockResolvedValue([ + { + types: ["image/png"], + getType: vi.fn().mockResolvedValue( + new Blob(["image-bytes"], { type: "image/png" }), + ), + }, + ]); + const readText = vi.fn().mockResolvedValue("clipboard text"); + setClipboard({ + read, + readText, + }); + + const result = await formatter.formatFileName("{{clipboard}}", "Prompt"); + + expect(result).toBe("clipboard text"); + expect(read).not.toHaveBeenCalled(); + expect(readText).toHaveBeenCalledOnce(); + expect(app.vault.createBinary).not.toHaveBeenCalled(); + }); +}); diff --git a/src/formatters/completeFormatter.ts b/src/formatters/completeFormatter.ts index 90171cbf..363b82af 100644 --- a/src/formatters/completeFormatter.ts +++ b/src/formatters/completeFormatter.ts @@ -24,12 +24,20 @@ import { generateFieldCacheKey, } from "../utils/FieldValueCollector"; import { FieldValueProcessor } from "../utils/FieldValueProcessor"; +import { resolveClipboardForNoteContent } from "../utilityObsidian"; import { Formatter, type PromptContext } from "./formatter"; import { MacroAbortError } from "../errors/MacroAbortError"; import { isCancellationError } from "../utils/errorUtils"; +import type { TFile } from "obsidian"; export class CompleteFormatter extends Formatter { private valueHeader: string; + private destinationSourcePath: string | null = null; + private formattingFileContent = false; + private resolvedClipboardContent: { + destinationSourcePath: string; + value: string; + } | null = null; constructor( protected app: App, @@ -66,6 +74,22 @@ export class CompleteFormatter extends Formatter { return output; } + public setDestinationFile(file: TFile): void { + this.setDestinationSourcePath(file.path); + } + + public setDestinationSourcePath(path: string): void { + if (this.destinationSourcePath !== path) { + this.resolvedClipboardContent = null; + } + this.destinationSourcePath = path; + } + + public clearDestinationContext(): void { + this.destinationSourcePath = null; + this.resolvedClipboardContent = null; + } + protected async replaceGlobalVarInString(input: string): Promise { let output = input; // Allow nested globals up to a small recursion limit @@ -98,14 +122,20 @@ export class CompleteFormatter extends Formatter { } async formatFileContent(input: string): Promise { - let output: string = input; + this.formattingFileContent = true; - output = await this.format(output); - output = await this.replaceLinkToCurrentFileInString(output); - output = await this.replaceCurrentFileNameInString(output); - output = this.replaceTitleInString(output); + try { + let output: string = input; - return output; + output = await this.format(output); + output = await this.replaceLinkToCurrentFileInString(output); + output = await this.replaceCurrentFileNameInString(output); + output = this.replaceTitleInString(output); + + return output; + } finally { + this.formattingFileContent = false; + } } async formatFolderPath(folderName: string): Promise { @@ -134,7 +164,7 @@ export class CompleteFormatter extends Formatter { } protected getLinkSourcePath(): string | null { - return null; + return this.destinationSourcePath; } protected getCurrentFileLink(): string | null { @@ -383,6 +413,26 @@ export class CompleteFormatter extends Formatter { } protected async getClipboardContent(): Promise { + if (this.formattingFileContent && this.destinationSourcePath !== null) { + if ( + this.resolvedClipboardContent && + this.resolvedClipboardContent.destinationSourcePath === + this.destinationSourcePath + ) { + return this.resolvedClipboardContent.value; + } + + const resolvedContent = await resolveClipboardForNoteContent( + this.app, + this.destinationSourcePath, + ); + this.resolvedClipboardContent = { + destinationSourcePath: this.destinationSourcePath, + value: resolvedContent, + }; + return resolvedContent; + } + try { return await navigator.clipboard.readText(); } catch { diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index 8686cfdb..51292d29 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -227,6 +227,7 @@ export abstract class Formatter { protected async replaceClipboardInString(input: string): Promise { let output: string = input; + if (!output.toLowerCase().includes("{{clipboard}}")) return output; const clipboardContent = await this.getClipboardContent(); diff --git a/src/utilityObsidian.ts b/src/utilityObsidian.ts index 622db400..445fdfaa 100644 --- a/src/utilityObsidian.ts +++ b/src/utilityObsidian.ts @@ -740,6 +740,233 @@ export function insertFileLinkToActiveView( return true; } +const CLIPBOARD_IMAGE_EXTENSIONS: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/webp": "webp", + "image/gif": "gif", + "image/svg+xml": "svg", + "image/bmp": "bmp", + "image/tiff": "tiff", +}; + +const CLIPBOARD_IMAGE_FILE_EXTENSIONS = new Set([ + ...Object.values(CLIPBOARD_IMAGE_EXTENSIONS), + "jpeg", +]); + +function getClipboardImageExtension(mimeType: string): string | null { + return CLIPBOARD_IMAGE_EXTENSIONS[mimeType.toLowerCase()] ?? null; +} + +function formatClipboardImageTimestamp(date: Date): string { + const pad = (value: number) => value.toString().padStart(2, "0"); + + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + "-", + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(""); +} + +function getClipboardImageFileName(mimeType: string, now = new Date()): string | null { + const extension = getClipboardImageExtension(mimeType); + if (!extension) return null; + + return `Pasted image ${formatClipboardImageTimestamp(now)}.${extension}`; +} + +function getClipboardImageFileNameFromExtension( + extension: string, + now = new Date(), +): string | null { + const normalizedExtension = extension.toLowerCase().replace(/^\./, ""); + if (!CLIPBOARD_IMAGE_FILE_EXTENSIONS.has(normalizedExtension)) return null; + + return `Pasted image ${formatClipboardImageTimestamp(now)}.${normalizedExtension}`; +} + +async function readClipboardImageItem(): Promise<{ + blob: Blob; + mimeType: string; +} | null> { + if (!("clipboard" in navigator)) return null; + const clipboard = navigator.clipboard; + if (!("read" in clipboard) || typeof clipboard.read !== "function") return null; + + try { + const clipboardItems = await clipboard.read(); + + for (const item of clipboardItems) { + const imageType = item.types.find((type) => { + return type.toLowerCase() in CLIPBOARD_IMAGE_EXTENSIONS; + }); + if (!imageType) continue; + + return { + blob: await item.getType(imageType), + mimeType: imageType, + }; + } + } catch (error) { + log.logWarning(`Failed to inspect clipboard image content: ${error}`); + } + + return null; +} + +async function readBlobArrayBuffer(blob: Blob): Promise { + if (typeof blob.arrayBuffer === "function") { + return await blob.arrayBuffer(); + } + + return await new Response(blob).arrayBuffer(); +} + +function absolutePathToFileUrl(absolutePath: string): string { + const forwardSlashPath = absolutePath.replace(/\\/g, "/"); + const segments = forwardSlashPath.split("/"); + const encodedPath = segments + .map((segment) => encodeURIComponent(segment)) + .join("/"); + const urlPath = encodedPath.startsWith("/") + ? encodedPath + : `/${encodedPath}`; + return `file://${urlPath}`; +} + +function parseClipboardLocalImagePath(clipboardText: string): { + path: string; + extension: string; +} | null { + const trimmedText = clipboardText.trim(); + if (!trimmedText || /\r|\n/.test(trimmedText)) return null; + + let filePath = trimmedText; + if (trimmedText.startsWith("file://")) { + try { + const parsedUrl = new URL(trimmedText); + if (parsedUrl.protocol !== "file:") return null; + filePath = decodeURIComponent(parsedUrl.pathname); + } catch { + return null; + } + } + + const isUnixAbsolutePath = filePath.startsWith("/"); + const isWindowsAbsolutePath = /^[a-zA-Z]:[\\/]/.test(filePath); + if (!isUnixAbsolutePath && !isWindowsAbsolutePath) return null; + + const extensionMatch = /\.([^.\\/]+)$/.exec(filePath); + if (!extensionMatch) return null; + + const extension = extensionMatch[1].toLowerCase(); + if (!CLIPBOARD_IMAGE_FILE_EXTENSIONS.has(extension)) return null; + + return { path: filePath, extension }; +} + +async function readClipboardLocalImagePath( + clipboardText: string, +): Promise<{ + bytes: ArrayBuffer; + extension: string; +} | null> { + const imagePath = parseClipboardLocalImagePath(clipboardText); + if (!imagePath) return null; + + try { + const fileUrl = absolutePathToFileUrl(imagePath.path); + const response = await fetch(fileUrl); + if (!response.ok) return null; + + return { + bytes: await response.arrayBuffer(), + extension: imagePath.extension, + }; + } catch (error) { + log.logWarning(`Failed to read clipboard image path: ${error}`); + return null; + } +} + +async function saveClipboardImageAttachment( + app: App, + destinationPath: string, + content: { + bytes: ArrayBuffer; + fileName: string; + }, +): Promise { + try { + const attachmentPath = await app.fileManager.getAvailablePathForAttachment( + content.fileName, + destinationPath, + ); + const attachmentFile = await app.vault.createBinary( + attachmentPath, + content.bytes, + ); + const link = app.fileManager.generateMarkdownLink( + attachmentFile, + destinationPath, + ); + return convertLinkToEmbed(link); + } catch (error) { + log.logWarning(`Failed to save clipboard image attachment: ${error}`); + return null; + } +} + +export async function resolveClipboardForNoteContent( + app: App, + destinationPath?: string | null, +): Promise { + let clipboardText = ""; + + try { + clipboardText = await navigator.clipboard.readText(); + } catch { + clipboardText = ""; + } + + if (destinationPath) { + const imageItem = await readClipboardImageItem(); + + if (imageItem) { + const fileName = getClipboardImageFileName(imageItem.mimeType); + if (fileName) { + const embed = await saveClipboardImageAttachment(app, destinationPath, { + fileName, + bytes: await readBlobArrayBuffer(imageItem.blob), + }); + if (embed) return embed; + } + } + + const localImagePath = await readClipboardLocalImagePath(clipboardText); + if (localImagePath) { + const fileName = getClipboardImageFileNameFromExtension( + localImagePath.extension, + ); + if (fileName) { + const embed = await saveClipboardImageAttachment(app, destinationPath, { + fileName, + bytes: localImagePath.bytes, + }); + if (embed) return embed; + } + } + } + + return clipboardText; +} + export function findObsidianCommand(app: App, commandId: string) { return app.commands.findCommand(commandId); } @@ -1077,4 +1304,6 @@ export function getMarkdownFilesWithTag(app: App, tag: string): TFile[] { export const __test = { convertLinkToEmbed, extractMarkdownLinkTarget, + getClipboardImageExtension, + getClipboardImageFileName, } as const;