feat(quick-capture): add template folder support and content merging

- Add templateFolder setting for organizing Quick Capture templates
- Implement content merging with {{CONTENT}} placeholder support
- Refactor frontmatter building into dedicated methods for better maintainability
- Add dropdown template selector when template folder is configured
- Improve template error handling with user-friendly notifications
- Ensure minimal frontmatter when using templates
- Support automatic template content merging at end if no placeholder found
This commit is contained in:
Quorafind 2025-10-10 09:16:22 +08:00
parent ab8af59b34
commit 39c5cf329c
4 changed files with 263 additions and 101 deletions

View file

@ -308,6 +308,7 @@ export interface QuickCaptureSettings {
createFileMode?: {
defaultFolder: string; // Default folder for file creation
useTemplate: boolean; // Whether to use a template for new files
templateFolder?: string; // Folder containing available templates
templateFile: string; // Template file path
writeContentTagsToFrontmatter?: boolean; // When true, write #tags from content into frontmatter.tags (merged, deduped)
};
@ -1024,6 +1025,7 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
createFileMode: {
defaultFolder: "",
useTemplate: false,
templateFolder: "",
templateFile: "",
writeContentTagsToFrontmatter: false,
},

View file

@ -509,57 +509,10 @@ export abstract class BaseQuickCaptureModal extends Modal {
`${defaultFolder}/${targetFile}`
);
}
// Inject frontmatter (and tags) for File mode
const useTemplate = !!this.plugin.settings.quickCapture.createFileMode?.useTemplate;
const hasFrontmatter = processedContent.trimStart().startsWith("---");
if (useTemplate) {
// Only ensure frontmatter exists
if (!hasFrontmatter) {
processedContent = `---\nstatus: ${JSON.stringify(this.mapStatusToText(this.taskMetadata.status))}\n---\n\n${processedContent}`;
}
} else {
// Build full frontmatter only when none exists
if (!hasFrontmatter) {
// Status -> textual metadata
const statusText = this.mapStatusToText(this.taskMetadata.status);
// Dates
const startDate = this.taskMetadata.startDate
? this.formatDate(this.taskMetadata.startDate)
: undefined;
const dueDate = this.taskMetadata.dueDate
? this.formatDate(this.taskMetadata.dueDate)
: undefined;
const scheduledDate = this.taskMetadata.scheduledDate
? this.formatDate(this.taskMetadata.scheduledDate)
: undefined;
// Priority/project/context
const priorityVal =
this.taskMetadata.priority !== undefined && this.taskMetadata.priority !== null
? String(this.taskMetadata.priority)
: undefined;
const projectVal = this.taskMetadata.project || undefined;
const contextVal = this.taskMetadata.context || undefined;
// Repeat from recurrence
const repeatVal = this.taskMetadata.recurrence || undefined;
// Tags: do not use recognition config at creation; only write content tags when enabled
const writeContentTags = !!this.plugin.settings.quickCapture.createFileMode?.writeContentTagsToFrontmatter;
const mergedTags = writeContentTags ? this.extractTagsFromContentForFrontmatter(content) : [];
// Build YAML lines
const yamlLines: string[] = [];
yamlLines.push(`status: ${JSON.stringify(statusText)}`);
if (dueDate) yamlLines.push(`dueDate: ${JSON.stringify(dueDate)}`);
if (startDate) yamlLines.push(`startDate: ${JSON.stringify(startDate)}`);
if (scheduledDate) yamlLines.push(`scheduledDate: ${JSON.stringify(scheduledDate)}`);
if (priorityVal) yamlLines.push(`priority: ${JSON.stringify(priorityVal)}`);
if (projectVal) yamlLines.push(`project: ${JSON.stringify(projectVal)}`);
if (contextVal) yamlLines.push(`context: ${JSON.stringify(contextVal)}`);
if (repeatVal) yamlLines.push(`repeat: ${JSON.stringify(repeatVal)}`);
if (mergedTags.length > 0) {
yamlLines.push(`tags: [${mergedTags.map((t) => JSON.stringify(t)).join(", ")}]`);
}
processedContent = `---\n${yamlLines.join("\n")}\n---\n\n${processedContent}`;
}
}
processedContent = await this.buildFileModeContent(
content,
processedContent
);
}
// Convert location/targetType to valid QuickCaptureOptions type
@ -662,6 +615,153 @@ export abstract class BaseQuickCaptureModal extends Modal {
}
}
protected async buildFileModeContent(
rawContent: string,
processedContent: string,
options: { preview?: boolean } = {}
): Promise<string> {
const createFileMode =
this.plugin.settings.quickCapture.createFileMode;
const useTemplate = !!createFileMode?.useTemplate;
if (useTemplate) {
const templatePath = createFileMode?.templateFile?.trim();
if (templatePath) {
const templateFile =
this.app.vault.getAbstractFileByPath(templatePath);
if (templateFile instanceof TFile) {
try {
const templateContent =
await this.app.vault.read(templateFile);
const merged = this.mergeContentIntoTemplate(
templateContent,
processedContent
);
return this.ensureMinimalFrontmatter(merged);
} catch (error) {
console.error(
"Failed to read quick capture template:",
error
);
if (!options.preview) {
new Notice(
`${t("Failed to read template file:")} ${templatePath}`
);
}
}
} else if (!options.preview) {
new Notice(
`${t("Template file not found:")} ${templatePath}`
);
}
} else if (!options.preview) {
new Notice(
t(
"Template file is not configured for Quick Capture file mode."
)
);
}
}
const hasFrontmatter = processedContent
.trimStart()
.startsWith("---");
if (useTemplate && hasFrontmatter) {
return processedContent;
}
if (useTemplate) {
return this.ensureMinimalFrontmatter(processedContent);
}
return this.buildFullFrontmatter(processedContent, rawContent);
}
private mergeContentIntoTemplate(
templateContent: string,
captureContent: string
): string {
if (!templateContent) {
return captureContent;
}
const placeholderRegex = /\{\{\s*CONTENT\s*\}\}/gi;
if (placeholderRegex.test(templateContent)) {
return templateContent.replace(placeholderRegex, captureContent);
}
const trimmedTemplate = templateContent.trimEnd();
const separator = trimmedTemplate ? "\n\n" : "";
return `${trimmedTemplate}${separator}${captureContent}`;
}
private ensureMinimalFrontmatter(content: string): string {
const trimmed = content.trimStart();
if (trimmed.startsWith("---")) {
return content;
}
const statusText = this.mapStatusToText(this.taskMetadata.status);
return `---\nstatus: ${JSON.stringify(
statusText
)}\n---\n\n${content}`;
}
private buildFullFrontmatter(
processedContent: string,
rawContent: string
): string {
const trimmed = processedContent.trimStart();
if (trimmed.startsWith("---")) {
return processedContent;
}
const statusText = this.mapStatusToText(this.taskMetadata.status);
const startDate = this.taskMetadata.startDate
? this.formatDate(this.taskMetadata.startDate)
: undefined;
const dueDate = this.taskMetadata.dueDate
? this.formatDate(this.taskMetadata.dueDate)
: undefined;
const scheduledDate = this.taskMetadata.scheduledDate
? this.formatDate(this.taskMetadata.scheduledDate)
: undefined;
const priorityVal =
this.taskMetadata.priority !== undefined &&
this.taskMetadata.priority !== null
? String(this.taskMetadata.priority)
: undefined;
const projectVal = this.taskMetadata.project || undefined;
const contextVal = this.taskMetadata.context || undefined;
const repeatVal = this.taskMetadata.recurrence || undefined;
const writeContentTags =
!!this.plugin.settings.quickCapture.createFileMode
?.writeContentTagsToFrontmatter;
const mergedTags = writeContentTags
? this.extractTagsFromContentForFrontmatter(rawContent)
: [];
const yamlLines: string[] = [];
yamlLines.push(`status: ${JSON.stringify(statusText)}`);
if (dueDate) yamlLines.push(`dueDate: ${JSON.stringify(dueDate)}`);
if (startDate) yamlLines.push(`startDate: ${JSON.stringify(startDate)}`);
if (scheduledDate)
yamlLines.push(`scheduledDate: ${JSON.stringify(scheduledDate)}`);
if (priorityVal)
yamlLines.push(`priority: ${JSON.stringify(priorityVal)}`);
if (projectVal) yamlLines.push(`project: ${JSON.stringify(projectVal)}`);
if (contextVal) yamlLines.push(`context: ${JSON.stringify(contextVal)}`);
if (repeatVal) yamlLines.push(`repeat: ${JSON.stringify(repeatVal)}`);
if (mergedTags.length > 0) {
yamlLines.push(
`tags: [${mergedTags
.map((t) => JSON.stringify(t))
.join(", ")}]`
);
}
return `---\n${yamlLines.join("\n")}\n---\n\n${processedContent}`;
}
/**
* Extract #tags from content for frontmatter tags array
* Simple regex scan; remove leading '#', dedupe

View file

@ -591,10 +591,17 @@ export class QuickCaptureModal extends BaseQuickCaptureModal {
}
} else {
if (this.previewPlainEl) {
const finalContent = this.computeFileModePreviewContent(
this.capturedContent
const snapshot = this.capturedContent;
void this.computeFileModePreviewContent(snapshot).then(
(finalContent) => {
if (
this.previewPlainEl &&
this.capturedContent === snapshot
) {
this.previewPlainEl.textContent = finalContent;
}
}
);
this.previewPlainEl.textContent = finalContent;
}
}
}
@ -602,44 +609,13 @@ export class QuickCaptureModal extends BaseQuickCaptureModal {
/**
* Build preview content for file mode by mirroring saveContent's file-mode processing
*/
private computeFileModePreviewContent(content: string): string {
let processedContent = this.processContentWithMetadata(content);
const hasFrontmatter = processedContent.trimStart().startsWith("---");
const useTemplate = !!this.plugin.settings.quickCapture.createFileMode?.useTemplate;
if (useTemplate) {
if (!hasFrontmatter) {
const statusText = this.mapStatusToText(this.taskMetadata.status);
processedContent = `---\nstatus: ${JSON.stringify(statusText)}\n---\n\n${processedContent}`;
}
} else {
if (!hasFrontmatter) {
const statusText = this.mapStatusToText(this.taskMetadata.status);
const startDate = this.taskMetadata.startDate ? this.formatDate(this.taskMetadata.startDate) : undefined;
const dueDate = this.taskMetadata.dueDate ? this.formatDate(this.taskMetadata.dueDate) : undefined;
const scheduledDate = this.taskMetadata.scheduledDate ? this.formatDate(this.taskMetadata.scheduledDate) : undefined;
const priorityVal = this.taskMetadata.priority !== undefined && this.taskMetadata.priority !== null ? String(this.taskMetadata.priority) : undefined;
const projectVal = this.taskMetadata.project || undefined;
const contextVal = this.taskMetadata.context || undefined;
const repeatVal = this.taskMetadata.recurrence || undefined;
// Tags: do not use recognition config at creation; only write content tags when enabled
const writeContentTags = !!this.plugin.settings.quickCapture.createFileMode?.writeContentTagsToFrontmatter;
const mergedTags = writeContentTags ? this.extractTagsFromContentForFrontmatter(content) : [];
const yamlLines: string[] = [];
yamlLines.push(`status: ${JSON.stringify(statusText)}`);
if (dueDate) yamlLines.push(`dueDate: ${JSON.stringify(dueDate)}`);
if (startDate) yamlLines.push(`startDate: ${JSON.stringify(startDate)}`);
if (scheduledDate) yamlLines.push(`scheduledDate: ${JSON.stringify(scheduledDate)}`);
if (priorityVal) yamlLines.push(`priority: ${JSON.stringify(priorityVal)}`);
if (projectVal) yamlLines.push(`project: ${JSON.stringify(projectVal)}`);
if (contextVal) yamlLines.push(`context: ${JSON.stringify(contextVal)}`);
if (repeatVal) yamlLines.push(`repeat: ${JSON.stringify(repeatVal)}`);
if (mergedTags.length > 0) {
yamlLines.push(`tags: [${mergedTags.map((t) => JSON.stringify(t)).join(", ")}]`);
}
processedContent = `---\n${yamlLines.join("\n")}\n---\n\n${processedContent}`;
}
}
return processedContent;
private async computeFileModePreviewContent(
content: string
): Promise<string> {
const processedContent = this.processContentWithMetadata(content);
return this.buildFileModeContent(content, processedContent, {
preview: true,
});
}
/**

View file

@ -1,6 +1,7 @@
import { Setting, Notice } from "obsidian";
import { Setting, Notice, TFile, TFolder } from "obsidian";
import { TaskProgressBarSettingTab } from "@/setting";
import { t } from "@/translations/helper";
import { FolderSuggest } from "@/components/ui/inputs/AutoComplete";
export function renderQuickCaptureSettingsTab(
settingTab: TaskProgressBarSettingTab,
@ -335,7 +336,7 @@ export function renderQuickCaptureSettingsTab(
.setName(t("Use template for new files"))
.setDesc(
t(
"When File mode is used, ensure the new file has frontmatter; if enabled, only frontmatter is auto-inserted when missing."
"When File mode is used, create the new note from a template and then insert the captured content."
)
)
.addToggle((toggle) =>
@ -392,17 +393,100 @@ export function renderQuickCaptureSettingsTab(
// Template file path
if (createFileMode.useTemplate) {
const templateFolderPath = (createFileMode.templateFolder || "").trim();
const folderFile = templateFolderPath
? settingTab.app.vault.getAbstractFileByPath(templateFolderPath)
: null;
const folderExists = folderFile instanceof TFolder;
const templateFiles: TFile[] = [];
if (folderExists) {
const collectMarkdownFiles = (folder: TFolder) => {
for (const child of folder.children) {
if (child instanceof TFolder) {
collectMarkdownFiles(child);
} else if (
child instanceof TFile &&
child.extension.toLowerCase() === "md"
) {
templateFiles.push(child);
}
}
};
collectMarkdownFiles(folderFile);
templateFiles.sort((a, b) => a.path.localeCompare(b.path));
}
new Setting(containerEl)
.setName(t("Template file"))
.setDesc(t("Template file to use for new files"))
.addText((text) =>
.setName(t("Template folder"))
.setDesc(
folderExists || !templateFolderPath
? t("Folder that contains Quick Capture templates for File mode.")
: t("Selected folder was not found in the vault.")
)
.addText((text) => {
text
.setValue(createFileMode.templateFile || "")
.setPlaceholder(t("Templates/Quick Capture"))
.setValue(createFileMode.templateFolder || "")
.onChange(async (value) => {
createFileMode.templateFile = value;
const previous = createFileMode.templateFolder || "";
const normalized = value.trim();
createFileMode.templateFolder = normalized;
if (previous !== normalized) {
createFileMode.templateFile = "";
}
settingTab.applySettingsUpdate();
})
);
setTimeout(() => {
settingTab.display();
}, 100);
});
new FolderSuggest(
settingTab.app,
text.inputEl,
settingTab.plugin,
"single"
);
});
new Setting(containerEl)
.setName(t("Template note"))
.setDesc(
!templateFolderPath
? t("Select a template folder above to enable the dropdown.")
: !folderExists
? t("Template folder is invalid; update the folder to continue.")
: templateFiles.length > 0
? t(
"Choose the note that should be copied; {{CONTENT}} placeholders are replaced, otherwise the captured text is appended."
)
: t("No markdown notes were found in the selected folder.")
)
.addDropdown((dropdown) => {
dropdown.addOption("", t("None"));
const existingTemplate = createFileMode.templateFile || "";
if (
existingTemplate &&
!templateFiles.some((file) => file.path === existingTemplate)
) {
dropdown.addOption(existingTemplate, existingTemplate);
}
for (const file of templateFiles) {
dropdown.addOption(file.path, file.basename);
}
dropdown.setValue(createFileMode.templateFile || "");
dropdown.onChange(async (value) => {
createFileMode.templateFile = value;
settingTab.applySettingsUpdate();
});
if (!templateFiles.length || !folderExists) {
dropdown.selectEl.disabled = true;
}
});
}
// Minimal mode settings