Skip to content

Commit b2e1ef5

Browse files
authored
feat: add inline insert-after capture mode
Add inline insert-after mode with optional replace-to-EOL. Hide section/blank-line options in inline mode and warn on unknown inline create-if-not-found location. Fix await for same-folder template selection.
1 parent 98fa7db commit b2e1ef5

File tree

6 files changed

+417
-66
lines changed

6 files changed

+417
-66
lines changed

src/engine/TemplateChoiceEngine.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ export class TemplateChoiceEngine extends TemplateEngine {
280280
return "";
281281
}
282282

283-
return this.getOrCreateFolder([activeFile.parent.path]);
283+
return await this.getOrCreateFolder([activeFile.parent.path]);
284284
}
285285

286286
return await this.getOrCreateFolder(folders);

src/formatters/captureChoiceFormatter-frontmatter.test.ts

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ vi.mock('../gui/VDateInputPrompt/VDateInputPrompt', () => ({
3838
},
3939
}));
4040

41+
vi.mock('../utils/errorUtils', () => ({
42+
__esModule: true,
43+
reportError: vi.fn(),
44+
}));
45+
4146
vi.mock('../gui/MathModal', () => ({
4247
__esModule: true,
4348
MathModal: {
@@ -98,6 +103,7 @@ vi.mock('../main', () => ({
98103
}));
99104

100105
import { CaptureChoiceFormatter } from './captureChoiceFormatter';
106+
import { reportError } from '../utils/errorUtils';
101107

102108
const createChoice = (overrides: Partial<ICaptureChoice> = {}): ICaptureChoice => ({
103109
id: 'test',
@@ -111,7 +117,7 @@ const createChoice = (overrides: Partial<ICaptureChoice> = {}): ICaptureChoice =
111117
prepend: false,
112118
appendLink: false,
113119
task: false,
114-
insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '', blankLineAfterMatchMode: 'auto' },
120+
insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '', inline: false, replaceExisting: false, blankLineAfterMatchMode: 'auto' },
115121
newLineCapture: { enabled: false, direction: 'below' },
116122
openFile: false,
117123
fileOpening: { location: 'tab', direction: 'vertical', mode: 'default', focus: true },
@@ -356,3 +362,175 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
356362
expect(result).toBe(['# H', 'X', '', 'A'].join('\n'));
357363
});
358364
});
365+
366+
describe('CaptureChoiceFormatter insert after inline', () => {
367+
beforeEach(() => {
368+
vi.resetAllMocks();
369+
(global as any).navigator = {
370+
clipboard: {
371+
readText: vi.fn().mockResolvedValue(''),
372+
},
373+
};
374+
});
375+
376+
const createFormatter = () => {
377+
const app = createMockApp();
378+
const plugin = {
379+
settings: {
380+
enableTemplatePropertyTypes: false,
381+
globalVariables: {},
382+
showCaptureNotification: false,
383+
showInputCancellationNotification: true,
384+
},
385+
} as any;
386+
const formatter = new CaptureChoiceFormatter(app, plugin);
387+
const file = createTFile('Inline.md');
388+
389+
return { formatter, file };
390+
};
391+
392+
const createInlineChoice = (
393+
after: string,
394+
overrides: Partial<ICaptureChoice['insertAfter']> = {},
395+
): ICaptureChoice =>
396+
createChoice({
397+
insertAfter: {
398+
enabled: true,
399+
after,
400+
insertAtEnd: false,
401+
considerSubsections: false,
402+
createIfNotFound: false,
403+
createIfNotFoundLocation: 'top',
404+
inline: true,
405+
replaceExisting: false,
406+
blankLineAfterMatchMode: 'auto',
407+
...overrides,
408+
},
409+
});
410+
411+
it('inserts inline at match end and preserves suffix', async () => {
412+
const { formatter, file } = createFormatter();
413+
const choice = createInlineChoice('Status:', { replaceExisting: false });
414+
const fileContent = 'Status: pending';
415+
416+
const result = await formatter.formatContentWithFile(
417+
' done',
418+
choice,
419+
fileContent,
420+
file,
421+
);
422+
423+
expect(result).toBe('Status: done pending');
424+
});
425+
426+
it('replaces to end-of-line when enabled, preserving newline', async () => {
427+
const { formatter, file } = createFormatter();
428+
const choice = createInlineChoice('Status: ', { replaceExisting: true });
429+
const fileContent = ['Status: pending', 'Next'].join('\n');
430+
431+
const result = await formatter.formatContentWithFile(
432+
'done',
433+
choice,
434+
fileContent,
435+
file,
436+
);
437+
438+
expect(result).toBe(['Status: done', 'Next'].join('\n'));
439+
});
440+
441+
it('replace mode behaves like append when target is at end-of-line', async () => {
442+
const { formatter, file } = createFormatter();
443+
const choice = createInlineChoice('pending', { replaceExisting: true });
444+
const fileContent = 'Status: pending';
445+
446+
const result = await formatter.formatContentWithFile(
447+
'!',
448+
choice,
449+
fileContent,
450+
file,
451+
);
452+
453+
expect(result).toBe('Status: pending!');
454+
});
455+
456+
it('creates a single inline line when target is not found', async () => {
457+
const { formatter, file } = createFormatter();
458+
const choice = createInlineChoice('Status: ', {
459+
createIfNotFound: true,
460+
createIfNotFoundLocation: 'top',
461+
});
462+
const fileContent = '# Header';
463+
464+
const result = await formatter.formatContentWithFile(
465+
'done',
466+
choice,
467+
fileContent,
468+
file,
469+
);
470+
471+
expect(result).toBe(['Status: done', '# Header'].join('\n'));
472+
});
473+
474+
it('does not modify the file when target is missing and create-if-not-found is off', async () => {
475+
const { formatter, file } = createFormatter();
476+
const choice = createInlineChoice('Missing: ', { createIfNotFound: false });
477+
const fileContent = 'Status: pending';
478+
479+
const result = await formatter.formatContentWithFile(
480+
'done',
481+
choice,
482+
fileContent,
483+
file,
484+
);
485+
486+
expect(result).toBe(fileContent);
487+
expect(reportError).toHaveBeenCalled();
488+
});
489+
490+
it('updates only the first match', async () => {
491+
const { formatter, file } = createFormatter();
492+
const choice = createInlineChoice('Tag: ', { replaceExisting: true });
493+
const fileContent = ['Tag: a', 'Tag: b'].join('\n');
494+
495+
const result = await formatter.formatContentWithFile(
496+
'X',
497+
choice,
498+
fileContent,
499+
file,
500+
);
501+
502+
expect(result).toBe(['Tag: X', 'Tag: b'].join('\n'));
503+
});
504+
505+
it('works with capture to active file enabled', async () => {
506+
const { formatter, file } = createFormatter();
507+
const choice = createInlineChoice('Status: ', { replaceExisting: true });
508+
choice.captureToActiveFile = true;
509+
const fileContent = 'Status: pending';
510+
511+
const result = await formatter.formatContentWithFile(
512+
'done',
513+
choice,
514+
fileContent,
515+
file,
516+
);
517+
518+
expect(result).toBe('Status: done');
519+
});
520+
521+
it('reports an error and leaves content unchanged when target contains a newline', async () => {
522+
const { formatter, file } = createFormatter();
523+
const choice = createInlineChoice('Status:\n', { replaceExisting: true });
524+
const fileContent = 'Status:\npending';
525+
526+
const result = await formatter.formatContentWithFile(
527+
'done',
528+
choice,
529+
fileContent,
530+
file,
531+
);
532+
533+
expect(result).toBe(fileContent);
534+
expect(reportError).toHaveBeenCalled();
535+
});
536+
});

