feat: add syndication dialog for cross-posting targets

Shows a modal before publishing to let users choose which syndication
targets (e.g. Mastodon, Twitter) to cross-post to, with configurable
behaviour: always show, show when needed, or never show.

- SyndicationDialog: new Modal with per-target toggles, resolves via promise
- Publisher: accept syndicateToOverride, write mp-syndicate-to to frontmatter
- SettingsTab: add showSyndicationDialog dropdown + default targets display
- main.ts: fetch ?q=config, read frontmatter, show dialog per setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-31 00:03:55 +02:00
6 changed files with 293 additions and 22 deletions
+11 -8
View File
File diff suppressed because one or more lines are too long
+54 -12
View File
@@ -35,7 +35,7 @@ export class Publisher {
} }
/** Publish the given file. Returns PublishResult. */ /** Publish the given file. Returns PublishResult. */
async publish(file: TFile): Promise<PublishResult> { async publish(file: TFile, syndicateToOverride?: string[]): Promise<PublishResult> {
const raw = await this.app.vault.read(file); const raw = await this.app.vault.read(file);
const { frontmatter, body } = this.parseFrontmatter(raw); const { frontmatter, body } = this.parseFrontmatter(raw);
@@ -53,7 +53,7 @@ export class Publisher {
const linkedBody = this.resolveWikilinks(processedBody, file.path); const linkedBody = this.resolveWikilinks(processedBody, file.path);
// Build Micropub properties // Build Micropub properties
const properties = this.buildProperties(frontmatter, linkedBody, uploadedUrls, file.basename, file.path); const properties = this.buildProperties(frontmatter, linkedBody, uploadedUrls, file.basename, file.path, syndicateToOverride);
let result: PublishResult; let result: PublishResult;
@@ -69,9 +69,14 @@ export class Publisher {
result = await this.client.createPost(properties); result = await this.client.createPost(properties);
} }
// Write URL back to frontmatter // Write URL (and syndication targets) back to frontmatter
if (result.success && result.url && this.settings.writeUrlToFrontmatter) { if (result.success && this.settings.writeUrlToFrontmatter) {
await this.writeUrlToNote(file, raw, result.url); if (result.url) {
await this.writeUrlToNote(file, raw, result.url, syndicateToOverride);
} else if (syndicateToOverride !== undefined) {
// No URL returned but we still want to record the syndication targets
await this.writeSyndicateToNote(file, raw, syndicateToOverride);
}
} }
return result; return result;
@@ -85,6 +90,7 @@ export class Publisher {
uploadedUrls: string[], uploadedUrls: string[],
basename: string, basename: string,
filePath: string, filePath: string,
syndicateToOverride?: string[],
): Record<string, unknown> { ): Record<string, unknown> {
const props: Record<string, unknown> = {}; const props: Record<string, unknown> = {};
@@ -176,13 +182,17 @@ export class Publisher {
} }
// Syndication targets // Syndication targets
// Support both camelCase (mpSyndicateTo) used in existing blog posts and mp-syndicate-to // When the dialog was shown, syndicateToOverride contains the user's selection
const syndicateTo = this.resolveArray( // and takes precedence over frontmatter + settings defaults.
fm["mp-syndicate-to"] ?? fm["mpSyndicateTo"], // Support both camelCase (mpSyndicateTo) used in existing blog posts and mp-syndicate-to.
); const allSyndicateTo = syndicateToOverride !== undefined
const allSyndicateTo = [ ? syndicateToOverride
...new Set([...this.settings.defaultSyndicateTo, ...syndicateTo]), : [
]; ...new Set([
...this.settings.defaultSyndicateTo,
...this.resolveArray(fm["mp-syndicate-to"] ?? fm["mpSyndicateTo"]),
]),
];
if (allSyndicateTo.length > 0) { if (allSyndicateTo.length > 0) {
props["mp-syndicate-to"] = allSyndicateTo; props["mp-syndicate-to"] = allSyndicateTo;
} }
@@ -375,6 +385,7 @@ export class Publisher {
file: TFile, file: TFile,
originalContent: string, originalContent: string,
url: string, url: string,
syndicateToOverride?: string[],
): Promise<void> { ): Promise<void> {
// Build all fields to write back after a successful publish // Build all fields to write back after a successful publish
const now = new Date(); const now = new Date();
@@ -390,6 +401,11 @@ export class Publisher {
["published", publishedDate], ["published", publishedDate],
]; ];
// Record the syndication targets used so future publishes know what was sent
if (syndicateToOverride !== undefined) {
fields.push(["mp-syndicate-to", `[${syndicateToOverride.join(", ")}]`]);
}
if (this.settings.siteUrl) { if (this.settings.siteUrl) {
try { try {
const hostname = new URL(this.settings.siteUrl).hostname.replace(/^www\./, ""); const hostname = new URL(this.settings.siteUrl).hostname.replace(/^www\./, "");
@@ -437,6 +453,32 @@ export class Publisher {
await this.app.vault.modify(file, fmBlock + body); await this.app.vault.modify(file, fmBlock + body);
} }
/**
* Write mp-syndicate-to to frontmatter without touching other fields.
* Used when publish succeeds but returns no URL (e.g. update responses).
*/
private async writeSyndicateToNote(
file: TFile,
originalContent: string,
syndicateTo: string[],
): Promise<void> {
const fmMatch = originalContent.match(
/^(---\r?\n[\s\S]*?\r?\n---\r?\n)([\s\S]*)$/,
);
const value = `[${syndicateTo.join(", ")}]`;
if (!fmMatch) {
await this.app.vault.modify(
file,
`---\nmp-syndicate-to: ${value}\n---\n` + originalContent,
);
return;
}
const fmBlock = this.setFrontmatterField(fmMatch[1], "mp-syndicate-to", value);
await this.app.vault.modify(file, fmBlock + fmMatch[2]);
}
/** /**
* Replace the value of an existing frontmatter field, or insert it before * Replace the value of an existing frontmatter field, or insert it before
* the closing `---` if the field is not yet present. * the closing `---` if the field is not yet present.
+43
View File
@@ -110,6 +110,49 @@ export class MicropubSettingsTab extends PluginSettingTab {
}), }),
); );
new Setting(containerEl)
.setName("Syndication dialog")
.setDesc(
"When to show the cross-posting dialog before publishing. " +
"'When needed' shows it only if the note has no mp-syndicate-to frontmatter.",
)
.addDropdown((drop) =>
drop
.addOption("when-needed", "When needed")
.addOption("always", "Always")
.addOption("never", "Never")
.setValue(this.plugin.settings.showSyndicationDialog)
.onChange(async (value) => {
this.plugin.settings.showSyndicationDialog = value as
| "when-needed"
| "always"
| "never";
await this.plugin.saveSettings();
}),
);
// Show configured defaults with a clear button
const defaults = this.plugin.settings.defaultSyndicateTo;
const defaultsSetting = new Setting(containerEl)
.setName("Default syndication targets")
.setDesc(
defaults.length > 0
? defaults.join(", ")
: "None configured. Targets checked by default in the publish dialog.",
);
if (defaults.length > 0) {
defaultsSetting.addButton((btn) =>
btn
.setButtonText("Clear defaults")
.setWarning()
.onClick(async () => {
this.plugin.settings.defaultSyndicateTo = [];
await this.plugin.saveSettings();
this.display();
}),
);
}
// ── Digital Garden ─────────────────────────────────────────────────── // ── Digital Garden ───────────────────────────────────────────────────
containerEl.createEl("h3", { text: "Digital Garden" }); containerEl.createEl("h3", { text: "Digital Garden" });
+89
View File
@@ -0,0 +1,89 @@
/**
* SyndicationDialog.ts
*
* Modal that lets the user choose which syndication targets to cross-post to.
* Opens as a promise — resolves with the selected UIDs, or null if cancelled.
*/
import { App, Modal, Setting } from "obsidian";
import type { SyndicationTarget } from "./types";
export class SyndicationDialog extends Modal {
private selected: Set<string>;
private resolvePromise: ((value: string[] | null) => void) | null = null;
private resolved = false;
constructor(
app: App,
private readonly targets: SyndicationTarget[],
defaultSelected: string[],
) {
super(app);
this.selected = new Set(defaultSelected.filter((uid) =>
targets.some((t) => t.uid === uid),
));
}
/**
* Opens the dialog and waits for user selection.
* @returns Selected target UIDs, or null if cancelled.
*/
async awaitSelection(): Promise<string[] | null> {
return new Promise((resolve) => {
this.resolvePromise = resolve;
this.open();
});
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h2", { text: "Syndication targets" });
contentEl.createEl("p", {
text: "Choose where to cross-post this note.",
cls: "setting-item-description",
});
for (const target of this.targets) {
new Setting(contentEl)
.setName(target.name)
.addToggle((toggle) =>
toggle
.setValue(this.selected.has(target.uid))
.onChange((value) => {
if (value) this.selected.add(target.uid);
else this.selected.delete(target.uid);
}),
);
}
new Setting(contentEl)
.addButton((btn) =>
btn
.setButtonText("Cancel")
.onClick(() => {
this.finish(null);
}),
)
.addButton((btn) =>
btn
.setButtonText("Publish")
.setCta()
.onClick(() => {
this.finish([...this.selected]);
}),
);
}
onClose(): void {
// Resolve as cancelled if user pressed Escape or clicked outside
this.finish(null);
this.contentEl.empty();
}
private finish(value: string[] | null): void {
if (this.resolved) return;
this.resolved = true;
this.resolvePromise?.(value);
this.resolvePromise = null;
}
}
+87 -2
View File
@@ -15,10 +15,12 @@
* Based on: https://github.com/svemagie/obsidian-microblog (MIT) * Based on: https://github.com/svemagie/obsidian-microblog (MIT)
*/ */
import { Notice, Plugin, TFile } from "obsidian"; import { Notice, Plugin, TFile, parseYaml } from "obsidian";
import { DEFAULT_SETTINGS, type MicropubSettings } from "./types"; import { DEFAULT_SETTINGS, type MicropubSettings } from "./types";
import { MicropubSettingsTab } from "./SettingsTab"; import { MicropubSettingsTab } from "./SettingsTab";
import { Publisher } from "./Publisher"; import { Publisher } from "./Publisher";
import { MicropubClient } from "./MicropubClient";
import { SyndicationDialog } from "./SyndicationDialog";
import { handleProtocolCallback } from "./IndieAuth"; import { handleProtocolCallback } from "./IndieAuth";
export default class MicropubPlugin extends Plugin { export default class MicropubPlugin extends Plugin {
@@ -101,11 +103,19 @@ export default class MicropubPlugin extends Plugin {
return; return;
} }
// ── Syndication dialog ────────────────────────────────────────────────
// Determine which syndication targets to use, optionally showing a dialog.
const syndicateToOverride = await this.resolveSyndicationTargets(file);
if (syndicateToOverride === null) {
// User cancelled the dialog — abort publish
return;
}
const notice = new Notice("Publishing…", 0 /* persist until dismissed */); const notice = new Notice("Publishing…", 0 /* persist until dismissed */);
try { try {
const publisher = new Publisher(this.app, this.settings); const publisher = new Publisher(this.app, this.settings);
const result = await publisher.publish(file); const result = await publisher.publish(file, syndicateToOverride);
notice.hide(); notice.hide();
@@ -126,6 +136,81 @@ export default class MicropubPlugin extends Plugin {
} }
} }
/**
* Decide whether to show the syndication dialog and return the selected targets.
*
* Returns:
* string[] — targets to use as override (may be empty)
* undefined — no override; Publisher will use frontmatter + settings defaults
* null — user cancelled; abort publish
*/
private async resolveSyndicationTargets(
file: TFile,
): Promise<string[] | undefined | null> {
const dialogSetting = this.settings.showSyndicationDialog;
// "never" — skip dialog entirely, let Publisher handle targets from frontmatter + settings
if (dialogSetting === "never") return undefined;
// Fetch available targets from the server
let availableTargets: import("./types").SyndicationTarget[] = [];
try {
const client = new MicropubClient(
() => this.settings.micropubEndpoint,
() => this.settings.mediaEndpoint,
() => this.settings.accessToken,
);
const config = await client.fetchConfig();
availableTargets = config["syndicate-to"] ?? [];
} catch {
// Config fetch failed — fall back to normal publish without dialog
new Notice(
"⚠️ Could not fetch syndication targets. Publishing without dialog.",
4000,
);
return undefined;
}
// No targets on this server — skip dialog (backward compatible)
if (availableTargets.length === 0) return undefined;
// Read mp-syndicate-to from frontmatter
let fmSyndicateTo: string[] | undefined;
try {
const raw = await this.app.vault.read(file);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
if (fmMatch) {
const fm = (parseYaml(fmMatch[1]) ?? {}) as Record<string, unknown>;
const val = fm["mp-syndicate-to"];
if (val !== undefined) {
fmSyndicateTo = Array.isArray(val) ? val.map(String) : [String(val)];
}
}
} catch {
// Malformed frontmatter — treat as absent
}
// Decide whether to show dialog
const showDialog =
dialogSetting === "always" ||
(dialogSetting === "when-needed" && fmSyndicateTo === undefined) ||
(fmSyndicateTo !== undefined && fmSyndicateTo.length === 0);
if (!showDialog) {
// Frontmatter has values and setting is "when-needed" — skip dialog
return undefined;
}
// Pre-check: use frontmatter values if non-empty, otherwise plugin defaults
const defaultSelected =
fmSyndicateTo && fmSyndicateTo.length > 0
? fmSyndicateTo
: this.settings.defaultSyndicateTo;
const dialog = new SyndicationDialog(this.app, availableTargets, defaultSelected);
return dialog.awaitSelection();
}
// ── Settings persistence ────────────────────────────────────────────────── // ── Settings persistence ──────────────────────────────────────────────────
async loadSettings(): Promise<void> { async loadSettings(): Promise<void> {
+9
View File
@@ -68,6 +68,14 @@ export interface MicropubSettings {
/** Visibility default for new posts: "public" | "unlisted" | "private" */ /** Visibility default for new posts: "public" | "unlisted" | "private" */
defaultVisibility: "public" | "unlisted" | "private"; defaultVisibility: "public" | "unlisted" | "private";
/**
* Controls when the syndication target dialog is shown before publishing.
* "when-needed" — Show only if mp-syndicate-to is absent from frontmatter
* "always" — Show every time, even if frontmatter has targets
* "never" — Never show dialog; use defaultSyndicateTo + frontmatter
*/
showSyndicationDialog: "when-needed" | "always" | "never";
} }
export const DEFAULT_SETTINGS: MicropubSettings = { export const DEFAULT_SETTINGS: MicropubSettings = {
@@ -83,6 +91,7 @@ export const DEFAULT_SETTINGS: MicropubSettings = {
writeUrlToFrontmatter: true, writeUrlToFrontmatter: true,
mapGardenTags: true, mapGardenTags: true,
defaultVisibility: "public", defaultVisibility: "public",
showSyndicationDialog: "when-needed",
}; };
/** A syndication target as returned by Micropub config query */ /** A syndication target as returned by Micropub config query */