const { Notice, Plugin, PluginSettingTab, Setting } = require("obsidian"); const { spawn } = require("child_process"); const fs = require("fs/promises"); const nodeFs = require("fs"); const http = require("http"); const https = require("https"); const path = require("path"); const NODE_BIN = "/Users/neeldhara/.nvm/versions/node/v22.22.0/bin"; const NPM = `${NODE_BIN}/npm`; const BLOGS_ROOT = "/Users/neeldhara/forgejo/blogs"; const ALLBLOGS_REPO = `${BLOGS_ROOT}/allblogs`; const MICRO_REPO = `${BLOGS_ROOT}/micro`; const HOME_REPO = `${BLOGS_ROOT}/home`; const DEFAULT_TARGETS = [ { id: "micro", label: "Micro", vaultFolder: "blogs/micro", repoFolder: MICRO_REPO, appFolder: MICRO_REPO, contentFolder: `${MICRO_REPO}/src/content/blog`, previewPathPrefix: "/blog", devServerUrl: "http://127.0.0.1:4321", devServerCommand: `${NPM} run dev -- --host 127.0.0.1 --port 4321 --strictPort`, publishBuildCommand: `${NPM} run build`, publishCommitMessage: "Publish microblog updates", stageAllPaths: ["src/content/blog", "public/_redirects"], extraCurrentStagePaths: ["public/_redirects"], }, blogTarget("research", "Research", "01-research", 4331), blogTarget("vibes", "Vibes", "02-vibes", 4332), blogTarget("puzzles", "Puzzles", "03-puzzles", 4333), blogTarget("reflections", "Reflections", "04-reflections", 4334), blogTarget("poetry", "Poetry", "05-poetry", 4335), blogTarget("reviews", "Reviews", "06-reviews", 4336), blogTarget("art", "Art", "07-art", 4337), ]; const DEFAULT_SETTINGS = { syncDirection: "vault-to-repo", autoSyncOnChange: true, watchFilesystemChanges: true, pollForExternalChanges: true, pollIntervalMs: 3000, syncOnStartup: true, showStatusBar: true, ignoreDraftMarkdown: true, deleteOrphans: false, debounceMs: 1000, includeExtensions: "md,mdx,jpg,jpeg,png,gif,webp,svg,pdf,js,json,yml,yaml,css,html,txt", devServerShell: "/bin/zsh", commandPathPrepend: `${NODE_BIN}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`, devServerReadyTimeoutMs: 15000, openPreviewEvenIfServerStartFails: false, debugLogPath: ".obsidian/plugins/microblog-sync/debug.log", targets: DEFAULT_TARGETS, }; const MARKDOWN_EXTENSIONS = new Set([".md", ".mdx"]); module.exports = class BlogFamilySyncPlugin extends Plugin { async onload() { await this.loadSettings(); this.isSyncing = false; this.debounceTimers = new Map(); this.pollIntervalId = null; this.fileWatchers = []; this.devServers = new Map(); this.lastVaultSnapshot = null; this.statusBar = this.addStatusBarItem(); this.updateStatusBar("Blog sync: idle"); this.addRibbonIcon("refresh-cw", "Sync all blog content", () => { this.syncWithNotice(this.settings.syncDirection); }); this.addRibbonIcon( "external-link", "Sync and preview current blog note", () => { this.syncAndPreviewCurrentNote(); }, ); this.addCommand({ id: "blog-family-sync-all", name: "Sync all configured blogs", callback: () => this.syncWithNotice(this.settings.syncDirection), }); this.addCommand({ id: "blog-family-sync-current-vault-to-repo", name: "Sync current blog vault folder to repo", callback: () => this.syncCurrentTargetWithNotice("vault-to-repo"), }); this.addCommand({ id: "blog-family-sync-current-repo-to-vault", name: "Sync current blog repo folder to vault", callback: () => this.syncCurrentTargetWithNotice("repo-to-vault"), }); this.addCommand({ id: "blog-family-sync-current-two-way", name: "Two-way sync current blog", callback: () => this.syncCurrentTargetWithNotice("two-way"), }); this.addCommand({ id: "blog-family-preview-current", name: "Sync and preview current note", callback: () => this.syncAndPreviewCurrentNote(), }); this.addCommand({ id: "blog-family-publish-current-note", name: "Publish current note", callback: () => this.publishCurrentNote(), }); this.addCommand({ id: "blog-family-publish-current-blog", name: "Publish all changes for current blog", callback: () => this.publishCurrentBlog(), }); this.registerEvent( this.app.vault.on("create", (file) => this.scheduleAutoSync(file)), ); this.registerEvent( this.app.vault.on("modify", (file) => this.scheduleAutoSync(file)), ); this.registerEvent( this.app.vault.on("delete", (file) => this.scheduleAutoSync(file)), ); this.registerEvent( this.app.vault.on("rename", (file, oldPath) => this.scheduleAutoSync(file, oldPath), ), ); this.addSettingTab(new BlogFamilySyncSettingTab(this.app, this)); await this.resetPollSnapshot(); this.startPolling(); this.startFileWatchers(); if (this.settings.syncOnStartup) { await this.syncWithNotice(this.settings.syncDirection, true); await this.resetPollSnapshot(); } } onunload() { for (const timer of this.debounceTimers.values()) { window.clearTimeout(timer); } this.debounceTimers.clear(); if (this.pollIntervalId) { window.clearInterval(this.pollIntervalId); } for (const watcher of this.fileWatchers) { watcher.close(); } this.fileWatchers = []; for (const state of this.devServers.values()) { if (state.process && !state.process.killed) { state.process.kill(); } } this.devServers.clear(); } async loadSettings() { const saved = await this.loadData(); this.settings = Object.assign({}, DEFAULT_SETTINGS, saved || {}); if ( !Array.isArray(this.settings.targets) || !this.settings.targets.length ) { this.settings.targets = DEFAULT_TARGETS; } if ( !this.settings.includeExtensions || this.settings.includeExtensions === "md,mdx,jpg,jpeg,png,gif,webp,svg" ) { this.settings.includeExtensions = DEFAULT_SETTINGS.includeExtensions; } this.settings.syncDirection = normalizeDirection( this.settings.syncDirection, ); } async saveSettings() { await this.saveData(this.settings); } getTargets() { return this.settings.targets.map((target) => normalizeTarget(target)); } getTargetById(targetId) { return this.getTargets().find((target) => target.id === targetId); } getCurrentTarget() { const activePath = this.getActiveFilePath(); const target = this.getTargetForVaultPath(activePath); if (!target) { throw new Error( "Open a Markdown note under one of the configured blog folders first.", ); } return target; } getTargetForVaultPath(filePath) { if (!filePath) { return null; } const normalized = normalizeRelativePath(filePath); return ( this.getTargets().find((target) => { const folder = normalizeRelativePath(target.vaultFolder); return normalized === folder || normalized.startsWith(`${folder}/`); }) || null ); } startPolling() { if (this.pollIntervalId) { window.clearInterval(this.pollIntervalId); this.pollIntervalId = null; } if ( !this.settings.autoSyncOnChange || !this.settings.pollForExternalChanges ) { return; } const intervalMs = Math.max( 1000, Number(this.settings.pollIntervalMs) || DEFAULT_SETTINGS.pollIntervalMs, ); this.pollIntervalId = window.setInterval(() => { this.pollVaultFolders(); }, intervalMs); this.registerInterval(this.pollIntervalId); } startFileWatchers() { for (const watcher of this.fileWatchers) { watcher.close(); } this.fileWatchers = []; if ( !this.settings.autoSyncOnChange || !this.settings.watchFilesystemChanges ) { return; } const vaultRoot = this.getVaultRoot(); const extensions = getIncludedExtensions(this.settings.includeExtensions); for (const target of this.getTargets()) { try { const vaultFolder = path.resolve(vaultRoot, target.vaultFolder); const watcher = nodeFs.watch( vaultFolder, { recursive: true }, (_eventType, filename) => { if (!filename) { this.scheduleSync(target.id, true); return; } const relativePath = toPosix(String(filename)); if (isHiddenPath(relativePath)) { return; } const ext = path.extname(relativePath).toLowerCase(); if (extensions.has(ext)) { this.scheduleSync(target.id, true); } }, ); watcher.on("error", (error) => { console.warn( `Blog sync filesystem watcher failed for ${target.id}; polling remains active.`, error, ); }); this.fileWatchers.push(watcher); } catch (error) { console.warn( `Blog sync could not start filesystem watcher for ${target.id}; polling remains active.`, error, ); } } } async restartWatchers() { await this.resetPollSnapshot(); this.startPolling(); this.startFileWatchers(); } async resetPollSnapshot() { try { this.lastVaultSnapshot = await this.buildVaultSnapshot(); } catch (error) { console.warn("Blog sync could not initialize folder polling", error); this.lastVaultSnapshot = null; } } async pollVaultFolders() { if ( this.isSyncing || !this.settings.autoSyncOnChange || !this.settings.pollForExternalChanges ) { return; } try { const snapshot = await this.buildVaultSnapshot(); if (this.lastVaultSnapshot === null) { this.lastVaultSnapshot = snapshot; return; } if (snapshot !== this.lastVaultSnapshot) { this.lastVaultSnapshot = snapshot; await this.syncWithNotice(this.settings.syncDirection, true); this.lastVaultSnapshot = await this.buildVaultSnapshot(); } } catch (error) { console.error("Blog sync polling failed", error); } } async buildVaultSnapshot() { const vaultRoot = this.getVaultRoot(); const extensions = getIncludedExtensions(this.settings.includeExtensions); const parts = []; for (const target of this.getTargets()) { const vaultFolder = path.resolve(vaultRoot, target.vaultFolder); const entries = await collectFiles( vaultFolder, extensions, "vault", this.settings.ignoreDraftMarkdown, ); const snapshot = [...entries.values()] .sort((left, right) => left.logicalPath.localeCompare(right.logicalPath), ) .map( (entry) => `${target.id}:${entry.logicalPath}:${Math.round(entry.mtimeMs)}:${entry.size}`, ) .join("\n"); parts.push(snapshot); } return parts.join("\n"); } scheduleAutoSync(file, oldPath) { if (!this.settings.autoSyncOnChange || this.isSyncing) { return; } const target = this.getTargetForVaultPath(file?.path) || this.getTargetForVaultPath(oldPath); if (!target) { return; } this.scheduleSync(target.id, true); } scheduleSync(targetId, quiet = true) { if (!this.settings.autoSyncOnChange || this.isSyncing) { return; } if (this.debounceTimers.has(targetId)) { window.clearTimeout(this.debounceTimers.get(targetId)); } const timer = window.setTimeout(() => { const target = this.getTargetById(targetId); if (target) { this.syncWithNotice(this.settings.syncDirection, quiet, target); } this.debounceTimers.delete(targetId); }, this.settings.debounceMs); this.debounceTimers.set(targetId, timer); } async syncCurrentTargetWithNotice(direction) { const target = this.getCurrentTarget(); await this.syncWithNotice(direction, false, target); } async syncWithNotice(direction, quiet = false, target = null) { const normalizedDirection = normalizeDirection(direction); try { const label = target ? target.label : "all blogs"; this.updateStatusBar(`Blog sync: syncing ${label}...`); const result = await this.sync(normalizedDirection, target); const message = `Blog sync complete (${label}): ${result.copied} copied, ${result.deleted} deleted, ${result.skipped} unchanged.`; console.info(message, result); this.updateStatusBar( `Blog sync: ${result.copied} copied, ${result.skipped} unchanged`, ); await this.resetPollSnapshot(); if (!quiet) { new Notice(message); } } catch (error) { console.error("Blog sync failed", error); this.updateStatusBar("Blog sync: failed"); new Notice(`Blog sync failed: ${error.message}`); } } updateStatusBar(message) { if (!this.statusBar) { return; } if (!this.settings.showStatusBar) { this.statusBar.setText(""); return; } this.statusBar.setText(message); } async syncAndPreviewCurrentNote() { try { const activePath = this.getActiveFilePath(); const target = this.getCurrentTarget(); const url = this.getPreviewUrl(activePath, target); await this.logDebug( `Preview requested for ${target.id}:${activePath} -> ${url}`, ); this.updateStatusBar(`Blog sync: syncing ${target.label} for preview...`); await this.sync("vault-to-repo", target); await this.resetPollSnapshot(); this.updateStatusBar(`Blog sync: starting ${target.label} dev server...`); try { await this.ensureDevServer(target); } catch (error) { await this.logDebug( `Dev server failed for ${target.id}: ${error.stack || error.message}`, ); if (!this.settings.openPreviewEvenIfServerStartFails) { throw error; } } await openExternalUrl(url); await this.logDebug(`Opened preview URL: ${url}`); this.updateStatusBar("Blog sync: preview opened"); new Notice(`Opened preview: ${url}`); } catch (error) { console.error("Blog preview failed", error); await this.logDebug(`Preview failed: ${error.stack || error.message}`); this.updateStatusBar("Blog sync: preview failed"); new Notice( `Blog preview failed: ${error.message}. See ${this.getDebugLogPath()}`, ); } } async publishCurrentNote() { try { const activePath = this.getActiveFilePath(); const target = this.getCurrentTarget(); const slug = this.getSlugForVaultPath(activePath, target); if (!slug) { throw new Error( "Open a Markdown note under one of the configured blog folders first.", ); } const contentRelativePath = this.getContentRepoPath(activePath, target); const paths = [ contentRelativePath, ...(target.extraCurrentStagePaths || []), ]; const message = `${target.publishCommitMessage}: ${slug}`; await this.publish(target, paths, message); } catch (error) { console.error("Blog publish current note failed", error); this.updateStatusBar("Blog sync: publish failed"); new Notice(`Blog publish failed: ${error.message}`); } } async publishCurrentBlog() { try { const target = this.getCurrentTarget(); const paths = target.stageAllPaths || [ toPosix(path.relative(target.repoFolder, target.contentFolder)), ]; await this.publish(target, paths, target.publishCommitMessage); } catch (error) { console.error("Blog publish failed", error); this.updateStatusBar("Blog sync: publish failed"); new Notice(`Blog publish failed: ${error.message}`); } } async publish(target, pathsToStage, commitMessage) { this.updateStatusBar( `Blog sync: syncing ${target.label} before publish...`, ); await this.sync("vault-to-repo", target); await this.resetPollSnapshot(); const repoFolder = path.resolve(target.repoFolder); const buildCommand = target.publishBuildCommand; this.updateStatusBar(`Blog sync: building ${target.label}...`); await this.runShellCommand(buildCommand, repoFolder); const addTargets = [...new Set(pathsToStage)].map(shellQuote).join(" "); const commit = shellQuote(commitMessage || target.publishCommitMessage); const publishCommand = [ `git add ${addTargets}`, `if git diff --cached --quiet; then echo "No staged changes to publish."; else git commit -m ${commit}; fi`, "git push", ].join(" && "); this.updateStatusBar(`Blog sync: pushing ${target.label}...`); const output = await this.runShellCommand(publishCommand, repoFolder); let homeOutput = ""; if (path.resolve(target.repoFolder) === path.resolve(ALLBLOGS_REPO)) { homeOutput = await this.publishHomeIndex(); } this.updateStatusBar("Blog sync: published"); new Notice( [ output.includes("No staged changes") ? `No ${target.label} changes to publish.` : `${target.label} changes pushed.`, homeOutput ? homeOutput.includes("No homepage index changes") ? "Homepage index unchanged." : "Homepage index pushed." : "", ] .filter(Boolean) .join(" "), ); } async publishHomeIndex() { this.updateStatusBar("Blog sync: refreshing homepage index..."); const command = [ `${shellQuote(NPM)} run refresh:index`, "git add content/blog-index.json", 'if git diff --cached --quiet; then echo "No homepage index changes to publish."; else git commit -m "Refresh homepage blog index"; fi', "git push", ].join(" && "); return await this.runShellCommand(command, HOME_REPO); } async ensureDevServer(target) { const baseUrl = normalizeBaseUrl(target.devServerUrl); if (await urlResponds(baseUrl)) { return; } const existing = this.devServers.get(target.id); if (existing?.process && !existing.process.killed) { try { await waitForUrl(baseUrl, 3000, () => existing.output); return; } catch (_) { existing.process.kill(); this.devServers.delete(target.id); } } const cwd = path.resolve(target.appFolder || target.repoFolder); const shell = this.settings.devServerShell || DEFAULT_SETTINGS.devServerShell; const command = target.devServerCommand; await this.logDebug( `Starting dev server for ${target.id} with shell=${shell}, cwd=${cwd}, command=${command}, PATH=${this.getCommandEnv().PATH}`, ); const state = { process: null, output: "" }; let exited = false; let exitCode = null; state.process = spawn(shell, ["-lc", command], { cwd, env: this.getCommandEnv(), stdio: ["ignore", "pipe", "pipe"], }); this.devServers.set(target.id, state); state.process.stdout.on("data", (chunk) => { state.output = trimOutput(`${state.output}${chunk.toString()}`); }); state.process.stderr.on("data", (chunk) => { state.output = trimOutput(`${state.output}${chunk.toString()}`); }); state.process.on("exit", (code) => { exited = true; exitCode = code; if (code !== 0 && code !== null) { console.warn("Blog dev server exited", target.id, code, state.output); this.logDebug( `Dev server exited for ${target.id} with code ${code}: ${state.output}`, ); } this.devServers.delete(target.id); }); await waitForUrl( baseUrl, Number(this.settings.devServerReadyTimeoutMs) || DEFAULT_SETTINGS.devServerReadyTimeoutMs, () => { if (exited) { return `Dev server exited with code ${exitCode}.\n${state.output}`; } return state.output; }, () => exited, ); } async runShellCommand(command, cwd) { const shell = this.settings.devServerShell || DEFAULT_SETTINGS.devServerShell; return await new Promise((resolve, reject) => { let output = ""; const child = spawn(shell, ["-lc", command], { cwd, env: this.getCommandEnv(), stdio: ["ignore", "pipe", "pipe"], }); child.stdout.on("data", (chunk) => { output = trimOutput(`${output}${chunk.toString()}`); }); child.stderr.on("data", (chunk) => { output = trimOutput(`${output}${chunk.toString()}`); }); child.on("error", reject); child.on("exit", (code) => { if (code === 0) { resolve(output); return; } reject( new Error(`${command} failed with exit code ${code}\n${output}`), ); }); }); } getDebugLogPath() { const configuredPath = this.settings.debugLogPath || DEFAULT_SETTINGS.debugLogPath; if (path.isAbsolute(configuredPath)) { return configuredPath; } return path.join(this.getVaultRoot(), configuredPath); } getCommandEnv() { const pathPrepend = this.settings.commandPathPrepend || DEFAULT_SETTINGS.commandPathPrepend; const existingPath = process.env.PATH || "/usr/bin:/bin:/usr/sbin:/sbin"; const commandPath = pathPrepend ? `${pathPrepend}:${existingPath}` : existingPath; return { ...process.env, PATH: commandPath, }; } async logDebug(message) { try { const logPath = this.getDebugLogPath(); await fs.mkdir(path.dirname(logPath), { recursive: true }); await fs.appendFile( logPath, `[${new Date().toISOString()}] ${message}\n`, ); } catch (error) { console.warn("Blog sync could not write debug log", error); } } getActiveFilePath() { const file = this.app.workspace.getActiveFile(); if (!file) { throw new Error("Open a blog note first."); } return file.path; } getPreviewUrl(filePath, target) { const slug = this.getSlugForVaultPath(filePath, target); const baseUrl = normalizeBaseUrl(target.devServerUrl); if (!slug) { return `${baseUrl}/`; } const prefix = normalizeUrlPath(target.previewPathPrefix || ""); return `${baseUrl}${prefix}/${slug}/`; } getContentRepoPath(filePath, target) { const folder = normalizeRelativePath(target.vaultFolder); const normalized = normalizeRelativePath(filePath); if (normalized !== folder && !normalized.startsWith(`${folder}/`)) { throw new Error( `Open a Markdown note under ${target.vaultFolder} first.`, ); } const relativePath = normalized.slice(folder.length + 1); const logicalPath = vaultToLogicalPath(relativePath); const contentFolder = path.resolve(target.contentFolder); const repoFolder = path.resolve(target.repoFolder); return toPosix( path.relative( repoFolder, path.join(contentFolder, fromPosix(logicalPath)), ), ); } getSlugForVaultPath(filePath, target) { const folder = normalizeRelativePath(target.vaultFolder); const normalized = normalizeRelativePath(filePath); if (normalized !== folder && !normalized.startsWith(`${folder}/`)) { return null; } const relativePath = normalized.slice(folder.length + 1); const ext = path.extname(relativePath).toLowerCase(); if (!MARKDOWN_EXTENSIONS.has(ext)) { return null; } const withoutExt = toPosix(relativePath).slice(0, -ext.length); if (withoutExt.endsWith("/index")) { return withoutExt.slice(0, -"/index".length); } return withoutExt; } async sync(direction, target = null) { if (this.isSyncing) { return { copied: 0, deleted: 0, skipped: 0 }; } this.isSyncing = true; try { const targets = target ? [target] : this.getTargets(); const total = { copied: 0, deleted: 0, skipped: 0 }; for (const currentTarget of targets) { const result = await this.syncTarget(direction, currentTarget); total.copied += result.copied; total.deleted += result.deleted; total.skipped += result.skipped; } return total; } finally { this.isSyncing = false; } } async syncTarget(direction, target) { const vaultRoot = this.getVaultRoot(); const vaultFolder = path.resolve(vaultRoot, target.vaultFolder); const contentFolder = path.resolve(target.contentFolder); const extensions = getIncludedExtensions(this.settings.includeExtensions); await fs.mkdir(vaultFolder, { recursive: true }); await fs.mkdir(contentFolder, { recursive: true }); const vaultEntries = await collectFiles( vaultFolder, extensions, "vault", this.settings.ignoreDraftMarkdown, ); const contentEntries = await collectFiles( contentFolder, extensions, "content", this.settings.ignoreDraftMarkdown, ); if (direction === "repo-to-vault") { return await syncOneWay( contentEntries, vaultEntries, contentFolder, vaultFolder, this.settings.deleteOrphans, ); } if (direction === "two-way") { return await syncTwoWay( vaultEntries, contentEntries, vaultFolder, contentFolder, ); } return await syncOneWay( vaultEntries, contentEntries, vaultFolder, contentFolder, this.settings.deleteOrphans, ); } getVaultRoot() { const adapter = this.app.vault.adapter; if (!adapter || typeof adapter.getBasePath !== "function") { throw new Error( "This plugin needs Obsidian's desktop filesystem adapter.", ); } return adapter.getBasePath(); } }; class BlogFamilySyncSettingTab extends PluginSettingTab { constructor(app, plugin) { super(app, plugin); this.plugin = plugin; } display() { const { containerEl } = this; containerEl.empty(); containerEl.createEl("h2", { text: "Blog Family Sync" }); const targetList = containerEl.createEl("div"); targetList.createEl("p", { text: "Configured blog folders:", }); const ul = targetList.createEl("ul"); for (const target of this.plugin.getTargets()) { ul.createEl("li", { text: `${target.label}: ${target.vaultFolder} -> ${target.contentFolder}`, }); } new Setting(containerEl) .setName("Default sync direction") .setDesc("Use Vault -> repo if Obsidian is the source of truth.") .addDropdown((dropdown) => dropdown .addOption("vault-to-repo", "Vault -> repo") .addOption("repo-to-vault", "Repo -> vault") .addOption("two-way", "Two-way, newest file wins") .setValue(this.plugin.settings.syncDirection) .onChange(async (value) => { this.plugin.settings.syncDirection = value; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName("Auto-sync on vault changes") .setDesc( "When enabled, changes under any configured blog folder trigger sync for that blog.", ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.autoSyncOnChange) .onChange(async (value) => { this.plugin.settings.autoSyncOnChange = value; await this.plugin.saveSettings(); await this.plugin.restartWatchers(); }), ); new Setting(containerEl) .setName("Watch filesystem changes") .setDesc("Uses the desktop filesystem watcher to catch changes quickly.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.watchFilesystemChanges) .onChange(async (value) => { this.plugin.settings.watchFilesystemChanges = value; await this.plugin.saveSettings(); await this.plugin.restartWatchers(); }), ); new Setting(containerEl) .setName("Poll for external file changes") .setDesc("Catches edits made outside Obsidian.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.pollForExternalChanges) .onChange(async (value) => { this.plugin.settings.pollForExternalChanges = value; await this.plugin.saveSettings(); await this.plugin.restartWatchers(); }), ); new Setting(containerEl) .setName("Polling interval") .setDesc("Milliseconds between scans. Minimum is 1000.") .addText((text) => text .setPlaceholder(String(DEFAULT_SETTINGS.pollIntervalMs)) .setValue(String(this.plugin.settings.pollIntervalMs)) .onChange(async (value) => { this.plugin.settings.pollIntervalMs = Number(value) || DEFAULT_SETTINGS.pollIntervalMs; await this.plugin.saveSettings(); await this.plugin.restartWatchers(); }), ); new Setting(containerEl) .setName("Sync once when plugin loads") .setDesc("Useful when Obsidian starts after files changed on disk.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.syncOnStartup) .onChange(async (value) => { this.plugin.settings.syncOnStartup = value; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName("Show status bar item") .setDesc("Shows the latest sync status at the bottom of Obsidian.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.showStatusBar) .onChange(async (value) => { this.plugin.settings.showStatusBar = value; await this.plugin.saveSettings(); this.plugin.updateStatusBar("Blog sync: idle"); }), ); new Setting(containerEl) .setName("Ignore draft Markdown") .setDesc( "Skips empty Markdown files and Obsidian's default Untitled drafts.", ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.ignoreDraftMarkdown) .onChange(async (value) => { this.plugin.settings.ignoreDraftMarkdown = value; await this.plugin.saveSettings(); await this.plugin.restartWatchers(); }), ); new Setting(containerEl) .setName("Delete orphaned target files") .setDesc( "Only applies to one-way sync. Leave this off unless you want deletions mirrored.", ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.deleteOrphans) .onChange(async (value) => { this.plugin.settings.deleteOrphans = value; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName("Included extensions") .setDesc("Comma-separated extensions synced as content/assets.") .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.includeExtensions) .setValue(this.plugin.settings.includeExtensions) .onChange(async (value) => { this.plugin.settings.includeExtensions = value || DEFAULT_SETTINGS.includeExtensions; await this.plugin.saveSettings(); }), ); containerEl.createEl("h3", { text: "Preview and Publish" }); new Setting(containerEl) .setName("Dev server ready timeout") .setDesc( "Milliseconds to wait for local Astro before reporting captured startup output.", ) .addText((text) => text .setPlaceholder(String(DEFAULT_SETTINGS.devServerReadyTimeoutMs)) .setValue(String(this.plugin.settings.devServerReadyTimeoutMs)) .onChange(async (value) => { this.plugin.settings.devServerReadyTimeoutMs = Number(value) || DEFAULT_SETTINGS.devServerReadyTimeoutMs; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName("Open preview even if server start fails") .setDesc("Useful only if you usually start the Astro server yourself.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.openPreviewEvenIfServerStartFails) .onChange(async (value) => { this.plugin.settings.openPreviewEvenIfServerStartFails = value; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName("Debug log path") .setDesc("Relative to the vault unless absolute.") .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.debugLogPath) .setValue(this.plugin.settings.debugLogPath) .onChange(async (value) => { this.plugin.settings.debugLogPath = value || DEFAULT_SETTINGS.debugLogPath; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName("Shell") .setDesc("Shell used to run dev and publish commands.") .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.devServerShell) .setValue(this.plugin.settings.devServerShell) .onChange(async (value) => { this.plugin.settings.devServerShell = value || DEFAULT_SETTINGS.devServerShell; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName("Command PATH prefix") .setDesc("Prepended to PATH when running npm/git commands from Obsidian.") .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.commandPathPrepend) .setValue(this.plugin.settings.commandPathPrepend) .onChange(async (value) => { this.plugin.settings.commandPathPrepend = value || DEFAULT_SETTINGS.commandPathPrepend; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName("Sync all now") .setDesc("Run the configured sync direction for every configured blog.") .addButton((button) => button .setButtonText("Sync all") .setCta() .onClick(() => { this.plugin.syncWithNotice(this.plugin.settings.syncDirection); }), ); new Setting(containerEl) .setName("Preview current note") .setDesc( "Sync, start the matching local Astro dev server if needed, and open the current post.", ) .addButton((button) => button.setButtonText("Sync and preview").onClick(() => { this.plugin.syncAndPreviewCurrentNote(); }), ); new Setting(containerEl) .setName("Publish current note") .setDesc("Sync, build, commit the current post, and push.") .addButton((button) => button.setButtonText("Publish current").onClick(() => { this.plugin.publishCurrentNote(); }), ); new Setting(containerEl) .setName("Publish current blog") .setDesc( "Sync, build, commit all content changes for the current blog, and push.", ) .addButton((button) => button.setButtonText("Publish blog").onClick(() => { this.plugin.publishCurrentBlog(); }), ); } } function blogTarget(id, label, vaultFolder, port) { return { id, label, vaultFolder: `blogs/${vaultFolder}`, repoFolder: ALLBLOGS_REPO, appFolder: `${ALLBLOGS_REPO}/sites/${id}`, contentFolder: `${ALLBLOGS_REPO}/sites/${id}/src/content/${id}`, previewPathPrefix: "", devServerUrl: `http://127.0.0.1:${port}`, devServerCommand: `${NPM} run dev -- --host 127.0.0.1 --port ${port} --strictPort`, publishBuildCommand: `${NPM} run build --prefix sites/${id}`, publishCommitMessage: `Publish ${label.toLowerCase()} blog updates`, stageAllPaths: [`sites/${id}/src/content/${id}`], extraCurrentStagePaths: [], }; } function normalizeTarget(target) { const defaults = DEFAULT_TARGETS.find((candidate) => candidate.id === target.id) || {}; return Object.assign({}, defaults, target); } async function collectFiles(root, extensions, side, ignoreDraftMarkdown) { const files = await walk(root); const entries = new Map(); for (const absolutePath of files) { const relativePath = toPosix(path.relative(root, absolutePath)); if (isHiddenPath(relativePath)) { continue; } const ext = path.extname(relativePath).toLowerCase(); if (!extensions.has(ext)) { continue; } const stat = await fs.stat(absolutePath); if ( ignoreDraftMarkdown && MARKDOWN_EXTENSIONS.has(ext) && shouldSkipDraftMarkdown(relativePath, stat) ) { continue; } const logicalPath = side === "vault" ? vaultToLogicalPath(relativePath) : toPosix(relativePath); entries.set(logicalPath, { absolutePath, relativePath, logicalPath, mtimeMs: stat.mtimeMs, size: stat.size, ext, }); } return entries; } async function walk(root) { let dirents = []; try { dirents = await fs.readdir(root, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") { return []; } throw error; } const paths = []; for (const dirent of dirents) { const absolutePath = path.join(root, dirent.name); if (dirent.isDirectory()) { paths.push(...(await walk(absolutePath))); } else if (dirent.isFile()) { paths.push(absolutePath); } } return paths; } async function syncOneWay( sourceEntries, targetEntries, sourceRoot, targetRoot, deleteOrphans, ) { const result = { copied: 0, deleted: 0, skipped: 0 }; for (const [logicalPath, source] of sourceEntries) { const targetRelativePath = targetPathForLogical( logicalPath, targetRoot, targetEntries, ); const targetAbsolutePath = path.join( targetRoot, fromPosix(targetRelativePath), ); const target = targetEntries.get(logicalPath); if ( target && (await sameFileContent(source.absolutePath, target.absolutePath)) ) { result.skipped += 1; continue; } await copyFile(source.absolutePath, targetAbsolutePath, source.mtimeMs); result.copied += 1; } if (deleteOrphans) { for (const [logicalPath, target] of targetEntries) { if (!sourceEntries.has(logicalPath)) { await fs.rm(target.absolutePath, { force: true }); await removeEmptyParents(path.dirname(target.absolutePath), targetRoot); result.deleted += 1; } } } return result; } async function syncTwoWay( vaultEntries, contentEntries, vaultRoot, contentRoot, ) { const result = { copied: 0, deleted: 0, skipped: 0 }; const logicalPaths = new Set([ ...vaultEntries.keys(), ...contentEntries.keys(), ]); for (const logicalPath of logicalPaths) { const vault = vaultEntries.get(logicalPath); const content = contentEntries.get(logicalPath); if (vault && content) { if (await sameFileContent(vault.absolutePath, content.absolutePath)) { result.skipped += 1; continue; } if (vault.mtimeMs >= content.mtimeMs) { await copyFile( vault.absolutePath, path.join(contentRoot, fromPosix(logicalPath)), vault.mtimeMs, ); } else { const targetRelativePath = targetPathForLogical( logicalPath, vaultRoot, vaultEntries, ); await copyFile( content.absolutePath, path.join(vaultRoot, fromPosix(targetRelativePath)), content.mtimeMs, ); } result.copied += 1; continue; } if (vault) { await copyFile( vault.absolutePath, path.join(contentRoot, fromPosix(logicalPath)), vault.mtimeMs, ); result.copied += 1; continue; } if (content) { const targetRelativePath = targetPathForLogical( logicalPath, vaultRoot, vaultEntries, ); await copyFile( content.absolutePath, path.join(vaultRoot, fromPosix(targetRelativePath)), content.mtimeMs, ); result.copied += 1; } } return result; } function vaultToLogicalPath(relativePath) { const normalized = toPosix(relativePath); const ext = path.extname(normalized).toLowerCase(); if (!MARKDOWN_EXTENSIONS.has(ext)) { return normalized; } if (!normalized.includes("/")) { const slug = normalized.slice(0, -ext.length); return `${slug}/index${ext}`; } return normalized; } function targetPathForLogical(logicalPath, targetRoot, targetEntries) { const ext = path.extname(logicalPath).toLowerCase(); if (!MARKDOWN_EXTENSIONS.has(ext)) { return logicalPath; } const match = logicalPath.match(/^([^/]+)\/index(\.mdx?)$/); if (!match) { return logicalPath; } const flatPath = `${match[1]}${match[2]}`; const existingFlat = path.join(targetRoot, fromPosix(flatPath)); if ( targetEntries.has(logicalPath) && targetEntries.get(logicalPath).relativePath === flatPath ) { return flatPath; } try { const stat = nodeFs.statSync(existingFlat); if (stat.isFile()) { return flatPath; } } catch (_) { // Fall through to folder/index form. } return logicalPath; } async function copyFile(source, target, mtimeMs) { await fs.mkdir(path.dirname(target), { recursive: true }); await fs.copyFile(source, target); const mtime = new Date(mtimeMs); await fs.utimes(target, mtime, mtime); } async function sameFileContent(left, right) { try { const [leftBuffer, rightBuffer] = await Promise.all([ fs.readFile(left), fs.readFile(right), ]); return leftBuffer.equals(rightBuffer); } catch (error) { if (error.code === "ENOENT") { return false; } throw error; } } async function removeEmptyParents(start, stop) { let current = path.resolve(start); const stopAt = path.resolve(stop); while (current.startsWith(stopAt) && current !== stopAt) { try { await fs.rmdir(current); } catch (_) { return; } current = path.dirname(current); } } function getIncludedExtensions(value) { const extensions = new Set(); for (const part of String(value || "").split(",")) { const cleaned = part.trim().toLowerCase(); if (!cleaned) { continue; } extensions.add(cleaned.startsWith(".") ? cleaned : `.${cleaned}`); } return extensions; } async function openExternalUrl(url) { try { const electron = require("electron"); if (electron?.shell?.openExternal) { await electron.shell.openExternal(url); return; } } catch (_) { // Fall back to macOS open below. } const child = spawn("/usr/bin/open", [url], { detached: true, stdio: "ignore", }); child.unref(); } async function waitForUrl(url, timeoutMs, getOutput, shouldAbort) { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (await urlResponds(url)) { return; } if (typeof shouldAbort === "function" && shouldAbort()) { const output = typeof getOutput === "function" ? getOutput() : ""; throw new Error( `Dev server stopped before ${url} was ready.${output ? `\n${output}` : ""}`, ); } await delay(500); } const output = typeof getOutput === "function" ? getOutput() : ""; throw new Error( `Timed out waiting for ${url}.${output ? `\n${output}` : ""}`, ); } function urlResponds(url) { return new Promise((resolve) => { let parsed; try { parsed = new URL(url); } catch (_) { resolve(false); return; } const client = parsed.protocol === "https:" ? https : http; const request = client.request( { hostname: parsed.hostname, port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), path: `${parsed.pathname}${parsed.search}`, method: "GET", timeout: 1200, }, (response) => { response.resume(); resolve(response.statusCode >= 200 && response.statusCode < 500); }, ); request.on("timeout", () => { request.destroy(); resolve(false); }); request.on("error", () => resolve(false)); request.end(); }); } function delay(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function normalizeBaseUrl(value) { return String(value || "").replace(/\/+$/, ""); } function normalizeUrlPath(value) { const cleaned = String(value || "").replace(/^\/+|\/+$/g, ""); return cleaned ? `/${cleaned}` : ""; } function normalizeDirection(value) { if (value === "vault-to-micro") { return "vault-to-repo"; } if (value === "micro-to-vault") { return "repo-to-vault"; } if (value === "repo-to-vault" || value === "two-way") { return value; } return "vault-to-repo"; } function shellQuote(value) { return `'${String(value).replace(/'/g, "'\\''")}'`; } function trimOutput(value) { const maxLength = 20000; if (value.length <= maxLength) { return value; } return value.slice(value.length - maxLength); } function normalizeRelativePath(value) { return String(value || "") .replace(/\\/g, "/") .replace(/^\/+/, "") .replace(/\/+$/, ""); } function isHiddenPath(relativePath) { return relativePath.startsWith(".") || relativePath.includes("/."); } function shouldSkipDraftMarkdown(relativePath, stat) { const normalized = toPosix(relativePath); if (stat.size === 0) { return true; } return /(^|\/)Untitled(\.mdx?|\/index\.mdx?)$/i.test(normalized); } function toPosix(value) { return value.split(path.sep).join("/"); } function fromPosix(value) { return value.split("/").join(path.sep); }