src/formatters/captureChoiceFormatter.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
262262
this.choice.insertAfter.after,
263263
);
264264

265+
if (this.choice.insertAfter?.inline) {
266+
return await this.insertAfterInlineHandler(formatted, targetString);
267+
}
268+
265269
const fileContentLines: string[] = getLinesInString(this.fileContent);
266270
let targetPosition = this.findInsertAfterIndex(
267271
fileContentLines,
@@ -307,6 +311,64 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
307311
);
308312
}
309313

314+
private hasInlineTargetLinebreak(target: string): boolean {
315+
return target.includes("\n") || target.includes("\r");
316+
}
317+
318+
private getInlineEndOfLine(startIndex: number): number {
319+
const newlineIndex = this.fileContent.indexOf("\n", startIndex);
320+
if (newlineIndex === -1) return this.fileContent.length;
321+
if (newlineIndex > 0 && this.fileContent[newlineIndex - 1] === "\r") {
322+
return newlineIndex - 1;
323+
}
324+
return newlineIndex;
325+
}
326+
327+
private async insertAfterInlineHandler(
328+
formatted: string,
329+
targetString: string,
330+
): Promise<string> {
331+
if (this.hasInlineTargetLinebreak(targetString)) {
332+
reportError(
333+
new Error("Inline insert after target must be a single line."),
334+
"Insert After Inline Error",
335+
);
336+
return this.fileContent;
337+
}
338+
339+
const matchIndex = this.fileContent.indexOf(targetString);
340+
if (matchIndex === -1) {
341+
if (this.choice.insertAfter?.createIfNotFound) {
342+
return await this.createInlineInsertAfterIfNotFound(
343+
formatted,
344+
targetString,
345+
);
346+
}
347+
348+
reportError(
349+
new Error("Unable to find insert after text in file."),
350+
"Insert After Inline Error",
351+
);
352+
return this.fileContent;
353+
}
354+
355+
const matchEnd = matchIndex + targetString.length;
356+
if (this.choice.insertAfter?.replaceExisting) {
357+
const endOfLine = this.getInlineEndOfLine(matchEnd);
358+
return (
359+
this.fileContent.slice(0, matchEnd) +
360+
formatted +
361+
this.fileContent.slice(endOfLine)
362+
);
363+
}
364+
365+
return (
366+
this.fileContent.slice(0, matchEnd) +
367+
formatted +
368+
this.fileContent.slice(matchEnd)
369+
);
370+
}
371+
310372
private async createInsertAfterIfNotFound(formatted: string) {
311373
// Build the line to insert using centralized location formatting
312374
const insertAfterLine: string = this.replaceLinebreakInString(
@@ -381,6 +443,66 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
381443
}
382444
}
383445

