1603 lines
45 KiB
JavaScript
1603 lines
45 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 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);
|
|
}
|