Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/engine/TemplateChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
180 changes: 179 additions & 1 deletion src/formatters/captureChoiceFormatter-frontmatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -98,6 +103,7 @@ vi.mock('../main', () => ({
}));

import { CaptureChoiceFormatter } from './captureChoiceFormatter';
import { reportError } from '../utils/errorUtils';

const createChoice = (overrides: Partial<ICaptureChoice> = {}): ICaptureChoice => ({
id: 'test',
Expand All @@ -111,7 +117,7 @@ const createChoice = (overrides: Partial<ICaptureChoice> = {}): 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 },
Expand Down Expand Up @@ -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['insertAfter']> = {},
): 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();
});
});
122 changes: 122 additions & 0 deletions src/formatters/captureChoiceFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
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(
Expand Down Expand Up @@ -381,6 +443,66 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
}
}

private async createInlineInsertAfterIfNotFound(
formatted: string,
targetString: string,
): Promise<string> {
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);

Expand Down
Loading