mirror of
https://github.com/svemagie/obsidian-micropub.git
synced 2026-05-15 11:58:51 +02:00
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:
+54
-12
@@ -35,7 +35,7 @@ export class Publisher {
|
||||
}
|
||||
|
||||
/** 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 { frontmatter, body } = this.parseFrontmatter(raw);
|
||||
|
||||
@@ -53,7 +53,7 @@ export class Publisher {
|
||||
const linkedBody = this.resolveWikilinks(processedBody, file.path);
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -69,9 +69,14 @@ export class Publisher {
|
||||
result = await this.client.createPost(properties);
|
||||
}
|
||||
|
||||
// Write URL back to frontmatter
|
||||
if (result.success && result.url && this.settings.writeUrlToFrontmatter) {
|
||||
await this.writeUrlToNote(file, raw, result.url);
|
||||
// Write URL (and syndication targets) back to frontmatter
|
||||
if (result.success && this.settings.writeUrlToFrontmatter) {
|
||||
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;
|
||||
@@ -85,6 +90,7 @@ export class Publisher {
|
||||
uploadedUrls: string[],
|
||||
basename: string,
|
||||
filePath: string,
|
||||
syndicateToOverride?: string[],
|
||||
): Record<string, unknown> {
|
||||
const props: Record<string, unknown> = {};
|
||||
|
||||
@@ -176,13 +182,17 @@ export class Publisher {
|
||||
}
|
||||
|
||||
// Syndication targets
|
||||
// Support both camelCase (mpSyndicateTo) used in existing blog posts and mp-syndicate-to
|
||||
const syndicateTo = this.resolveArray(
|
||||
fm["mp-syndicate-to"] ?? fm["mpSyndicateTo"],
|
||||
);
|
||||
const allSyndicateTo = [
|
||||
...new Set([...this.settings.defaultSyndicateTo, ...syndicateTo]),
|
||||
];
|
||||
// When the dialog was shown, syndicateToOverride contains the user's selection
|
||||
// and takes precedence over frontmatter + settings defaults.
|
||||
// Support both camelCase (mpSyndicateTo) used in existing blog posts and mp-syndicate-to.
|
||||
const allSyndicateTo = syndicateToOverride !== undefined
|
||||
? syndicateToOverride
|
||||
: [
|
||||
...new Set([
|
||||
...this.settings.defaultSyndicateTo,
|
||||
...this.resolveArray(fm["mp-syndicate-to"] ?? fm["mpSyndicateTo"]),
|
||||
]),
|
||||
];
|
||||
if (allSyndicateTo.length > 0) {
|
||||
props["mp-syndicate-to"] = allSyndicateTo;
|
||||
}
|
||||
@@ -375,6 +385,7 @@ export class Publisher {
|
||||
file: TFile,
|
||||
originalContent: string,
|
||||
url: string,
|
||||
syndicateToOverride?: string[],
|
||||
): Promise<void> {
|
||||
// Build all fields to write back after a successful publish
|
||||
const now = new Date();
|
||||
@@ -390,6 +401,11 @@ export class Publisher {
|
||||
["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) {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the closing `---` if the field is not yet present.
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
containerEl.createEl("h3", { text: "Digital Garden" });
|
||||
|
||||
|
||||
@@ -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
@@ -15,10 +15,12 @@
|
||||
* 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 { MicropubSettingsTab } from "./SettingsTab";
|
||||
import { Publisher } from "./Publisher";
|
||||
import { MicropubClient } from "./MicropubClient";
|
||||
import { SyndicationDialog } from "./SyndicationDialog";
|
||||
import { handleProtocolCallback } from "./IndieAuth";
|
||||
|
||||
export default class MicropubPlugin extends Plugin {
|
||||
@@ -101,11 +103,19 @@ export default class MicropubPlugin extends Plugin {
|
||||
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 */);
|
||||
|
||||
try {
|
||||
const publisher = new Publisher(this.app, this.settings);
|
||||
const result = await publisher.publish(file);
|
||||
const result = await publisher.publish(file, syndicateToOverride);
|
||||
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
|
||||
@@ -68,6 +68,14 @@ export interface MicropubSettings {
|
||||
|
||||
/** Visibility default for new posts: "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 = {
|
||||
@@ -83,6 +91,7 @@ export const DEFAULT_SETTINGS: MicropubSettings = {
|
||||
writeUrlToFrontmatter: true,
|
||||
mapGardenTags: true,
|
||||
defaultVisibility: "public",
|
||||
showSyndicationDialog: "when-needed",
|
||||
};
|
||||
|
||||
/** A syndication target as returned by Micropub config query */
|
||||
|
||||
Reference in New Issue
Block a user