From dfbc0ec43f80317fa2c2748462bf4724d411b406 Mon Sep 17 00:00:00 2001 From: Ethan Troy Date: Tue, 17 Mar 2026 20:43:28 -0400 Subject: [PATCH] Add post-conversion hooks for frontmatter and tags (ENG-650) --- main.ts | 14 ++- src/converter/MarkitdownConverter.ts | 21 +++- src/settings/SettingsTab.ts | 26 +++++ src/types/settings.ts | 8 ++ src/utils/postprocess.ts | 154 +++++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 src/utils/postprocess.ts diff --git a/main.ts b/main.ts index a700c40..f91fd14 100644 --- a/main.ts +++ b/main.ts @@ -125,7 +125,7 @@ export default class MarkitdownPlugin extends Plugin { const outputFolder = resolveOutputFolder(vaultPath, this.settings.outputPath); const baseName = file.basename; const outputPath = path.join(outputFolder, `${baseName}.md`); - const options = this.buildConversionOptions(outputPath); + const options = this.buildConversionOptions(outputPath, inputPath); new Notice('Converting file...'); const result = await this.converter.convert(inputPath, outputPath, options); @@ -149,12 +149,12 @@ export default class MarkitdownPlugin extends Plugin { inputPath: string, outputPath: string ): Promise { - const options = this.buildConversionOptions(outputPath); + const options = this.buildConversionOptions(outputPath, inputPath); return this.converter.convert(inputPath, outputPath, options); } /** Build ConversionOptions from current settings. */ - buildConversionOptions(outputPath: string): ConversionOptions { + buildConversionOptions(outputPath: string, inputPath?: string): ConversionOptions { const options: ConversionOptions = { enablePlugins: this.settings.enablePlugins, docintelEndpoint: this.settings.docintelEndpoint || undefined, @@ -172,6 +172,14 @@ export default class MarkitdownPlugin extends Plugin { ); } + // Attach post-processing settings when hooks are enabled + if (inputPath && (this.settings.enableAutoFrontmatter || this.settings.autoTags.trim())) { + options.postProcess = { + settings: this.settings, + inputPath, + }; + } + return options; } diff --git a/src/converter/MarkitdownConverter.ts b/src/converter/MarkitdownConverter.ts index 31ffda9..69bce15 100644 --- a/src/converter/MarkitdownConverter.ts +++ b/src/converter/MarkitdownConverter.ts @@ -1,8 +1,9 @@ import * as path from 'path'; import * as fs from 'fs'; -import { ConversionOptions, ConversionResult } from '../types/settings'; +import { ConversionOptions, ConversionResult, MarkitdownSettings } from '../types/settings'; import { runPythonScript, getPythonScriptPath } from '../utils/python'; import { SUPPORTED_EXTENSIONS } from '../utils/fileTypes'; +import { applyPostConversionHooks } from '../utils/postprocess'; export class MarkitdownConverter { constructor( @@ -77,6 +78,24 @@ export class MarkitdownConverter { }; } + // Apply post-conversion hooks (frontmatter, tags) + if (options?.postProcess) { + try { + const raw = fs.readFileSync(outputPath, 'utf-8'); + const processed = applyPostConversionHooks( + raw, + options.postProcess.inputPath, + options.postProcess.settings + ); + if (processed !== raw) { + fs.writeFileSync(outputPath, processed, 'utf-8'); + } + } catch (hookError: unknown) { + // Log but don't fail the conversion for a post-processing error + console.warn('markitdown: post-conversion hook error:', hookError); + } + } + // Parse success response let imagesExtracted = 0; try { diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index 7cc2a42..a30a102 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -120,6 +120,32 @@ export class SettingsTab extends PluginSettingTab { await this.plugin.saveSettings(); })); + // ── Post-conversion ───────────────────── + new Setting(containerEl) + .setName('Post-conversion') + .setHeading(); + + new Setting(containerEl) + .setName('Auto frontmatter') + .setDesc('Automatically add YAML frontmatter with source filename, conversion date, and converter name') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableAutoFrontmatter) + .onChange(async (value) => { + this.plugin.settings.enableAutoFrontmatter = value; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Auto tags') + .setDesc('Comma-separated tags to add to frontmatter (e.g., "converted, imported, pdf"). Requires auto frontmatter or adds a frontmatter block for tags alone.') + .addText(text => text + .setPlaceholder('converted, imported') + .setValue(this.plugin.settings.autoTags) + .onChange(async (value) => { + this.plugin.settings.autoTags = value; + await this.plugin.saveSettings(); + })); + // ── Advanced ───────────────────────────── new Setting(containerEl) .setName('Advanced') diff --git a/src/types/settings.ts b/src/types/settings.ts index a2e1e8d..5cd5485 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -13,6 +13,8 @@ export interface MarkitdownSettings { imageSubfolderTemplate: string; enableBatchProgress: boolean; enableContextMenu: boolean; + enableAutoFrontmatter: boolean; + autoTags: string; } export const DEFAULT_SETTINGS: MarkitdownSettings = { @@ -25,6 +27,8 @@ export const DEFAULT_SETTINGS: MarkitdownSettings = { imageSubfolderTemplate: '{filename}-images', enableBatchProgress: true, enableContextMenu: true, + enableAutoFrontmatter: false, + autoTags: '', }; export interface ConversionOptions { @@ -33,6 +37,10 @@ export interface ConversionOptions { docintelEndpoint?: string; extractImages?: boolean; imageDir?: string; + postProcess?: { + settings: MarkitdownSettings; + inputPath: string; + }; } export interface ConversionResult { diff --git a/src/utils/postprocess.ts b/src/utils/postprocess.ts new file mode 100644 index 0000000..d023514 --- /dev/null +++ b/src/utils/postprocess.ts @@ -0,0 +1,154 @@ +import * as path from 'path'; +import { MarkitdownSettings } from '../types/settings'; + +/** + * Escape a string value for safe inclusion in YAML. + * Wraps in double quotes if the value contains characters that could + * break YAML parsing. + */ +function yamlEscape(value: string): string { + if (/[:#\[\]{}&*!|>'"`,@%\\]/.test(value) || value.trim() !== value) { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return value; +} + +/** + * Build frontmatter fields based on settings and input file. + */ +function buildFrontmatterFields( + inputPath: string, + settings: MarkitdownSettings +): Record { + const fields: Record = {}; + + if (settings.enableAutoFrontmatter) { + const filename = path.basename(inputPath); + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + + fields['source'] = filename; + fields['converted'] = `${yyyy}-${mm}-${dd}`; + fields['converter'] = 'markitdown'; + } + + if (settings.autoTags.trim()) { + const tags = settings.autoTags + .split(',') + .map(t => t.trim()) + .filter(t => t.length > 0); + if (tags.length > 0) { + fields['tags'] = tags; + } + } + + return fields; +} + +/** + * Serialize a record of fields into YAML frontmatter lines (without delimiters). + */ +function serializeFields(fields: Record): string { + const lines: string[] = []; + for (const [key, value] of Object.entries(fields)) { + if (Array.isArray(value)) { + lines.push(`${key}:`); + for (const item of value) { + lines.push(` - ${yamlEscape(item)}`); + } + } else { + lines.push(`${key}: ${yamlEscape(value)}`); + } + } + return lines.join('\n'); +} + +/** + * Parse an existing YAML frontmatter block. + * Returns the raw YAML body (without delimiters) and the rest of the content. + * Returns null if no frontmatter is found. + */ +function parseExistingFrontmatter(content: string): { + yaml: string; + body: string; +} | null { + // Frontmatter must start at the very beginning of the file + if (!content.startsWith('---')) return null; + + // Find the closing delimiter — skip the first line + const closingIndex = content.indexOf('\n---', 3); + if (closingIndex === -1) return null; + + const yaml = content.substring(4, closingIndex).trim(); // after "---\n" + // Body starts after the closing "---" line + const afterClosing = closingIndex + 4; // skip "\n---" + const body = content.substring(afterClosing); + + return { yaml, body }; +} + +/** + * Merge new fields into existing YAML frontmatter text. + * Only adds fields that don't already exist (to avoid overwriting user edits). + */ +function mergeIntoYaml( + existingYaml: string, + fields: Record +): string { + const existingKeys = new Set(); + for (const line of existingYaml.split('\n')) { + const match = line.match(/^(\w[\w-]*)\s*:/); + if (match) { + existingKeys.add(match[1]); + } + } + + const newFields: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (!existingKeys.has(key)) { + newFields[key] = value; + } + } + + if (Object.keys(newFields).length === 0) { + return existingYaml; + } + + const additional = serializeFields(newFields); + return existingYaml + '\n' + additional; +} + +/** + * Apply post-conversion hooks to the converted Markdown content. + * + * - Adds/merges YAML frontmatter with source metadata if enabled. + * - Adds tags to frontmatter if configured. + * + * Returns the (possibly modified) content string. + */ +export function applyPostConversionHooks( + content: string, + inputPath: string, + settings: MarkitdownSettings +): string { + const fields = buildFrontmatterFields(inputPath, settings); + + // Nothing to add + if (Object.keys(fields).length === 0) { + return content; + } + + const existing = parseExistingFrontmatter(content); + + if (existing) { + // Merge into existing frontmatter + const merged = mergeIntoYaml(existing.yaml, fields); + return `---\n${merged}\n---${existing.body}`; + } + + // Prepend new frontmatter block + const yaml = serializeFields(fields); + return `---\n${yaml}\n---\n\n${content}`; +}