diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index ecd43da1..6b303d66 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -280,7 +280,7 @@ export class TemplateChoiceEngine extends TemplateEngine { return ""; } - return this.getOrCreateFolder([activeFile.parent.path]); + return await this.getOrCreateFolder([activeFile.parent.path]); } return await this.getOrCreateFolder(folders); diff --git a/src/formatters/captureChoiceFormatter-frontmatter.test.ts b/src/formatters/captureChoiceFormatter-frontmatter.test.ts index fcba4517..4bbd6422 100644 --- a/src/formatters/captureChoiceFormatter-frontmatter.test.ts +++ b/src/formatters/captureChoiceFormatter-frontmatter.test.ts @@ -38,6 +38,11 @@ vi.mock('../gui/VDateInputPrompt/VDateInputPrompt', () => ({ }, })); +vi.mock('../utils/errorUtils', () => ({ + __esModule: true, + reportError: vi.fn(), +})); + vi.mock('../gui/MathModal', () => ({ __esModule: true, MathModal: { @@ -98,6 +103,7 @@ vi.mock('../main', () => ({ })); import { CaptureChoiceFormatter } from './captureChoiceFormatter'; +import { reportError } from '../utils/errorUtils'; const createChoice = (overrides: Partial = {}): ICaptureChoice => ({ id: 'test', @@ -111,7 +117,7 @@ const createChoice = (overrides: Partial = {}): ICaptureChoice = prepend: false, appendLink: false, task: false, - insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '', blankLineAfterMatchMode: 'auto' }, + insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '', inline: false, replaceExisting: false, blankLineAfterMatchMode: 'auto' }, newLineCapture: { enabled: false, direction: 'below' }, openFile: false, fileOpening: { location: 'tab', direction: 'vertical', mode: 'default', focus: true }, @@ -356,3 +362,175 @@ describe('CaptureChoiceFormatter insert after blank lines', () => { expect(result).toBe(['# H', 'X', '', 'A'].join('\n')); }); }); + +describe('CaptureChoiceFormatter insert after inline', () => { + beforeEach(() => { + vi.resetAllMocks(); + (global as any).navigator = { + clipboard: { + readText: vi.fn().mockResolvedValue(''), + }, + }; + }); + + const createFormatter = () => { + const app = createMockApp(); + const plugin = { + settings: { + enableTemplatePropertyTypes: false, + globalVariables: {}, + showCaptureNotification: false, + showInputCancellationNotification: true, + }, + } as any; + const formatter = new CaptureChoiceFormatter(app, plugin); + const file = createTFile('Inline.md'); + + return { formatter, file }; + }; + + const createInlineChoice = ( + after: string, + overrides: Partial = {}, + ): ICaptureChoice => + createChoice({ + insertAfter: { + enabled: true, + after, + insertAtEnd: false, + considerSubsections: false, + createIfNotFound: false, + createIfNotFoundLocation: 'top', + inline: true, + replaceExisting: false, + blankLineAfterMatchMode: 'auto', + ...overrides, + }, + }); + + it('inserts inline at match end and preserves suffix', async () => { + const { formatter, file } = createFormatter(); + const choice = createInlineChoice('Status:', { replaceExisting: false }); + const fileContent = 'Status: pending'; + + const result = await formatter.formatContentWithFile( + ' done', + choice, + fileContent, + file, + ); + + expect(result).toBe('Status: done pending'); + }); + + it('replaces to end-of-line when enabled, preserving newline', async () => { + const { formatter, file } = createFormatter(); + const choice = createInlineChoice('Status: ', { replaceExisting: true }); + const fileContent = ['Status: pending', 'Next'].join('\n'); + + const result = await formatter.formatContentWithFile( + 'done', + choice, + fileContent, + file, + ); + + expect(result).toBe(['Status: done', 'Next'].join('\n')); + }); + + it('replace mode behaves like append when target is at end-of-line', async () => { + const { formatter, file } = createFormatter(); + const choice = createInlineChoice('pending', { replaceExisting: true }); + const fileContent = 'Status: pending'; + + const result = await formatter.formatContentWithFile( + '!', + choice, + fileContent, + file, + ); + + expect(result).toBe('Status: pending!'); + }); + + it('creates a single inline line when target is not found', async () => { + const { formatter, file } = createFormatter(); + const choice = createInlineChoice('Status: ', { + createIfNotFound: true, + createIfNotFoundLocation: 'top', + }); + const fileContent = '# Header'; + + const result = await formatter.formatContentWithFile( + 'done', + choice, + fileContent, + file, + ); + + expect(result).toBe(['Status: done', '# Header'].join('\n')); + }); + + it('does not modify the file when target is missing and create-if-not-found is off', async () => { + const { formatter, file } = createFormatter(); + const choice = createInlineChoice('Missing: ', { createIfNotFound: false }); + const fileContent = 'Status: pending'; + + const result = await formatter.formatContentWithFile( + 'done', + choice, + fileContent, + file, + ); + + expect(result).toBe(fileContent); + expect(reportError).toHaveBeenCalled(); + }); + + it('updates only the first match', async () => { + const { formatter, file } = createFormatter(); + const choice = createInlineChoice('Tag: ', { replaceExisting: true }); + const fileContent = ['Tag: a', 'Tag: b'].join('\n'); + + const result = await formatter.formatContentWithFile( + 'X', + choice, + fileContent, + file, + ); + + expect(result).toBe(['Tag: X', 'Tag: b'].join('\n')); + }); + + it('works with capture to active file enabled', async () => { + const { formatter, file } = createFormatter(); + const choice = createInlineChoice('Status: ', { replaceExisting: true }); + choice.captureToActiveFile = true; + const fileContent = 'Status: pending'; + + const result = await formatter.formatContentWithFile( + 'done', + choice, + fileContent, + file, + ); + + expect(result).toBe('Status: done'); + }); + + it('reports an error and leaves content unchanged when target contains a newline', async () => { + const { formatter, file } = createFormatter(); + const choice = createInlineChoice('Status:\n', { replaceExisting: true }); + const fileContent = 'Status:\npending'; + + const result = await formatter.formatContentWithFile( + 'done', + choice, + fileContent, + file, + ); + + expect(result).toBe(fileContent); + expect(reportError).toHaveBeenCalled(); + }); +}); diff --git a/src/formatters/captureChoiceFormatter.ts b/src/formatters/captureChoiceFormatter.ts index 69b4c924..f5391824 100644 --- a/src/formatters/captureChoiceFormatter.ts +++ b/src/formatters/captureChoiceFormatter.ts @@ -262,6 +262,10 @@ export class CaptureChoiceFormatter extends CompleteFormatter { this.choice.insertAfter.after, ); + if (this.choice.insertAfter?.inline) { + return await this.insertAfterInlineHandler(formatted, targetString); + } + const fileContentLines: string[] = getLinesInString(this.fileContent); let targetPosition = this.findInsertAfterIndex( fileContentLines, @@ -307,6 +311,64 @@ export class CaptureChoiceFormatter extends CompleteFormatter { ); } + private hasInlineTargetLinebreak(target: string): boolean { + return target.includes("\n") || target.includes("\r"); + } + + private getInlineEndOfLine(startIndex: number): number { + const newlineIndex = this.fileContent.indexOf("\n", startIndex); + if (newlineIndex === -1) return this.fileContent.length; + if (newlineIndex > 0 && this.fileContent[newlineIndex - 1] === "\r") { + return newlineIndex - 1; + } + return newlineIndex; + } + + private async insertAfterInlineHandler( + formatted: string, + targetString: string, + ): Promise { + if (this.hasInlineTargetLinebreak(targetString)) { + reportError( + new Error("Inline insert after target must be a single line."), + "Insert After Inline Error", + ); + return this.fileContent; + } + + const matchIndex = this.fileContent.indexOf(targetString); + if (matchIndex === -1) { + if (this.choice.insertAfter?.createIfNotFound) { + return await this.createInlineInsertAfterIfNotFound( + formatted, + targetString, + ); + } + + reportError( + new Error("Unable to find insert after text in file."), + "Insert After Inline Error", + ); + return this.fileContent; + } + + const matchEnd = matchIndex + targetString.length; + if (this.choice.insertAfter?.replaceExisting) { + const endOfLine = this.getInlineEndOfLine(matchEnd); + return ( + this.fileContent.slice(0, matchEnd) + + formatted + + this.fileContent.slice(endOfLine) + ); + } + + return ( + this.fileContent.slice(0, matchEnd) + + formatted + + this.fileContent.slice(matchEnd) + ); + } + private async createInsertAfterIfNotFound(formatted: string) { // Build the line to insert using centralized location formatting const insertAfterLine: string = this.replaceLinebreakInString( @@ -381,6 +443,66 @@ export class CaptureChoiceFormatter extends CompleteFormatter { } } + private async createInlineInsertAfterIfNotFound( + formatted: string, + targetString: string, + ): Promise { + const insertAfterLineAndFormatted = `${targetString}${formatted}`; + + if ( + this.choice.insertAfter?.createIfNotFoundLocation === + CREATE_IF_NOT_FOUND_TOP + ) { + const frontmatterEndPosition = this.file + ? this.getFrontmatterEndPosition(this.file, this.fileContent) + : -1; + return this.insertTextAfterPositionInBody( + insertAfterLineAndFormatted, + this.fileContent, + frontmatterEndPosition, + ); + } + + if ( + this.choice.insertAfter?.createIfNotFoundLocation === + CREATE_IF_NOT_FOUND_BOTTOM + ) { + return `${this.fileContent}\n${insertAfterLineAndFormatted}`; + } + + if ( + this.choice.insertAfter?.createIfNotFoundLocation === + CREATE_IF_NOT_FOUND_CURSOR + ) { + try { + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + + if (!activeView) { + throw new Error("No active view."); + } + + const cursor = activeView.editor.getCursor(); + const targetPosition = cursor.line; + + return this.insertTextAfterPositionInBody( + insertAfterLineAndFormatted, + this.fileContent, + targetPosition, + ); + } catch (err) { + reportError( + err, + `Unable to insert line '${this.choice.insertAfter.after}' at cursor position`, + ); + } + } + + log.logWarning( + `Unknown createIfNotFoundLocation: ${this.choice.insertAfter?.createIfNotFoundLocation}`, + ); + return this.fileContent; + } + private getFrontmatterEndPosition(file: TFile, fallbackContent?: string) { const fileCache = this.app.metadataCache.getFileCache(file); diff --git a/src/gui/ChoiceBuilder/captureChoiceBuilder.ts b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts index 58b728a5..c11f750c 100644 --- a/src/gui/ChoiceBuilder/captureChoiceBuilder.ts +++ b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts @@ -439,7 +439,7 @@ export class CaptureChoiceBuilder extends ChoiceBuilder { private addInsertAfterFields() { const descText = - "Insert capture after specified line. Accepts format syntax. " + + "Insert capture after specified text. Accepts format syntax. " + "Tip: use a heading (starts with #) to target a section. " + "Blank line handling is configurable below."; @@ -464,7 +464,7 @@ export class CaptureChoiceBuilder extends ChoiceBuilder { initialValue: this.choice.insertAfter.after, placeholder: "Insert after", required: true, - requiredMessage: "Insert after line is required", + requiredMessage: "Insert after text is required", attachSuggesters: [ (el) => new FormatSyntaxSuggester(this.app, el, this.plugin), ], @@ -488,82 +488,121 @@ export class CaptureChoiceBuilder extends ChoiceBuilder { } })(); - const insertAtEndSetting: Setting = new Setting(this.contentEl); - insertAtEndSetting - .setName("Insert at end of section") + if (this.choice.insertAfter.inline === undefined) { + this.choice.insertAfter.inline = false; + } + + if (this.choice.insertAfter.replaceExisting === undefined) { + this.choice.insertAfter.replaceExisting = false; + } + + new Setting(this.contentEl) + .setName("Inline insertion") .setDesc( - "Place the text at the end of the matched section instead of the top.", + "Insert captured content on the same line, immediately after the matched text (no newline added).", ) .addToggle((toggle) => toggle - .setValue(this.choice.insertAfter?.insertAtEnd) + .setValue(!!this.choice.insertAfter?.inline) .onChange((value) => { - this.choice.insertAfter.insertAtEnd = value; + this.choice.insertAfter.inline = value; this.reload(); }), ); - if (!this.choice.insertAfter?.blankLineAfterMatchMode) { - this.choice.insertAfter.blankLineAfterMatchMode = "auto"; + if (this.choice.insertAfter.inline) { + new Setting(this.contentEl) + .setName("Replace existing value") + .setDesc("Replace everything after the matched text up to end-of-line.") + .addToggle((toggle) => + toggle + .setValue(!!this.choice.insertAfter?.replaceExisting) + .onChange( + (value) => (this.choice.insertAfter.replaceExisting = value), + ), + ); } - const blankLineModeDesc = - "Controls whether Insert After skips existing blank lines after the matched line."; - const insertAtEndEnabled = !!this.choice.insertAfter?.insertAtEnd; - const blankLineModeSetting: Setting = new Setting(this.contentEl); - blankLineModeSetting - .setName("Blank lines after match") - .setDesc( - insertAtEndEnabled - ? "Not used when inserting at end of section." - : blankLineModeDesc, - ) - .addDropdown((dropdown) => { - dropdown - .addOption("auto", "Auto (headings only)") - .addOption("skip", "Always skip") - .addOption("none", "Never skip") - .setValue( - this.choice.insertAfter?.blankLineAfterMatchMode ?? "auto", - ) + const inlineEnabled = !!this.choice.insertAfter?.inline; + + if (!inlineEnabled) { + const insertAtEndSetting: Setting = new Setting(this.contentEl); + insertAtEndSetting + .setName("Insert at end of section") + .setDesc( + "Place the text at the end of the matched section instead of the top.", + ) + .addToggle((toggle) => + toggle + .setValue(this.choice.insertAfter?.insertAtEnd) .onChange((value) => { - this.choice.insertAfter.blankLineAfterMatchMode = value as - | "auto" - | "skip" - | "none"; - }); - dropdown.setDisabled(insertAtEndEnabled); - }); - blankLineModeSetting.setDisabled(insertAtEndEnabled); + this.choice.insertAfter.insertAtEnd = value; + this.reload(); + }), + ); - new Setting(this.contentEl) - .setName("Consider subsections") - .setDesc( - "Also include the section’s subsections (requires target to be a heading starting with #). Subsections are headings inside the section.", - ) - .addToggle((toggle) => - toggle - .setValue(this.choice.insertAfter?.considerSubsections) - .onChange((value) => { - if (!value) { - this.choice.insertAfter.considerSubsections = false; - return; - } + if (!this.choice.insertAfter?.blankLineAfterMatchMode) { + this.choice.insertAfter.blankLineAfterMatchMode = "auto"; + } - const targetIsHeading = - this.choice.insertAfter.after.startsWith("#"); - if (targetIsHeading) { - this.choice.insertAfter.considerSubsections = value; - } else { - this.choice.insertAfter.considerSubsections = false; - // reset the toggle to match state and inform user - toggle.setValue(false); - new Notice( - "Consider subsections requires the target to be a heading (starts with #)", - ); - } - }), - ); + const blankLineModeDesc = + "Controls whether Insert After skips existing blank lines after the matched line."; + const insertAtEndEnabled = !!this.choice.insertAfter?.insertAtEnd; + const blankLineModeSetting: Setting = new Setting(this.contentEl); + blankLineModeSetting + .setName("Blank lines after match") + .setDesc( + insertAtEndEnabled + ? "Not used when inserting at end of section." + : blankLineModeDesc, + ) + .addDropdown((dropdown) => { + dropdown + .addOption("auto", "Auto (headings only)") + .addOption("skip", "Always skip") + .addOption("none", "Never skip") + .setValue( + this.choice.insertAfter?.blankLineAfterMatchMode ?? "auto", + ) + .onChange((value) => { + this.choice.insertAfter.blankLineAfterMatchMode = value as + | "auto" + | "skip" + | "none"; + }); + dropdown.setDisabled(insertAtEndEnabled); + }); + blankLineModeSetting.setDisabled(insertAtEndEnabled); + + new Setting(this.contentEl) + .setName("Consider subsections") + .setDesc( + "Also include the section’s subsections (requires target to be a heading starting with #). Subsections are headings inside the section.", + ) + .addToggle((toggle) => + toggle + .setValue(this.choice.insertAfter?.considerSubsections) + .onChange((value) => { + if (!value) { + this.choice.insertAfter.considerSubsections = false; + return; + } + + const targetIsHeading = + this.choice.insertAfter.after.startsWith("#"); + if (targetIsHeading) { + this.choice.insertAfter.considerSubsections = value; + } else { + this.choice.insertAfter.considerSubsections = false; + // reset the toggle to match state and inform user + toggle.setValue(false); + new Notice( + "Consider subsections requires the target to be a heading (starts with #)", + ); + } + }), + ); + } const createLineIfNotFound: Setting = new Setting(this.contentEl); createLineIfNotFound diff --git a/src/types/choices/CaptureChoice.ts b/src/types/choices/CaptureChoice.ts index 3c2b0e41..5b825150 100644 --- a/src/types/choices/CaptureChoice.ts +++ b/src/types/choices/CaptureChoice.ts @@ -23,6 +23,8 @@ export class CaptureChoice extends Choice implements ICaptureChoice { considerSubsections: boolean; createIfNotFound: boolean; createIfNotFoundLocation: string; + inline?: boolean; + replaceExisting?: boolean; blankLineAfterMatchMode?: BlankLineAfterMatchMode; }; newLineCapture: { @@ -62,6 +64,8 @@ export class CaptureChoice extends Choice implements ICaptureChoice { considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: "top", + inline: false, + replaceExisting: false, blankLineAfterMatchMode: "auto", }; this.newLineCapture = { @@ -95,6 +99,12 @@ export class CaptureChoice extends Choice implements ICaptureChoice { if (loaded.insertAfter && !loaded.insertAfter.blankLineAfterMatchMode) { loaded.insertAfter.blankLineAfterMatchMode = "auto"; } + if (loaded.insertAfter && loaded.insertAfter.inline === undefined) { + loaded.insertAfter.inline = false; + } + if (loaded.insertAfter && loaded.insertAfter.replaceExisting === undefined) { + loaded.insertAfter.replaceExisting = false; + } return loaded; } } diff --git a/src/types/choices/ICaptureChoice.ts b/src/types/choices/ICaptureChoice.ts index 9b2b6d82..ff09f526 100644 --- a/src/types/choices/ICaptureChoice.ts +++ b/src/types/choices/ICaptureChoice.ts @@ -35,6 +35,8 @@ export default interface ICaptureChoice extends IChoice { considerSubsections: boolean; createIfNotFound: boolean; createIfNotFoundLocation: string; + inline?: boolean; + replaceExisting?: boolean; blankLineAfterMatchMode?: BlankLineAfterMatchMode; }; newLineCapture: {