diff --git a/src/common/setting-definition.ts b/src/common/setting-definition.ts index 42cee41b..e0874408 100644 --- a/src/common/setting-definition.ts +++ b/src/common/setting-definition.ts @@ -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, }, diff --git a/src/components/features/quick-capture/modals/BaseQuickCaptureModal.ts b/src/components/features/quick-capture/modals/BaseQuickCaptureModal.ts index 57d0ae54..d06fb80f 100644 --- a/src/components/features/quick-capture/modals/BaseQuickCaptureModal.ts +++ b/src/components/features/quick-capture/modals/BaseQuickCaptureModal.ts @@ -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 { + 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 diff --git a/src/components/features/quick-capture/modals/QuickCaptureModalWithSwitch.ts b/src/components/features/quick-capture/modals/QuickCaptureModalWithSwitch.ts index 25c69a1d..8fd6144d 100644 --- a/src/components/features/quick-capture/modals/QuickCaptureModalWithSwitch.ts +++ b/src/components/features/quick-capture/modals/QuickCaptureModalWithSwitch.ts @@ -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 { + const processedContent = this.processContentWithMetadata(content); + return this.buildFileModeContent(content, processedContent, { + preview: true, + }); } /** diff --git a/src/components/features/settings/tabs/QuickCaptureSettingsTab.ts b/src/components/features/settings/tabs/QuickCaptureSettingsTab.ts index a0332c6e..53f81905 100644 --- a/src/components/features/settings/tabs/QuickCaptureSettingsTab.ts +++ b/src/components/features/settings/tabs/QuickCaptureSettingsTab.ts @@ -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