From 6638f40fc9084766ad564160d8869871f18275e9 Mon Sep 17 00:00:00 2001 From: gameloader Date: Sun, 18 Jan 2026 19:17:36 +0800 Subject: [PATCH] feat(exporter): add Astro Post Exporter plugin --- main.js | 271 ++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 9 ++ 2 files changed, 280 insertions(+) create mode 100644 main.js create mode 100644 manifest.json diff --git a/main.js b/main.js new file mode 100644 index 0000000..a6cdd92 --- /dev/null +++ b/main.js @@ -0,0 +1,271 @@ +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"); +} + +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 output = this.applyFrontmatter(file, content); + 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 includePubDate = !hasFrontmatterKey(content, "pubDate"); + const pubDate = moment(created).format(this.settings.dateFormat); + const modifiedDate = moment(modified).format(this.settings.dateFormat); + + const frontmatterLines = [ + `title: "${escapeYamlString(title)}"`, + ...(includePubDate ? [`pubDate: "${pubDate}"`] : []), + `${this.settings.modifiedDateKey}: "${modifiedDate}"` + ]; + + const keysToReplace = [ + "title", + ...(includePubDate ? ["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; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..cb484b9 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "astro-post-exporter", + "name": "Astro Post Exporter", + "version": "0.1.0", + "minAppVersion": "1.4.0", + "description": "Export Obsidian notes to an Astro posts folder with frontmatter.", + "author": "astro-chiri", + "isDesktopOnly": true +}