const { Plugin, Notice, Setting, PluginSettingTab, TFile, moment } = require("obsidian"); const path = require("path"); const fs = require("fs"); const DEFAULT_SETTINGS = { exportDir: "/Users/logicluo/Project/astro-chiri/src/content/posts", dateFormat: "YYYY-MM-DD", modifiedDateKey: "modifiedDate", preserveSubfolders: false }; function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function escapeYamlString(value) { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } function getFrontmatterBlock(content) { const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n?/; const match = content.match(frontmatterRegex); return match ? match[1] : null; } function hasFrontmatterKey(content, key) { const block = getFrontmatterBlock(content); if (!block) return false; const keyRegex = new RegExp(`^${escapeRegExp(key)}\\s*:`, "m"); return keyRegex.test(block); } function upsertFrontmatter(content, frontmatterLines, keys) { const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n?/; const match = content.match(frontmatterRegex); if (match) { const existing = match[1]; const rest = content.slice(match[0].length); const existingLines = existing.split(/\r?\n/); const filteredLines = existingLines.filter((line) => { const trimmed = line.trim(); if (!trimmed) return false; return !keys.some((key) => new RegExp(`^${escapeRegExp(key)}\\s*:`).test(trimmed)); }); const mergedLines = [...filteredLines, ...frontmatterLines]; return ["---", ...mergedLines, "---", "", rest].join("\n"); } return ["---", ...frontmatterLines, "---", "", content].join("\n"); } function splitFrontmatter(content) { const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n?/; const match = content.match(frontmatterRegex); if (!match) { return { frontmatter: "", body: content }; } return { frontmatter: match[0], body: content.slice(match[0].length) }; } function convertSingleLineDisplayMath(content) { const { frontmatter, body } = splitFrontmatter(content); const newline = content.includes("\r\n") ? "\r\n" : "\n"; const lines = body.split(/\r?\n/); let inFence = false; let fenceMarker = ""; let fenceLength = 0; const converted = lines.flatMap((line) => { const fenceMatch = line.match(/^\s*([`~]{3,})/); if (fenceMatch) { if (!inFence) { inFence = true; fenceMarker = fenceMatch[1][0]; fenceLength = fenceMatch[1].length; } else if (fenceMatch[1][0] === fenceMarker && fenceMatch[1].length >= fenceLength) { inFence = false; fenceMarker = ""; fenceLength = 0; } return [line]; } if (inFence) return [line]; const match = line.match(/^(\s*)\$\$(.+?)\$\$\s*$/); if (!match) return [line]; const indent = match[1] || ""; const inner = match[2].trim(); return [indent + "$$", indent + inner, indent + "$$"]; }); return frontmatter + converted.join(newline); } class AstroPostExporter extends Plugin { async onload() { await this.loadSettings(); this.addCommand({ id: "export-current-note-to-astro", name: "Export current note to Astro", checkCallback: (checking) => { const file = this.getActiveMarkdownFile(); if (!file) return false; if (!checking) { this.exportFiles([file]); } return true; } }); this.addCommand({ id: "export-selected-notes-to-astro", name: "Export selected notes to Astro", callback: () => { this.exportSelectedOrActive(); } }); this.addSettingTab(new AstroPostExporterSettingTab(this.app, this)); } getActiveMarkdownFile() { const file = this.app.workspace.getActiveFile(); if (file && file instanceof TFile && file.extension === "md") { return file; } return null; } getSelectedMarkdownFiles() { const files = []; const leaves = this.app.workspace.getLeavesOfType("file-explorer"); for (const leaf of leaves) { const explorer = leaf.view && leaf.view.fileExplorer; if (!explorer) continue; const selected = explorer.selectedItems || explorer.selectedFileItems || explorer.selectedFiles; if (!selected) continue; for (const item of this.iterateSelection(selected)) { const file = item instanceof TFile ? item : item && item.file; if (file instanceof TFile && file.extension === "md") { files.push(file); } } } const seen = new Set(); return files.filter((file) => { if (!file || seen.has(file.path)) return false; seen.add(file.path); return true; }); } iterateSelection(selection) { if (Array.isArray(selection)) return selection; if (selection instanceof Set) return Array.from(selection); if (selection instanceof Map) return Array.from(selection.values()); if (selection && typeof selection === "object") return Object.values(selection); return []; } async exportSelectedOrActive() { const selected = this.getSelectedMarkdownFiles(); const files = selected.length > 0 ? selected : [this.getActiveMarkdownFile()].filter(Boolean); if (files.length === 0) { new Notice("No Markdown file selected or active."); return; } await this.exportFiles(files); } async exportFiles(files) { const exportDir = (this.settings.exportDir || "").trim(); if (!exportDir) { new Notice("Astro export path is not set. Configure it in plugin settings."); return; } try { await fs.promises.mkdir(exportDir, { recursive: true }); } catch (error) { console.error(error); new Notice("Failed to create export directory. Check the path and permissions."); return; } let exported = 0; for (const file of files) { try { const content = await this.app.vault.read(file); const transformed = convertSingleLineDisplayMath(content); const output = this.applyFrontmatter(file, transformed); const destinationPath = this.getDestinationPath(file, exportDir); await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true }); await fs.promises.writeFile(destinationPath, output, "utf8"); exported += 1; } catch (error) { console.error(error); new Notice(`Failed to export: ${file.path}`); } } if (exported > 0) { new Notice(`Exported ${exported} file${exported === 1 ? "" : "s"} to Astro.`); } } applyFrontmatter(file, content) { const created = file.stat && file.stat.ctime ? file.stat.ctime : Date.now(); const modified = file.stat && file.stat.mtime ? file.stat.mtime : Date.now(); const title = file.basename; const includeDescription = !hasFrontmatterKey(content, "description"); const pubDate = moment(created).format(this.settings.dateFormat); const modifiedDate = moment(modified).format(this.settings.dateFormat); const frontmatterLines = [ `title: "${escapeYamlString(title)}"`, ...(includeDescription ? [`description: "${escapeYamlString(title)}"`] : []), `pubDate: ${pubDate}`, `${this.settings.modifiedDateKey}: ${modifiedDate}` ]; const keysToReplace = [ "title", ...(includeDescription ? ["description"] : []), "pubDate", this.settings.modifiedDateKey ]; return upsertFrontmatter(content, frontmatterLines, keysToReplace); } getDestinationPath(file, exportDir) { if (this.settings.preserveSubfolders) { return path.join(exportDir, file.path); } return path.join(exportDir, file.name); } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } } class AstroPostExporterSettingTab extends PluginSettingTab { constructor(app, plugin) { super(app, plugin); this.plugin = plugin; } display() { const { containerEl } = this; containerEl.empty(); new Setting(containerEl) .setName("Astro posts directory") .setDesc("Absolute path to the Astro posts folder.") .addText((text) => text .setPlaceholder("/path/to/src/content/posts") .setValue(this.plugin.settings.exportDir) .onChange(async (value) => { this.plugin.settings.exportDir = value.trim(); await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Date format") .setDesc("Format for pubDate and ModifiedDate.") .addText((text) => text .setPlaceholder("YYYY-MM-DD") .setValue(this.plugin.settings.dateFormat) .onChange(async (value) => { this.plugin.settings.dateFormat = value.trim() || DEFAULT_SETTINGS.dateFormat; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Modified date key") .setDesc("Frontmatter key name for the modified date.") .addText((text) => text .setPlaceholder("modifiedDate") .setValue(this.plugin.settings.modifiedDateKey) .onChange(async (value) => { this.plugin.settings.modifiedDateKey = value.trim() || DEFAULT_SETTINGS.modifiedDateKey; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Preserve subfolders") .setDesc("Keep the vault folder structure under the export directory.") .addToggle((toggle) => toggle.setValue(this.plugin.settings.preserveSubfolders).onChange(async (value) => { this.plugin.settings.preserveSubfolders = value; await this.plugin.saveSettings(); }) ); } } module.exports = AstroPostExporter;