446+
private async createInlineInsertAfterIfNotFound(
447+
formatted: string,
448+
targetString: string,
449+
): Promise<string> {
450+
const insertAfterLineAndFormatted = `${targetString}${formatted}`;
451+
452+
if (
453+
this.choice.insertAfter?.createIfNotFoundLocation ===
454+
CREATE_IF_NOT_FOUND_TOP
455+
) {
456+
const frontmatterEndPosition = this.file
457+
? this.getFrontmatterEndPosition(this.file, this.fileContent)
458+
: -1;
459+
return this.insertTextAfterPositionInBody(
460+
insertAfterLineAndFormatted,
461+
this.fileContent,
462+
frontmatterEndPosition,
463+
);
464+
}
465+
466+
if (
467+
this.choice.insertAfter?.createIfNotFoundLocation ===
468+
CREATE_IF_NOT_FOUND_BOTTOM
469+
) {
470+
return `${this.fileContent}\n${insertAfterLineAndFormatted}`;
471+
}
472+
473+
if (
474+
this.choice.insertAfter?.createIfNotFoundLocation ===
475+
CREATE_IF_NOT_FOUND_CURSOR
476+
) {
477+
try {
478+
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
479+
480+
if (!activeView) {
481+
throw new Error("No active view.");
482+
}
483+
484+
const cursor = activeView.editor.getCursor();
485+
const targetPosition = cursor.line;
486+
487+
return this.insertTextAfterPositionInBody(
488+
insertAfterLineAndFormatted,
489+
this.fileContent,
490+
targetPosition,
491+
);
492+
} catch (err) {
493+
reportError(
494+
err,
495+
`Unable to insert line '${this.choice.insertAfter.after}' at cursor position`,
496+
);
497+
}
498+
}
499+
500+
log.logWarning(
501+
`Unknown createIfNotFoundLocation: ${this.choice.insertAfter?.createIfNotFoundLocation}`,
502+
);
503+
return this.fileContent;
504+
}
505+
384506
private getFrontmatterEndPosition(file: TFile, fallbackContent?: string) {
385507
const fileCache = this.app.metadataCache.getFileCache(file);
386508

0 commit comments

Comments
 (0)