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
11 changes: 8 additions & 3 deletions docs/docs/Choices/CaptureChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,15 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f
## Insert after

Insert After will allow you to insert the text after some line with the specified text.
If the matched line is followed by one or more blank lines (including whitespace-only
lines), QuickAdd inserts after those blank lines to preserve spacing under headings.
By default, QuickAdd preserves blank lines after ATX headings to keep heading
spacing intact. Use **Blank lines after match** to control this behavior:

Example (Insert After `# H` with content `X`):
- **Auto (headings only)** - Skip blank lines only when the matched line is
a Markdown heading.
- **Always skip** - Skip all consecutive blank lines after the match.
- **Never skip** - Insert immediately after the matched line.

Example (Auto, Insert After `# H` with content `X`):

```markdown
# H
Expand Down
65 changes: 57 additions & 8 deletions src/formatters/captureChoiceFormatter-frontmatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const createChoice = (overrides: Partial<ICaptureChoice> = {}): ICaptureChoice =
prepend: false,
appendLink: false,
task: false,
insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '' },
insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '', blankLineAfterMatchMode: 'auto' },
newLineCapture: { enabled: false, direction: 'below' },
openFile: false,
fileOpening: { location: 'tab', direction: 'vertical', mode: 'default', focus: true },
Expand Down Expand Up @@ -205,7 +205,10 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
return { formatter, file };
};

const createInsertAfterChoice = (after: string): ICaptureChoice =>
const createInsertAfterChoice = (
after: string,
blankLineAfterMatchMode?: 'auto' | 'skip' | 'none',
): ICaptureChoice =>
createChoice({
insertAfter: {
enabled: true,
Expand All @@ -214,10 +217,11 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
considerSubsections: false,
createIfNotFound: false,
createIfNotFoundLocation: '',
blankLineAfterMatchMode,
},
});

it('skips one blank line after the match', async () => {
it('auto mode skips one blank line after a heading match', async () => {
const { formatter, file } = createFormatter();
const choice = createInsertAfterChoice('# H');
const fileContent = ['# H', '', 'A'].join('\n');
Expand All @@ -232,7 +236,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
expect(result).toBe(['# H', '', 'X', 'A'].join('\n'));
});

it('skips multiple blank lines after the match', async () => {
it('auto mode skips multiple blank lines after a heading match', async () => {
const { formatter, file } = createFormatter();
const choice = createInsertAfterChoice('# H');
const fileContent = ['# H', '', '', 'A'].join('\n');
Expand All @@ -247,7 +251,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
expect(result).toBe(['# H', '', '', 'X', 'A'].join('\n'));
});

it('treats whitespace-only lines as blank', async () => {
it('auto mode treats whitespace-only lines as blank', async () => {
const { formatter, file } = createFormatter();
const choice = createInsertAfterChoice('# H');
const fileContent = ['# H', ' \t', 'A'].join('\n');
Expand All @@ -262,7 +266,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
expect(result).toBe(['# H', ' \t', 'X', 'A'].join('\n'));
});

it('keeps behavior unchanged when no blank lines follow', async () => {
it('auto mode keeps behavior unchanged when no blank lines follow', async () => {
const { formatter, file } = createFormatter();
const choice = createInsertAfterChoice('# H');
const fileContent = ['# H', 'A'].join('\n');
Expand All @@ -277,7 +281,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
expect(result).toBe(['# H', 'X', 'A'].join('\n'));
});

it('keeps behavior unchanged when match is at EOF', async () => {
it('auto mode keeps behavior unchanged when match is at EOF', async () => {
const { formatter, file } = createFormatter();
const choice = createInsertAfterChoice('# H');
const fileContent = '# H';
Expand All @@ -292,7 +296,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
expect(result).toBe('# H\nX\n');
});

it('handles CRLF content when skipping blank lines', async () => {
it('auto mode handles CRLF content when skipping blank lines', async () => {
const { formatter, file } = createFormatter();
const choice = createInsertAfterChoice('# H');
const fileContent = '# H\r\n\r\nA';
Expand All @@ -306,4 +310,49 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {

expect(result).toBe('# H\r\n\r\nX\nA');
});

it('auto mode does not skip blanks for non-heading matches', async () => {
const { formatter, file } = createFormatter();
const choice = createInsertAfterChoice('- Item 1');
const fileContent = ['- Item 1', '', '- Item 2'].join('\n');

const result = await formatter.formatContentWithFile(
'X\n',
choice,
fileContent,
file,
);

expect(result).toBe(['- Item 1', 'X', '', '- Item 2'].join('\n'));
});

it('always skip mode skips blank lines after non-heading matches', async () => {
const { formatter, file } = createFormatter();
const choice = createInsertAfterChoice('- Item 1', 'skip');
const fileContent = ['- Item 1', '', '- Item 2'].join('\n');

const result = await formatter.formatContentWithFile(
'X\n',
choice,
fileContent,
file,
);

expect(result).toBe(['- Item 1', '', 'X', '- Item 2'].join('\n'));
});

it('never skip mode inserts immediately after the match', async () => {
const { formatter, file } = createFormatter();
const choice = createInsertAfterChoice('# H', 'none');
const fileContent = ['# H', '', 'A'].join('\n');

const result = await formatter.formatContentWithFile(
'X\n',
choice,
fileContent,
file,
);

expect(result).toBe(['# H', 'X', '', 'A'].join('\n'));
});
});
22 changes: 22 additions & 0 deletions src/formatters/captureChoiceFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../constants";
import { log } from "../logger/logManager";
import type ICaptureChoice from "../types/choices/ICaptureChoice";
import type { BlankLineAfterMatchMode } from "../types/choices/ICaptureChoice";
import { templaterParseTemplate } from "../utilityObsidian";
import { reportError } from "../utils/errorUtils";
import { CompleteFormatter } from "./completeFormatter";
Expand Down Expand Up @@ -212,13 +213,31 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
return partialIndex; // -1 if no match at all
}

private shouldSkipBlankLinesAfterMatch(
mode: BlankLineAfterMatchMode,
line: string,
): boolean {
if (mode === "skip") return true;
if (mode === "none") return false;
return this.isAtxHeading(line);
}

private isAtxHeading(line: string): boolean {
return /^\s{0,3}#{1,6}\s+\S/.test(line);
}

private findInsertAfterPositionWithBlankLines(
lines: string[],
matchIndex: number,
body: string,
mode: BlankLineAfterMatchMode,
): number {
if (matchIndex < 0) return matchIndex;

const matchLine = lines[matchIndex] ?? "";
const shouldSkip = this.shouldSkipBlankLinesAfterMatch(mode, matchLine);
if (!shouldSkip) return matchIndex;

// Ignore the trailing empty line that results from split("\n") when the
// file ends with a newline. This preserves existing EOF behavior.
const scanLimit = body.endsWith("\n")
Expand Down Expand Up @@ -271,10 +290,13 @@ export class CaptureChoiceFormatter extends CompleteFormatter {

targetPosition = endOfSectionIndex ?? fileContentLines.length - 1;
} else {
const blankLineMode =
this.choice.insertAfter?.blankLineAfterMatchMode ?? "auto";
targetPosition = this.findInsertAfterPositionWithBlankLines(
fileContentLines,
targetPosition,
this.fileContent,
blankLineMode,
);
}

Expand Down
40 changes: 38 additions & 2 deletions src/gui/ChoiceBuilder/captureChoiceBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ export class CaptureChoiceBuilder extends ChoiceBuilder {
const descText =
"Insert capture after specified line. Accepts format syntax. " +
"Tip: use a heading (starts with #) to target a section. " +
"If the matched line is followed by blank lines, QuickAdd inserts after them to preserve spacing.";
"Blank line handling is configurable below.";

new Setting(this.contentEl)
.setName("Insert after")
Expand Down Expand Up @@ -497,9 +497,45 @@ export class CaptureChoiceBuilder extends ChoiceBuilder {
.addToggle((toggle) =>
toggle
.setValue(this.choice.insertAfter?.insertAtEnd)
.onChange((value) => (this.choice.insertAfter.insertAtEnd = value)),
.onChange((value) => {
this.choice.insertAfter.insertAtEnd = value;
this.reload();
}),
);

if (!this.choice.insertAfter?.blankLineAfterMatchMode) {
this.choice.insertAfter.blankLineAfterMatchMode = "auto";
}

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(
Expand Down
6 changes: 6 additions & 0 deletions src/types/choices/CaptureChoice.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Choice } from "./Choice";
import type ICaptureChoice from "./ICaptureChoice";
import type { BlankLineAfterMatchMode } from "./ICaptureChoice";
import type { OpenLocation, FileViewMode2 } from "../fileOpening";
import type { AppendLinkOptions } from "../linkPlacement";

Expand All @@ -22,6 +23,7 @@ export class CaptureChoice extends Choice implements ICaptureChoice {
considerSubsections: boolean;
createIfNotFound: boolean;
createIfNotFoundLocation: string;
blankLineAfterMatchMode?: BlankLineAfterMatchMode;
};
newLineCapture: {
enabled: boolean;
Expand Down Expand Up @@ -60,6 +62,7 @@ export class CaptureChoice extends Choice implements ICaptureChoice {
considerSubsections: false,
createIfNotFound: false,
createIfNotFoundLocation: "top",
blankLineAfterMatchMode: "auto",
};
this.newLineCapture = {
enabled: false,
Expand Down Expand Up @@ -89,6 +92,9 @@ export class CaptureChoice extends Choice implements ICaptureChoice {
if (loaded.templater.afterCapture !== "wholeFile") {
loaded.templater.afterCapture = "none";
}
if (loaded.insertAfter && !loaded.insertAfter.blankLineAfterMatchMode) {
loaded.insertAfter.blankLineAfterMatchMode = "auto";
}
return loaded;
}
}
3 changes: 3 additions & 0 deletions src/types/choices/ICaptureChoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type IChoice from "./IChoice";
import type { AppendLinkOptions } from "../linkPlacement";
import type { OpenLocation, FileViewMode2 } from "../fileOpening";

export type BlankLineAfterMatchMode = "auto" | "skip" | "none";

export default interface ICaptureChoice extends IChoice {
captureTo: string;
captureToActiveFile: boolean;
Expand Down Expand Up @@ -33,6 +35,7 @@ export default interface ICaptureChoice extends IChoice {
considerSubsections: boolean;
createIfNotFound: boolean;
createIfNotFoundLocation: string;
blankLineAfterMatchMode?: BlankLineAfterMatchMode;
};
newLineCapture: {
enabled: boolean;
Expand Down