mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
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:
parent
ab8af59b34
commit
39c5cf329c
4 changed files with 263 additions and 101 deletions
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue