blogs/obsidian-plugin/microblog-sync/main.js
Neeldhara Misra e7227844c7
Some checks failed
Build / build (push) Has been cancelled
Import Quarto posts and expand blog sync bridge
2026-06-20 04:01:13 +01:00

1575 lines
44 KiB
JavaScript

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 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);
this.updateStatusBar("Blog sync: published");
new Notice(
output.includes("No staged changes")
? `No ${target.label} changes to publish.`
: `${target.label} changes pushed.`,
);
}
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);
}