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:
+53
-11
@@ -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,12 +182,16 @@ 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.
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
* 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> {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user